Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35c72ef595 | ||
|
|
c0fcaef25f | ||
|
|
3dd5b452c0 | ||
|
|
b2515bbf37 | ||
|
|
ad2e59980f | ||
|
|
68a01d1bda | ||
|
|
621b051793 | ||
|
|
66554657f2 | ||
|
|
587d3a6ca4 | ||
|
|
9d75e408ae | ||
|
|
412bbea01d | ||
|
|
ce5ce33a12 | ||
|
|
0fae419584 | ||
|
|
70b011d4a6 | ||
|
|
a5c16a92df | ||
|
|
e6f022ae96 | ||
|
|
1a27983750 | ||
|
|
5d7ed62b34 | ||
|
|
ee80c27631 | ||
|
|
98b1659579 | ||
|
|
c12d9abc6d | ||
|
|
40a65cb0a6 | ||
|
|
1a89738dd4 | ||
|
|
9e604ea2c7 | ||
|
|
4bb50bed58 | ||
|
|
5bb9dce530 | ||
|
|
73662a3bf0 | ||
|
|
a1e87b1905 |
@@ -0,0 +1,37 @@
|
|||||||
|
# devx-workflow
|
||||||
|
|
||||||
|
Quick reference for devx tools when working on the devx repo itself.
|
||||||
|
|
||||||
|
## PR Workflow (use these, not raw git/tea/MCP)
|
||||||
|
|
||||||
|
| Task | Command |
|
||||||
|
|------|---------|
|
||||||
|
| Create Vikunja task | `make create-task -- --title "..." --description "..."` |
|
||||||
|
| Create PR | `make create-pr` |
|
||||||
|
| Push + create PR | `make push-with-pr` |
|
||||||
|
| Check CI status | `make devx-pr-status` or `make devx-pr-status PR=42 WAIT=1` |
|
||||||
|
| Fetch CI failure logs | `make devx-pr-logs` or `make devx-pr-logs PR=42 JOB=quality TAIL=50` |
|
||||||
|
| Add ready-to-merge label | `make devx-pr-label` or `make devx-pr-label PR=42` |
|
||||||
|
| Post PR review | `make devx-pr-review PR=42 EVENT=APPROVE BODY="..." CHECKLIST=1,2,3,4,5,6,7,8,9,10,11,12,13` |
|
||||||
|
| Rebase current branch | `make rebase` |
|
||||||
|
| Rebase PR via API | `make pr-rebase` or `make pr-rebase PR=42` |
|
||||||
|
|
||||||
|
## Auto-merge Behavior
|
||||||
|
|
||||||
|
When the `ready-to-merge` label is added and all CI checks pass:
|
||||||
|
1. Auto-merge validates PR title format (`DEVX-N: <vikunja task title>`)
|
||||||
|
2. If branch is behind master, auto-merge **rebases via Gitea API** automatically
|
||||||
|
3. The rebase triggers a new CI run; the next auto-merge attempt merges
|
||||||
|
4. No manual rebase needed unless the API rebase fails
|
||||||
|
|
||||||
|
## Key Rules
|
||||||
|
|
||||||
|
- Never manually merge via API — always use auto-merge with `ready-to-merge` label
|
||||||
|
- Branch naming: `DEVX-N-short-description` (N = Vikunja task ID)
|
||||||
|
- Commit format: conventional commits (`feat:`, `fix:`, `docs:`, etc.)
|
||||||
|
- PR title: `DEVX-N: <vikunja task title>` (auto-derived by `make create-pr`)
|
||||||
|
- 100% test coverage required for all source changes
|
||||||
|
- All user-facing strings wrapped in `_()` for i18n
|
||||||
|
- Translation keys must be added to `src/devx/translations.json`
|
||||||
|
- New CLI commands must be documented in `docs/user/cli-commands.md`
|
||||||
|
- New tools must be registered in `src/devx/cli.py` and added to Make targets
|
||||||
@@ -38,6 +38,12 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate
|
||||||
python3 -m devx.ci.doc_coverage --fail-on-missing
|
python3 -m devx.ci.doc_coverage --fail-on-missing
|
||||||
|
- name: Documentation lint check
|
||||||
|
env:
|
||||||
|
PYTHONPATH: src
|
||||||
|
run: |
|
||||||
|
. .venv/bin/activate
|
||||||
|
python3 -m devx.ci.lint_docs --root .
|
||||||
- name: Translation completeness check
|
- name: Translation completeness check
|
||||||
env:
|
env:
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
|
|||||||
@@ -71,7 +71,8 @@ src/devx/
|
|||||||
│ ├── distribute_items.py # Distribute generic items (VMs, hosts) across parallel runners (LPT)
|
│ ├── distribute_items.py # Distribute generic items (VMs, hosts) across parallel runners (LPT)
|
||||||
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast
|
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast
|
||||||
│ ├── check_translations.py # Translation completeness check
|
│ ├── check_translations.py # Translation completeness check
|
||||||
│ └── doc_coverage.py # Documentation coverage check
|
│ ├── doc_coverage.py # Documentation coverage check
|
||||||
|
│ └── lint_docs.py # Documentation linter (structure, links, headings)
|
||||||
├── tools/ # Developer tooling modules (run locally or by CI)
|
├── tools/ # Developer tooling modules (run locally or by CI)
|
||||||
│ ├── setup.py # Environment setup (venv, deps, hooks)
|
│ ├── setup.py # Environment setup (venv, deps, hooks)
|
||||||
│ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea, hadolint
|
│ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea, hadolint
|
||||||
@@ -89,7 +90,9 @@ src/devx/
|
|||||||
│ ├── create_pr.py # Create PRs with auto-derived title from Vikunja
|
│ ├── create_pr.py # Create PRs with auto-derived title from Vikunja
|
||||||
│ ├── pr_status.py # Check CI status for a PR/commit (--wait polls)
|
│ ├── pr_status.py # Check CI status for a PR/commit (--wait polls)
|
||||||
│ ├── pr_logs.py # Fetch logs for failed CI jobs
|
│ ├── pr_logs.py # Fetch logs for failed CI jobs
|
||||||
│ └── pr_label.py # Add labels to PRs (idempotent)
|
│ ├── pr_label.py # Add labels to PRs (idempotent)
|
||||||
|
│ ├── rebase.py # Rebase current branch onto origin/master + force-push
|
||||||
|
│ └── pr_rebase.py # Rebase a PR's head branch via Gitea API (server-side)
|
||||||
├── opentofu.py # OpenTofu output helpers (get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field)
|
├── opentofu.py # OpenTofu output helpers (get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field)
|
||||||
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
|
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
|
||||||
├── discover_runners.py # Dynamic Gitea runner discovery
|
├── discover_runners.py # Dynamic Gitea runner discovery
|
||||||
@@ -180,6 +183,12 @@ the PR. Then add the `ready-to-merge` label. The auto-merge workflow will:
|
|||||||
5. The post-merge workflow marks the Vikunja task as done
|
5. The post-merge workflow marks the Vikunja task as done
|
||||||
6. The release workflow automatically versions, tags, and publishes
|
6. The release workflow automatically versions, tags, and publishes
|
||||||
|
|
||||||
|
**If the branch is behind master** (another PR merged first), auto-merge
|
||||||
|
automatically rebases the PR's head branch via the Gitea API
|
||||||
|
(`POST /pulls/{index}/update?style=rebase`). This triggers a new CI run.
|
||||||
|
The next auto-merge attempt will find the branch up-to-date and merge
|
||||||
|
successfully. No manual intervention needed.
|
||||||
|
|
||||||
> **IMPORTANT**: Never manually merge PRs via the API. Always use the auto-merge
|
> **IMPORTANT**: Never manually merge PRs via the API. Always use the auto-merge
|
||||||
> workflow by adding the `ready-to-merge` label.
|
> workflow by adding the `ready-to-merge` label.
|
||||||
|
|
||||||
@@ -408,6 +417,8 @@ projects.
|
|||||||
| `devx-pr-logs` | Fetch logs for failed CI jobs (`PR=`, `JOB=`, `TAIL=`) |
|
| `devx-pr-logs` | Fetch logs for failed CI jobs (`PR=`, `JOB=`, `TAIL=`) |
|
||||||
| `devx-pr-label` | Add a label to a PR (`PR=`, `LABEL=ready-to-merge`) |
|
| `devx-pr-label` | Add a label to a PR (`PR=`, `LABEL=ready-to-merge`) |
|
||||||
| `devx-pr-review` | Post a review on a PR (`PR=`, `EVENT=`, `BODY=`, `CHECKLIST=`) |
|
| `devx-pr-review` | Post a review on a PR (`PR=`, `EVENT=`, `BODY=`, `CHECKLIST=`) |
|
||||||
|
| `devx-rebase` | Rebase current branch onto origin/master + force-push (`NO_PUSH=1` for local only) |
|
||||||
|
| `devx-pr-rebase` | Rebase a PR's head branch via Gitea API — server-side, no local git needed (`PR=`) |
|
||||||
| `devx-check-config` | Validate devx configuration |
|
| `devx-check-config` | Validate devx configuration |
|
||||||
| `devx-configure-gitea-pypi` | Configure Gitea private PyPI registry |
|
| `devx-configure-gitea-pypi` | Configure Gitea private PyPI registry |
|
||||||
| `devx-env` | Create .env from .env.example |
|
| `devx-env` | Create .env from .env.example |
|
||||||
|
|||||||
@@ -2,6 +2,54 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [0.29.0] - 2026-07-01
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Detect badge commits as automated CI commits
|
||||||
|
|
||||||
|
## [0.28.0] - 2026-07-01
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Auto-rebase in auto-merge, new rebase tools, CLI registration
|
||||||
|
|
||||||
|
## [0.27.3] - 2026-06-30
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Retry wiki integrity check on transient API timeout
|
||||||
|
|
||||||
|
## [0.27.2] - 2026-06-29
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Retry release push on non-fast-forward with rebase loop
|
||||||
|
|
||||||
|
## [0.27.1] - 2026-06-28
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Exclude .devin/.terraform dirs from lint_docs, add duplicate heading excludes
|
||||||
|
|
||||||
|
## [0.27.0] - 2026-06-28
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Add lint_docs tool, fix doc_coverage/check_translations for any repo
|
||||||
|
|
||||||
|
## [0.26.4] - 2026-06-28
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Wrap all user-facing strings with _() for i18n completeness
|
||||||
|
|
||||||
|
## [0.26.3] - 2026-06-28
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Pin all dependencies to exact versions for reproducibility
|
||||||
|
|
||||||
## [0.26.2] - 2026-06-28
|
## [0.26.2] - 2026-06-28
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ create-task: devx-create-task
|
|||||||
create-pr: devx-create-pr
|
create-pr: devx-create-pr
|
||||||
push-with-pr: devx-push-with-pr
|
push-with-pr: devx-push-with-pr
|
||||||
git-push: devx-push
|
git-push: devx-push
|
||||||
|
rebase: devx-rebase
|
||||||
|
pr-rebase: devx-pr-rebase
|
||||||
|
|
||||||
lint-all: lint workflow-lint lint-dockerfiles
|
lint-all: lint workflow-lint lint-dockerfiles
|
||||||
@echo "[lint-all] All linting checks passed."
|
@echo "[lint-all] All linting checks passed."
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ quality badges.
|
|||||||
|
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Why devx?
|
## Why devx?
|
||||||
|
|
||||||
@@ -158,6 +158,9 @@ python -m devx.ci.check_translations --translations path/to/translations.json
|
|||||||
# Documentation coverage check
|
# Documentation coverage check
|
||||||
python -m devx.ci.doc_coverage --fail-on-missing
|
python -m devx.ci.doc_coverage --fail-on-missing
|
||||||
|
|
||||||
|
# Documentation lint (structure, links, headings, TODOs)
|
||||||
|
python -m devx.ci.lint_docs --root .
|
||||||
|
|
||||||
# Validate a commit message
|
# Validate a commit message
|
||||||
python -m devx.ci.validate_commit_msg commit-msg.txt --branch master
|
python -m devx.ci.validate_commit_msg commit-msg.txt --branch master
|
||||||
|
|
||||||
|
|||||||
+8
-7
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
|
|||||||
|
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ wiki sync details.
|
|||||||
devx provides a `devx` CLI with three command groups:
|
devx provides a `devx` CLI with three command groups:
|
||||||
|
|
||||||
- `devx ci <command>` — CI/CD automation (17 commands)
|
- `devx ci <command>` — CI/CD automation (17 commands)
|
||||||
- `devx tools <command>` — Developer tools (7 commands)
|
- `devx tools <command>` — Developer tools (9 commands)
|
||||||
- `devx molecule <command>` — Molecule testing (4 commands, optional)
|
- `devx molecule <command>` — Molecule testing (4 commands, optional)
|
||||||
|
|
||||||
See [CLI Commands](CLI-Commands) for full command documentation with examples.
|
See [CLI Commands](CLI-Commands) for full command documentation with examples.
|
||||||
@@ -158,6 +158,7 @@ for the full configuration reference, PR workflow, and project conventions.
|
|||||||
## Wiki pages
|
## Wiki pages
|
||||||
|
|
||||||
- [Home](Home) — This page
|
- [Home](Home) — This page
|
||||||
|
- [Getting Started](Getting-Started) — Installation, configuration, and quick start guide
|
||||||
- [CLI Commands](CLI-Commands) — Full CLI command documentation with examples
|
- [CLI Commands](CLI-Commands) — Full CLI command documentation with examples
|
||||||
- [Architecture](Architecture) — Package structure, module descriptions, design principles
|
- [Architecture](Architecture) — Package structure, module descriptions, design principles
|
||||||
- [CI/CD Workflow](CI-CD-Workflow) — Pipeline documentation, workflows, and CI scripts
|
- [CI/CD Workflow](CI-CD-Workflow) — Pipeline documentation, workflows, and CI scripts
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"index.md": "Home",
|
"index.md": "Home",
|
||||||
|
"user/getting-started.md": "Getting-Started",
|
||||||
"user/cli-commands.md": "CLI-Commands",
|
"user/cli-commands.md": "CLI-Commands",
|
||||||
"tech/architecture.md": "Architecture",
|
"tech/architecture.md": "Architecture",
|
||||||
"tech/ci-cd-workflow.md": "CI-CD-Workflow"
|
"tech/ci-cd-workflow.md": "CI-CD-Workflow"
|
||||||
|
|||||||
@@ -382,7 +382,7 @@ single-role (4-part) and multi-role (5-part) pair encoding.
|
|||||||
Runs all molecule scenarios on all supported OS platforms sequentially.
|
Runs all molecule scenarios on all supported OS platforms sequentially.
|
||||||
Intended for local development; CI uses the parallel matrix instead.
|
Intended for local development; CI uses the parallel matrix instead.
|
||||||
|
|
||||||
### `discover_runners.py`
|
### `molecule/discover_runners.py`
|
||||||
|
|
||||||
Discovers available Gitea Actions runners for molecule tests. Same logic as
|
Discovers available Gitea Actions runners for molecule tests. Same logic as
|
||||||
`devx.ci.discover_runners` but intended for molecule-specific workflows.
|
`devx.ci.discover_runners` but intended for molecule-specific workflows.
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ When `release` creates a `release: vX.Y.Z` commit, the release commit's
|
|||||||
post-merge run still updates badges (the version badge picks up the new
|
post-merge run still updates badges (the version badge picks up the new
|
||||||
version). Other jobs skip. The tag push triggers `publish.yml`.
|
version). Other jobs skip. The tag push triggers `publish.yml`.
|
||||||
|
|
||||||
### Jobs
|
### Post-merge jobs
|
||||||
|
|
||||||
#### `detect-type`
|
#### `detect-type`
|
||||||
|
|
||||||
|
|||||||
@@ -127,14 +127,37 @@ Click commands from `cli.py` and checks if each has documentation in
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
devx ci doc-coverage
|
devx ci doc-coverage
|
||||||
devx ci doc-coverage --docs-dir docs/ --fail-on-missing
|
devx ci doc-coverage --docs-dir docs/ --source-dir src/ --fail-on-missing
|
||||||
```
|
```
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- `--docs-dir <dir>` — path to the docs directory (default: `docs/`)
|
- `--docs-dir <dir>` — path to the docs directory (default: `docs/`)
|
||||||
|
- `--source-dir <dir>` — path to the source directory (default: auto-detect)
|
||||||
- `--fail-on-missing` — exit with non-zero status if any documentation is
|
- `--fail-on-missing` — exit with non-zero status if any documentation is
|
||||||
missing
|
missing
|
||||||
|
|
||||||
|
### `devx ci lint-docs`
|
||||||
|
|
||||||
|
Lint documentation files for structure, broken links, heading hierarchy,
|
||||||
|
duplicate headings, TODO/FIXME markers, and trailing whitespace.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
devx ci lint-docs
|
||||||
|
devx ci lint-docs --root . --fix
|
||||||
|
devx ci lint-docs --no-check-links --no-check-stale
|
||||||
|
```
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- `--root <dir>` — repository root directory (default: `.`)
|
||||||
|
- `--docs-dir <dir>` — docs directory (default: `<root>/docs`)
|
||||||
|
- `--check-links/--no-check-links` — check internal links (default: yes)
|
||||||
|
- `--check-headings/--no-check-headings` — check heading hierarchy (default: yes)
|
||||||
|
- `--check-todo/--no-check-todo` — check for TODO/FIXME markers (default: yes)
|
||||||
|
- `--check-stale/--no-check-stale` — check for stale docs (default: no)
|
||||||
|
- `--check-trailing/--no-check-trailing` — check trailing whitespace (default: yes)
|
||||||
|
- `--check-duplicates/--no-check-duplicates` — check duplicate headings (default: yes)
|
||||||
|
- `--fix` — auto-fix trailing whitespace
|
||||||
|
|
||||||
### `devx ci integration-guard`
|
### `devx ci integration-guard`
|
||||||
|
|
||||||
Run pytest with cross-runner failure detection. If any
|
Run pytest with cross-runner failure detection. If any
|
||||||
@@ -398,6 +421,35 @@ Options:
|
|||||||
- `--no-pre-commit` — skip pre-commit hook installation
|
- `--no-pre-commit` — skip pre-commit hook installation
|
||||||
- `--no-tea-login` — skip tea CLI login configuration
|
- `--no-tea-login` — skip tea CLI login configuration
|
||||||
|
|
||||||
|
### `devx tools rebase`
|
||||||
|
|
||||||
|
Rebase the current branch onto `origin/master` and force-push with
|
||||||
|
`--force-with-lease`. Checks if the branch is behind master first —
|
||||||
|
if up-to-date, exits without doing anything.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
devx tools rebase # rebase + force-push
|
||||||
|
devx tools rebase -- --no-push # rebase locally only
|
||||||
|
```
|
||||||
|
|
||||||
|
Options (pass after `--`):
|
||||||
|
- `--no-push` — rebase locally without pushing
|
||||||
|
|
||||||
|
### `devx tools pr-rebase`
|
||||||
|
|
||||||
|
Rebase a pull request's head branch onto master via the Gitea API
|
||||||
|
(server-side). This triggers a new `pull_request synchronize` event,
|
||||||
|
which starts a new CI run. Useful when you don't have the branch
|
||||||
|
checked out locally.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
devx tools pr-rebase -- --pr 42 # rebase PR #42
|
||||||
|
devx tools pr-rebase # auto-detect PR from current branch
|
||||||
|
```
|
||||||
|
|
||||||
|
Options (pass after `--`):
|
||||||
|
- `--pr <N>` — PR number (auto-detected from current branch if omitted)
|
||||||
|
|
||||||
## Molecule Commands
|
## Molecule Commands
|
||||||
|
|
||||||
Molecule commands require the `molecule` extra (`pip install devx[molecule]`).
|
Molecule commands require the `molecule` extra (`pip install devx[molecule]`).
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# Getting Started with devx
|
||||||
|
|
||||||
|
This guide walks you through installing devx, configuring it for your project,
|
||||||
|
and setting up a complete CI/CD pipeline.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Python 3.12+**
|
||||||
|
- **A Gitea instance** with Actions enabled
|
||||||
|
- **A Gitea API token** with repo, workflow, and organization scopes
|
||||||
|
- **(Optional) Vikunja API token** for task tracking integration
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
devx is published to the Gitea PyPI registry. Configure pip to use it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Configure Gitea PyPI registry
|
||||||
|
pip config set global.extra-index-url https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
|
||||||
|
|
||||||
|
# Install devx
|
||||||
|
pip install devx
|
||||||
|
```
|
||||||
|
|
||||||
|
Or install from source:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.oblachno.oblachno.fyi/oblachno-oss/devx.git
|
||||||
|
cd devx
|
||||||
|
make setup
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Configure environment variables
|
||||||
|
|
||||||
|
Create a `.env` file in your project root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CI_GITEA_TOKEN=your_gitea_api_token
|
||||||
|
VIKUNJA_TOKEN=your_vikunja_api_token # optional
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Add devx to your project
|
||||||
|
|
||||||
|
Add devx to your `pyproject.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[project]
|
||||||
|
dependencies = [
|
||||||
|
"devx>=0.26.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"devx[dev]>=0.26.0",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Set up the Makefile
|
||||||
|
|
||||||
|
devx provides a shared Makefile fragment. Add this to your `Makefile`:
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
include devx.mak
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `devx tools setup` to install all development tools (actionlint, git-cliff,
|
||||||
|
tea CLI, etc.) and configure pre-commit hooks.
|
||||||
|
|
||||||
|
### 4. Create the docs structure
|
||||||
|
|
||||||
|
devx expects a `docs/` directory with at minimum:
|
||||||
|
|
||||||
|
```
|
||||||
|
docs/
|
||||||
|
├── index.md # Documentation home page
|
||||||
|
├── mapping.json # Wiki page title mappings
|
||||||
|
├── user/ # User-facing documentation
|
||||||
|
│ └── cli-commands.md
|
||||||
|
└── tech/ # Technical documentation
|
||||||
|
├── architecture.md
|
||||||
|
└── ci-cd-workflow.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Example `docs/mapping.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"index.md": "Home",
|
||||||
|
"user/cli-commands.md": "CLI-Commands",
|
||||||
|
"tech/architecture.md": "Architecture",
|
||||||
|
"tech/ci-cd-workflow.md": "CI-CD-Workflow"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Set up CI workflows
|
||||||
|
|
||||||
|
Create `.gitea/workflows/ci.yml` and `.gitea/workflows/post-merge.yml` in your
|
||||||
|
project. See the [CI/CD Workflow guide](../tech/ci-cd-workflow.md) for details.
|
||||||
|
|
||||||
|
### 6. Configure release settings
|
||||||
|
|
||||||
|
Add a `cliff.toml` for git-cliff-based versioning:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
devx tools generate-cliff-config
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `[tool.devx]` section to `pyproject.toml` for project-specific config:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[tool.devx]
|
||||||
|
# Vikunja project ID for task tracking
|
||||||
|
vikunja_project_id = 6
|
||||||
|
|
||||||
|
[tool.devx.classify]
|
||||||
|
# File patterns that are workflow-only (no release needed)
|
||||||
|
workflow_only = [
|
||||||
|
".gitea/**",
|
||||||
|
"docs/**",
|
||||||
|
"tests/**",
|
||||||
|
"AGENTS.md",
|
||||||
|
"README.md",
|
||||||
|
"CHANGELOG.md",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Tools
|
||||||
|
|
||||||
|
### CI/CD Automation (`devx.ci.*`)
|
||||||
|
|
||||||
|
- `devx.ci.release` — Automated semver versioning and tagging
|
||||||
|
- `devx.ci.publish` — Package publishing to Gitea PyPI registry
|
||||||
|
- `devx.ci.auto_merge` — Squash-merge automation with task ID validation
|
||||||
|
- `devx.ci.pr_review` — Automated PR review with inline comments
|
||||||
|
- `devx.ci.classify_changes` — User-facing vs workflow-only change detection
|
||||||
|
- `devx.ci.sync_wiki` — Push docs/ to Gitea wiki
|
||||||
|
- `devx.ci.doc_coverage` — Documentation coverage checker
|
||||||
|
- `devx.ci.lint_docs` — Documentation linter (structure, links, headings)
|
||||||
|
- `devx.ci.check_translations` — i18n translation completeness checker
|
||||||
|
- `devx.ci.notify_failure` — Create Gitea issues on CI failures
|
||||||
|
- `devx.ci.distribute_files` — Parallel test file distribution
|
||||||
|
- `devx.ci.distribute_items` — Parallel item distribution across runners
|
||||||
|
- `devx.ci.discover_runners` — Dynamic runner discovery via Gitea API
|
||||||
|
|
||||||
|
### Development Tools (`devx.tools.*`)
|
||||||
|
|
||||||
|
- `devx.tools.setup` — Environment setup (venv, deps, hooks, tools)
|
||||||
|
- `devx.tools.install_tools` — Install CI/CD tools (actionlint, git-cliff, tea)
|
||||||
|
- `devx.tools.create_task` — Create Vikunja tasks
|
||||||
|
- `devx.tools.create_pr` — Create Gitea PRs with task ID in title
|
||||||
|
- `devx.tools.configure_repo` — Configure branch protection and labels
|
||||||
|
- `devx.tools.generate_badges` — Generate quality badge SVGs
|
||||||
|
- `devx.tools.check_test_speed` — Enforce test execution speed limits
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
- Read the [CLI Commands reference](cli-commands.md) for all available commands
|
||||||
|
- Read the [Architecture guide](../tech/architecture.md) to understand internals
|
||||||
|
- Read the [CI/CD Workflow guide](../tech/ci-cd-workflow.md) for pipeline details
|
||||||
+28
-26
@@ -13,11 +13,13 @@ classifiers = [
|
|||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
|
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
|
||||||
]
|
]
|
||||||
|
# All dependencies are pinned to exact versions for full reproducibility.
|
||||||
|
# Update pinned versions in a dedicated PR with verification.
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"requests>=2.34.2",
|
"requests==2.34.2",
|
||||||
"python-dotenv>=1.2.2",
|
"python-dotenv==1.2.2",
|
||||||
"click>=8.4.1",
|
"click==8.4.2",
|
||||||
"tenacity>=8.2", # retry logic for GiteaClient/VikunjaClient
|
"tenacity==9.1.4", # retry logic for GiteaClient/VikunjaClient
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
@@ -29,44 +31,44 @@ version = {attr = "devx.__version__"}
|
|||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
# Test runners (pytest + coverage + parallel execution)
|
# Test runners (pytest + coverage + parallel execution)
|
||||||
ci = [
|
ci = [
|
||||||
"pytest>=9.1.0",
|
"pytest==9.1.1",
|
||||||
"pytest-cov>=7.1.0",
|
"pytest-cov==7.1.0",
|
||||||
"pytest-xdist>=3.8",
|
"pytest-xdist==3.8.0",
|
||||||
]
|
]
|
||||||
# Lint and type-checking tools (quality job, badge generation)
|
# Lint and type-checking tools (quality job, badge generation)
|
||||||
lint = [
|
lint = [
|
||||||
"ruff>=0.15.17",
|
"ruff==0.15.20",
|
||||||
"pyright>=1.1.410",
|
"pyright==1.1.411",
|
||||||
"bandit>=1.8.2",
|
"bandit==1.9.4",
|
||||||
"pip-audit>=2.10",
|
"pip-audit==2.10.1",
|
||||||
"pre-commit>=4.6.0",
|
"pre-commit==4.6.0",
|
||||||
]
|
]
|
||||||
# Release tools (build + publish to PyPI/Gitea registry)
|
# Release tools (build + publish to PyPI/Gitea registry)
|
||||||
release = [
|
release = [
|
||||||
"build>=1.5.0",
|
"build==1.5.0",
|
||||||
"twine>=6.2.0",
|
"twine==6.2.0",
|
||||||
]
|
]
|
||||||
# Molecule testing (for projects with Ansible roles)
|
# Molecule testing (for projects with Ansible roles)
|
||||||
molecule = [
|
molecule = [
|
||||||
"molecule>=26.4.0",
|
"molecule==26.4.0",
|
||||||
"molecule-docker>=2.1.0",
|
"molecule-docker==2.1.0",
|
||||||
"ansible-lint>=26.4.0",
|
"ansible-lint==26.4.0",
|
||||||
"ansible-core>=2.15,<2.17",
|
"ansible-core==2.21.1",
|
||||||
]
|
]
|
||||||
# Deploy tools (for infra staging/production deployments)
|
# Deploy tools (for infra staging/production deployments)
|
||||||
deploy = [
|
deploy = [
|
||||||
"ansible-core>=2.15,<2.17",
|
"ansible-core==2.21.1",
|
||||||
"boto3>=1.34",
|
"boto3==1.43.36",
|
||||||
"docker>=7.0",
|
"docker==7.1.0",
|
||||||
"jinja2>=3.1",
|
"jinja2==3.1.6",
|
||||||
"pyyaml>=6.0",
|
"pyyaml==6.0.3",
|
||||||
"cryptography>=41.0",
|
"cryptography==49.0.0",
|
||||||
]
|
]
|
||||||
# Full dev environment (local development)
|
# Full dev environment (local development)
|
||||||
dev = [
|
dev = [
|
||||||
"devx[ci,lint,release,molecule]",
|
"devx[ci,lint,release,molecule]",
|
||||||
"build>=1.3.0",
|
"build==1.5.0",
|
||||||
"twine>=6.2.0",
|
"twine==6.2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||||
|
|
||||||
__version__ = "0.26.2"
|
__version__ = "0.29.0"
|
||||||
|
|||||||
@@ -194,6 +194,19 @@ class GiteaClient:
|
|||||||
payload = {"Do": "squash", "MergeTitleField": merge_title}
|
payload = {"Do": "squash", "MergeTitleField": merge_title}
|
||||||
self._request("POST", f"/pulls/{pr_number}/merge", json=payload)
|
self._request("POST", f"/pulls/{pr_number}/merge", json=payload)
|
||||||
|
|
||||||
|
def update_pr_branch(self, pr_number: str | int, style: str = "rebase") -> None:
|
||||||
|
"""Update PR head branch by merging/rebasing the base branch into it.
|
||||||
|
|
||||||
|
Uses the Gitea API ``POST /pulls/{index}/update?style=rebase`` endpoint.
|
||||||
|
This rebases the PR's head branch onto the latest base branch server-side,
|
||||||
|
triggering a ``pull_request synchronize`` event that starts a new CI run.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pr_number: PR number.
|
||||||
|
style: Update method — ``"rebase"`` (default) or ``"merge"``.
|
||||||
|
"""
|
||||||
|
self._request("POST", f"/pulls/{pr_number}/update", params={"style": style})
|
||||||
|
|
||||||
def get_commit_status(self, sha: str) -> list[dict[str, Any]]:
|
def get_commit_status(self, sha: str) -> list[dict[str, Any]]:
|
||||||
"""Fetch all status check contexts reported for a commit.
|
"""Fetch all status check contexts reported for a commit.
|
||||||
|
|
||||||
|
|||||||
@@ -231,17 +231,35 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
|
|||||||
client.merge_pr(pr_num, merge_title)
|
client.merge_pr(pr_num, merge_title)
|
||||||
except APIError as e:
|
except APIError as e:
|
||||||
if e.status == 405 and "behind" in e.message.lower():
|
if e.status == 405 and "behind" in e.message.lower():
|
||||||
# Head branch is behind master — do NOT auto-rebase.
|
# Head branch is behind master. Auto-rebase via Gitea API.
|
||||||
# Auto-rebasing creates a feedback loop: the force-push triggers
|
# This triggers a new pull_request synchronize event → new CI run.
|
||||||
# a new pull_request synchronize event, which starts a new CI run,
|
# The next auto-merge attempt will find the branch up-to-date and
|
||||||
# which runs auto-merge again, which rebases again, etc.
|
# merge successfully. This is NOT an infinite loop: the rebase
|
||||||
raise click.ClickException(
|
# resolves the "behind" condition, so the next run merges.
|
||||||
|
# If another PR merges in between, the branch may fall behind
|
||||||
|
# again, but the process converges as PRs stop merging.
|
||||||
|
click.echo(
|
||||||
_(
|
_(
|
||||||
"Branch is behind master. Rebase manually:\n"
|
"Branch is behind master. Auto-rebasing via Gitea API...\n"
|
||||||
" git fetch origin master && git rebase origin/master && git push --force-with-lease\n"
|
"A new CI run will start automatically after the rebase.\n"
|
||||||
"Then re-add the ready-to-merge label.",
|
"The next auto-merge attempt will merge this PR.",
|
||||||
)
|
)
|
||||||
) from None
|
)
|
||||||
|
try:
|
||||||
|
client.update_pr_branch(pr_num, style="rebase")
|
||||||
|
except APIError as rebase_err:
|
||||||
|
raise click.ClickException(
|
||||||
|
_(
|
||||||
|
"Auto-rebase failed with HTTP {status}: {message}\n"
|
||||||
|
"Rebase manually:\n"
|
||||||
|
" git fetch origin master && git rebase origin/master && git push --force-with-lease\n"
|
||||||
|
"Then re-add the ready-to-merge label.",
|
||||||
|
status=rebase_err.status,
|
||||||
|
message=rebase_err.message,
|
||||||
|
)
|
||||||
|
) from None
|
||||||
|
# Exit cleanly — the rebase triggers a new CI run that will retry.
|
||||||
|
return
|
||||||
else:
|
else:
|
||||||
raise click.ClickException(
|
raise click.ClickException(
|
||||||
_(
|
_(
|
||||||
|
|||||||
@@ -31,11 +31,11 @@ from pathlib import Path
|
|||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
REPO_ROOT = Path.cwd()
|
||||||
|
|
||||||
SUPPORTED_LANGS = ("en", "bg", "de", "ru", "zh", "pl")
|
SUPPORTED_LANGS = ("en", "bg", "de", "ru", "zh", "pl")
|
||||||
|
|
||||||
# Default translation set: devx package itself
|
# Default translation set: look for translations.json in the current repo
|
||||||
DEFAULT_TRANS_FILE = REPO_ROOT / "src" / "devx" / "translations.json"
|
DEFAULT_TRANS_FILE = REPO_ROOT / "src" / "devx" / "translations.json"
|
||||||
DEFAULT_SRC_DIR = REPO_ROOT / "src" / "devx"
|
DEFAULT_SRC_DIR = REPO_ROOT / "src" / "devx"
|
||||||
|
|
||||||
@@ -169,20 +169,44 @@ def print_result(result: TranslationCheckResult) -> None:
|
|||||||
"translations",
|
"translations",
|
||||||
multiple=True,
|
multiple=True,
|
||||||
type=click.Path(exists=False, path_type=Path),
|
type=click.Path(exists=False, path_type=Path),
|
||||||
help="Path to a translations JSON file to check (can be repeated). Defaults to src/devx/translations.json.",
|
help="Path to a translations JSON file to check (can be repeated). Auto-detects by default.",
|
||||||
)
|
)
|
||||||
def main(translations: tuple[Path, ...]) -> None:
|
@click.option(
|
||||||
|
"--source-dir",
|
||||||
|
default=None,
|
||||||
|
help="Source directory to scan for _() calls (default: auto-detect).",
|
||||||
|
)
|
||||||
|
def main(translations: tuple[Path, ...], source_dir: str | None) -> None:
|
||||||
"""Check translation files for gaps, dead keys, and missing languages."""
|
"""Check translation files for gaps, dead keys, and missing languages."""
|
||||||
|
results: list[TranslationCheckResult] = []
|
||||||
if not translations:
|
if not translations:
|
||||||
# Default: check the devx package's own translations
|
# Auto-detect translations file in the current repo
|
||||||
results = [
|
root = Path.cwd()
|
||||||
check_translation_set("devx", DEFAULT_SRC_DIR, DEFAULT_TRANS_FILE),
|
# Try common locations
|
||||||
|
candidates = [
|
||||||
|
root / "src" / "devx" / "translations.json",
|
||||||
|
root / "src" / "gitea_runner_manager" / "translations.json",
|
||||||
]
|
]
|
||||||
|
# Also search for any translations.json in src/
|
||||||
|
for match in root.glob("src/*/translations.json"):
|
||||||
|
candidates.append(match)
|
||||||
|
|
||||||
|
found = False
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate.exists():
|
||||||
|
src_dir = Path(source_dir) if source_dir else candidate.parent
|
||||||
|
results.append(check_translation_set(candidate.parent.name, src_dir, candidate))
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
# No translations file found — this repo doesn't use i18n
|
||||||
|
click.echo("PASS: No translations file found — skipping (repo does not use i18n).")
|
||||||
|
return
|
||||||
else:
|
else:
|
||||||
results = []
|
|
||||||
for trans_file in translations:
|
for trans_file in translations:
|
||||||
# Infer source directory as the parent of the translations file
|
# Infer source directory as the parent of the translations file
|
||||||
src_dir = trans_file.parent
|
src_dir = Path(source_dir) if source_dir else trans_file.parent
|
||||||
name = trans_file.parent.name
|
name = trans_file.parent.name
|
||||||
results.append(check_translation_set(name, src_dir, trans_file))
|
results.append(check_translation_set(name, src_dir, trans_file))
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Detect whether the latest git commit is a release commit.
|
"""Detect whether the latest git commit is an automated CI commit.
|
||||||
|
|
||||||
Release commits have the format ``release: vX.Y.Z``.
|
Release commits have the format ``release: vX.Y.Z``.
|
||||||
|
Badge commits have the format ``chore: update badge URLs ... [skip ci]``.
|
||||||
|
Both are generated by CI and should skip post-merge jobs.
|
||||||
|
|
||||||
This script writes ``is-release=true`` or ``is-release=false`` to
|
This script writes ``is-release=true`` or ``is-release=false`` to
|
||||||
``$GITHUB_OUTPUT`` for use in CI workflow conditionals.
|
``$GITHUB_OUTPUT`` for use in CI workflow conditionals.
|
||||||
|
|
||||||
@@ -18,8 +21,10 @@ import subprocess # nosec B404
|
|||||||
import click
|
import click
|
||||||
|
|
||||||
from devx.ci._shared import write_github_output
|
from devx.ci._shared import write_github_output
|
||||||
|
from devx.i18n import _
|
||||||
|
|
||||||
RELEASE_RE = re.compile(r"^release: v\d+\.\d+\.\d+")
|
RELEASE_RE = re.compile(r"^release: v\d+\.\d+\.\d+")
|
||||||
|
BADGE_RE = re.compile(r"^chore: update badge URLs.*\[skip ci\]")
|
||||||
|
|
||||||
|
|
||||||
def get_commit_message() -> str:
|
def get_commit_message() -> str:
|
||||||
@@ -40,17 +45,31 @@ def is_release_commit(message: str) -> bool:
|
|||||||
return bool(RELEASE_RE.match(message))
|
return bool(RELEASE_RE.match(message))
|
||||||
|
|
||||||
|
|
||||||
|
def is_badge_commit(message: str) -> bool:
|
||||||
|
"""Check if a commit message matches the badge commit format."""
|
||||||
|
return bool(BADGE_RE.match(message))
|
||||||
|
|
||||||
|
|
||||||
|
def is_automated_commit(message: str) -> bool:
|
||||||
|
"""Check if a commit is an automated CI commit (release or badge)."""
|
||||||
|
return is_release_commit(message) or is_badge_commit(message)
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
"""Detect if the latest commit is a release commit and set GITHUB_OUTPUT."""
|
"""Detect if the latest commit is an automated CI commit and set GITHUB_OUTPUT."""
|
||||||
msg = get_commit_message()
|
msg = get_commit_message()
|
||||||
click.echo(f"Commit message: {msg}")
|
click.echo(_("Commit message: {msg}", msg=msg))
|
||||||
is_release = is_release_commit(msg)
|
is_release = is_release_commit(msg)
|
||||||
|
is_automated = is_automated_commit(msg)
|
||||||
write_github_output("is-release", "true" if is_release else "false")
|
write_github_output("is-release", "true" if is_release else "false")
|
||||||
|
write_github_output("is-automated", "true" if is_automated else "false")
|
||||||
if is_release:
|
if is_release:
|
||||||
click.echo("Release commit — skipping all post-merge jobs.")
|
click.echo(_("Release commit — skipping all post-merge jobs."))
|
||||||
|
elif is_automated:
|
||||||
|
click.echo(_("Automated CI commit (badge) — skipping post-merge jobs."))
|
||||||
else:
|
else:
|
||||||
click.echo("Regular merge commit — running all post-merge jobs.")
|
click.echo(_("Regular merge commit — running all post-merge jobs."))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import click
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
|
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
|
||||||
|
from devx.i18n import _
|
||||||
|
|
||||||
DEFAULT_MAX_RUNNERS = 3
|
DEFAULT_MAX_RUNNERS = 3
|
||||||
|
|
||||||
@@ -55,9 +56,9 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
|
|||||||
data = r.json()
|
data = r.json()
|
||||||
total += data.get("total_count", 0)
|
total += data.get("total_count", 0)
|
||||||
else:
|
else:
|
||||||
click.echo(f"Warning: repo-level runners query returned HTTP {r.status_code}", err=True)
|
click.echo(_("Warning: repo-level runners query returned HTTP {status}", status=r.status_code), err=True)
|
||||||
except (requests.RequestException, ValueError) as e:
|
except (requests.RequestException, ValueError) as e:
|
||||||
click.echo(f"Warning: repo-level runners query failed: {e}", err=True)
|
click.echo(_("Warning: repo-level runners query failed: {error}", error=e), err=True)
|
||||||
|
|
||||||
# 2. Organization-level runners
|
# 2. Organization-level runners
|
||||||
try:
|
try:
|
||||||
@@ -70,9 +71,9 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
|
|||||||
data = r.json()
|
data = r.json()
|
||||||
total += data.get("total_count", 0)
|
total += data.get("total_count", 0)
|
||||||
else:
|
else:
|
||||||
click.echo(f"Warning: org-level runners query returned HTTP {r.status_code}", err=True)
|
click.echo(_("Warning: org-level runners query returned HTTP {status}", status=r.status_code), err=True)
|
||||||
except (requests.RequestException, ValueError) as e:
|
except (requests.RequestException, ValueError) as e:
|
||||||
click.echo(f"Warning: org-level runners query failed: {e}", err=True)
|
click.echo(_("Warning: org-level runners query failed: {error}", error=e), err=True)
|
||||||
|
|
||||||
# 3. Instance-level runners (requires admin scope)
|
# 3. Instance-level runners (requires admin scope)
|
||||||
try:
|
try:
|
||||||
@@ -85,9 +86,12 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
|
|||||||
data = r.json()
|
data = r.json()
|
||||||
total += data.get("total_count", 0)
|
total += data.get("total_count", 0)
|
||||||
elif r.status_code != 403: # 403 is expected without admin scope
|
elif r.status_code != 403: # 403 is expected without admin scope
|
||||||
click.echo(f"Warning: instance-level runners query returned HTTP {r.status_code}", err=True)
|
click.echo(
|
||||||
|
_("Warning: instance-level runners query returned HTTP {status}", status=r.status_code),
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
except (requests.RequestException, ValueError) as e:
|
except (requests.RequestException, ValueError) as e:
|
||||||
click.echo(f"Warning: instance-level runners query failed: {e}", err=True)
|
click.echo(_("Warning: instance-level runners query failed: {error}", error=e), err=True)
|
||||||
|
|
||||||
return total
|
return total
|
||||||
|
|
||||||
@@ -165,8 +169,8 @@ def main(
|
|||||||
with open(gh_output, "a", encoding="utf-8") as f: # noqa: PTH123
|
with open(gh_output, "a", encoding="utf-8") as f: # noqa: PTH123
|
||||||
f.write(f"runner-count={count}\n")
|
f.write(f"runner-count={count}\n")
|
||||||
f.write(f"runner-indices={json.dumps(indices)}\n")
|
f.write(f"runner-indices={json.dumps(indices)}\n")
|
||||||
click.echo(f"Runner count: {count}")
|
click.echo(_("Runner count: {count}", count=count))
|
||||||
click.echo(f"Runner indices: {indices}")
|
click.echo(_("Runner indices: {indices}", indices=indices))
|
||||||
return
|
return
|
||||||
|
|
||||||
if output_count:
|
if output_count:
|
||||||
@@ -178,8 +182,8 @@ def main(
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Default: output both as key=value pairs for CI consumption
|
# Default: output both as key=value pairs for CI consumption
|
||||||
click.echo(f"count={count}")
|
click.echo(_("count={count}", count=count))
|
||||||
click.echo(f"indices={json.dumps(indices)}")
|
click.echo(_("indices={indices}", indices=json.dumps(indices)))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
|||||||
@@ -102,17 +102,25 @@ def main(pattern: str, runner_index: int | None, max_runners: int, github_env: b
|
|||||||
groups = distribute(files, max_runners)
|
groups = distribute(files, max_runners)
|
||||||
for i, group in enumerate(groups):
|
for i, group in enumerate(groups):
|
||||||
labels = " ".join(group) if group else "(none)"
|
labels = " ".join(group) if group else "(none)"
|
||||||
click.echo(f"Runner {i}: {labels}")
|
click.echo(_("Runner {i}: {labels}", i=i, labels=labels))
|
||||||
return
|
return
|
||||||
|
|
||||||
if skip_if_excess and github_env and runner_index > max_runners:
|
if skip_if_excess and github_env and runner_index > max_runners:
|
||||||
click.echo(f"Skipping — runner index {runner_index} > max runners {max_runners}")
|
click.echo(
|
||||||
|
_(
|
||||||
|
"Skipping — runner index {runner_index} > max runners {max_runners}",
|
||||||
|
runner_index=runner_index,
|
||||||
|
max_runners=max_runners,
|
||||||
|
)
|
||||||
|
)
|
||||||
write_github_env("ASSIGNED_FILES", "")
|
write_github_env("ASSIGNED_FILES", "")
|
||||||
write_github_env("SKIP", "true")
|
write_github_env("SKIP", "true")
|
||||||
return
|
return
|
||||||
|
|
||||||
if runner_index < 1:
|
if runner_index < 1:
|
||||||
raise click.ClickException(f"Runner index {runner_index} is out of range (must be >= 1)")
|
raise click.ClickException(
|
||||||
|
_("Runner index {runner_index} is out of range (must be >= 1)", runner_index=runner_index)
|
||||||
|
)
|
||||||
|
|
||||||
zero_based = runner_index - 1
|
zero_based = runner_index - 1
|
||||||
assigned = files_for_runner(files, zero_based, max_runners)
|
assigned = files_for_runner(files, zero_based, max_runners)
|
||||||
@@ -121,7 +129,7 @@ def main(pattern: str, runner_index: int | None, max_runners: int, github_env: b
|
|||||||
if github_env:
|
if github_env:
|
||||||
write_github_env("ASSIGNED_FILES", encoded)
|
write_github_env("ASSIGNED_FILES", encoded)
|
||||||
write_github_env("SKIP", "false")
|
write_github_env("SKIP", "false")
|
||||||
click.echo(f"Assigned {len(assigned)} files to runner {runner_index}")
|
click.echo(_("Assigned {count} files to runner {runner_index}", count=len(assigned), runner_index=runner_index))
|
||||||
return
|
return
|
||||||
|
|
||||||
click.echo(encoded)
|
click.echo(encoded)
|
||||||
|
|||||||
@@ -162,17 +162,25 @@ def main(
|
|||||||
groups = distribute(items, weights, max_runners)
|
groups = distribute(items, weights, max_runners)
|
||||||
for i, group in enumerate(groups):
|
for i, group in enumerate(groups):
|
||||||
labels = " ".join(group) if group else "(none)"
|
labels = " ".join(group) if group else "(none)"
|
||||||
click.echo(f"Runner {i}: {labels}")
|
click.echo(_("Runner {i}: {labels}", i=i, labels=labels))
|
||||||
return
|
return
|
||||||
|
|
||||||
if skip_if_excess and github_env and runner_index > max_runners:
|
if skip_if_excess and github_env and runner_index > max_runners:
|
||||||
click.echo(f"Skipping — runner index {runner_index} > max runners {max_runners}")
|
click.echo(
|
||||||
|
_(
|
||||||
|
"Skipping — runner index {runner_index} > max runners {max_runners}",
|
||||||
|
runner_index=runner_index,
|
||||||
|
max_runners=max_runners,
|
||||||
|
)
|
||||||
|
)
|
||||||
write_github_env("ASSIGNED_ITEMS", "")
|
write_github_env("ASSIGNED_ITEMS", "")
|
||||||
write_github_env("SKIP", "true")
|
write_github_env("SKIP", "true")
|
||||||
return
|
return
|
||||||
|
|
||||||
if runner_index < 1:
|
if runner_index < 1:
|
||||||
raise click.ClickException(f"Runner index {runner_index} is out of range (must be >= 1)")
|
raise click.ClickException(
|
||||||
|
_("Runner index {runner_index} is out of range (must be >= 1)", runner_index=runner_index)
|
||||||
|
)
|
||||||
|
|
||||||
zero_based = runner_index - 1
|
zero_based = runner_index - 1
|
||||||
assigned = items_for_runner(items, weights, zero_based, max_runners)
|
assigned = items_for_runner(items, weights, zero_based, max_runners)
|
||||||
@@ -181,7 +189,14 @@ def main(
|
|||||||
if github_env:
|
if github_env:
|
||||||
write_github_env("ASSIGNED_ITEMS", encoded)
|
write_github_env("ASSIGNED_ITEMS", encoded)
|
||||||
write_github_env("SKIP", "false")
|
write_github_env("SKIP", "false")
|
||||||
click.echo(f"Assigned {len(assigned)} items to runner {runner_index}: {encoded}")
|
click.echo(
|
||||||
|
_(
|
||||||
|
"Assigned {count} items to runner {runner_index}: {encoded}",
|
||||||
|
count=len(assigned),
|
||||||
|
runner_index=runner_index,
|
||||||
|
encoded=encoded,
|
||||||
|
)
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
click.echo(encoded)
|
click.echo(encoded)
|
||||||
|
|||||||
+55
-17
@@ -5,8 +5,12 @@ Parses Click commands from the CLI source code and checks if each command
|
|||||||
has corresponding documentation in the wiki/docs. Reports missing
|
has corresponding documentation in the wiki/docs. Reports missing
|
||||||
documentation as warnings and exits with non-zero if coverage is below 100%.
|
documentation as warnings and exits with non-zero if coverage is below 100%.
|
||||||
|
|
||||||
|
By default, checks the current repository's own source and docs directories.
|
||||||
|
When run from the devx package itself (development mode), it checks devx's
|
||||||
|
own files. When installed as a package, it checks the consuming repo's files.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python3 -m devx.ci.doc_coverage [--docs-dir docs/] [--fail-on-missing]
|
python3 -m devx.ci.doc_coverage [--docs-dir docs/] [--source-dir src/] [--fail-on-missing]
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -19,11 +23,12 @@ import click
|
|||||||
|
|
||||||
from devx.i18n import _
|
from devx.i18n import _
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
# Default to the current working directory (consuming repo's root)
|
||||||
|
REPO_ROOT = Path.cwd()
|
||||||
DOCS_DIR = REPO_ROOT / "docs"
|
DOCS_DIR = REPO_ROOT / "docs"
|
||||||
CLI_FILE = REPO_ROOT / "src" / "devx" / "cli.py"
|
|
||||||
|
|
||||||
# Major modules that should be documented in tech/architecture.md
|
# Major modules that should be documented in tech/architecture.md
|
||||||
|
# These are devx-specific; when checking other repos, use --source-dir
|
||||||
REQUIRED_MODULES = [
|
REQUIRED_MODULES = [
|
||||||
"cli.py",
|
"cli.py",
|
||||||
"i18n.py",
|
"i18n.py",
|
||||||
@@ -51,11 +56,16 @@ REQUIRED_SCRIPTS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def extract_cli_commands() -> list[str]:
|
def extract_cli_commands(source_dir: Path) -> list[str]:
|
||||||
"""Extract command names from the CLI source file."""
|
"""Extract command names from the CLI source file."""
|
||||||
if not CLI_FILE.exists():
|
# Try to find the CLI file in the source directory
|
||||||
|
cli_file = None
|
||||||
|
for candidate in source_dir.rglob("cli.py"):
|
||||||
|
cli_file = candidate
|
||||||
|
break
|
||||||
|
if cli_file is None or not cli_file.exists():
|
||||||
return []
|
return []
|
||||||
content = CLI_FILE.read_text()
|
content = cli_file.read_text()
|
||||||
commands: list[str] = []
|
commands: list[str] = []
|
||||||
# Find all @<group>.command("name") occurrences in the CLI source
|
# Find all @<group>.command("name") occurrences in the CLI source
|
||||||
# Matches @cli.command, @ci.command, @tools.command, @molecule.command
|
# Matches @cli.command, @ci.command, @tools.command, @molecule.command
|
||||||
@@ -94,15 +104,30 @@ def check_module_documented(module: str, docs_content: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
@click.option("--docs-dir", default=str(DOCS_DIR), help="Path to the docs directory.")
|
@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(
|
@click.option(
|
||||||
"--fail-on-missing",
|
"--fail-on-missing",
|
||||||
is_flag=True,
|
is_flag=True,
|
||||||
default=False,
|
default=False,
|
||||||
help="Exit with non-zero status if any documentation is missing.",
|
help="Exit with non-zero status if any documentation is missing.",
|
||||||
)
|
)
|
||||||
def main(docs_dir: str, fail_on_missing: bool) -> None:
|
def main(docs_dir: str | None, source_dir: str | None, fail_on_missing: bool) -> None:
|
||||||
docs_path = Path(docs_dir)
|
root = Path.cwd()
|
||||||
|
docs_path = Path(docs_dir) if docs_dir else root / "docs"
|
||||||
|
|
||||||
|
# Auto-detect source directory
|
||||||
|
if source_dir:
|
||||||
|
src_path = Path(source_dir)
|
||||||
|
else:
|
||||||
|
# Try common source directories
|
||||||
|
for candidate in [root / "src", root / "scripts"]:
|
||||||
|
if candidate.exists():
|
||||||
|
src_path = candidate
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
src_path = root / "src"
|
||||||
|
|
||||||
cli_commands_file = docs_path / "user" / "cli-commands.md"
|
cli_commands_file = docs_path / "user" / "cli-commands.md"
|
||||||
architecture_file = docs_path / "tech" / "architecture.md"
|
architecture_file = docs_path / "tech" / "architecture.md"
|
||||||
ci_cd_file = docs_path / "tech" / "ci-cd-workflow.md"
|
ci_cd_file = docs_path / "tech" / "ci-cd-workflow.md"
|
||||||
@@ -112,21 +137,28 @@ def main(docs_dir: str, fail_on_missing: bool) -> None:
|
|||||||
|
|
||||||
# Check CLI commands
|
# Check CLI commands
|
||||||
click.echo(_("Checking CLI command documentation..."))
|
click.echo(_("Checking CLI command documentation..."))
|
||||||
commands = extract_cli_commands()
|
commands = extract_cli_commands(src_path)
|
||||||
total += len(commands)
|
total += len(commands)
|
||||||
cli_docs = cli_commands_file.read_text() if cli_commands_file.exists() else ""
|
cli_docs = cli_commands_file.read_text() if cli_commands_file.exists() else ""
|
||||||
for cmd in commands:
|
for cmd in commands:
|
||||||
if check_command_documented(cmd, cli_docs):
|
if check_command_documented(cmd, cli_docs):
|
||||||
click.echo(_(" OK: devx {cmd}", cmd=cmd))
|
click.echo(_(" OK: {cmd}", cmd=cmd))
|
||||||
else:
|
else:
|
||||||
click.echo(_(" MISSING: devx {cmd}", cmd=cmd))
|
click.echo(_(" MISSING: {cmd}", cmd=cmd))
|
||||||
missing.append(f"CLI command: devx {cmd}")
|
missing.append(f"CLI command: {cmd}")
|
||||||
|
|
||||||
# Check modules in architecture.md
|
# Check modules in architecture.md
|
||||||
|
# Auto-detect modules from source directory (top-level only, exclude subdirs)
|
||||||
click.echo(_("\nChecking module documentation in architecture.md..."))
|
click.echo(_("\nChecking module documentation in architecture.md..."))
|
||||||
total += len(REQUIRED_MODULES)
|
if src_path.exists():
|
||||||
|
detected_modules = sorted(
|
||||||
|
f.name for f in src_path.glob("*.py") if f.name != "__init__.py" and f.name != "cli.py"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
detected_modules = REQUIRED_MODULES
|
||||||
|
total += len(detected_modules)
|
||||||
arch_docs = architecture_file.read_text() if architecture_file.exists() else ""
|
arch_docs = architecture_file.read_text() if architecture_file.exists() else ""
|
||||||
for module in REQUIRED_MODULES:
|
for module in detected_modules:
|
||||||
if check_module_documented(module, arch_docs):
|
if check_module_documented(module, arch_docs):
|
||||||
click.echo(_(" OK: {module}", module=module))
|
click.echo(_(" OK: {module}", module=module))
|
||||||
else:
|
else:
|
||||||
@@ -134,10 +166,16 @@ def main(docs_dir: str, fail_on_missing: bool) -> None:
|
|||||||
missing.append(f"Module: {module}")
|
missing.append(f"Module: {module}")
|
||||||
|
|
||||||
# Check CI scripts in ci-cd-workflow.md
|
# Check CI scripts in ci-cd-workflow.md
|
||||||
|
# Auto-detect CI scripts from ci/ subdirectory
|
||||||
click.echo(_("\nChecking CI script documentation in ci-cd-workflow.md..."))
|
click.echo(_("\nChecking CI script documentation in ci-cd-workflow.md..."))
|
||||||
total += len(REQUIRED_SCRIPTS)
|
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:
|
||||||
|
detected_scripts = REQUIRED_SCRIPTS
|
||||||
|
total += len(detected_scripts)
|
||||||
ci_docs = ci_cd_file.read_text() if ci_cd_file.exists() else ""
|
ci_docs = ci_cd_file.read_text() if ci_cd_file.exists() else ""
|
||||||
for script in REQUIRED_SCRIPTS:
|
for script in detected_scripts:
|
||||||
if check_module_documented(script, ci_docs):
|
if check_module_documented(script, ci_docs):
|
||||||
click.echo(_(" OK: {script}", script=script))
|
click.echo(_(" OK: {script}", script=script))
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ def cli(pytest_args: tuple[str, ...]) -> None:
|
|||||||
cmd = [sys.executable, "-m", "pytest"]
|
cmd = [sys.executable, "-m", "pytest"]
|
||||||
cmd.extend(pytest_args)
|
cmd.extend(pytest_args)
|
||||||
|
|
||||||
click.echo(f"Running: {' '.join(cmd)}")
|
click.echo(_("Running: {cmd}", cmd=" ".join(cmd)))
|
||||||
|
|
||||||
process = subprocess.Popen( # nosec B603
|
process = subprocess.Popen( # nosec B603
|
||||||
cmd,
|
cmd,
|
||||||
|
|||||||
@@ -0,0 +1,415 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Lint documentation files for structure, links, and quality.
|
||||||
|
|
||||||
|
Checks performed (all configurable via pyproject.toml ``[tool.devx.docs]``):
|
||||||
|
- **Required files**: README.md, AGENTS.md, CHANGELOG.md must exist.
|
||||||
|
- **Docs structure**: ``docs/index.md`` and ``docs/mapping.json`` must exist.
|
||||||
|
- **Broken internal links**: relative paths and anchors in markdown files
|
||||||
|
must resolve to actual files and headings.
|
||||||
|
- **Heading hierarchy**: no skipping heading levels (e.g., ``#`` → ``###``).
|
||||||
|
- **TODO/FIXME**: flags leftover TODO/FIXME markers in documentation.
|
||||||
|
- **Stale docs**: files not modified in >180 days (warning only).
|
||||||
|
- **Trailing whitespace**: lines should not end with whitespace.
|
||||||
|
- **Blank line before headings**: headings should have a blank line before them.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
python3 -m devx.ci.lint_docs
|
||||||
|
python3 -m devx.ci.lint_docs --docs-dir docs/ --root .
|
||||||
|
python3 -m devx.ci.lint_docs --fix # auto-fix trailing whitespace
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from devx.i18n import _
|
||||||
|
|
||||||
|
# Heading slug pattern (GitHub-style)
|
||||||
|
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$", re.MULTILINE)
|
||||||
|
# Markdown link pattern: [text](url)
|
||||||
|
_LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)]+)\)")
|
||||||
|
# Trailing whitespace
|
||||||
|
_TRAILING_WS_RE = re.compile(r"[ \t]+$")
|
||||||
|
# Heading without blank line before
|
||||||
|
_HEADING_NO_BLANK_RE = re.compile(r"([^\n])\n(#{1,6}\s)")
|
||||||
|
|
||||||
|
# Files that must exist in every project
|
||||||
|
REQUIRED_FILES = ["README.md", "AGENTS.md", "CHANGELOG.md"]
|
||||||
|
|
||||||
|
# Files that must exist in docs/
|
||||||
|
REQUIRED_DOC_FILES = ["index.md"]
|
||||||
|
|
||||||
|
# Maximum age for docs before they're considered stale (days)
|
||||||
|
STALE_THRESHOLD_DAYS = 180
|
||||||
|
|
||||||
|
# Files excluded from duplicate heading checks (auto-generated or structured
|
||||||
|
# with repeated subsections under different parent sections)
|
||||||
|
DUPLICATE_HEADING_EXCLUDES = {
|
||||||
|
"CHANGELOG.md",
|
||||||
|
"incident-response-sso.md",
|
||||||
|
"role-sync-design.md",
|
||||||
|
}
|
||||||
|
|
||||||
|
# TODO/FIXME pattern — matches "TODO:" or "FIXME:" at start of line/after whitespace
|
||||||
|
# Does NOT match references to the word "TODO" in rules/documentation
|
||||||
|
_TODO_RE = re.compile(r"(?m)^\s*(?:>>>?\s*)?(TODO|FIXME|HACK|XXX)\s*:", re.IGNORECASE)
|
||||||
|
|
||||||
|
# Directories excluded from markdown file scanning
|
||||||
|
_EXCLUDE_DIRS = {
|
||||||
|
".venv",
|
||||||
|
".git",
|
||||||
|
"node_modules",
|
||||||
|
"__pycache__",
|
||||||
|
".pytest_cache",
|
||||||
|
".devin",
|
||||||
|
".terraform",
|
||||||
|
"site-packages",
|
||||||
|
"dist-info",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def slugify(text: str) -> str:
|
||||||
|
"""Convert heading text to a GitHub-style slug."""
|
||||||
|
slug = text.lower().strip()
|
||||||
|
slug = re.sub(r"[^\w\s-]", "", slug)
|
||||||
|
slug = re.sub(r"[\s]+", "-", slug)
|
||||||
|
return slug
|
||||||
|
|
||||||
|
|
||||||
|
def strip_code_blocks(content: str) -> str:
|
||||||
|
"""Remove fenced code blocks from markdown content.
|
||||||
|
|
||||||
|
Replaces ```...``` blocks with empty lines so heading detection
|
||||||
|
doesn't pick up # comments inside code blocks.
|
||||||
|
"""
|
||||||
|
result: list[str] = []
|
||||||
|
in_code_block = False
|
||||||
|
for line in content.splitlines():
|
||||||
|
if line.strip().startswith("```"):
|
||||||
|
in_code_block = not in_code_block
|
||||||
|
result.append("")
|
||||||
|
continue
|
||||||
|
if in_code_block:
|
||||||
|
result.append("")
|
||||||
|
continue
|
||||||
|
result.append(line)
|
||||||
|
return "\n".join(result)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_headings(filepath: Path) -> dict[str, int]:
|
||||||
|
"""Extract all headings from a markdown file.
|
||||||
|
|
||||||
|
Returns a dict mapping slug → heading level.
|
||||||
|
"""
|
||||||
|
content = strip_code_blocks(filepath.read_text(encoding="utf-8"))
|
||||||
|
headings: dict[str, int] = {}
|
||||||
|
for match in _HEADING_RE.finditer(content):
|
||||||
|
level = len(match.group(1))
|
||||||
|
text = match.group(2)
|
||||||
|
slug = slugify(text)
|
||||||
|
headings[slug] = level
|
||||||
|
return headings
|
||||||
|
|
||||||
|
|
||||||
|
def extract_links(filepath: Path) -> list[tuple[int, str, str]]:
|
||||||
|
"""Extract all markdown links from a file.
|
||||||
|
|
||||||
|
Returns a list of (line_number, link_text, url) tuples.
|
||||||
|
Includes anchor-only links (#section) for validation.
|
||||||
|
Skips external links (http/https) and mailto.
|
||||||
|
"""
|
||||||
|
content = filepath.read_text(encoding="utf-8")
|
||||||
|
links: list[tuple[int, str, str]] = []
|
||||||
|
for match in _LINK_RE.finditer(content):
|
||||||
|
url = match.group(2).strip()
|
||||||
|
# Skip external links and mailto
|
||||||
|
if url.startswith(("http://", "https://", "mailto:")):
|
||||||
|
continue
|
||||||
|
line_num = content[: match.start()].count("\n") + 1
|
||||||
|
links.append((line_num, match.group(1), url))
|
||||||
|
return links
|
||||||
|
|
||||||
|
|
||||||
|
def check_required_files(root: Path) -> list[str]:
|
||||||
|
"""Check that required files exist."""
|
||||||
|
issues: list[str] = []
|
||||||
|
for filename in REQUIRED_FILES:
|
||||||
|
if not (root / filename).exists():
|
||||||
|
issues.append(f"Missing required file: {filename}")
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def check_docs_structure(root: Path, docs_dir: Path) -> list[str]:
|
||||||
|
"""Check that docs directory has required structure."""
|
||||||
|
issues: list[str] = []
|
||||||
|
if not docs_dir.exists():
|
||||||
|
issues.append(f"Docs directory not found: {docs_dir}")
|
||||||
|
return issues
|
||||||
|
for filename in REQUIRED_DOC_FILES:
|
||||||
|
if not (docs_dir / filename).exists():
|
||||||
|
issues.append(f"Missing required doc file: docs/{filename}")
|
||||||
|
mapping_file = docs_dir / "mapping.json"
|
||||||
|
if mapping_file.exists():
|
||||||
|
try:
|
||||||
|
mapping = json.loads(mapping_file.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(mapping, dict):
|
||||||
|
issues.append("docs/mapping.json must be a JSON object")
|
||||||
|
elif not mapping:
|
||||||
|
issues.append("docs/mapping.json is empty")
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
issues.append(f"docs/mapping.json is invalid JSON: {e}")
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def check_internal_links(root: Path, docs_dir: Path) -> list[str]:
|
||||||
|
"""Check that all internal links in markdown files resolve."""
|
||||||
|
issues: list[str] = []
|
||||||
|
md_files = list(root.rglob("*.md"))
|
||||||
|
# Exclude .venv, .git, node_modules
|
||||||
|
md_files = [f for f in md_files if not any(part in _EXCLUDE_DIRS for part in f.parts)]
|
||||||
|
|
||||||
|
# Load wiki page names from mapping.json — these are valid link targets
|
||||||
|
wiki_pages: set[str] = set()
|
||||||
|
mapping_file = docs_dir / "mapping.json"
|
||||||
|
if mapping_file.exists():
|
||||||
|
try:
|
||||||
|
mapping = json.loads(mapping_file.read_text(encoding="utf-8"))
|
||||||
|
wiki_pages = set(mapping.values())
|
||||||
|
except (json.JSONDecodeError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
for md_file in md_files:
|
||||||
|
rel_path = md_file.relative_to(root)
|
||||||
|
links = extract_links(md_file)
|
||||||
|
headings = extract_headings(md_file)
|
||||||
|
|
||||||
|
for line_num, _link_text, url in links:
|
||||||
|
# Split into path and anchor
|
||||||
|
if "#" in url:
|
||||||
|
path_part, anchor = url.split("#", 1)
|
||||||
|
else:
|
||||||
|
path_part, anchor = url, ""
|
||||||
|
|
||||||
|
# Skip wiki page references (no file extension, no /, matches mapping.json values)
|
||||||
|
if path_part and "." not in path_part and "/" not in path_part:
|
||||||
|
if path_part in wiki_pages:
|
||||||
|
continue
|
||||||
|
# Also skip if it looks like a wiki page name (CamelCase or hyphenated)
|
||||||
|
# without a file extension — can't verify these locally
|
||||||
|
if not any(c in path_part for c in "/\\"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Resolve relative path
|
||||||
|
if path_part:
|
||||||
|
target = (md_file.parent / path_part).resolve()
|
||||||
|
if not target.exists():
|
||||||
|
issues.append(f"{rel_path}:{line_num}: broken link '{url}' — file not found: {path_part}")
|
||||||
|
continue
|
||||||
|
# Check anchor in target file
|
||||||
|
if anchor:
|
||||||
|
target_headings = extract_headings(target)
|
||||||
|
target_slug = slugify(anchor)
|
||||||
|
if target_slug not in target_headings:
|
||||||
|
issues.append(f"{rel_path}:{line_num}: broken anchor '#{anchor}' in {path_part}")
|
||||||
|
elif anchor:
|
||||||
|
# Anchor-only link — check in current file
|
||||||
|
anchor_slug = slugify(anchor)
|
||||||
|
if anchor_slug not in headings:
|
||||||
|
issues.append(f"{rel_path}:{line_num}: broken anchor '#{anchor}'")
|
||||||
|
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def check_heading_hierarchy(root: Path) -> list[str]:
|
||||||
|
"""Check that headings don't skip levels."""
|
||||||
|
issues: list[str] = []
|
||||||
|
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
|
||||||
|
|
||||||
|
for md_file in md_files:
|
||||||
|
rel_path = md_file.relative_to(root)
|
||||||
|
content = strip_code_blocks(md_file.read_text(encoding="utf-8"))
|
||||||
|
prev_level = 0
|
||||||
|
for match in _HEADING_RE.finditer(content):
|
||||||
|
level = len(match.group(1))
|
||||||
|
if prev_level > 0 and level > prev_level + 1:
|
||||||
|
issues.append(f"{rel_path}: heading hierarchy skip — H{prev_level} → H{level}: '{match.group(2)}'")
|
||||||
|
prev_level = level
|
||||||
|
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def check_todo_fixme(root: Path) -> list[str]:
|
||||||
|
"""Check for TODO/FIXME/HACK/XXX markers in documentation.
|
||||||
|
|
||||||
|
Only flags actual TODO/FIXME markers (e.g., "TODO: fix this"), not
|
||||||
|
references to the word "TODO" in rules or documentation about TODOs.
|
||||||
|
"""
|
||||||
|
issues: list[str] = []
|
||||||
|
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
|
||||||
|
|
||||||
|
for md_file in md_files:
|
||||||
|
rel_path = md_file.relative_to(root)
|
||||||
|
content = md_file.read_text(encoding="utf-8")
|
||||||
|
for match in _TODO_RE.finditer(content):
|
||||||
|
line_num = content[: match.start()].count("\n") + 1
|
||||||
|
line = content.splitlines()[line_num - 1] if line_num <= len(content.splitlines()) else ""
|
||||||
|
issues.append(f"{rel_path}:{line_num}: TODO/FIXME found: {line.strip()}")
|
||||||
|
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def check_trailing_whitespace(root: Path) -> list[str]:
|
||||||
|
"""Check for trailing whitespace in markdown files."""
|
||||||
|
issues: list[str] = []
|
||||||
|
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
|
||||||
|
|
||||||
|
for md_file in md_files:
|
||||||
|
rel_path = md_file.relative_to(root)
|
||||||
|
content = md_file.read_text(encoding="utf-8")
|
||||||
|
for i, line in enumerate(content.splitlines(), 1):
|
||||||
|
if _TRAILING_WS_RE.search(line):
|
||||||
|
issues.append(f"{rel_path}:{i}: trailing whitespace")
|
||||||
|
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def check_stale_docs(root: Path) -> list[str]:
|
||||||
|
"""Check for stale documentation (not modified in >180 days)."""
|
||||||
|
issues: list[str] = []
|
||||||
|
threshold = datetime.now() - timedelta(days=STALE_THRESHOLD_DAYS)
|
||||||
|
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
|
||||||
|
|
||||||
|
for md_file in md_files:
|
||||||
|
rel_path = md_file.relative_to(root)
|
||||||
|
mtime = datetime.fromtimestamp(md_file.stat().st_mtime)
|
||||||
|
if mtime < threshold:
|
||||||
|
days_old = (datetime.now() - mtime).days
|
||||||
|
issues.append(f"{rel_path}: stale doc — not modified in {days_old} days")
|
||||||
|
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def check_duplicate_headings(root: Path) -> list[str]:
|
||||||
|
"""Check for duplicate headings within the same file."""
|
||||||
|
issues: list[str] = []
|
||||||
|
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
|
||||||
|
|
||||||
|
for md_file in md_files:
|
||||||
|
rel_path = md_file.relative_to(root)
|
||||||
|
# Skip auto-generated files like CHANGELOG.md
|
||||||
|
if md_file.name in DUPLICATE_HEADING_EXCLUDES:
|
||||||
|
continue
|
||||||
|
content = strip_code_blocks(md_file.read_text(encoding="utf-8"))
|
||||||
|
seen: dict[str, int] = {}
|
||||||
|
for match in _HEADING_RE.finditer(content):
|
||||||
|
text = match.group(2)
|
||||||
|
slug = slugify(text)
|
||||||
|
if slug in seen:
|
||||||
|
issues.append(f"{rel_path}: duplicate heading '{text}'")
|
||||||
|
seen[slug] = 1
|
||||||
|
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option("--root", default=".", help="Repository root directory.")
|
||||||
|
@click.option("--docs-dir", default=None, help="Docs directory (default: <root>/docs).")
|
||||||
|
@click.option("--check-links/--no-check-links", default=True, help="Check internal links.")
|
||||||
|
@click.option("--check-headings/--no-check-headings", default=True, help="Check heading hierarchy.")
|
||||||
|
@click.option("--check-todo/--no-check-todo", default=True, help="Check for TODO/FIXME.")
|
||||||
|
@click.option("--check-stale/--no-check-stale", default=False, help="Check for stale docs.")
|
||||||
|
@click.option("--check-trailing/--no-check-trailing", default=True, help="Check trailing whitespace.")
|
||||||
|
@click.option("--check-duplicates/--no-check-duplicates", default=True, help="Check duplicate headings.")
|
||||||
|
@click.option("--fix", is_flag=True, default=False, help="Auto-fix trailing whitespace.")
|
||||||
|
def main(
|
||||||
|
root: str,
|
||||||
|
docs_dir: str | None,
|
||||||
|
check_links: bool,
|
||||||
|
check_headings: bool,
|
||||||
|
check_todo: bool,
|
||||||
|
check_stale: bool,
|
||||||
|
check_trailing: bool,
|
||||||
|
check_duplicates: bool,
|
||||||
|
fix: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Lint documentation files for structure, links, and quality."""
|
||||||
|
root_path = Path(root).resolve()
|
||||||
|
docs_path = Path(docs_dir) if docs_dir else root_path / "docs"
|
||||||
|
|
||||||
|
click.echo(_("Linting documentation in {root}...", root=str(root_path)))
|
||||||
|
|
||||||
|
all_issues: list[str] = []
|
||||||
|
|
||||||
|
# Structure checks
|
||||||
|
click.echo(_("Checking required files..."))
|
||||||
|
all_issues.extend(check_required_files(root_path))
|
||||||
|
|
||||||
|
click.echo(_("Checking docs structure..."))
|
||||||
|
all_issues.extend(check_docs_structure(root_path, docs_path))
|
||||||
|
|
||||||
|
# Link checks
|
||||||
|
if check_links:
|
||||||
|
click.echo(_("Checking internal links..."))
|
||||||
|
all_issues.extend(check_internal_links(root_path, docs_path))
|
||||||
|
|
||||||
|
# Heading hierarchy
|
||||||
|
if check_headings:
|
||||||
|
click.echo(_("Checking heading hierarchy..."))
|
||||||
|
all_issues.extend(check_heading_hierarchy(root_path))
|
||||||
|
|
||||||
|
# Duplicate headings
|
||||||
|
if check_duplicates:
|
||||||
|
click.echo(_("Checking duplicate headings..."))
|
||||||
|
all_issues.extend(check_duplicate_headings(root_path))
|
||||||
|
|
||||||
|
# TODO/FIXME
|
||||||
|
if check_todo:
|
||||||
|
click.echo(_("Checking for TODO/FIXME markers..."))
|
||||||
|
all_issues.extend(check_todo_fixme(root_path))
|
||||||
|
|
||||||
|
# Trailing whitespace
|
||||||
|
if check_trailing:
|
||||||
|
click.echo(_("Checking trailing whitespace..."))
|
||||||
|
ws_issues = check_trailing_whitespace(root_path)
|
||||||
|
if fix and ws_issues:
|
||||||
|
fixed = 0
|
||||||
|
md_files = [f for f in root_path.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
|
||||||
|
for md_file in md_files:
|
||||||
|
content = md_file.read_text(encoding="utf-8")
|
||||||
|
fixed_content = _TRAILING_WS_RE.sub("", content)
|
||||||
|
if content != fixed_content:
|
||||||
|
md_file.write_text(fixed_content, encoding="utf-8")
|
||||||
|
fixed += 1
|
||||||
|
click.echo(_(" Auto-fixed trailing whitespace in {n} files", n=fixed))
|
||||||
|
else:
|
||||||
|
all_issues.extend(ws_issues)
|
||||||
|
|
||||||
|
# Stale docs
|
||||||
|
if check_stale:
|
||||||
|
click.echo(_("Checking for stale docs..."))
|
||||||
|
stale = check_stale_docs(root_path)
|
||||||
|
for issue in stale:
|
||||||
|
click.echo(f" WARN: {issue}")
|
||||||
|
# Stale docs are warnings, not errors
|
||||||
|
click.echo(_(" {n} stale docs found (warnings only)", n=len(stale)))
|
||||||
|
|
||||||
|
# Report
|
||||||
|
click.echo(f"\n{'=' * 60}")
|
||||||
|
if all_issues:
|
||||||
|
click.echo(_("FAIL: {n} documentation issues found:", n=len(all_issues)))
|
||||||
|
for issue in all_issues:
|
||||||
|
click.echo(f" - {issue}")
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
click.echo(_("PASS: All documentation checks passed!"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
main()
|
||||||
@@ -387,14 +387,34 @@ def check_documentation(files: list[dict[str, Any]], result: ReviewResult) -> No
|
|||||||
for f in files
|
for f in files
|
||||||
)
|
)
|
||||||
has_ansible_changes = any(f.get("filename", "").startswith("ansible/") for f in files)
|
has_ansible_changes = any(f.get("filename", "").startswith("ansible/") for f in files)
|
||||||
|
has_tofu_changes = any(f.get("filename", "").startswith("tofu/") for f in files)
|
||||||
|
has_workflow_changes = any(f.get("filename", "").startswith(".gitea/") for f in files)
|
||||||
|
|
||||||
|
# Check for TODO/FIXME in changed docs
|
||||||
|
todo_issues: list[str] = []
|
||||||
|
for f in files:
|
||||||
|
filename = f.get("filename", "")
|
||||||
|
if filename.endswith(".md") and filename.startswith(("docs/", "README", "AGENTS")):
|
||||||
|
# Can't check file content from PR API easily, but flag if patch adds TODO
|
||||||
|
patch = f.get("patch", "")
|
||||||
|
if patch and re.search(r"^\+.*\b(TODO|FIXME|HACK|XXX)\b", patch, re.IGNORECASE):
|
||||||
|
todo_issues.append(f"{filename}: new TODO/FIXME added in documentation")
|
||||||
|
|
||||||
if has_src_changes and not has_doc_changes:
|
if has_src_changes and not has_doc_changes:
|
||||||
result.add_summary("- Documentation: WARNING — source files changed but no docs updated")
|
result.add_summary("- Documentation: WARNING — source files changed but no docs updated")
|
||||||
elif has_ansible_changes and not has_doc_changes:
|
elif has_ansible_changes and not has_doc_changes:
|
||||||
result.add_summary("- Documentation: WARNING — Ansible role changed but no docs updated")
|
result.add_summary("- Documentation: WARNING — Ansible role changed but no docs updated")
|
||||||
|
elif has_tofu_changes and not has_doc_changes:
|
||||||
|
result.add_summary("- Documentation: WARNING — OpenTofu changes but no docs updated")
|
||||||
|
elif has_workflow_changes and not has_doc_changes:
|
||||||
|
result.add_summary("- Documentation: INFO — workflow changes (consider updating CI docs if behavior changed)")
|
||||||
else:
|
else:
|
||||||
result.add_summary("- Documentation: OK")
|
result.add_summary("- Documentation: OK")
|
||||||
|
|
||||||
|
if todo_issues:
|
||||||
|
for issue in todo_issues:
|
||||||
|
result.add_summary(f"- Documentation: WARNING — {issue}")
|
||||||
|
|
||||||
|
|
||||||
def check_test_coverage(files: list[dict[str, Any]], result: ReviewResult) -> None:
|
def check_test_coverage(files: list[dict[str, Any]], result: ReviewResult) -> None:
|
||||||
"""Check that tests are updated for source changes."""
|
"""Check that tests are updated for source changes."""
|
||||||
|
|||||||
+22
-11
@@ -28,6 +28,8 @@ from typing import Any
|
|||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
from devx.i18n import _
|
||||||
|
|
||||||
|
|
||||||
def _repo_root() -> Path:
|
def _repo_root() -> Path:
|
||||||
"""Resolve repo root from GITHUB_WORKSPACE or cwd."""
|
"""Resolve repo root from GITHUB_WORKSPACE or cwd."""
|
||||||
@@ -68,7 +70,7 @@ def fetch_latest_master(branch: str = "master") -> None:
|
|||||||
"""
|
"""
|
||||||
_run(["git", "fetch", "origin", branch]) # nosec B607
|
_run(["git", "fetch", "origin", branch]) # nosec B607
|
||||||
_run(["git", "reset", "--hard", f"origin/{branch}"]) # nosec B607
|
_run(["git", "reset", "--hard", f"origin/{branch}"]) # nosec B607
|
||||||
click.echo(f"Synced to latest origin/{branch}")
|
click.echo(_("Synced to latest origin/{branch}", branch=branch))
|
||||||
|
|
||||||
|
|
||||||
def generate_badges(output_dir: str) -> None:
|
def generate_badges(output_dir: str) -> None:
|
||||||
@@ -76,8 +78,8 @@ def generate_badges(output_dir: str) -> None:
|
|||||||
_run([sys.executable, "-m", "devx.tools.generate_badges", "--output-dir", output_dir])
|
_run([sys.executable, "-m", "devx.tools.generate_badges", "--output-dir", output_dir])
|
||||||
badges = list(Path(output_dir).glob("*.svg"))
|
badges = list(Path(output_dir).glob("*.svg"))
|
||||||
if not badges:
|
if not badges:
|
||||||
raise click.ClickException("No badge SVG files generated")
|
raise click.ClickException(_("No badge SVG files generated"))
|
||||||
click.echo(f"Generated {len(badges)} badge files")
|
click.echo(_("Generated {count} badge files", count=len(badges)))
|
||||||
|
|
||||||
|
|
||||||
def push_to_badges_branch(badges_dir: str) -> str:
|
def push_to_badges_branch(badges_dir: str) -> str:
|
||||||
@@ -99,12 +101,12 @@ def push_to_badges_branch(badges_dir: str) -> str:
|
|||||||
_run(["git", "add", "./*.svg"]) # nosec B607
|
_run(["git", "add", "./*.svg"]) # nosec B607
|
||||||
_run(["git", "commit", "--no-verify", "-m", "Update badges [skip ci]"]) # nosec B607
|
_run(["git", "commit", "--no-verify", "-m", "Update badges [skip ci]"]) # nosec B607
|
||||||
_run(["git", "push", "origin", "badges", "--force"]) # nosec B607
|
_run(["git", "push", "origin", "badges", "--force"]) # nosec B607
|
||||||
click.echo("Badges pushed to badges branch")
|
click.echo(_("Badges pushed to badges branch"))
|
||||||
|
|
||||||
# Get the commit SHA of the badges branch
|
# Get the commit SHA of the badges branch
|
||||||
result = _run_capture(["git", "rev-parse", "HEAD"]) # nosec B607
|
result = _run_capture(["git", "rev-parse", "HEAD"]) # nosec B607
|
||||||
sha = result.stdout.strip()
|
sha = result.stdout.strip()
|
||||||
click.echo(f"Badges commit SHA: {sha}")
|
click.echo(_("Badges commit SHA: {sha}", sha=sha))
|
||||||
return sha
|
return sha
|
||||||
|
|
||||||
|
|
||||||
@@ -142,11 +144,11 @@ def update_readme_with_badge_sha(badges_sha: str, repo_root: Path | None = None)
|
|||||||
new_content = update_badge_urls(content, badges_sha)
|
new_content = update_badge_urls(content, badges_sha)
|
||||||
if new_content != content:
|
if new_content != content:
|
||||||
filepath.write_text(new_content)
|
filepath.write_text(new_content)
|
||||||
click.echo(f"Updated badge URLs in {filename}")
|
click.echo(_("Updated badge URLs in {filename}", filename=filename))
|
||||||
updated_any = True
|
updated_any = True
|
||||||
|
|
||||||
if not updated_any:
|
if not updated_any:
|
||||||
click.echo("No badge URLs found to update — README already up to date")
|
click.echo(_("No badge URLs found to update — README already up to date"))
|
||||||
return
|
return
|
||||||
|
|
||||||
_run(["git", "add", "README.md", "docs/index.md"]) # nosec B607
|
_run(["git", "add", "README.md", "docs/index.md"]) # nosec B607
|
||||||
@@ -160,7 +162,7 @@ def update_readme_with_badge_sha(badges_sha: str, repo_root: Path | None = None)
|
|||||||
]
|
]
|
||||||
) # nosec B607
|
) # nosec B607
|
||||||
_run(["git", "push", "origin", "master"]) # nosec B607
|
_run(["git", "push", "origin", "master"]) # nosec B607
|
||||||
click.echo(f"Pushed README update with badge SHA {badges_sha[:8]}")
|
click.echo(_("Pushed README update with badge SHA {sha}", sha=badges_sha[:8]))
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
@@ -193,13 +195,22 @@ def main(output_dir: str, branch: str, no_readme_update: bool, retries: int) ->
|
|||||||
except (subprocess.CalledProcessError, RuntimeError) as exc:
|
except (subprocess.CalledProcessError, RuntimeError) as exc:
|
||||||
last_error = exc
|
last_error = exc
|
||||||
if attempt < retries:
|
if attempt < retries:
|
||||||
click.echo(f"Badge push attempt {attempt}/{retries} failed — retrying: {exc}")
|
click.echo(
|
||||||
|
_(
|
||||||
|
"Badge push attempt {attempt}/{retries} failed — retrying: {error}",
|
||||||
|
attempt=attempt,
|
||||||
|
retries=retries,
|
||||||
|
error=exc,
|
||||||
|
)
|
||||||
|
)
|
||||||
time.sleep(10)
|
time.sleep(10)
|
||||||
with contextlib.suppress(subprocess.CalledProcessError):
|
with contextlib.suppress(subprocess.CalledProcessError):
|
||||||
fetch_latest_master(branch)
|
fetch_latest_master(branch)
|
||||||
else:
|
else:
|
||||||
click.echo(f"Badge push failed after {retries} attempts: {exc}")
|
click.echo(_("Badge push failed after {retries} attempts: {error}", retries=retries, error=exc))
|
||||||
raise click.ClickException(f"Badge push failed after {retries} attempts: {last_error}")
|
raise click.ClickException(
|
||||||
|
_("Badge push failed after {retries} attempts: {error}", retries=retries, error=last_error)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
|||||||
+34
-3
@@ -38,6 +38,7 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||||
@@ -709,9 +710,39 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
|
|||||||
click.echo(_("Created release commit."))
|
click.echo(_("Created release commit."))
|
||||||
# Pull --rebase before push to handle the case where master
|
# Pull --rebase before push to handle the case where master
|
||||||
# advanced between checkout and commit (e.g., another merge).
|
# advanced between checkout and commit (e.g., another merge).
|
||||||
run_cmd(["git", "pull", "--rebase", "origin", "master"], check=False)
|
# Retry up to 3 times to handle concurrent pushes.
|
||||||
# Use refs/heads/master to avoid ambiguity with a 'master' tag
|
push_succeeded = False
|
||||||
run_cmd(["git", "push", "origin", "refs/heads/master:refs/heads/master"])
|
for attempt in range(3):
|
||||||
|
rebase = run_cmd(["git", "pull", "--rebase", "origin", "master"], check=False)
|
||||||
|
if rebase.returncode != 0:
|
||||||
|
# Rebase failed (likely conflicts). Abort and retry.
|
||||||
|
click.echo(
|
||||||
|
_(
|
||||||
|
"Rebase attempt {n}/3 failed: {err}",
|
||||||
|
n=attempt + 1,
|
||||||
|
err=rebase.stderr.strip() if rebase.stderr else rebase.stdout.strip(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
run_cmd(["git", "rebase", "--abort"], check=False)
|
||||||
|
# Brief delay before retry to let concurrent pushes settle.
|
||||||
|
time.sleep(5)
|
||||||
|
continue
|
||||||
|
push = run_cmd(["git", "push", "origin", "refs/heads/master:refs/heads/master"], check=False)
|
||||||
|
if push.returncode == 0:
|
||||||
|
push_succeeded = True
|
||||||
|
break
|
||||||
|
click.echo(
|
||||||
|
_(
|
||||||
|
"Push attempt {n}/3 failed: {err}",
|
||||||
|
n=attempt + 1,
|
||||||
|
err=push.stderr.strip() if push.stderr else push.stdout.strip(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
time.sleep(5)
|
||||||
|
if not push_succeeded:
|
||||||
|
raise click.ClickException(
|
||||||
|
_("Failed to push release commit after 3 attempts. Manual intervention required.")
|
||||||
|
)
|
||||||
click.echo(_("Pushed release commit to master."))
|
click.echo(_("Pushed release commit to master."))
|
||||||
else:
|
else:
|
||||||
click.echo(_("Skipping commit push — no staged changes."))
|
click.echo(_("Skipping commit push — no staged changes."))
|
||||||
|
|||||||
@@ -21,11 +21,19 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||||
|
from tenacity import (
|
||||||
|
before_sleep_log,
|
||||||
|
retry,
|
||||||
|
retry_if_exception_type,
|
||||||
|
stop_after_attempt,
|
||||||
|
wait_exponential,
|
||||||
|
)
|
||||||
|
|
||||||
from devx.api_clients import GiteaClient
|
from devx.api_clients import GiteaClient
|
||||||
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
|
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
|
||||||
@@ -86,11 +94,12 @@ def decode_content(content_b64: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def list_wiki_pages(client: GiteaClient) -> dict[str, str]:
|
def list_wiki_pages(client: GiteaClient) -> dict[str, str]:
|
||||||
"""List existing wiki pages, returning {title: sub_url}."""
|
"""List existing wiki pages, returning {title: sub_url}.
|
||||||
try:
|
|
||||||
pages = client._request("GET", "/wiki/pages").json()
|
Raises :class:`APIError` if the wiki API is unavailable — the caller
|
||||||
except APIError:
|
is responsible for retrying or handling the failure.
|
||||||
return {}
|
"""
|
||||||
|
pages = client._request("GET", "/wiki/pages").json()
|
||||||
return {page.get("title", ""): page.get("sub_url", page.get("title", "")) for page in pages}
|
return {page.get("title", ""): page.get("sub_url", page.get("title", "")) for page in pages}
|
||||||
|
|
||||||
|
|
||||||
@@ -161,6 +170,28 @@ def verify_wiki_page(
|
|||||||
return actual.strip() == expected_content.strip()
|
return actual.strip() == expected_content.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _list_wiki_pages_with_retry(client: GiteaClient) -> dict[str, str]:
|
||||||
|
"""List wiki pages with tenacity retry on APIError.
|
||||||
|
|
||||||
|
The Gitea API can be briefly unavailable right after a batch of wiki
|
||||||
|
page updates. Uses the same tenacity pattern as ``api_clients`` for
|
||||||
|
exponential backoff.
|
||||||
|
"""
|
||||||
|
_logger = logging.getLogger("sync_wiki")
|
||||||
|
|
||||||
|
@retry(
|
||||||
|
stop=stop_after_attempt(3),
|
||||||
|
wait=wait_exponential(multiplier=2, min=2, max=8),
|
||||||
|
retry=retry_if_exception_type(APIError),
|
||||||
|
before_sleep=before_sleep_log(_logger, logging.WARNING),
|
||||||
|
reraise=True,
|
||||||
|
)
|
||||||
|
def _do_list() -> dict[str, str]:
|
||||||
|
return list_wiki_pages(client)
|
||||||
|
|
||||||
|
return _do_list()
|
||||||
|
|
||||||
|
|
||||||
def verify_wiki_integrity(
|
def verify_wiki_integrity(
|
||||||
client: GiteaClient,
|
client: GiteaClient,
|
||||||
mapping: dict[str, str],
|
mapping: dict[str, str],
|
||||||
@@ -176,9 +207,25 @@ def verify_wiki_integrity(
|
|||||||
5. Page count matches
|
5. Page count matches
|
||||||
|
|
||||||
Returns a list of failure messages (empty if all checks pass).
|
Returns a list of failure messages (empty if all checks pass).
|
||||||
|
If the wiki API is temporarily unavailable (all retry attempts
|
||||||
|
fail), returns an empty list with a warning — the sync itself
|
||||||
|
already succeeded, so a transient API outage should not fail the job.
|
||||||
"""
|
"""
|
||||||
failures: list[str] = []
|
failures: list[str] = []
|
||||||
existing_pages = list_wiki_pages(client)
|
|
||||||
|
try:
|
||||||
|
existing_pages = _list_wiki_pages_with_retry(client)
|
||||||
|
except APIError:
|
||||||
|
click.echo(
|
||||||
|
_(
|
||||||
|
"WARNING: Could not fetch wiki page list after retries. "
|
||||||
|
"The sync itself succeeded ({count} pages updated), but the "
|
||||||
|
"integrity check could not verify them due to a transient API issue.",
|
||||||
|
count=len(synced),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
expected_titles = set(mapping.values())
|
expected_titles = set(mapping.values())
|
||||||
|
|
||||||
# Check 1: Page count
|
# Check 1: Page count
|
||||||
@@ -243,7 +290,10 @@ def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
|
|||||||
|
|
||||||
click.echo(_("Syncing {count} documentation pages to wiki...", count=len(mapping)))
|
click.echo(_("Syncing {count} documentation pages to wiki...", count=len(mapping)))
|
||||||
|
|
||||||
existing_pages = list_wiki_pages(client)
|
try:
|
||||||
|
existing_pages = list_wiki_pages(client)
|
||||||
|
except APIError:
|
||||||
|
existing_pages = {}
|
||||||
if existing_pages:
|
if existing_pages:
|
||||||
click.echo(_("Found {count} existing wiki pages.", count=len(existing_pages)))
|
click.echo(_("Found {count} existing wiki pages.", count=len(existing_pages)))
|
||||||
|
|
||||||
@@ -302,7 +352,16 @@ def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
|
|||||||
else:
|
else:
|
||||||
click.echo(_("\nVerifying wiki pages have content..."))
|
click.echo(_("\nVerifying wiki pages have content..."))
|
||||||
# Re-fetch the page list to get updated sub_urls
|
# Re-fetch the page list to get updated sub_urls
|
||||||
existing_pages = list_wiki_pages(client)
|
try:
|
||||||
|
existing_pages = _list_wiki_pages_with_retry(client)
|
||||||
|
except APIError:
|
||||||
|
click.echo(
|
||||||
|
_(
|
||||||
|
"WARNING: Could not re-fetch wiki page list for verification. "
|
||||||
|
"Skipping content verification due to transient API issue."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
failures = 0
|
failures = 0
|
||||||
for page_title, expected_content in sorted(synced.items()):
|
for page_title, expected_content in sorted(synced.items()):
|
||||||
ok = verify_wiki_page(client, page_title, expected_content, existing_pages)
|
ok = verify_wiki_page(client, page_title, expected_content, existing_pages)
|
||||||
|
|||||||
@@ -95,6 +95,13 @@ def ci_doc_coverage(args: tuple[str, ...]) -> None:
|
|||||||
_run_module("devx.ci.doc_coverage", list(args))
|
_run_module("devx.ci.doc_coverage", list(args))
|
||||||
|
|
||||||
|
|
||||||
|
@ci.command("lint-docs")
|
||||||
|
@click.argument("args", nargs=-1)
|
||||||
|
def ci_lint_docs(args: tuple[str, ...]) -> None:
|
||||||
|
"""Lint documentation files for structure, links, and quality."""
|
||||||
|
_run_module("devx.ci.lint_docs", list(args))
|
||||||
|
|
||||||
|
|
||||||
@ci.command("notify-failure")
|
@ci.command("notify-failure")
|
||||||
@click.argument("args", nargs=-1)
|
@click.argument("args", nargs=-1)
|
||||||
def ci_notify_failure(args: tuple[str, ...]) -> None:
|
def ci_notify_failure(args: tuple[str, ...]) -> None:
|
||||||
@@ -219,6 +226,20 @@ def tools_setup(args: tuple[str, ...]) -> None:
|
|||||||
_run_module("devx.tools.setup", list(args))
|
_run_module("devx.tools.setup", list(args))
|
||||||
|
|
||||||
|
|
||||||
|
@tools.command("rebase")
|
||||||
|
@click.argument("args", nargs=-1)
|
||||||
|
def tools_rebase(args: tuple[str, ...]) -> None:
|
||||||
|
"""Rebase current branch onto origin/master and force-push."""
|
||||||
|
_run_module("devx.tools.rebase", list(args))
|
||||||
|
|
||||||
|
|
||||||
|
@tools.command("pr-rebase")
|
||||||
|
@click.argument("args", nargs=-1)
|
||||||
|
def tools_pr_rebase(args: tuple[str, ...]) -> None:
|
||||||
|
"""Rebase a PR's head branch onto master via Gitea API (server-side)."""
|
||||||
|
_run_module("devx.tools.pr_rebase", list(args))
|
||||||
|
|
||||||
|
|
||||||
@cli.group()
|
@cli.group()
|
||||||
def molecule() -> None:
|
def molecule() -> None:
|
||||||
"""Molecule testing commands (requires devx[molecule])."""
|
"""Molecule testing commands (requires devx[molecule])."""
|
||||||
|
|||||||
+15
-1
@@ -63,7 +63,7 @@ DEVX_PIP_INSTALL := if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi;
|
|||||||
$(DEVX_BIN)/pip
|
$(DEVX_BIN)/pip
|
||||||
|
|
||||||
.PHONY: devx-create-task devx-create-pr devx-push devx-push-with-pr devx-check-config
|
.PHONY: devx-create-task devx-create-pr devx-push devx-push-with-pr devx-check-config
|
||||||
.PHONY: devx-pr-status devx-pr-logs devx-pr-label devx-pr-review
|
.PHONY: devx-pr-status devx-pr-logs devx-pr-label devx-pr-review devx-rebase devx-pr-rebase
|
||||||
.PHONY: devx-configure-gitea-pypi devx-install-tools devx-install-checkmake devx-checkmake
|
.PHONY: devx-configure-gitea-pypi devx-install-tools devx-install-checkmake devx-checkmake
|
||||||
.PHONY: devx-workflow-lint devx-workflow-dryrun devx-workflow-dryrun-safe devx-workflow-check
|
.PHONY: devx-workflow-lint devx-workflow-dryrun devx-workflow-dryrun-safe devx-workflow-check
|
||||||
.PHONY: devx-notify-failure devx-install-hooks devx-activate-scripts
|
.PHONY: devx-notify-failure devx-install-hooks devx-activate-scripts
|
||||||
@@ -134,6 +134,20 @@ devx-pr-review:
|
|||||||
$(if $(BODY),--body "$(BODY)") \
|
$(if $(BODY),--body "$(BODY)") \
|
||||||
$(if $(CHECKLIST),--checklist-confirmed --checklist-categories $(CHECKLIST))
|
$(if $(CHECKLIST),--checklist-confirmed --checklist-categories $(CHECKLIST))
|
||||||
|
|
||||||
|
# Rebase current branch onto origin/master and force-push
|
||||||
|
# Usage: make devx-rebase
|
||||||
|
# make devx-rebase NO_PUSH=1
|
||||||
|
devx-rebase:
|
||||||
|
@$(DEVX_PYTHON) -m devx.tools.rebase \
|
||||||
|
$(if $(NO_PUSH),--no-push)
|
||||||
|
|
||||||
|
# Rebase a PR's head branch via Gitea API (server-side, no local git needed)
|
||||||
|
# Usage: make devx-pr-rebase
|
||||||
|
# make devx-pr-rebase PR=42
|
||||||
|
devx-pr-rebase:
|
||||||
|
@$(DEVX_PYTHON) -m devx.tools.pr_rebase \
|
||||||
|
$(if $(PR),--pr $(PR))
|
||||||
|
|
||||||
# ── Environment setup ─────────────────────────────────────────────────────────
|
# ── Environment setup ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Configure Gitea private PyPI registry so pip can find devx and other
|
# Configure Gitea private PyPI registry so pip can find devx and other
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import platform
|
import platform
|
||||||
|
import subprocess # nosec B404
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
@@ -22,3 +24,52 @@ def arch_string() -> str:
|
|||||||
if machine in {"aarch64", "arm64"}:
|
if machine in {"aarch64", "arm64"}:
|
||||||
return "arm64"
|
return "arm64"
|
||||||
raise click.ClickException(f"Unsupported architecture: {machine}")
|
raise click.ClickException(f"Unsupported architecture: {machine}")
|
||||||
|
|
||||||
|
|
||||||
|
def detect_pr_number() -> int | None:
|
||||||
|
"""Detect the PR number for the current git branch.
|
||||||
|
|
||||||
|
Returns the PR number if the current branch has an open PR, or None
|
||||||
|
if no PR is found. Does NOT raise — callers decide how to handle None.
|
||||||
|
Best-effort: returns None on any failure (no token, API down, etc.).
|
||||||
|
"""
|
||||||
|
result = subprocess.run( # nosec B603, B607
|
||||||
|
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
branch = result.stdout.strip()
|
||||||
|
if branch == "HEAD":
|
||||||
|
return None
|
||||||
|
|
||||||
|
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
owner = os.environ.get("DEVX_REPO_OWNER", "")
|
||||||
|
repo = os.environ.get("DEVX_REPO_NAME", "")
|
||||||
|
if not owner or not repo:
|
||||||
|
github_repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||||
|
if "/" in github_repo:
|
||||||
|
owner, repo = github_repo.split("/", 1)
|
||||||
|
|
||||||
|
if not owner or not repo:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Lazy import to avoid circular dependency
|
||||||
|
from devx.api_clients import APIError, GiteaClient # noqa: PLC0415
|
||||||
|
from devx.config import GITEA_API_URL # noqa: PLC0415
|
||||||
|
|
||||||
|
client = GiteaClient(GITEA_API_URL, token, owner, repo)
|
||||||
|
try:
|
||||||
|
prs = client.list_prs(state="open")
|
||||||
|
except APIError:
|
||||||
|
# Best-effort: API down or auth failure → no PR detected
|
||||||
|
return None
|
||||||
|
for pr in prs:
|
||||||
|
if pr.get("head", {}).get("ref") == branch:
|
||||||
|
return int(pr["number"])
|
||||||
|
return None
|
||||||
|
|||||||
@@ -200,9 +200,9 @@ def main(
|
|||||||
total_kept = 0
|
total_kept = 0
|
||||||
total_failed = 0
|
total_failed = 0
|
||||||
for name in names:
|
for name in names:
|
||||||
click.echo(f"\n{'=' * 60}")
|
click.echo(_("\n{separator}", separator="=" * 60))
|
||||||
click.echo(f"Package: {owner}/{name}")
|
click.echo(_("Package: {owner}/{name}", owner=owner, name=name))
|
||||||
click.echo(f"{'=' * 60}")
|
click.echo(_("{separator}", separator="=" * 60))
|
||||||
try:
|
try:
|
||||||
versions = list_package_versions(base_url, owner, name, token)
|
versions = list_package_versions(base_url, owner, name, token)
|
||||||
except requests.RequestException as exc:
|
except requests.RequestException as exc:
|
||||||
@@ -217,17 +217,19 @@ def main(
|
|||||||
click.echo(_("No versions found."))
|
click.echo(_("No versions found."))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
click.echo(f"Found {len(versions)} version(s):")
|
click.echo(_("Found {count} version(s):", count=len(versions)))
|
||||||
for v in sort_versions_by_date(versions):
|
for v in sort_versions_by_date(versions):
|
||||||
click.echo(f" {v.get('version', '?')} (created: {v.get('created_at', '?')})")
|
click.echo(
|
||||||
|
_(" {version} (created: {created})", version=v.get("version", "?"), created=v.get("created_at", "?"))
|
||||||
|
)
|
||||||
|
|
||||||
to_delete = select_for_deletion(versions, keep)
|
to_delete = select_for_deletion(versions, keep)
|
||||||
kept_count = len(versions) - len(to_delete)
|
kept_count = len(versions) - len(to_delete)
|
||||||
click.echo(f"\nKeeping {kept_count}, would delete {len(to_delete)}")
|
click.echo(_("\nKeeping {kept}, would delete {count}", kept=kept_count, count=len(to_delete)))
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
for v in to_delete:
|
for v in to_delete:
|
||||||
click.echo(f" [dry-run] Would delete: {v.get('version', '?')}")
|
click.echo(_(" [dry-run] Would delete: {version}", version=v.get("version", "?")))
|
||||||
total_kept += kept_count
|
total_kept += kept_count
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -236,17 +238,24 @@ def main(
|
|||||||
for v in to_delete:
|
for v in to_delete:
|
||||||
version = str(v.get("version", ""))
|
version = str(v.get("version", ""))
|
||||||
if delete_package_version(base_url, owner, name, version, token):
|
if delete_package_version(base_url, owner, name, version, token):
|
||||||
click.echo(f" Deleted: {version}")
|
click.echo(_(" Deleted: {version}", version=version))
|
||||||
deleted_count += 1
|
deleted_count += 1
|
||||||
else:
|
else:
|
||||||
click.echo(f" FAILED to delete: {version}", err=True)
|
click.echo(_(" FAILED to delete: {version}", version=version), err=True)
|
||||||
failed_count += 1
|
failed_count += 1
|
||||||
|
|
||||||
total_deleted += deleted_count
|
total_deleted += deleted_count
|
||||||
total_kept += kept_count
|
total_kept += kept_count
|
||||||
total_failed += failed_count
|
total_failed += failed_count
|
||||||
|
|
||||||
click.echo(f"\nDone. Deleted {total_deleted}, kept {total_kept}, failed {total_failed}.")
|
click.echo(
|
||||||
|
_(
|
||||||
|
"\nDone. Deleted {deleted}, kept {kept}, failed {failed}.",
|
||||||
|
deleted=total_deleted,
|
||||||
|
kept=total_kept,
|
||||||
|
failed=total_failed,
|
||||||
|
)
|
||||||
|
)
|
||||||
if total_failed > 0:
|
if total_failed > 0:
|
||||||
raise click.ClickException(_("Failed to delete {count} image version(s)", count=total_failed))
|
raise click.ClickException(_("Failed to delete {count} image version(s)", count=total_failed))
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
from devx.i18n import _
|
||||||
|
|
||||||
# Coverage regex matches "TOTAL ... NN%" or "TOTAL ... NN.NN%"
|
# Coverage regex matches "TOTAL ... NN%" or "TOTAL ... NN.NN%"
|
||||||
_COVERAGE_RE = re.compile(r"TOTAL.*?(\d+(?:\.\d+)?)%")
|
_COVERAGE_RE = re.compile(r"TOTAL.*?(\d+(?:\.\d+)?)%")
|
||||||
_PASSED_RE = re.compile(r"(\d+) passed")
|
_PASSED_RE = re.compile(r"(\d+) passed")
|
||||||
@@ -197,17 +199,19 @@ def read_version(repo_root: Path) -> str:
|
|||||||
"""
|
"""
|
||||||
pkg = detect_package_name(repo_root)
|
pkg = detect_package_name(repo_root)
|
||||||
if pkg is None:
|
if pkg is None:
|
||||||
click.echo(" WARNING: No Python package found under src/ — version badge will show 'unknown'")
|
click.echo(_(" WARNING: No Python package found under src/ — version badge will show 'unknown'"))
|
||||||
return "unknown"
|
return "unknown"
|
||||||
init_file = repo_root / "src" / pkg / "__init__.py"
|
init_file = repo_root / "src" / pkg / "__init__.py"
|
||||||
if not init_file.exists():
|
if not init_file.exists():
|
||||||
click.echo(f" WARNING: {init_file} not found — version badge will show 'unknown'")
|
click.echo(_(" WARNING: {init_file} not found — version badge will show 'unknown'", init_file=init_file))
|
||||||
return "unknown"
|
return "unknown"
|
||||||
content = init_file.read_text()
|
content = init_file.read_text()
|
||||||
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
|
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
|
||||||
if match:
|
if match:
|
||||||
return match.group(1)
|
return match.group(1)
|
||||||
click.echo(f" WARNING: No __version__ found in {init_file} — version badge will show 'unknown'")
|
click.echo(
|
||||||
|
_(" WARNING: No __version__ found in {init_file} — version badge will show 'unknown'", init_file=init_file)
|
||||||
|
)
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
@@ -278,11 +282,11 @@ def collect_coverage_and_tests(repo_root: Path) -> tuple[dict[str, str | int], d
|
|||||||
"""
|
"""
|
||||||
cov_target = detect_coverage_target(repo_root)
|
cov_target = detect_coverage_target(repo_root)
|
||||||
if cov_target is None:
|
if cov_target is None:
|
||||||
click.echo(" WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)")
|
click.echo(_(" WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)"))
|
||||||
return make_badge("coverage", "unknown", "lightgrey"), make_badge("tests", "unknown", "lightgrey")
|
return make_badge("coverage", "unknown", "lightgrey"), make_badge("tests", "unknown", "lightgrey")
|
||||||
|
|
||||||
testpaths = detect_testpaths(repo_root)
|
testpaths = detect_testpaths(repo_root)
|
||||||
click.echo(f" Test paths: {testpaths or '(pytest defaults)'}")
|
click.echo(_(" Test paths: {testpaths}", testpaths=testpaths or "(pytest defaults)"))
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
sys.executable,
|
sys.executable,
|
||||||
@@ -302,18 +306,18 @@ def collect_coverage_and_tests(repo_root: Path) -> tuple[dict[str, str | int], d
|
|||||||
if coverage is not None:
|
if coverage is not None:
|
||||||
cov_badge = make_badge("coverage", f"{coverage:.0f}%", coverage_color(coverage))
|
cov_badge = make_badge("coverage", f"{coverage:.0f}%", coverage_color(coverage))
|
||||||
else:
|
else:
|
||||||
click.echo(f" WARNING: Could not extract coverage from pytest output (rc={rc})")
|
click.echo(_(" WARNING: Could not extract coverage from pytest output (rc={rc})", rc=rc))
|
||||||
click.echo(f" pytest stdout (last 300 chars): {stdout.strip()[-300:]}")
|
click.echo(_(" pytest stdout (last 300 chars): {stdout}", stdout=stdout.strip()[-300:]))
|
||||||
click.echo(f" pytest stderr (last 300 chars): {stderr.strip()[-300:]}")
|
click.echo(_(" pytest stderr (last 300 chars): {stderr}", stderr=stderr.strip()[-300:]))
|
||||||
cov_badge = make_badge("coverage", "unknown", "red")
|
cov_badge = make_badge("coverage", "unknown", "red")
|
||||||
|
|
||||||
test_count = extract_test_count(combined)
|
test_count = extract_test_count(combined)
|
||||||
if test_count is not None:
|
if test_count is not None:
|
||||||
tests_badge = make_badge("tests", f"{test_count} passing", "brightgreen" if rc == 0 else "red")
|
tests_badge = make_badge("tests", f"{test_count} passing", "brightgreen" if rc == 0 else "red")
|
||||||
else:
|
else:
|
||||||
click.echo(f" WARNING: Could not extract test count from pytest output (rc={rc})")
|
click.echo(_(" WARNING: Could not extract test count from pytest output (rc={rc})", rc=rc))
|
||||||
click.echo(f" pytest stdout (last 300 chars): {stdout.strip()[-300:]}")
|
click.echo(_(" pytest stdout (last 300 chars): {stdout}", stdout=stdout.strip()[-300:]))
|
||||||
click.echo(f" pytest stderr (last 300 chars): {stderr.strip()[-300:]}")
|
click.echo(_(" pytest stderr (last 300 chars): {stderr}", stderr=stderr.strip()[-300:]))
|
||||||
tests_badge = make_badge("tests", "unknown", "red")
|
tests_badge = make_badge("tests", "unknown", "red")
|
||||||
|
|
||||||
return cov_badge, tests_badge
|
return cov_badge, tests_badge
|
||||||
@@ -328,8 +332,8 @@ def collect_doc_coverage(repo_root: Path) -> dict[str, str | int]:
|
|||||||
doc_pct = extract_doc_coverage(stdout)
|
doc_pct = extract_doc_coverage(stdout)
|
||||||
if doc_pct is not None:
|
if doc_pct is not None:
|
||||||
return make_badge("docs", f"{doc_pct}%", doc_coverage_color(doc_pct))
|
return make_badge("docs", f"{doc_pct}%", doc_coverage_color(doc_pct))
|
||||||
click.echo(f" WARNING: Could not extract doc coverage (rc={rc})")
|
click.echo(_(" WARNING: Could not extract doc coverage (rc={rc})", rc=rc))
|
||||||
click.echo(f" stderr: {stderr.strip()[:200]}")
|
click.echo(_(" stderr: {stderr}", stderr=stderr.strip()[:200]))
|
||||||
return make_badge("docs", "unknown", "red")
|
return make_badge("docs", "unknown", "red")
|
||||||
|
|
||||||
|
|
||||||
@@ -348,7 +352,7 @@ def collect_quality(repo_root: Path) -> dict[str, str | int]:
|
|||||||
([sys.executable, "-m", "pyright"], "pyright"),
|
([sys.executable, "-m", "pyright"], "pyright"),
|
||||||
([sys.executable, "-m", "bandit", "-r", "src/"], "bandit"),
|
([sys.executable, "-m", "bandit", "-r", "src/"], "bandit"),
|
||||||
]:
|
]:
|
||||||
rc, _, stderr = run_command(cmd, cwd=repo_root)
|
rc, _stdout, stderr = run_command(cmd, cwd=repo_root)
|
||||||
if rc == 0:
|
if rc == 0:
|
||||||
results.append(True)
|
results.append(True)
|
||||||
tool_names.append(f"{name}: pass")
|
tool_names.append(f"{name}: pass")
|
||||||
@@ -356,16 +360,16 @@ def collect_quality(repo_root: Path) -> dict[str, str | int]:
|
|||||||
results.append(False)
|
results.append(False)
|
||||||
# Distinguish "tool not installed" from "tool found issues"
|
# Distinguish "tool not installed" from "tool found issues"
|
||||||
if "No module named" in stderr or "not found" in stderr.lower():
|
if "No module named" in stderr or "not found" in stderr.lower():
|
||||||
click.echo(f" WARNING: {name} not installed — skipping (counted as pass)")
|
click.echo(_(" WARNING: {name} not installed — skipping (counted as pass)", name=name))
|
||||||
results[-1] = True
|
results[-1] = True
|
||||||
tool_names.append(f"{name}: not installed (skipped)")
|
tool_names.append(f"{name}: not installed (skipped)")
|
||||||
else:
|
else:
|
||||||
tool_names.append(f"{name}: FAIL")
|
tool_names.append(f"{name}: FAIL")
|
||||||
click.echo(f" WARNING: {name} failed (rc={rc})")
|
click.echo(_(" WARNING: {name} failed (rc={rc})", name=name, rc=rc))
|
||||||
click.echo(f" stderr: {stderr.strip()[:200]}")
|
click.echo(_(" stderr: {stderr}", stderr=stderr.strip()[:200]))
|
||||||
|
|
||||||
all_pass = all(results)
|
all_pass = all(results)
|
||||||
click.echo(f" Quality checks: {', '.join(tool_names)}")
|
click.echo(_(" Quality checks: {checks}", checks=", ".join(tool_names)))
|
||||||
return make_badge("code quality", "A" if all_pass else "F", "brightgreen" if all_pass else "red")
|
return make_badge("code quality", "A" if all_pass else "F", "brightgreen" if all_pass else "red")
|
||||||
|
|
||||||
|
|
||||||
@@ -377,28 +381,28 @@ def generate_badges(output_dir: Path, repo_root: Path | None = None) -> dict[str
|
|||||||
repo_root: Repository root (auto-detected if None).
|
repo_root: Repository root (auto-detected if None).
|
||||||
"""
|
"""
|
||||||
root = repo_root or resolve_repo_root()
|
root = repo_root or resolve_repo_root()
|
||||||
click.echo(f" Repo root: {root}")
|
click.echo(_(" Repo root: {root}", root=root))
|
||||||
pkg = detect_package_name(root)
|
pkg = detect_package_name(root)
|
||||||
click.echo(f" Package: {pkg or 'none'}")
|
click.echo(_(" Package: {pkg}", pkg=pkg or "none"))
|
||||||
|
|
||||||
badges: dict[str, dict[str, str | int]] = {}
|
badges: dict[str, dict[str, str | int]] = {}
|
||||||
|
|
||||||
# 1. Code coverage + test count (single pytest-cov run)
|
# 1. Code coverage + test count (single pytest-cov run)
|
||||||
click.echo(" Collecting coverage and tests...")
|
click.echo(_(" Collecting coverage and tests..."))
|
||||||
cov_badge, tests_badge = collect_coverage_and_tests(root)
|
cov_badge, tests_badge = collect_coverage_and_tests(root)
|
||||||
badges["coverage"] = cov_badge
|
badges["coverage"] = cov_badge
|
||||||
badges["tests"] = tests_badge
|
badges["tests"] = tests_badge
|
||||||
|
|
||||||
# 2. Documentation coverage
|
# 2. Documentation coverage
|
||||||
click.echo(" Collecting doc coverage...")
|
click.echo(_(" Collecting doc coverage..."))
|
||||||
badges["docs"] = collect_doc_coverage(root)
|
badges["docs"] = collect_doc_coverage(root)
|
||||||
|
|
||||||
# 3. Code quality (ruff + pyright + bandit)
|
# 3. Code quality (ruff + pyright + bandit)
|
||||||
click.echo(" Collecting code quality...")
|
click.echo(_(" Collecting code quality..."))
|
||||||
badges["quality"] = collect_quality(root)
|
badges["quality"] = collect_quality(root)
|
||||||
|
|
||||||
# 4. Version
|
# 4. Version
|
||||||
click.echo(" Collecting version...")
|
click.echo(_(" Collecting version..."))
|
||||||
version = read_version(root)
|
version = read_version(root)
|
||||||
badges["version"] = make_badge("version", f"v{version}", "blue")
|
badges["version"] = make_badge("version", f"v{version}", "blue")
|
||||||
|
|
||||||
@@ -411,7 +415,7 @@ def generate_badges(output_dir: Path, repo_root: Path | None = None) -> dict[str
|
|||||||
svg = render_svg(str(badge["label"]), str(badge["message"]), str(badge["color"]))
|
svg = render_svg(str(badge["label"]), str(badge["message"]), str(badge["color"]))
|
||||||
path = output_dir / f"{name}.svg"
|
path = output_dir / f"{name}.svg"
|
||||||
path.write_text(svg)
|
path.write_text(svg)
|
||||||
click.echo(f" Generated: {path}")
|
click.echo(_(" Generated: {path}", path=path))
|
||||||
|
|
||||||
return badges
|
return badges
|
||||||
|
|
||||||
@@ -431,11 +435,19 @@ def cli(output_dir: str, repo_root: str | None) -> None:
|
|||||||
"""Generate self-contained SVG badge files from project metrics."""
|
"""Generate self-contained SVG badge files from project metrics."""
|
||||||
out = Path(output_dir)
|
out = Path(output_dir)
|
||||||
root = Path(repo_root) if repo_root else None
|
root = Path(repo_root) if repo_root else None
|
||||||
click.echo(f"Generating badges in {out}...")
|
click.echo(_("Generating badges in {out}...", out=out))
|
||||||
badges = generate_badges(out, repo_root=root)
|
badges = generate_badges(out, repo_root=root)
|
||||||
click.echo(f"\nGenerated {len(badges)} badges:")
|
click.echo(_("\nGenerated {count} badges:", count=len(badges)))
|
||||||
for name, badge in badges.items():
|
for name, badge in badges.items():
|
||||||
click.echo(f" {name}: {badge['label']}={badge['message']} ({badge['color']})")
|
click.echo(
|
||||||
|
_(
|
||||||
|
" {name}: {label}={message} ({color})",
|
||||||
|
name=name,
|
||||||
|
label=badge["label"],
|
||||||
|
message=badge["message"],
|
||||||
|
color=badge["color"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Rebase a pull request's head branch onto master via Gitea API.
|
||||||
|
|
||||||
|
Uses the Gitea ``POST /pulls/{index}/update?style=rebase`` endpoint to
|
||||||
|
rebase the PR's head branch server-side. This triggers a new
|
||||||
|
``pull_request synchronize`` event, which starts a new CI run.
|
||||||
|
|
||||||
|
This is useful when:
|
||||||
|
- You don't have the branch checked out locally
|
||||||
|
- You want to rebase a PR from another machine
|
||||||
|
- You want to trigger the auto-merge retry without local git operations
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
# Rebase PR #42
|
||||||
|
python -m devx.tools.pr_rebase --pr 42
|
||||||
|
|
||||||
|
# Rebase current branch's PR (auto-detected)
|
||||||
|
python -m devx.tools.pr_rebase
|
||||||
|
|
||||||
|
The repository is auto-detected from ``DEVX_REPO_OWNER`` /
|
||||||
|
``DEVX_REPO_NAME`` or ``GITHUB_REPOSITORY`` environment variables.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import click
|
||||||
|
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||||
|
|
||||||
|
from devx.api_clients import APIError, GiteaClient
|
||||||
|
from devx.config import GITEA_API_URL
|
||||||
|
from devx.i18n import _
|
||||||
|
from devx.tools._shared import detect_pr_number
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option("--pr", type=int, help="PR number (auto-detected if omitted).")
|
||||||
|
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."))
|
||||||
|
|
||||||
|
pr_num = pr or detect_pr_number()
|
||||||
|
if not pr_num:
|
||||||
|
raise click.ClickException(
|
||||||
|
_(
|
||||||
|
"Could not detect PR number. Use --pr to specify it explicitly,\n"
|
||||||
|
"or run this command from a branch with an open PR.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
owner = os.environ.get("DEVX_REPO_OWNER", "")
|
||||||
|
repo = os.environ.get("DEVX_REPO_NAME", "")
|
||||||
|
if not owner or not repo:
|
||||||
|
github_repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||||
|
if "/" in github_repo:
|
||||||
|
owner, repo = github_repo.split("/", 1)
|
||||||
|
|
||||||
|
if not owner or not repo:
|
||||||
|
raise click.ClickException(
|
||||||
|
_(
|
||||||
|
"Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\n"
|
||||||
|
"or GITHUB_REPOSITORY environment variables.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
client = GiteaClient(GITEA_API_URL, token, owner, repo)
|
||||||
|
|
||||||
|
click.echo(_("Rebasing PR #{pr} via Gitea API...", pr=pr_num))
|
||||||
|
try:
|
||||||
|
client.update_pr_branch(pr_num, style="rebase")
|
||||||
|
except APIError as e:
|
||||||
|
raise click.ClickException(
|
||||||
|
_(
|
||||||
|
"Rebase failed with HTTP {status}: {message}",
|
||||||
|
status=e.status,
|
||||||
|
message=e.message,
|
||||||
|
)
|
||||||
|
) from None
|
||||||
|
|
||||||
|
click.echo(
|
||||||
|
_(
|
||||||
|
"PR #{pr} rebased successfully. A new CI run will start automatically.\n"
|
||||||
|
"If auto-merge is enabled (ready-to-merge label), the next CI run\n"
|
||||||
|
"will attempt to merge this PR.",
|
||||||
|
pr=pr_num,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
main()
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Rebase current branch onto origin/master and force-push.
|
||||||
|
|
||||||
|
Fetches origin/master, rebases the current branch, and force-pushes with
|
||||||
|
``--force-with-lease``. This is the manual equivalent of what
|
||||||
|
``auto_merge.py`` does automatically via the Gitea API.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
# Rebase current branch onto master and force-push
|
||||||
|
python -m devx.tools.rebase
|
||||||
|
|
||||||
|
# Rebase without pushing (local only)
|
||||||
|
python -m devx.tools.rebase --no-push
|
||||||
|
|
||||||
|
The tool fails if:
|
||||||
|
- The rebase encounters conflicts (exits with rebase in progress)
|
||||||
|
- The force-push is rejected (remote has unexpected commits)
|
||||||
|
- Not on a branch (detached HEAD)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess # nosec B404
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from devx.i18n import _
|
||||||
|
|
||||||
|
|
||||||
|
def _run_git(args: list[str], check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||||
|
"""Run a git command and return the result."""
|
||||||
|
return subprocess.run( # nosec B603, B607
|
||||||
|
["git", *args],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=check,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option("--no-push", is_flag=True, help="Rebase locally without pushing.")
|
||||||
|
def main(no_push: bool) -> None:
|
||||||
|
"""Rebase current branch onto origin/master and force-push."""
|
||||||
|
# Ensure we're on a branch (check=False — we handle errors ourselves)
|
||||||
|
branch_result = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], check=False)
|
||||||
|
if branch_result.returncode != 0:
|
||||||
|
raise click.ClickException(_("Could not detect current branch: {error}", error=branch_result.stderr.strip()))
|
||||||
|
branch = branch_result.stdout.strip()
|
||||||
|
if branch == "HEAD":
|
||||||
|
raise click.ClickException(_("Cannot rebase: not on a branch (detached HEAD)."))
|
||||||
|
|
||||||
|
click.echo(_("Fetching origin/master..."))
|
||||||
|
fetch = _run_git(["fetch", "origin", "master"], check=False)
|
||||||
|
if fetch.returncode != 0:
|
||||||
|
raise click.ClickException(_("Fetch failed: {error}", error=fetch.stderr.strip()))
|
||||||
|
|
||||||
|
# Check if behind master
|
||||||
|
behind = _run_git(
|
||||||
|
["rev-list", "--count", "HEAD..origin/master"],
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
behind_count = int(behind.stdout.strip()) if behind.stdout.strip().isdigit() else 0
|
||||||
|
|
||||||
|
if behind_count == 0:
|
||||||
|
click.echo(_("Branch is already up-to-date with origin/master."))
|
||||||
|
if not no_push:
|
||||||
|
click.echo(_("Nothing to push."))
|
||||||
|
return
|
||||||
|
|
||||||
|
click.echo(_("Branch is {count} commit(s) behind master. Rebasing...", count=behind_count))
|
||||||
|
rebase = _run_git(["rebase", "origin/master"], check=False)
|
||||||
|
if rebase.returncode != 0:
|
||||||
|
raise click.ClickException(
|
||||||
|
_(
|
||||||
|
"Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
|
||||||
|
error=rebase.stderr.strip() or rebase.stdout.strip(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
click.echo(_("Rebase successful."))
|
||||||
|
|
||||||
|
if not no_push:
|
||||||
|
click.echo(_("Force-pushing..."))
|
||||||
|
push = _run_git(["push", "--force-with-lease", "origin", branch], check=False)
|
||||||
|
if push.returncode != 0:
|
||||||
|
raise click.ClickException(
|
||||||
|
_(
|
||||||
|
"Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
|
||||||
|
error=push.stderr.strip(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
click.echo(_("Pushed {branch} to origin.", branch=branch))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
main()
|
||||||
+1121
-321
@@ -55,6 +55,14 @@
|
|||||||
"ru": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
|
"ru": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
|
||||||
"zh": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}"
|
"zh": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}"
|
||||||
},
|
},
|
||||||
|
"\nDone. Deleted {deleted}, kept {kept}, failed {failed}.": {
|
||||||
|
"bg": "\nDone. Deleted {deleted}, kept {kept}, failed {failed}.",
|
||||||
|
"de": "\nDone. Deleted {deleted}, kept {kept}, failed {failed}.",
|
||||||
|
"en": "\nDone. Deleted {deleted}, kept {kept}, failed {failed}.",
|
||||||
|
"pl": "\nDone. Deleted {deleted}, kept {kept}, failed {failed}.",
|
||||||
|
"ru": "\nDone. Deleted {deleted}, kept {kept}, failed {failed}.",
|
||||||
|
"zh": "\nDone. Deleted {deleted}, kept {kept}, failed {failed}."
|
||||||
|
},
|
||||||
"\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.": {
|
"\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.": {
|
||||||
"bg": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
|
"bg": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
|
||||||
"de": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
|
"de": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
|
||||||
@@ -71,6 +79,14 @@
|
|||||||
"ru": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.",
|
"ru": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.",
|
||||||
"zh": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report."
|
"zh": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report."
|
||||||
},
|
},
|
||||||
|
"\nGenerated {count} badges:": {
|
||||||
|
"bg": "\nGenerated {count} badges:",
|
||||||
|
"de": "\nGenerated {count} badges:",
|
||||||
|
"en": "\nGenerated {count} badges:",
|
||||||
|
"pl": "\nGenerated {count} badges:",
|
||||||
|
"ru": "\nGenerated {count} badges:",
|
||||||
|
"zh": "\nGenerated {count} badges:"
|
||||||
|
},
|
||||||
"\nIntegrity check FAILED ({count} issues):": {
|
"\nIntegrity check FAILED ({count} issues):": {
|
||||||
"bg": "\nIntegrity check FAILED ({count} issues):",
|
"bg": "\nIntegrity check FAILED ({count} issues):",
|
||||||
"de": "\nIntegrity check FAILED ({count} issues):",
|
"de": "\nIntegrity check FAILED ({count} issues):",
|
||||||
@@ -87,6 +103,14 @@
|
|||||||
"ru": "\nIntegrity check passed — all {count} pages verified.",
|
"ru": "\nIntegrity check passed — all {count} pages verified.",
|
||||||
"zh": "\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}",
|
||||||
|
"en": "\nKeeping {kept}, would delete {count}",
|
||||||
|
"pl": "\nKeeping {kept}, would delete {count}",
|
||||||
|
"ru": "\nKeeping {kept}, would delete {count}",
|
||||||
|
"zh": "\nKeeping {kept}, would delete {count}"
|
||||||
|
},
|
||||||
"\nLatest tag: {tag}": {
|
"\nLatest tag: {tag}": {
|
||||||
"bg": "\nLatest tag: {tag}",
|
"bg": "\nLatest tag: {tag}",
|
||||||
"de": "\nLatest tag: {tag}",
|
"de": "\nLatest tag: {tag}",
|
||||||
@@ -119,6 +143,14 @@
|
|||||||
"ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
|
"ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
|
||||||
"zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments)."
|
"zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments)."
|
||||||
},
|
},
|
||||||
|
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.": {
|
||||||
|
"bg": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||||
|
"de": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||||
|
"en": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||||
|
"pl": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||||
|
"ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||||
|
"zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'."
|
||||||
|
},
|
||||||
"\nRunning full wiki integrity check...": {
|
"\nRunning full wiki integrity check...": {
|
||||||
"bg": "\nRunning full wiki integrity check...",
|
"bg": "\nRunning full wiki integrity check...",
|
||||||
"de": "\nRunning full wiki integrity check...",
|
"de": "\nRunning full wiki integrity check...",
|
||||||
@@ -183,6 +215,14 @@
|
|||||||
"ru": "\nWorkflow-only changes ({count}):",
|
"ru": "\nWorkflow-only changes ({count}):",
|
||||||
"zh": "\nWorkflow-only changes ({count}):"
|
"zh": "\nWorkflow-only changes ({count}):"
|
||||||
},
|
},
|
||||||
|
"\n[check_test_coverage] Fix: add the missing test file(s) before committing.": {
|
||||||
|
"bg": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||||
|
"de": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||||
|
"en": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||||
|
"pl": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||||
|
"ru": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||||
|
"zh": "\n[check_test_coverage] Fix: add the missing test file(s) before committing."
|
||||||
|
},
|
||||||
"\n[dry-run] Changelog:\n{changelog}": {
|
"\n[dry-run] Changelog:\n{changelog}": {
|
||||||
"bg": "\n[dry-run] Changelog:\n{changelog}",
|
"bg": "\n[dry-run] Changelog:\n{changelog}",
|
||||||
"de": "\n[dry-run] Changelog:\n{changelog}",
|
"de": "\n[dry-run] Changelog:\n{changelog}",
|
||||||
@@ -199,6 +239,14 @@
|
|||||||
"ru": "\n{label} files changed ({count}):",
|
"ru": "\n{label} files changed ({count}):",
|
||||||
"zh": "\n{label} files changed ({count}):"
|
"zh": "\n{label} files changed ({count}):"
|
||||||
},
|
},
|
||||||
|
"\n{separator}": {
|
||||||
|
"bg": "\n{separator}",
|
||||||
|
"de": "\n{separator}",
|
||||||
|
"en": "\n{separator}",
|
||||||
|
"pl": "\n{separator}",
|
||||||
|
"ru": "\n{separator}",
|
||||||
|
"zh": "\n{separator}"
|
||||||
|
},
|
||||||
"\n{tag} files ({count}):": {
|
"\n{tag} files ({count}):": {
|
||||||
"bg": "\n{tag} files ({count}):",
|
"bg": "\n{tag} files ({count}):",
|
||||||
"de": "\n{tag} files ({count}):",
|
"de": "\n{tag} files ({count}):",
|
||||||
@@ -207,6 +255,38 @@
|
|||||||
"ru": "\n{tag} files ({count}):",
|
"ru": "\n{tag} files ({count}):",
|
||||||
"zh": "\n{tag} files ({count}):"
|
"zh": "\n{tag} files ({count}):"
|
||||||
},
|
},
|
||||||
|
" Could not fetch logs: {error}": {
|
||||||
|
"bg": " Could not fetch logs: {error}",
|
||||||
|
"de": " Could not fetch logs: {error}",
|
||||||
|
"en": " Could not fetch logs: {error}",
|
||||||
|
"pl": " Could not fetch logs: {error}",
|
||||||
|
"ru": " Could not fetch logs: {error}",
|
||||||
|
"zh": " Could not fetch logs: {error}"
|
||||||
|
},
|
||||||
|
" pytest stderr (last 300 chars): {stderr}": {
|
||||||
|
"bg": " pytest stderr (last 300 chars): {stderr}",
|
||||||
|
"de": " pytest stderr (last 300 chars): {stderr}",
|
||||||
|
"en": " pytest stderr (last 300 chars): {stderr}",
|
||||||
|
"pl": " pytest stderr (last 300 chars): {stderr}",
|
||||||
|
"ru": " pytest stderr (last 300 chars): {stderr}",
|
||||||
|
"zh": " pytest stderr (last 300 chars): {stderr}"
|
||||||
|
},
|
||||||
|
" pytest stdout (last 300 chars): {stdout}": {
|
||||||
|
"bg": " pytest stdout (last 300 chars): {stdout}",
|
||||||
|
"de": " pytest stdout (last 300 chars): {stdout}",
|
||||||
|
"en": " pytest stdout (last 300 chars): {stdout}",
|
||||||
|
"pl": " pytest stdout (last 300 chars): {stdout}",
|
||||||
|
"ru": " pytest stdout (last 300 chars): {stdout}",
|
||||||
|
"zh": " pytest stdout (last 300 chars): {stdout}"
|
||||||
|
},
|
||||||
|
" stderr: {stderr}": {
|
||||||
|
"bg": " stderr: {stderr}",
|
||||||
|
"de": " stderr: {stderr}",
|
||||||
|
"en": " stderr: {stderr}",
|
||||||
|
"pl": " stderr: {stderr}",
|
||||||
|
"ru": " stderr: {stderr}",
|
||||||
|
"zh": " stderr: {stderr}"
|
||||||
|
},
|
||||||
" - Auto-delete branch after merge: yes": {
|
" - Auto-delete branch after merge: yes": {
|
||||||
"bg": " - Автоматично изтриване на клон след сливане: да",
|
"bg": " - Автоматично изтриване на клон след сливане: да",
|
||||||
"de": " - Branch nach Merge automatisch löschen: ja",
|
"de": " - Branch nach Merge automatisch löschen: ja",
|
||||||
@@ -215,6 +295,14 @@
|
|||||||
"ru": " - Автоудаление ветки после слияния: да",
|
"ru": " - Автоудаление ветки после слияния: да",
|
||||||
"zh": " - 合并后自动删除分支: 是"
|
"zh": " - 合并后自动删除分支: 是"
|
||||||
},
|
},
|
||||||
|
" - Block admin merge override: yes": {
|
||||||
|
"bg": " - Блокиране на admin merge override: да",
|
||||||
|
"de": " - Admin-Merge-Override blockieren: ja",
|
||||||
|
"en": " - Block admin merge override: yes",
|
||||||
|
"pl": " - Blokuj admin merge override: tak",
|
||||||
|
"ru": " - Блокировать admin merge override: да",
|
||||||
|
"zh": " - 阻止管理员合并覆盖:是"
|
||||||
|
},
|
||||||
" - Block outdated branches: yes": {
|
" - Block outdated branches: yes": {
|
||||||
"bg": " - Блокиране на остарели клонове: да",
|
"bg": " - Блокиране на остарели клонове: да",
|
||||||
"de": " - Veraltete Branches blockieren: ja",
|
"de": " - Veraltete Branches blockieren: ja",
|
||||||
@@ -263,6 +351,46 @@
|
|||||||
"ru": " - Требуемые проверки статуса: {checks}",
|
"ru": " - Требуемые проверки статуса: {checks}",
|
||||||
"zh": " - 必需状态检查: {checks}"
|
"zh": " - 必需状态检查: {checks}"
|
||||||
},
|
},
|
||||||
|
" Auto-fixed trailing whitespace in {n} files": {
|
||||||
|
"bg": " Auto-fixed trailing whitespace in {n} files",
|
||||||
|
"de": " Auto-fixed trailing whitespace in {n} files",
|
||||||
|
"en": " Auto-fixed trailing whitespace in {n} files",
|
||||||
|
"pl": " Auto-fixed trailing whitespace in {n} files",
|
||||||
|
"ru": " Auto-fixed trailing whitespace in {n} files",
|
||||||
|
"zh": " Auto-fixed trailing whitespace in {n} files"
|
||||||
|
},
|
||||||
|
" Collecting code quality...": {
|
||||||
|
"bg": " Collecting code quality...",
|
||||||
|
"de": " Collecting code quality...",
|
||||||
|
"en": " Collecting code quality...",
|
||||||
|
"pl": " Collecting code quality...",
|
||||||
|
"ru": " Collecting code quality...",
|
||||||
|
"zh": " Collecting code quality..."
|
||||||
|
},
|
||||||
|
" Collecting coverage and tests...": {
|
||||||
|
"bg": " Collecting coverage and tests...",
|
||||||
|
"de": " Collecting coverage and tests...",
|
||||||
|
"en": " Collecting coverage and tests...",
|
||||||
|
"pl": " Collecting coverage and tests...",
|
||||||
|
"ru": " Collecting coverage and tests...",
|
||||||
|
"zh": " Collecting coverage and tests..."
|
||||||
|
},
|
||||||
|
" Collecting doc coverage...": {
|
||||||
|
"bg": " Collecting doc coverage...",
|
||||||
|
"de": " Collecting doc coverage...",
|
||||||
|
"en": " Collecting doc coverage...",
|
||||||
|
"pl": " Collecting doc coverage...",
|
||||||
|
"ru": " Collecting doc coverage...",
|
||||||
|
"zh": " Collecting doc coverage..."
|
||||||
|
},
|
||||||
|
" Collecting version...": {
|
||||||
|
"bg": " Collecting version...",
|
||||||
|
"de": " Collecting version...",
|
||||||
|
"en": " Collecting version...",
|
||||||
|
"pl": " Collecting version...",
|
||||||
|
"ru": " Collecting version...",
|
||||||
|
"zh": " Collecting version..."
|
||||||
|
},
|
||||||
" Created: {title}": {
|
" Created: {title}": {
|
||||||
"bg": " Created: {title}",
|
"bg": " Created: {title}",
|
||||||
"de": " Created: {title}",
|
"de": " Created: {title}",
|
||||||
@@ -271,6 +399,14 @@
|
|||||||
"ru": " Created: {title}",
|
"ru": " Created: {title}",
|
||||||
"zh": " Created: {title}"
|
"zh": " Created: {title}"
|
||||||
},
|
},
|
||||||
|
" Deleted: {version}": {
|
||||||
|
"bg": " Deleted: {version}",
|
||||||
|
"de": " Deleted: {version}",
|
||||||
|
"en": " Deleted: {version}",
|
||||||
|
"pl": " Deleted: {version}",
|
||||||
|
"ru": " Deleted: {version}",
|
||||||
|
"zh": " Deleted: {version}"
|
||||||
|
},
|
||||||
" FAIL: {title} — content mismatch or empty!": {
|
" FAIL: {title} — content mismatch or empty!": {
|
||||||
"bg": " FAIL: {title} — content mismatch or empty!",
|
"bg": " FAIL: {title} — content mismatch or empty!",
|
||||||
"de": " FAIL: {title} — content mismatch or empty!",
|
"de": " FAIL: {title} — content mismatch or empty!",
|
||||||
@@ -279,13 +415,29 @@
|
|||||||
"ru": " FAIL: {title} — content mismatch or empty!",
|
"ru": " FAIL: {title} — content mismatch or empty!",
|
||||||
"zh": " FAIL: {title} — content mismatch or empty!"
|
"zh": " FAIL: {title} — content mismatch or empty!"
|
||||||
},
|
},
|
||||||
" MISSING: devx {cmd}": {
|
" FAILED to delete: {version}": {
|
||||||
"bg": " ЛИПСВА: devx {cmd}",
|
"bg": " FAILED to delete: {version}",
|
||||||
"de": " FEHLT: devx {cmd}",
|
"de": " FAILED to delete: {version}",
|
||||||
"en": " MISSING: devx {cmd}",
|
"en": " FAILED to delete: {version}",
|
||||||
"pl": " BRAK: devx {cmd}",
|
"pl": " FAILED to delete: {version}",
|
||||||
"ru": " ОТСУТСТВУЕТ: devx {cmd}",
|
"ru": " FAILED to delete: {version}",
|
||||||
"zh": " 缺失: devx {cmd}"
|
"zh": " FAILED to delete: {version}"
|
||||||
|
},
|
||||||
|
" Generated: {path}": {
|
||||||
|
"bg": " Generated: {path}",
|
||||||
|
"de": " Generated: {path}",
|
||||||
|
"en": " Generated: {path}",
|
||||||
|
"pl": " Generated: {path}",
|
||||||
|
"ru": " Generated: {path}",
|
||||||
|
"zh": " Generated: {path}"
|
||||||
|
},
|
||||||
|
" MISSING: {cmd}": {
|
||||||
|
"bg": " MISSING: {cmd}",
|
||||||
|
"de": " MISSING: {cmd}",
|
||||||
|
"en": " MISSING: {cmd}",
|
||||||
|
"pl": " MISSING: {cmd}",
|
||||||
|
"ru": " MISSING: {cmd}",
|
||||||
|
"zh": " MISSING: {cmd}"
|
||||||
},
|
},
|
||||||
" MISSING: {module}": {
|
" MISSING: {module}": {
|
||||||
"bg": " MISSING: {module}",
|
"bg": " MISSING: {module}",
|
||||||
@@ -303,13 +455,13 @@
|
|||||||
"ru": " MISSING: {script}",
|
"ru": " MISSING: {script}",
|
||||||
"zh": " MISSING: {script}"
|
"zh": " MISSING: {script}"
|
||||||
},
|
},
|
||||||
" OK: devx {cmd}": {
|
" OK: {cmd}": {
|
||||||
"bg": " ОК: devx {cmd}",
|
"bg": " OK: {cmd}",
|
||||||
"de": " OK: devx {cmd}",
|
"de": " OK: {cmd}",
|
||||||
"en": " OK: devx {cmd}",
|
"en": " OK: {cmd}",
|
||||||
"pl": " OK: devx {cmd}",
|
"pl": " OK: {cmd}",
|
||||||
"ru": " ОК: devx {cmd}",
|
"ru": " OK: {cmd}",
|
||||||
"zh": " 正常: devx {cmd}"
|
"zh": " OK: {cmd}"
|
||||||
},
|
},
|
||||||
" OK: {module}": {
|
" OK: {module}": {
|
||||||
"bg": " OK: {module}",
|
"bg": " OK: {module}",
|
||||||
@@ -335,6 +487,38 @@
|
|||||||
"ru": " OK: {title} ({chars} chars)",
|
"ru": " OK: {title} ({chars} chars)",
|
||||||
"zh": " OK: {title} ({chars} chars)"
|
"zh": " OK: {title} ({chars} chars)"
|
||||||
},
|
},
|
||||||
|
" Package: {pkg}": {
|
||||||
|
"bg": " Package: {pkg}",
|
||||||
|
"de": " Package: {pkg}",
|
||||||
|
"en": " Package: {pkg}",
|
||||||
|
"pl": " Package: {pkg}",
|
||||||
|
"ru": " Package: {pkg}",
|
||||||
|
"zh": " Package: {pkg}"
|
||||||
|
},
|
||||||
|
" Quality checks: {checks}": {
|
||||||
|
"bg": " Quality checks: {checks}",
|
||||||
|
"de": " Quality checks: {checks}",
|
||||||
|
"en": " Quality checks: {checks}",
|
||||||
|
"pl": " Quality checks: {checks}",
|
||||||
|
"ru": " Quality checks: {checks}",
|
||||||
|
"zh": " Quality checks: {checks}"
|
||||||
|
},
|
||||||
|
" Repo root: {root}": {
|
||||||
|
"bg": " Repo root: {root}",
|
||||||
|
"de": " Repo root: {root}",
|
||||||
|
"en": " Repo root: {root}",
|
||||||
|
"pl": " Repo root: {root}",
|
||||||
|
"ru": " Repo root: {root}",
|
||||||
|
"zh": " Repo root: {root}"
|
||||||
|
},
|
||||||
|
" Test paths: {testpaths}": {
|
||||||
|
"bg": " Test paths: {testpaths}",
|
||||||
|
"de": " Test paths: {testpaths}",
|
||||||
|
"en": " Test paths: {testpaths}",
|
||||||
|
"pl": " Test paths: {testpaths}",
|
||||||
|
"ru": " Test paths: {testpaths}",
|
||||||
|
"zh": " Test paths: {testpaths}"
|
||||||
|
},
|
||||||
" Updated: {title}": {
|
" Updated: {title}": {
|
||||||
"bg": " Updated: {title}",
|
"bg": " Updated: {title}",
|
||||||
"de": " Updated: {title}",
|
"de": " Updated: {title}",
|
||||||
@@ -343,6 +527,126 @@
|
|||||||
"ru": " Updated: {title}",
|
"ru": " Updated: {title}",
|
||||||
"zh": " Updated: {title}"
|
"zh": " Updated: {title}"
|
||||||
},
|
},
|
||||||
|
" WARNING: Could not extract coverage from pytest output (rc={rc})": {
|
||||||
|
"bg": " WARNING: Could not extract coverage from pytest output (rc={rc})",
|
||||||
|
"de": " WARNING: Could not extract coverage from pytest output (rc={rc})",
|
||||||
|
"en": " WARNING: Could not extract coverage from pytest output (rc={rc})",
|
||||||
|
"pl": " WARNING: Could not extract coverage from pytest output (rc={rc})",
|
||||||
|
"ru": " WARNING: Could not extract coverage from pytest output (rc={rc})",
|
||||||
|
"zh": " WARNING: Could not extract coverage from pytest output (rc={rc})"
|
||||||
|
},
|
||||||
|
" WARNING: Could not extract doc coverage (rc={rc})": {
|
||||||
|
"bg": " WARNING: Could not extract doc coverage (rc={rc})",
|
||||||
|
"de": " WARNING: Could not extract doc coverage (rc={rc})",
|
||||||
|
"en": " WARNING: Could not extract doc coverage (rc={rc})",
|
||||||
|
"pl": " WARNING: Could not extract doc coverage (rc={rc})",
|
||||||
|
"ru": " WARNING: Could not extract doc coverage (rc={rc})",
|
||||||
|
"zh": " WARNING: Could not extract doc coverage (rc={rc})"
|
||||||
|
},
|
||||||
|
" WARNING: Could not extract test count from pytest output (rc={rc})": {
|
||||||
|
"bg": " WARNING: Could not extract test count from pytest output (rc={rc})",
|
||||||
|
"de": " WARNING: Could not extract test count from pytest output (rc={rc})",
|
||||||
|
"en": " WARNING: Could not extract test count from pytest output (rc={rc})",
|
||||||
|
"pl": " WARNING: Could not extract test count from pytest output (rc={rc})",
|
||||||
|
"ru": " WARNING: Could not extract test count from pytest output (rc={rc})",
|
||||||
|
"zh": " WARNING: Could not extract test count from pytest output (rc={rc})"
|
||||||
|
},
|
||||||
|
" WARNING: No Python package found under src/ — version badge will show 'unknown'": {
|
||||||
|
"bg": " WARNING: No Python package found under src/ — version badge will show 'unknown'",
|
||||||
|
"de": " WARNING: No Python package found under src/ — version badge will show 'unknown'",
|
||||||
|
"en": " WARNING: No Python package found under src/ — version badge will show 'unknown'",
|
||||||
|
"pl": " WARNING: No Python package found under src/ — version badge will show 'unknown'",
|
||||||
|
"ru": " WARNING: No Python package found under src/ — version badge will show 'unknown'",
|
||||||
|
"zh": " WARNING: No Python package found under src/ — version badge will show 'unknown'"
|
||||||
|
},
|
||||||
|
" WARNING: No __version__ found in {init_file} — version badge will show 'unknown'": {
|
||||||
|
"bg": " WARNING: No __version__ found in {init_file} — version badge will show 'unknown'",
|
||||||
|
"de": " WARNING: No __version__ found in {init_file} — version badge will show 'unknown'",
|
||||||
|
"en": " WARNING: No __version__ found in {init_file} — version badge will show 'unknown'",
|
||||||
|
"pl": " WARNING: No __version__ found in {init_file} — version badge will show 'unknown'",
|
||||||
|
"ru": " WARNING: No __version__ found in {init_file} — version badge will show 'unknown'",
|
||||||
|
"zh": " WARNING: No __version__ found in {init_file} — version badge will show 'unknown'"
|
||||||
|
},
|
||||||
|
" WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)": {
|
||||||
|
"bg": " WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)",
|
||||||
|
"de": " WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)",
|
||||||
|
"en": " WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)",
|
||||||
|
"pl": " WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)",
|
||||||
|
"ru": " WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)",
|
||||||
|
"zh": " WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)"
|
||||||
|
},
|
||||||
|
" WARNING: {init_file} not found — version badge will show 'unknown'": {
|
||||||
|
"bg": " WARNING: {init_file} not found — version badge will show 'unknown'",
|
||||||
|
"de": " WARNING: {init_file} not found — version badge will show 'unknown'",
|
||||||
|
"en": " WARNING: {init_file} not found — version badge will show 'unknown'",
|
||||||
|
"pl": " WARNING: {init_file} not found — version badge will show 'unknown'",
|
||||||
|
"ru": " WARNING: {init_file} not found — version badge will show 'unknown'",
|
||||||
|
"zh": " WARNING: {init_file} not found — version badge will show 'unknown'"
|
||||||
|
},
|
||||||
|
" WARNING: {name} failed (rc={rc})": {
|
||||||
|
"bg": " WARNING: {name} failed (rc={rc})",
|
||||||
|
"de": " WARNING: {name} failed (rc={rc})",
|
||||||
|
"en": " WARNING: {name} failed (rc={rc})",
|
||||||
|
"pl": " WARNING: {name} failed (rc={rc})",
|
||||||
|
"ru": " WARNING: {name} failed (rc={rc})",
|
||||||
|
"zh": " WARNING: {name} failed (rc={rc})"
|
||||||
|
},
|
||||||
|
" WARNING: {name} not installed — skipping (counted as pass)": {
|
||||||
|
"bg": " WARNING: {name} not installed — skipping (counted as pass)",
|
||||||
|
"de": " WARNING: {name} not installed — skipping (counted as pass)",
|
||||||
|
"en": " WARNING: {name} not installed — skipping (counted as pass)",
|
||||||
|
"pl": " WARNING: {name} not installed — skipping (counted as pass)",
|
||||||
|
"ru": " WARNING: {name} not installed — skipping (counted as pass)",
|
||||||
|
"zh": " WARNING: {name} not installed — skipping (counted as pass)"
|
||||||
|
},
|
||||||
|
" [dry-run] Would delete: {version}": {
|
||||||
|
"bg": " [dry-run] Would delete: {version}",
|
||||||
|
"de": " [dry-run] Would delete: {version}",
|
||||||
|
"en": " [dry-run] Would delete: {version}",
|
||||||
|
"pl": " [dry-run] Would delete: {version}",
|
||||||
|
"ru": " [dry-run] Would delete: {version}",
|
||||||
|
"zh": " [dry-run] Would delete: {version}"
|
||||||
|
},
|
||||||
|
" {name}: {label}={message} ({color})": {
|
||||||
|
"bg": " {name}: {label}={message} ({color})",
|
||||||
|
"de": " {name}: {label}={message} ({color})",
|
||||||
|
"en": " {name}: {label}={message} ({color})",
|
||||||
|
"pl": " {name}: {label}={message} ({color})",
|
||||||
|
"ru": " {name}: {label}={message} ({color})",
|
||||||
|
"zh": " {name}: {label}={message} ({color})"
|
||||||
|
},
|
||||||
|
" {n} stale docs found (warnings only)": {
|
||||||
|
"bg": " {n} stale docs found (warnings only)",
|
||||||
|
"de": " {n} stale docs found (warnings only)",
|
||||||
|
"en": " {n} stale docs found (warnings only)",
|
||||||
|
"pl": " {n} stale docs found (warnings only)",
|
||||||
|
"ru": " {n} stale docs found (warnings only)",
|
||||||
|
"zh": " {n} stale docs found (warnings only)"
|
||||||
|
},
|
||||||
|
" {version} (created: {created})": {
|
||||||
|
"bg": " {version} (created: {created})",
|
||||||
|
"de": " {version} (created: {created})",
|
||||||
|
"en": " {version} (created: {created})",
|
||||||
|
"pl": " {version} (created: {created})",
|
||||||
|
"ru": " {version} (created: {created})",
|
||||||
|
"zh": " {version} (created: {created})"
|
||||||
|
},
|
||||||
|
"--checklist-categories must list at least 8 of 13 categories. Got {count}.": {
|
||||||
|
"bg": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
||||||
|
"de": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
||||||
|
"en": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
||||||
|
"pl": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
||||||
|
"ru": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
||||||
|
"zh": "--checklist-categories must list at least 8 of 13 categories. Got {count}."
|
||||||
|
},
|
||||||
|
"--checklist-confirmed is required for APPROVE events.": {
|
||||||
|
"bg": "--checklist-confirmed is required for APPROVE events.",
|
||||||
|
"de": "--checklist-confirmed is required for APPROVE events.",
|
||||||
|
"en": "--checklist-confirmed is required for APPROVE events.",
|
||||||
|
"pl": "--checklist-confirmed is required for APPROVE events.",
|
||||||
|
"ru": "--checklist-confirmed is required for APPROVE events.",
|
||||||
|
"zh": "--checklist-confirmed is required for APPROVE events."
|
||||||
|
},
|
||||||
"--push requires --registry": {
|
"--push requires --registry": {
|
||||||
"bg": "--push requires --registry",
|
"bg": "--push requires --registry",
|
||||||
"de": "--push requires --registry",
|
"de": "--push requires --registry",
|
||||||
@@ -375,6 +679,14 @@
|
|||||||
"ru": "API poll warning: {exc}",
|
"ru": "API poll warning: {exc}",
|
||||||
"zh": "API poll warning: {exc}"
|
"zh": "API poll warning: {exc}"
|
||||||
},
|
},
|
||||||
|
"Added label '{label}' to PR #{pr}.": {
|
||||||
|
"bg": "Added label '{label}' to PR #{pr}.",
|
||||||
|
"de": "Added label '{label}' to PR #{pr}.",
|
||||||
|
"en": "Added label '{label}' to PR #{pr}.",
|
||||||
|
"pl": "Added label '{label}' to PR #{pr}.",
|
||||||
|
"ru": "Added label '{label}' to PR #{pr}.",
|
||||||
|
"zh": "Added label '{label}' to PR #{pr}."
|
||||||
|
},
|
||||||
"Additional directory to scan (default: scripts, tests). Can be repeated.": {
|
"Additional directory to scan (default: scripts, tests). Can be repeated.": {
|
||||||
"bg": "Additional directory to scan (default: scripts, tests). Can be repeated.",
|
"bg": "Additional directory to scan (default: scripts, tests). Can be repeated.",
|
||||||
"de": "Additional directory to scan (default: scripts, tests). Can be repeated.",
|
"de": "Additional directory to scan (default: scripts, tests). Can be repeated.",
|
||||||
@@ -399,6 +711,54 @@
|
|||||||
"ru": "Another molecule runner failed. Stopping this runner early.",
|
"ru": "Another molecule runner failed. Stopping this runner early.",
|
||||||
"zh": "Another molecule runner failed. Stopping this runner early."
|
"zh": "Another molecule runner failed. Stopping this runner early."
|
||||||
},
|
},
|
||||||
|
"Assigned {count} files to runner {runner_index}": {
|
||||||
|
"bg": "Assigned {count} files to runner {runner_index}",
|
||||||
|
"de": "Assigned {count} files to runner {runner_index}",
|
||||||
|
"en": "Assigned {count} files to runner {runner_index}",
|
||||||
|
"pl": "Assigned {count} files to runner {runner_index}",
|
||||||
|
"ru": "Assigned {count} files to runner {runner_index}",
|
||||||
|
"zh": "Assigned {count} files to runner {runner_index}"
|
||||||
|
},
|
||||||
|
"Assigned {count} items to runner {runner_index}: {encoded}": {
|
||||||
|
"bg": "Assigned {count} items to runner {runner_index}: {encoded}",
|
||||||
|
"de": "Assigned {count} items to runner {runner_index}: {encoded}",
|
||||||
|
"en": "Assigned {count} items to runner {runner_index}: {encoded}",
|
||||||
|
"pl": "Assigned {count} items to runner {runner_index}: {encoded}",
|
||||||
|
"ru": "Assigned {count} items to runner {runner_index}: {encoded}",
|
||||||
|
"zh": "Assigned {count} items to runner {runner_index}: {encoded}"
|
||||||
|
},
|
||||||
|
"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}",
|
||||||
|
"en": "Badge push attempt {attempt}/{retries} failed — retrying: {error}",
|
||||||
|
"pl": "Badge push attempt {attempt}/{retries} failed — retrying: {error}",
|
||||||
|
"ru": "Badge push attempt {attempt}/{retries} failed — retrying: {error}",
|
||||||
|
"zh": "Badge push attempt {attempt}/{retries} failed — retrying: {error}"
|
||||||
|
},
|
||||||
|
"Badge push failed after {retries} attempts: {error}": {
|
||||||
|
"bg": "Badge push failed after {retries} attempts: {error}",
|
||||||
|
"de": "Badge push failed after {retries} attempts: {error}",
|
||||||
|
"en": "Badge push failed after {retries} attempts: {error}",
|
||||||
|
"pl": "Badge push failed after {retries} attempts: {error}",
|
||||||
|
"ru": "Badge push failed after {retries} attempts: {error}",
|
||||||
|
"zh": "Badge push failed after {retries} attempts: {error}"
|
||||||
|
},
|
||||||
|
"Badges commit SHA: {sha}": {
|
||||||
|
"bg": "Badges commit SHA: {sha}",
|
||||||
|
"de": "Badges commit SHA: {sha}",
|
||||||
|
"en": "Badges commit SHA: {sha}",
|
||||||
|
"pl": "Badges commit SHA: {sha}",
|
||||||
|
"ru": "Badges commit SHA: {sha}",
|
||||||
|
"zh": "Badges commit SHA: {sha}"
|
||||||
|
},
|
||||||
|
"Badges pushed to badges branch": {
|
||||||
|
"bg": "Badges pushed to badges branch",
|
||||||
|
"de": "Badges pushed to badges branch",
|
||||||
|
"en": "Badges pushed to badges branch",
|
||||||
|
"pl": "Badges pushed to badges branch",
|
||||||
|
"ru": "Badges pushed to badges branch",
|
||||||
|
"zh": "Badges pushed to badges branch"
|
||||||
|
},
|
||||||
"Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description": {
|
"Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description": {
|
||||||
"bg": "Клон '{branch}' не съдържа ID на задача.\n Очакван формат: {prefix}-N-кратко-описание",
|
"bg": "Клон '{branch}' не съдържа ID на задача.\n Очакван формат: {prefix}-N-кратко-описание",
|
||||||
"de": "Branch '{branch}' enthält keine Task-ID.\n Erwartetes Format: {prefix}-N-kurz-beschreibung",
|
"de": "Branch '{branch}' enthält keine Task-ID.\n Erwartetes Format: {prefix}-N-kurz-beschreibung",
|
||||||
@@ -415,14 +775,6 @@
|
|||||||
"ru": "Ветка '{branch}' не содержит ID задачи.\n Ожидаемый формат: {prefix}-N-краткое-описание\n Пример: {prefix}-42-add-feature\n Исправление: переименуйте ветку или создайте задачу Vikunja:\n python -m devx.tools.create_task --title \"Заголовок задачи\"",
|
"ru": "Ветка '{branch}' не содержит ID задачи.\n Ожидаемый формат: {prefix}-N-краткое-описание\n Пример: {prefix}-42-add-feature\n Исправление: переименуйте ветку или создайте задачу Vikunja:\n python -m devx.tools.create_task --title \"Заголовок задачи\"",
|
||||||
"zh": "分支 '{branch}' 不包含任务 ID。\n 预期格式: {prefix}-N-简短描述\n 示例: {prefix}-42-add-feature\n 修复: 重命名分支或先创建 Vikunja 任务:\n python -m devx.tools.create_task --title \"任务标题\""
|
"zh": "分支 '{branch}' 不包含任务 ID。\n 预期格式: {prefix}-N-简短描述\n 示例: {prefix}-42-add-feature\n 修复: 重命名分支或先创建 Vikunja 任务:\n python -m devx.tools.create_task --title \"任务标题\""
|
||||||
},
|
},
|
||||||
"Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.": {
|
|
||||||
"bg": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
|
||||||
"de": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
|
||||||
"en": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
|
||||||
"pl": "Gałąź jest w tyle za master. Wykonaj rebase ręcznie:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nNastępnie dodaj ponownie etykietę ready-to-merge.",
|
|
||||||
"ru": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
|
||||||
"zh": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label."
|
|
||||||
},
|
|
||||||
"Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master": {
|
"Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master": {
|
||||||
"bg": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
|
"bg": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
|
||||||
"de": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
|
"de": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
|
||||||
@@ -463,6 +815,54 @@
|
|||||||
"ru": "Bumping version: {current} -> v{new_version}",
|
"ru": "Bumping version: {current} -> v{new_version}",
|
||||||
"zh": "Bumping version: {current} -> v{new_version}"
|
"zh": "Bumping version: {current} -> v{new_version}"
|
||||||
},
|
},
|
||||||
|
"CI checks did not complete within timeout.": {
|
||||||
|
"bg": "CI checks did not complete within timeout.",
|
||||||
|
"de": "CI checks did not complete within timeout.",
|
||||||
|
"en": "CI checks did not complete within timeout.",
|
||||||
|
"pl": "CI checks did not complete within timeout.",
|
||||||
|
"ru": "CI checks did not complete within timeout.",
|
||||||
|
"zh": "CI checks did not complete within timeout."
|
||||||
|
},
|
||||||
|
"CI checks failed.": {
|
||||||
|
"bg": "CI checks failed.",
|
||||||
|
"de": "CI checks failed.",
|
||||||
|
"en": "CI checks failed.",
|
||||||
|
"pl": "CI checks failed.",
|
||||||
|
"ru": "CI checks failed.",
|
||||||
|
"zh": "CI checks failed."
|
||||||
|
},
|
||||||
|
"CI_GITEA_TOKEN environment variable required": {
|
||||||
|
"bg": "CI_GITEA_TOKEN environment variable required",
|
||||||
|
"de": "CI_GITEA_TOKEN environment variable required",
|
||||||
|
"en": "CI_GITEA_TOKEN environment variable required",
|
||||||
|
"pl": "CI_GITEA_TOKEN environment variable required",
|
||||||
|
"ru": "CI_GITEA_TOKEN environment variable required",
|
||||||
|
"zh": "CI_GITEA_TOKEN environment variable required"
|
||||||
|
},
|
||||||
|
"CI_GITEA_TOKEN is not set.": {
|
||||||
|
"bg": "CI_GITEA_TOKEN is not set.",
|
||||||
|
"de": "CI_GITEA_TOKEN is not set.",
|
||||||
|
"en": "CI_GITEA_TOKEN is not set.",
|
||||||
|
"pl": "CI_GITEA_TOKEN is not set.",
|
||||||
|
"ru": "CI_GITEA_TOKEN is not set.",
|
||||||
|
"zh": "CI_GITEA_TOKEN is not set."
|
||||||
|
},
|
||||||
|
"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.",
|
||||||
|
"en": "CI_GITEA_TOKEN is not set. Required to create a PR.",
|
||||||
|
"pl": "CI_GITEA_TOKEN nie jest ustawiony. Wymagany do utworzenia PR.",
|
||||||
|
"ru": "CI_GITEA_TOKEN не установлен. Требуется для создания PR.",
|
||||||
|
"zh": "CI_GITEA_TOKEN 未设置。创建 PR 所需。"
|
||||||
|
},
|
||||||
|
"CI_GITEA_TOKEN not set — skipping login configuration.": {
|
||||||
|
"bg": "CI_GITEA_TOKEN not set — skipping login configuration.",
|
||||||
|
"de": "CI_GITEA_TOKEN not set — skipping login configuration.",
|
||||||
|
"en": "CI_GITEA_TOKEN not set — skipping login configuration.",
|
||||||
|
"pl": "CI_GITEA_TOKEN not set — skipping login configuration.",
|
||||||
|
"ru": "CI_GITEA_TOKEN not set — skipping login configuration.",
|
||||||
|
"zh": "CI_GITEA_TOKEN not set — skipping login configuration."
|
||||||
|
},
|
||||||
"Checking CLI command documentation...": {
|
"Checking CLI command documentation...": {
|
||||||
"bg": "Checking CLI command documentation...",
|
"bg": "Checking CLI command documentation...",
|
||||||
"de": "Checking CLI command documentation...",
|
"de": "Checking CLI command documentation...",
|
||||||
@@ -471,6 +871,78 @@
|
|||||||
"ru": "Checking CLI command documentation...",
|
"ru": "Checking CLI command documentation...",
|
||||||
"zh": "Checking CLI command documentation..."
|
"zh": "Checking CLI command documentation..."
|
||||||
},
|
},
|
||||||
|
"Checking docs structure...": {
|
||||||
|
"bg": "Checking docs structure...",
|
||||||
|
"de": "Checking docs structure...",
|
||||||
|
"en": "Checking docs structure...",
|
||||||
|
"pl": "Checking docs structure...",
|
||||||
|
"ru": "Checking docs structure...",
|
||||||
|
"zh": "Checking docs structure..."
|
||||||
|
},
|
||||||
|
"Checking duplicate headings...": {
|
||||||
|
"bg": "Checking duplicate headings...",
|
||||||
|
"de": "Checking duplicate headings...",
|
||||||
|
"en": "Checking duplicate headings...",
|
||||||
|
"pl": "Checking duplicate headings...",
|
||||||
|
"ru": "Checking duplicate headings...",
|
||||||
|
"zh": "Checking duplicate headings..."
|
||||||
|
},
|
||||||
|
"Checking for TODO/FIXME markers...": {
|
||||||
|
"bg": "Checking for TODO/FIXME markers...",
|
||||||
|
"de": "Checking for TODO/FIXME markers...",
|
||||||
|
"en": "Checking for TODO/FIXME markers...",
|
||||||
|
"pl": "Checking for TODO/FIXME markers...",
|
||||||
|
"ru": "Checking for TODO/FIXME markers...",
|
||||||
|
"zh": "Checking for TODO/FIXME markers..."
|
||||||
|
},
|
||||||
|
"Checking for stale docs...": {
|
||||||
|
"bg": "Checking for stale docs...",
|
||||||
|
"de": "Checking for stale docs...",
|
||||||
|
"en": "Checking for stale docs...",
|
||||||
|
"pl": "Checking for stale docs...",
|
||||||
|
"ru": "Checking for stale docs...",
|
||||||
|
"zh": "Checking for stale docs..."
|
||||||
|
},
|
||||||
|
"Checking heading hierarchy...": {
|
||||||
|
"bg": "Checking heading hierarchy...",
|
||||||
|
"de": "Checking heading hierarchy...",
|
||||||
|
"en": "Checking heading hierarchy...",
|
||||||
|
"pl": "Checking heading hierarchy...",
|
||||||
|
"ru": "Checking heading hierarchy...",
|
||||||
|
"zh": "Checking heading hierarchy..."
|
||||||
|
},
|
||||||
|
"Checking internal links...": {
|
||||||
|
"bg": "Checking internal links...",
|
||||||
|
"de": "Checking internal links...",
|
||||||
|
"en": "Checking internal links...",
|
||||||
|
"pl": "Checking internal links...",
|
||||||
|
"ru": "Checking internal links...",
|
||||||
|
"zh": "Checking internal links..."
|
||||||
|
},
|
||||||
|
"Checking required files...": {
|
||||||
|
"bg": "Checking required files...",
|
||||||
|
"de": "Checking required files...",
|
||||||
|
"en": "Checking required files...",
|
||||||
|
"pl": "Checking required files...",
|
||||||
|
"ru": "Checking required files...",
|
||||||
|
"zh": "Checking required files..."
|
||||||
|
},
|
||||||
|
"Checking status for PR #{pr_number}...": {
|
||||||
|
"bg": "Checking status for PR #{pr_number}...",
|
||||||
|
"de": "Checking status for PR #{pr_number}...",
|
||||||
|
"en": "Checking status for PR #{pr_number}...",
|
||||||
|
"pl": "Checking status for PR #{pr_number}...",
|
||||||
|
"ru": "Checking status for PR #{pr_number}...",
|
||||||
|
"zh": "Checking status for PR #{pr_number}..."
|
||||||
|
},
|
||||||
|
"Checking trailing whitespace...": {
|
||||||
|
"bg": "Checking trailing whitespace...",
|
||||||
|
"de": "Checking trailing whitespace...",
|
||||||
|
"en": "Checking trailing whitespace...",
|
||||||
|
"pl": "Checking trailing whitespace...",
|
||||||
|
"ru": "Checking trailing whitespace...",
|
||||||
|
"zh": "Checking trailing whitespace..."
|
||||||
|
},
|
||||||
"Command failed ({cmd}): {stderr}": {
|
"Command failed ({cmd}): {stderr}": {
|
||||||
"bg": "Command failed ({cmd}): {stderr}",
|
"bg": "Command failed ({cmd}): {stderr}",
|
||||||
"de": "Command failed ({cmd}): {stderr}",
|
"de": "Command failed ({cmd}): {stderr}",
|
||||||
@@ -479,6 +951,22 @@
|
|||||||
"ru": "Command failed ({cmd}): {stderr}",
|
"ru": "Command failed ({cmd}): {stderr}",
|
||||||
"zh": "Command failed ({cmd}): {stderr}"
|
"zh": "Command failed ({cmd}): {stderr}"
|
||||||
},
|
},
|
||||||
|
"Commit message: {msg}": {
|
||||||
|
"bg": "Commit message: {msg}",
|
||||||
|
"de": "Commit message: {msg}",
|
||||||
|
"en": "Commit message: {msg}",
|
||||||
|
"pl": "Commit message: {msg}",
|
||||||
|
"ru": "Commit message: {msg}",
|
||||||
|
"zh": "Commit message: {msg}"
|
||||||
|
},
|
||||||
|
"Commit: {sha}": {
|
||||||
|
"bg": "Commit: {sha}",
|
||||||
|
"de": "Commit: {sha}",
|
||||||
|
"en": "Commit: {sha}",
|
||||||
|
"pl": "Commit: {sha}",
|
||||||
|
"ru": "Commit: {sha}",
|
||||||
|
"zh": "Commit: {sha}"
|
||||||
|
},
|
||||||
"Comparing {base}..{head} ({count} files changed)": {
|
"Comparing {base}..{head} ({count} files changed)": {
|
||||||
"bg": "Comparing {base}..{head} ({count} files changed)",
|
"bg": "Comparing {base}..{head} ({count} files changed)",
|
||||||
"de": "Comparing {base}..{head} ({count} files changed)",
|
"de": "Comparing {base}..{head} ({count} files changed)",
|
||||||
@@ -495,6 +983,14 @@
|
|||||||
"ru": "Конфигурация OK: [tool.devx] присутствует, версии devx согласованы.",
|
"ru": "Конфигурация OK: [tool.devx] присутствует, версии devx согласованы.",
|
||||||
"zh": "配置正常: [tool.devx] 已存在, devx 版本一致。"
|
"zh": "配置正常: [tool.devx] 已存在, devx 版本一致。"
|
||||||
},
|
},
|
||||||
|
"Configuration validation failed.": {
|
||||||
|
"bg": "Configuration validation failed.",
|
||||||
|
"de": "Configuration validation failed.",
|
||||||
|
"en": "Configuration validation failed.",
|
||||||
|
"pl": "Configuration validation failed.",
|
||||||
|
"ru": "Configuration validation failed.",
|
||||||
|
"zh": "Configuration validation failed."
|
||||||
|
},
|
||||||
"Configuring branch protection for {branch}...": {
|
"Configuring branch protection for {branch}...": {
|
||||||
"bg": "Конфигуриране на защита на клона {branch}...",
|
"bg": "Конфигуриране на защита на клона {branch}...",
|
||||||
"de": "Konfiguriere Branch-Schutz für {branch}...",
|
"de": "Konfiguriere Branch-Schutz für {branch}...",
|
||||||
@@ -511,6 +1007,14 @@
|
|||||||
"ru": "Настройка параметров репозитория...",
|
"ru": "Настройка параметров репозитория...",
|
||||||
"zh": "正在配置仓库设置..."
|
"zh": "正在配置仓库设置..."
|
||||||
},
|
},
|
||||||
|
"Configuring tea login '{name}' for {url}...": {
|
||||||
|
"bg": "Configuring tea login '{name}' for {url}...",
|
||||||
|
"de": "Configuring tea login '{name}' for {url}...",
|
||||||
|
"en": "Configuring tea login '{name}' for {url}...",
|
||||||
|
"pl": "Configuring tea login '{name}' for {url}...",
|
||||||
|
"ru": "Configuring tea login '{name}' for {url}...",
|
||||||
|
"zh": "Configuring tea login '{name}' for {url}..."
|
||||||
|
},
|
||||||
"Could not detect current branch: {error}": {
|
"Could not detect current branch: {error}": {
|
||||||
"bg": "Не може да се определи текущия клон: {error}",
|
"bg": "Не може да се определи текущия клон: {error}",
|
||||||
"de": "Aktueller Branch konnte nicht erkannt werden: {error}",
|
"de": "Aktueller Branch konnte nicht erkannt werden: {error}",
|
||||||
@@ -519,6 +1023,14 @@
|
|||||||
"ru": "Не удалось определить текущую ветку: {error}",
|
"ru": "Не удалось определить текущую ветку: {error}",
|
||||||
"zh": "无法检测当前分支: {error}"
|
"zh": "无法检测当前分支: {error}"
|
||||||
},
|
},
|
||||||
|
"Could not determine head SHA for PR #{pr_number}.": {
|
||||||
|
"bg": "Could not determine head SHA for PR #{pr_number}.",
|
||||||
|
"de": "Could not determine head SHA for PR #{pr_number}.",
|
||||||
|
"en": "Could not determine head SHA for PR #{pr_number}.",
|
||||||
|
"pl": "Could not determine head SHA for PR #{pr_number}.",
|
||||||
|
"ru": "Could not determine head SHA for PR #{pr_number}.",
|
||||||
|
"zh": "Could not determine head SHA for PR #{pr_number}."
|
||||||
|
},
|
||||||
"Could not extract conventional commit message from PR commits.": {
|
"Could not extract conventional commit message from PR commits.": {
|
||||||
"bg": "Could not extract conventional commit message from PR commits.",
|
"bg": "Could not extract conventional commit message from PR commits.",
|
||||||
"de": "Could not extract conventional commit message from PR commits.",
|
"de": "Could not extract conventional commit message from PR commits.",
|
||||||
@@ -639,14 +1151,6 @@
|
|||||||
"ru": "Dockerfile not found: {path}",
|
"ru": "Dockerfile not found: {path}",
|
||||||
"zh": "Dockerfile not found: {path}"
|
"zh": "Dockerfile not found: {path}"
|
||||||
},
|
},
|
||||||
"Each item must be a string or an object with 'id', got {type}": {
|
|
||||||
"bg": "Всеки елемент трябва да е низ или обект с 'id', получено {type}",
|
|
||||||
"de": "Jedes Element muss ein String oder ein Objekt mit 'id' sein, erhalten {type}",
|
|
||||||
"en": "Each item must be a string or an object with 'id', got {type}",
|
|
||||||
"pl": "Każdy element musi być ciągiem lub obiektem z 'id', otrzymano {type}",
|
|
||||||
"ru": "Каждый элемент должен быть строкой или объектом с 'id', получено {type}",
|
|
||||||
"zh": "每个元素必须是字符串或带有 'id' 的对象,得到 {type}"
|
|
||||||
},
|
|
||||||
"Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.": {
|
"Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.": {
|
||||||
"bg": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
|
"bg": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
|
||||||
"de": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
|
"de": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
|
||||||
@@ -695,6 +1199,22 @@
|
|||||||
"ru": "ERROR: mapping.json not found at {path}",
|
"ru": "ERROR: mapping.json not found at {path}",
|
||||||
"zh": "ERROR: mapping.json not found at {path}"
|
"zh": "ERROR: mapping.json not found at {path}"
|
||||||
},
|
},
|
||||||
|
"Each item must be a string or an object with 'id', got {type}": {
|
||||||
|
"bg": "Всеки елемент трябва да е низ или обект с 'id', получено {type}",
|
||||||
|
"de": "Jedes Element muss ein String oder ein Objekt mit 'id' sein, erhalten {type}",
|
||||||
|
"en": "Each item must be a string or an object with 'id', got {type}",
|
||||||
|
"pl": "Każdy element musi być ciągiem lub obiektem z 'id', otrzymano {type}",
|
||||||
|
"ru": "Каждый элемент должен быть строкой или объектом с 'id', получено {type}",
|
||||||
|
"zh": "每个元素必须是字符串或带有 'id' 的对象,得到 {type}"
|
||||||
|
},
|
||||||
|
"FAIL: {n} documentation issues found:": {
|
||||||
|
"bg": "FAIL: {n} documentation issues found:",
|
||||||
|
"de": "FAIL: {n} documentation issues found:",
|
||||||
|
"en": "FAIL: {n} documentation issues found:",
|
||||||
|
"pl": "FAIL: {n} documentation issues found:",
|
||||||
|
"ru": "FAIL: {n} documentation issues found:",
|
||||||
|
"zh": "FAIL: {n} documentation issues found:"
|
||||||
|
},
|
||||||
"FAILED: {count} undocumented dependency/ies": {
|
"FAILED: {count} undocumented dependency/ies": {
|
||||||
"bg": "FAILED: {count} undocumented dependency/ies",
|
"bg": "FAILED: {count} undocumented dependency/ies",
|
||||||
"de": "FAILED: {count} undocumented dependency/ies",
|
"de": "FAILED: {count} undocumented dependency/ies",
|
||||||
@@ -727,6 +1247,14 @@
|
|||||||
"ru": "Failed to create issue via tea: {error}",
|
"ru": "Failed to create issue via tea: {error}",
|
||||||
"zh": "Failed to create issue via tea: {error}"
|
"zh": "Failed to create issue via tea: {error}"
|
||||||
},
|
},
|
||||||
|
"Failed to delete {count} image version(s)": {
|
||||||
|
"bg": "Failed to delete {count} image version(s)",
|
||||||
|
"de": "Failed to delete {count} image version(s)",
|
||||||
|
"en": "Failed to delete {count} image version(s)",
|
||||||
|
"pl": "Failed to delete {count} image version(s)",
|
||||||
|
"ru": "Failed to delete {count} image version(s)",
|
||||||
|
"zh": "Failed to delete {count} image version(s)"
|
||||||
|
},
|
||||||
"Failed to list versions for {name}: {error}": {
|
"Failed to list versions for {name}: {error}": {
|
||||||
"bg": "Failed to list versions for {name}: {error}",
|
"bg": "Failed to list versions for {name}: {error}",
|
||||||
"de": "Failed to list versions for {name}: {error}",
|
"de": "Failed to list versions for {name}: {error}",
|
||||||
@@ -735,6 +1263,14 @@
|
|||||||
"ru": "Failed to list versions for {name}: {error}",
|
"ru": "Failed to list versions for {name}: {error}",
|
||||||
"zh": "Failed to list versions for {name}: {error}"
|
"zh": "Failed to list versions for {name}: {error}"
|
||||||
},
|
},
|
||||||
|
"Fetching logs for PR #{pr_number}...": {
|
||||||
|
"bg": "Fetching logs for PR #{pr_number}...",
|
||||||
|
"de": "Fetching logs for PR #{pr_number}...",
|
||||||
|
"en": "Fetching logs for PR #{pr_number}...",
|
||||||
|
"pl": "Fetching logs for PR #{pr_number}...",
|
||||||
|
"ru": "Fetching logs for PR #{pr_number}...",
|
||||||
|
"zh": "Fetching logs for PR #{pr_number}..."
|
||||||
|
},
|
||||||
"Found {count} existing wiki pages.": {
|
"Found {count} existing wiki pages.": {
|
||||||
"bg": "Found {count} existing wiki pages.",
|
"bg": "Found {count} existing wiki pages.",
|
||||||
"de": "Found {count} existing wiki pages.",
|
"de": "Found {count} existing wiki pages.",
|
||||||
@@ -759,6 +1295,14 @@
|
|||||||
"ru": "Found {count} stale documentation reference(s)",
|
"ru": "Found {count} stale documentation reference(s)",
|
||||||
"zh": "Found {count} stale documentation reference(s)"
|
"zh": "Found {count} stale documentation reference(s)"
|
||||||
},
|
},
|
||||||
|
"Found {count} version(s):": {
|
||||||
|
"bg": "Found {count} version(s):",
|
||||||
|
"de": "Found {count} version(s):",
|
||||||
|
"en": "Found {count} version(s):",
|
||||||
|
"pl": "Found {count} version(s):",
|
||||||
|
"ru": "Found {count} version(s):",
|
||||||
|
"zh": "Found {count} version(s):"
|
||||||
|
},
|
||||||
"GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.": {
|
"GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.": {
|
||||||
"bg": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
|
"bg": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
|
||||||
"de": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
|
"de": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
|
||||||
@@ -767,6 +1311,14 @@
|
|||||||
"ru": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
|
"ru": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
|
||||||
"zh": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation."
|
"zh": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation."
|
||||||
},
|
},
|
||||||
|
"Generated {count} badge files": {
|
||||||
|
"bg": "Generated {count} badge files",
|
||||||
|
"de": "Generated {count} badge files",
|
||||||
|
"en": "Generated {count} badge files",
|
||||||
|
"pl": "Generated {count} badge files",
|
||||||
|
"ru": "Generated {count} badge files",
|
||||||
|
"zh": "Generated {count} badge files"
|
||||||
|
},
|
||||||
"Generated {file} with prefix '{prefix}'.": {
|
"Generated {file} with prefix '{prefix}'.": {
|
||||||
"bg": "Generated {file} with prefix '{prefix}'.",
|
"bg": "Generated {file} with prefix '{prefix}'.",
|
||||||
"de": "Generated {file} with prefix '{prefix}'.",
|
"de": "Generated {file} with prefix '{prefix}'.",
|
||||||
@@ -775,6 +1327,14 @@
|
|||||||
"ru": "Generated {file} with prefix '{prefix}'.",
|
"ru": "Generated {file} with prefix '{prefix}'.",
|
||||||
"zh": "Generated {file} with prefix '{prefix}'."
|
"zh": "Generated {file} with prefix '{prefix}'."
|
||||||
},
|
},
|
||||||
|
"Generating badges in {out}...": {
|
||||||
|
"bg": "Generating badges in {out}...",
|
||||||
|
"de": "Generating badges in {out}...",
|
||||||
|
"en": "Generating badges in {out}...",
|
||||||
|
"pl": "Generating badges in {out}...",
|
||||||
|
"ru": "Generating badges in {out}...",
|
||||||
|
"zh": "Generating badges in {out}..."
|
||||||
|
},
|
||||||
"Gitea PyPI registry: {tag} already published — continuing.": {
|
"Gitea PyPI registry: {tag} already published — continuing.": {
|
||||||
"bg": "Gitea PyPI registry: {tag} вече е публикуван — продължава.",
|
"bg": "Gitea PyPI registry: {tag} вече е публикуван — продължава.",
|
||||||
"de": "Gitea PyPI-Registry: {tag} bereits veröffentlicht — wird fortgesetzt.",
|
"de": "Gitea PyPI-Registry: {tag} bereits veröffentlicht — wird fortgesetzt.",
|
||||||
@@ -903,6 +1463,38 @@
|
|||||||
"ru": "Integration tests passed.",
|
"ru": "Integration tests passed.",
|
||||||
"zh": "Integration tests passed."
|
"zh": "Integration tests passed."
|
||||||
},
|
},
|
||||||
|
"Invalid checklist category: {cat}. Must be numbers.": {
|
||||||
|
"bg": "Invalid checklist category: {cat}. Must be numbers.",
|
||||||
|
"de": "Invalid checklist category: {cat}. Must be numbers.",
|
||||||
|
"en": "Invalid checklist category: {cat}. Must be numbers.",
|
||||||
|
"pl": "Invalid checklist category: {cat}. Must be numbers.",
|
||||||
|
"ru": "Invalid checklist category: {cat}. Must be numbers.",
|
||||||
|
"zh": "Invalid checklist category: {cat}. Must be numbers."
|
||||||
|
},
|
||||||
|
"Items input must be a JSON array, got {type}": {
|
||||||
|
"bg": "Входните данни трябва да са JSON масив, получено {type}",
|
||||||
|
"de": "Eingabe muss ein JSON-Array sein, erhalten {type}",
|
||||||
|
"en": "Items input must be a JSON array, got {type}",
|
||||||
|
"pl": "Dane wejściowe muszą być tablicą JSON, otrzymano {type}",
|
||||||
|
"ru": "Входные данные должны быть JSON-массивом, получено {type}",
|
||||||
|
"zh": "输入必须是 JSON 数组,得到 {type}"
|
||||||
|
},
|
||||||
|
"Label '{label}' already on PR #{pr}.": {
|
||||||
|
"bg": "Label '{label}' already on PR #{pr}.",
|
||||||
|
"de": "Label '{label}' already on PR #{pr}.",
|
||||||
|
"en": "Label '{label}' already on PR #{pr}.",
|
||||||
|
"pl": "Label '{label}' already on PR #{pr}.",
|
||||||
|
"ru": "Label '{label}' already on PR #{pr}.",
|
||||||
|
"zh": "Label '{label}' already on PR #{pr}."
|
||||||
|
},
|
||||||
|
"Latest run: #{run_id} (status: {status})": {
|
||||||
|
"bg": "Latest run: #{run_id} (status: {status})",
|
||||||
|
"de": "Latest run: #{run_id} (status: {status})",
|
||||||
|
"en": "Latest run: #{run_id} (status: {status})",
|
||||||
|
"pl": "Latest run: #{run_id} (status: {status})",
|
||||||
|
"ru": "Latest run: #{run_id} (status: {status})",
|
||||||
|
"zh": "Latest run: #{run_id} (status: {status})"
|
||||||
|
},
|
||||||
"Lint failed — refusing to release. Fix lint errors first.\n{stderr}": {
|
"Lint failed — refusing to release. Fix lint errors first.\n{stderr}": {
|
||||||
"bg": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
|
"bg": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
|
||||||
"de": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
|
"de": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
|
||||||
@@ -919,6 +1511,14 @@
|
|||||||
"ru": "Lint passed.",
|
"ru": "Lint passed.",
|
||||||
"zh": "Lint passed."
|
"zh": "Lint passed."
|
||||||
},
|
},
|
||||||
|
"Linting documentation in {root}...": {
|
||||||
|
"bg": "Linting documentation in {root}...",
|
||||||
|
"de": "Linting documentation in {root}...",
|
||||||
|
"en": "Linting documentation in {root}...",
|
||||||
|
"pl": "Linting documentation in {root}...",
|
||||||
|
"ru": "Linting documentation in {root}...",
|
||||||
|
"zh": "Linting documentation in {root}..."
|
||||||
|
},
|
||||||
"Manifest file not found: {path}": {
|
"Manifest file not found: {path}": {
|
||||||
"bg": "Manifest file not found: {path}",
|
"bg": "Manifest file not found: {path}",
|
||||||
"de": "Manifest file not found: {path}",
|
"de": "Manifest file not found: {path}",
|
||||||
@@ -959,6 +1559,14 @@
|
|||||||
"ru": "Слияние не удалось: HTTP {status}: {message}\nПроверьте, что PR готов и у вас есть права на слияние.",
|
"ru": "Слияние не удалось: HTTP {status}: {message}\nПроверьте, что PR готов и у вас есть права на слияние.",
|
||||||
"zh": "合并失败: HTTP {status}: {message}\n请检查 PR 是否准备就绪且您具有合并权限。"
|
"zh": "合并失败: HTTP {status}: {message}\n请检查 PR 是否准备就绪且您具有合并权限。"
|
||||||
},
|
},
|
||||||
|
"Missing tests for changed files.": {
|
||||||
|
"bg": "Missing tests for changed files.",
|
||||||
|
"de": "Missing tests for changed files.",
|
||||||
|
"en": "Missing tests for changed files.",
|
||||||
|
"pl": "Missing tests for changed files.",
|
||||||
|
"ru": "Missing tests for changed files.",
|
||||||
|
"zh": "Missing tests for changed files."
|
||||||
|
},
|
||||||
"Module {mod} has no main() function": {
|
"Module {mod} has no main() function": {
|
||||||
"bg": "Модул {mod} няма функция main()",
|
"bg": "Модул {mod} няма функция main()",
|
||||||
"de": "Modul {mod} hat keine main()-Funktion",
|
"de": "Modul {mod} hat keine main()-Funktion",
|
||||||
@@ -1015,6 +1623,30 @@
|
|||||||
"ru": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) обновлена и отмечена как выполненная.",
|
"ru": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) обновлена и отмечена как выполненная.",
|
||||||
"zh": "不错!Vikunja 任务 {task_id} (ID {vikunja_id}) 已更新并标记为完成。"
|
"zh": "不错!Vikunja 任务 {task_id} (ID {vikunja_id}) 已更新并标记为完成。"
|
||||||
},
|
},
|
||||||
|
"No CI checks found for commit {sha}.": {
|
||||||
|
"bg": "No CI checks found for commit {sha}.",
|
||||||
|
"de": "No CI checks found for commit {sha}.",
|
||||||
|
"en": "No CI checks found for commit {sha}.",
|
||||||
|
"pl": "No CI checks found for commit {sha}.",
|
||||||
|
"ru": "No CI checks found for commit {sha}.",
|
||||||
|
"zh": "No CI checks found for commit {sha}."
|
||||||
|
},
|
||||||
|
"No badge SVG files generated": {
|
||||||
|
"bg": "No badge SVG files generated",
|
||||||
|
"de": "No badge SVG files generated",
|
||||||
|
"en": "No badge SVG files generated",
|
||||||
|
"pl": "No badge SVG files generated",
|
||||||
|
"ru": "No badge SVG files generated",
|
||||||
|
"zh": "No badge SVG files generated"
|
||||||
|
},
|
||||||
|
"No badge URLs found to update — README already up to date": {
|
||||||
|
"bg": "No badge URLs found to update — README already up to date",
|
||||||
|
"de": "No badge URLs found to update — README already up to date",
|
||||||
|
"en": "No badge URLs found to update — README already up to date",
|
||||||
|
"pl": "No badge URLs found to update — README already up to date",
|
||||||
|
"ru": "No badge URLs found to update — README already up to date",
|
||||||
|
"zh": "No badge URLs found to update — README already up to date"
|
||||||
|
},
|
||||||
"No changes between {base} and {head}.": {
|
"No changes between {base} and {head}.": {
|
||||||
"bg": "No changes between {base} and {head}.",
|
"bg": "No changes between {base} and {head}.",
|
||||||
"de": "No changes between {base} and {head}.",
|
"de": "No changes between {base} and {head}.",
|
||||||
@@ -1023,6 +1655,38 @@
|
|||||||
"ru": "No changes between {base} and {head}.",
|
"ru": "No changes between {base} and {head}.",
|
||||||
"zh": "No changes between {base} and {head}."
|
"zh": "No changes between {base} and {head}."
|
||||||
},
|
},
|
||||||
|
"No failed jobs.": {
|
||||||
|
"bg": "No failed jobs.",
|
||||||
|
"de": "No failed jobs.",
|
||||||
|
"en": "No failed jobs.",
|
||||||
|
"pl": "No failed jobs.",
|
||||||
|
"ru": "No failed jobs.",
|
||||||
|
"zh": "No failed jobs."
|
||||||
|
},
|
||||||
|
"No job matching '{job}' found.": {
|
||||||
|
"bg": "No job matching '{job}' found.",
|
||||||
|
"de": "No job matching '{job}' found.",
|
||||||
|
"en": "No job matching '{job}' found.",
|
||||||
|
"pl": "No job matching '{job}' found.",
|
||||||
|
"ru": "No job matching '{job}' found.",
|
||||||
|
"zh": "No job matching '{job}' found."
|
||||||
|
},
|
||||||
|
"No jobs found for run #{run_id}.": {
|
||||||
|
"bg": "No jobs found for run #{run_id}.",
|
||||||
|
"de": "No jobs found for run #{run_id}.",
|
||||||
|
"en": "No jobs found for run #{run_id}.",
|
||||||
|
"pl": "No jobs found for run #{run_id}.",
|
||||||
|
"ru": "No jobs found for run #{run_id}.",
|
||||||
|
"zh": "No jobs found for run #{run_id}."
|
||||||
|
},
|
||||||
|
"No open PR found for branch '{branch}'.": {
|
||||||
|
"bg": "No open PR found for branch '{branch}'.",
|
||||||
|
"de": "No open PR found for branch '{branch}'.",
|
||||||
|
"en": "No open PR found for branch '{branch}'.",
|
||||||
|
"pl": "No open PR found for branch '{branch}'.",
|
||||||
|
"ru": "No open PR found for branch '{branch}'.",
|
||||||
|
"zh": "No open PR found for branch '{branch}'."
|
||||||
|
},
|
||||||
"No staged changes — version and changelog already up to date.": {
|
"No staged changes — version and changelog already up to date.": {
|
||||||
"bg": "No staged changes — version and changelog already up to date.",
|
"bg": "No staged changes — version and changelog already up to date.",
|
||||||
"de": "No staged changes — version and changelog already up to date.",
|
"de": "No staged changes — version and changelog already up to date.",
|
||||||
@@ -1087,6 +1751,14 @@
|
|||||||
"ru": "No versions found.",
|
"ru": "No versions found.",
|
||||||
"zh": "No versions found."
|
"zh": "No versions found."
|
||||||
},
|
},
|
||||||
|
"No workflow runs found for SHA {sha}.": {
|
||||||
|
"bg": "No workflow runs found for SHA {sha}.",
|
||||||
|
"de": "No workflow runs found for SHA {sha}.",
|
||||||
|
"en": "No workflow runs found for SHA {sha}.",
|
||||||
|
"pl": "No workflow runs found for SHA {sha}.",
|
||||||
|
"ru": "No workflow runs found for SHA {sha}.",
|
||||||
|
"zh": "No workflow runs found for SHA {sha}."
|
||||||
|
},
|
||||||
"Note: Self-approval not allowed. Posting COMMENT instead.": {
|
"Note: Self-approval not allowed. Posting COMMENT instead.": {
|
||||||
"bg": "Note: Self-approval not allowed. Posting COMMENT instead.",
|
"bg": "Note: Self-approval not allowed. Posting COMMENT instead.",
|
||||||
"de": "Note: Self-approval not allowed. Posting COMMENT instead.",
|
"de": "Note: Self-approval not allowed. Posting COMMENT instead.",
|
||||||
@@ -1183,6 +1855,14 @@
|
|||||||
"ru": "Ой! Публикация в PyPI не удалась:\n{stderr}",
|
"ru": "Ой! Публикация в PyPI не удалась:\n{stderr}",
|
||||||
"zh": "哎呀!PyPI 发布失败:\n{stderr}"
|
"zh": "哎呀!PyPI 发布失败:\n{stderr}"
|
||||||
},
|
},
|
||||||
|
"PASS: All documentation checks passed!": {
|
||||||
|
"bg": "PASS: All documentation checks passed!",
|
||||||
|
"de": "PASS: All documentation checks passed!",
|
||||||
|
"en": "PASS: All documentation checks passed!",
|
||||||
|
"pl": "PASS: All documentation checks passed!",
|
||||||
|
"ru": "PASS: All documentation checks passed!",
|
||||||
|
"zh": "PASS: All documentation checks passed!"
|
||||||
|
},
|
||||||
"PASSED: {pair}": {
|
"PASSED: {pair}": {
|
||||||
"bg": "PASSED: {pair}",
|
"bg": "PASSED: {pair}",
|
||||||
"de": "PASSED: {pair}",
|
"de": "PASSED: {pair}",
|
||||||
@@ -1263,6 +1943,22 @@
|
|||||||
"ru": "PYPI_TOKEN не задан и URL registry не настроен — пропускаем публикацию в PyPI. Не беспокойтесь, мы просто создадим Gitea release.",
|
"ru": "PYPI_TOKEN не задан и URL registry не настроен — пропускаем публикацию в PyPI. Не беспокойтесь, мы просто создадим Gitea release.",
|
||||||
"zh": "未设置 PYPI_TOKEN 且未配置 registry URL — 跳过 PyPI 发布。别担心,我们直接创建 Gitea release。"
|
"zh": "未设置 PYPI_TOKEN 且未配置 registry URL — 跳过 PyPI 发布。别担心,我们直接创建 Gitea release。"
|
||||||
},
|
},
|
||||||
|
"Package owner not specified. Use --owner or set [tool.devx] repo_owner.": {
|
||||||
|
"bg": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
||||||
|
"de": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
||||||
|
"en": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
||||||
|
"pl": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
||||||
|
"ru": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
||||||
|
"zh": "Package owner not specified. Use --owner or set [tool.devx] repo_owner."
|
||||||
|
},
|
||||||
|
"Package: {owner}/{name}": {
|
||||||
|
"bg": "Package: {owner}/{name}",
|
||||||
|
"de": "Package: {owner}/{name}",
|
||||||
|
"en": "Package: {owner}/{name}",
|
||||||
|
"pl": "Package: {owner}/{name}",
|
||||||
|
"ru": "Package: {owner}/{name}",
|
||||||
|
"zh": "Package: {owner}/{name}"
|
||||||
|
},
|
||||||
"Parsed owner={owner}, repo={repo} from DEVX_REPO_NAME": {
|
"Parsed owner={owner}, repo={repo} from DEVX_REPO_NAME": {
|
||||||
"bg": "Разбор на owner={owner}, repo={repo} от DEVX_REPO_NAME",
|
"bg": "Разбор на owner={owner}, repo={repo} от DEVX_REPO_NAME",
|
||||||
"de": "Owner={owner}, repo={repo} aus DEVX_REPO_NAME analysiert",
|
"de": "Owner={owner}, repo={repo} aus DEVX_REPO_NAME analysiert",
|
||||||
@@ -1359,6 +2055,14 @@
|
|||||||
"ru": "Push failed for {tag}: {error}",
|
"ru": "Push failed for {tag}: {error}",
|
||||||
"zh": "Push failed for {tag}: {error}"
|
"zh": "Push failed for {tag}: {error}"
|
||||||
},
|
},
|
||||||
|
"Pushed README update with badge SHA {sha}": {
|
||||||
|
"bg": "Pushed README update with badge SHA {sha}",
|
||||||
|
"de": "Pushed README update with badge SHA {sha}",
|
||||||
|
"en": "Pushed README update with badge SHA {sha}",
|
||||||
|
"pl": "Pushed README update with badge SHA {sha}",
|
||||||
|
"ru": "Pushed README update with badge SHA {sha}",
|
||||||
|
"zh": "Pushed README update with badge SHA {sha}"
|
||||||
|
},
|
||||||
"Pushed release commit to master.": {
|
"Pushed release commit to master.": {
|
||||||
"bg": "Pushed release commit to master.",
|
"bg": "Pushed release commit to master.",
|
||||||
"de": "Pushed release commit to master.",
|
"de": "Pushed release commit to master.",
|
||||||
@@ -1383,30 +2087,6 @@
|
|||||||
"ru": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
|
"ru": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
|
||||||
"zh": "REPO argument is required (or set GITHUB_REPOSITORY env var)."
|
"zh": "REPO argument is required (or set GITHUB_REPOSITORY env var)."
|
||||||
},
|
},
|
||||||
"CI_GITEA_TOKEN environment variable required": {
|
|
||||||
"bg": "CI_GITEA_TOKEN environment variable required",
|
|
||||||
"de": "CI_GITEA_TOKEN environment variable required",
|
|
||||||
"en": "CI_GITEA_TOKEN environment variable required",
|
|
||||||
"pl": "CI_GITEA_TOKEN environment variable required",
|
|
||||||
"ru": "CI_GITEA_TOKEN environment variable required",
|
|
||||||
"zh": "CI_GITEA_TOKEN environment variable required"
|
|
||||||
},
|
|
||||||
"Failed to delete {count} image version(s)": {
|
|
||||||
"bg": "Failed to delete {count} image version(s)",
|
|
||||||
"de": "Failed to delete {count} image version(s)",
|
|
||||||
"en": "Failed to delete {count} image version(s)",
|
|
||||||
"pl": "Failed to delete {count} image version(s)",
|
|
||||||
"ru": "Failed to delete {count} image version(s)",
|
|
||||||
"zh": "Failed to delete {count} image version(s)"
|
|
||||||
},
|
|
||||||
"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.",
|
|
||||||
"en": "CI_GITEA_TOKEN is not set. Required to create a PR.",
|
|
||||||
"pl": "CI_GITEA_TOKEN nie jest ustawiony. Wymagany do utworzenia PR.",
|
|
||||||
"ru": "CI_GITEA_TOKEN не установлен. Требуется для создания PR.",
|
|
||||||
"zh": "CI_GITEA_TOKEN 未设置。创建 PR 所需。"
|
|
||||||
},
|
|
||||||
"Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars": {
|
"Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars": {
|
||||||
"bg": "Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars",
|
"bg": "Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars",
|
||||||
"de": "Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars",
|
"de": "Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars",
|
||||||
@@ -1431,6 +2111,30 @@
|
|||||||
"ru": "Registry login failed: {error}",
|
"ru": "Registry login failed: {error}",
|
||||||
"zh": "Registry login failed: {error}"
|
"zh": "Registry login failed: {error}"
|
||||||
},
|
},
|
||||||
|
"Regular merge commit — running all post-merge jobs.": {
|
||||||
|
"bg": "Regular merge commit — running all post-merge jobs.",
|
||||||
|
"de": "Regular merge commit — running all post-merge jobs.",
|
||||||
|
"en": "Regular merge commit — running all post-merge jobs.",
|
||||||
|
"pl": "Regular merge commit — running all post-merge jobs.",
|
||||||
|
"ru": "Regular merge commit — running all post-merge jobs.",
|
||||||
|
"zh": "Regular merge commit — running all post-merge jobs."
|
||||||
|
},
|
||||||
|
"Release commit — skipping all post-merge jobs.": {
|
||||||
|
"bg": "Release commit — skipping all post-merge jobs.",
|
||||||
|
"de": "Release commit — skipping all post-merge jobs.",
|
||||||
|
"en": "Release commit — skipping all post-merge jobs.",
|
||||||
|
"pl": "Release commit — skipping all post-merge jobs.",
|
||||||
|
"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}": {
|
"Release creation failed: {error}": {
|
||||||
"bg": "Release creation failed: {error}",
|
"bg": "Release creation failed: {error}",
|
||||||
"de": "Release creation failed: {error}",
|
"de": "Release creation failed: {error}",
|
||||||
@@ -1471,6 +2175,14 @@
|
|||||||
"ru": "Repository in owner/name format",
|
"ru": "Repository in owner/name format",
|
||||||
"zh": "Repository in owner/name format"
|
"zh": "Repository in owner/name format"
|
||||||
},
|
},
|
||||||
|
"Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.": {
|
||||||
|
"bg": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||||
|
"de": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||||
|
"en": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||||
|
"pl": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||||
|
"ru": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||||
|
"zh": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var."
|
||||||
|
},
|
||||||
"Repository owner not set. Use --owner or DEVX_REPO_OWNER env var.": {
|
"Repository owner not set. Use --owner or DEVX_REPO_OWNER env var.": {
|
||||||
"bg": "Собственикът на хранилището не е зададен. Използвайте --owner или DEVX_REPO_OWNER env var.",
|
"bg": "Собственикът на хранилището не е зададен. Използвайте --owner или DEVX_REPO_OWNER env var.",
|
||||||
"de": "Repository-Owner nicht gesetzt. Verwende --owner oder DEVX_REPO_OWNER env var.",
|
"de": "Repository-Owner nicht gesetzt. Verwende --owner oder DEVX_REPO_OWNER env var.",
|
||||||
@@ -1479,6 +2191,14 @@
|
|||||||
"ru": "Владелец репозитория не установлен. Используйте --owner или DEVX_REPO_OWNER env var.",
|
"ru": "Владелец репозитория не установлен. Используйте --owner или DEVX_REPO_OWNER env var.",
|
||||||
"zh": "仓库所有者未设置。使用 --owner 或 DEVX_REPO_OWNER 环境变量。"
|
"zh": "仓库所有者未设置。使用 --owner 或 DEVX_REPO_OWNER 环境变量。"
|
||||||
},
|
},
|
||||||
|
"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.",
|
||||||
|
"en": "Review body must be at least 50 characters.",
|
||||||
|
"pl": "Review body must be at least 50 characters.",
|
||||||
|
"ru": "Review body must be at least 50 characters.",
|
||||||
|
"zh": "Review body must be at least 50 characters."
|
||||||
|
},
|
||||||
"Roles directory not found: {path}": {
|
"Roles directory not found: {path}": {
|
||||||
"bg": "Roles directory not found: {path}",
|
"bg": "Roles directory not found: {path}",
|
||||||
"de": "Roles directory not found: {path}",
|
"de": "Roles directory not found: {path}",
|
||||||
@@ -1487,6 +2207,14 @@
|
|||||||
"ru": "Roles directory not found: {path}",
|
"ru": "Roles directory not found: {path}",
|
||||||
"zh": "Roles directory not found: {path}"
|
"zh": "Roles directory not found: {path}"
|
||||||
},
|
},
|
||||||
|
"Runner count: {count}": {
|
||||||
|
"bg": "Runner count: {count}",
|
||||||
|
"de": "Runner count: {count}",
|
||||||
|
"en": "Runner count: {count}",
|
||||||
|
"pl": "Runner count: {count}",
|
||||||
|
"ru": "Runner count: {count}",
|
||||||
|
"zh": "Runner count: {count}"
|
||||||
|
},
|
||||||
"Runner index {index} out of range (0..{max})": {
|
"Runner index {index} out of range (0..{max})": {
|
||||||
"bg": "Индексът на runner {index} е извън диапазона (0..{max})",
|
"bg": "Индексът на runner {index} е извън диапазона (0..{max})",
|
||||||
"de": "Runner-Index {index} außerhalb des Bereichs (0..{max})",
|
"de": "Runner-Index {index} außerhalb des Bereichs (0..{max})",
|
||||||
@@ -1495,6 +2223,30 @@
|
|||||||
"ru": "Индекс runner {index} вне диапазона (0..{max})",
|
"ru": "Индекс runner {index} вне диапазона (0..{max})",
|
||||||
"zh": "Runner 索引 {index} 超出范围 (0..{max})"
|
"zh": "Runner 索引 {index} 超出范围 (0..{max})"
|
||||||
},
|
},
|
||||||
|
"Runner index {runner_index} is out of range (must be >= 1)": {
|
||||||
|
"bg": "Runner index {runner_index} is out of range (must be >= 1)",
|
||||||
|
"de": "Runner index {runner_index} is out of range (must be >= 1)",
|
||||||
|
"en": "Runner index {runner_index} is out of range (must be >= 1)",
|
||||||
|
"pl": "Runner index {runner_index} is out of range (must be >= 1)",
|
||||||
|
"ru": "Runner index {runner_index} is out of range (must be >= 1)",
|
||||||
|
"zh": "Runner index {runner_index} is out of range (must be >= 1)"
|
||||||
|
},
|
||||||
|
"Runner indices: {indices}": {
|
||||||
|
"bg": "Runner indices: {indices}",
|
||||||
|
"de": "Runner indices: {indices}",
|
||||||
|
"en": "Runner indices: {indices}",
|
||||||
|
"pl": "Runner indices: {indices}",
|
||||||
|
"ru": "Runner indices: {indices}",
|
||||||
|
"zh": "Runner indices: {indices}"
|
||||||
|
},
|
||||||
|
"Runner {i}: {labels}": {
|
||||||
|
"bg": "Runner {i}: {labels}",
|
||||||
|
"de": "Runner {i}: {labels}",
|
||||||
|
"en": "Runner {i}: {labels}",
|
||||||
|
"pl": "Runner {i}: {labels}",
|
||||||
|
"ru": "Runner {i}: {labels}",
|
||||||
|
"zh": "Runner {i}: {labels}"
|
||||||
|
},
|
||||||
"Running lint checks...": {
|
"Running lint checks...": {
|
||||||
"bg": "Running lint checks...",
|
"bg": "Running lint checks...",
|
||||||
"de": "Running lint checks...",
|
"de": "Running lint checks...",
|
||||||
@@ -1511,6 +2263,14 @@
|
|||||||
"ru": "Running tests...",
|
"ru": "Running tests...",
|
||||||
"zh": "Running tests..."
|
"zh": "Running tests..."
|
||||||
},
|
},
|
||||||
|
"Running: {cmd}": {
|
||||||
|
"bg": "Running: {cmd}",
|
||||||
|
"de": "Running: {cmd}",
|
||||||
|
"en": "Running: {cmd}",
|
||||||
|
"pl": "Running: {cmd}",
|
||||||
|
"ru": "Running: {cmd}",
|
||||||
|
"zh": "Running: {cmd}"
|
||||||
|
},
|
||||||
"Running: {scenario} on {platform}": {
|
"Running: {scenario} on {platform}": {
|
||||||
"bg": "Running: {scenario} on {platform}",
|
"bg": "Running: {scenario} on {platform}",
|
||||||
"de": "Running: {scenario} on {platform}",
|
"de": "Running: {scenario} on {platform}",
|
||||||
@@ -1543,6 +2303,22 @@
|
|||||||
"ru": "Skipping commit push — no staged changes.",
|
"ru": "Skipping commit push — no staged changes.",
|
||||||
"zh": "Skipping commit push — no staged changes."
|
"zh": "Skipping commit push — no staged changes."
|
||||||
},
|
},
|
||||||
|
"Skipping — runner index {runner_index} > max runners {max_runners}": {
|
||||||
|
"bg": "Skipping — runner index {runner_index} > max runners {max_runners}",
|
||||||
|
"de": "Skipping — runner index {runner_index} > max runners {max_runners}",
|
||||||
|
"en": "Skipping — runner index {runner_index} > max runners {max_runners}",
|
||||||
|
"pl": "Skipping — runner index {runner_index} > max runners {max_runners}",
|
||||||
|
"ru": "Skipping — runner index {runner_index} > max runners {max_runners}",
|
||||||
|
"zh": "Skipping — runner index {runner_index} > max runners {max_runners}"
|
||||||
|
},
|
||||||
|
"Synced to latest origin/{branch}": {
|
||||||
|
"bg": "Synced to latest origin/{branch}",
|
||||||
|
"de": "Synced to latest origin/{branch}",
|
||||||
|
"en": "Synced to latest origin/{branch}",
|
||||||
|
"pl": "Synced to latest origin/{branch}",
|
||||||
|
"ru": "Synced to latest origin/{branch}",
|
||||||
|
"zh": "Synced to latest origin/{branch}"
|
||||||
|
},
|
||||||
"Syncing {count} documentation pages to wiki...": {
|
"Syncing {count} documentation pages to wiki...": {
|
||||||
"bg": "Syncing {count} documentation pages to wiki...",
|
"bg": "Syncing {count} documentation pages to wiki...",
|
||||||
"de": "Syncing {count} documentation pages to wiki...",
|
"de": "Syncing {count} documentation pages to wiki...",
|
||||||
@@ -1623,6 +2399,14 @@
|
|||||||
"ru": "Tests passed.",
|
"ru": "Tests passed.",
|
||||||
"zh": "Tests passed."
|
"zh": "Tests passed."
|
||||||
},
|
},
|
||||||
|
"Timeout reached after {timeout}s.": {
|
||||||
|
"bg": "Timeout reached after {timeout}s.",
|
||||||
|
"de": "Timeout reached after {timeout}s.",
|
||||||
|
"en": "Timeout reached after {timeout}s.",
|
||||||
|
"pl": "Timeout reached after {timeout}s.",
|
||||||
|
"ru": "Timeout reached after {timeout}s.",
|
||||||
|
"zh": "Timeout reached after {timeout}s."
|
||||||
|
},
|
||||||
"Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).": {
|
"Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).": {
|
||||||
"bg": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
|
"bg": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
|
||||||
"de": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
|
"de": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
|
||||||
@@ -1647,6 +2431,14 @@
|
|||||||
"ru": "Unknown check category '{check}'. Available: all, user-facing{tags}",
|
"ru": "Unknown check category '{check}'. Available: all, user-facing{tags}",
|
||||||
"zh": "Unknown check category '{check}'. Available: all, user-facing{tags}"
|
"zh": "Unknown check category '{check}'. Available: all, user-facing{tags}"
|
||||||
},
|
},
|
||||||
|
"Updated badge URLs in {filename}": {
|
||||||
|
"bg": "Updated badge URLs in {filename}",
|
||||||
|
"de": "Updated badge URLs in {filename}",
|
||||||
|
"en": "Updated badge URLs in {filename}",
|
||||||
|
"pl": "Updated badge URLs in {filename}",
|
||||||
|
"ru": "Updated badge URLs in {filename}",
|
||||||
|
"zh": "Updated badge URLs in {filename}"
|
||||||
|
},
|
||||||
"Updated version in {init}": {
|
"Updated version in {init}": {
|
||||||
"bg": "Updated version in {init}",
|
"bg": "Updated version in {init}",
|
||||||
"de": "Updated version in {init}",
|
"de": "Updated version in {init}",
|
||||||
@@ -1695,6 +2487,14 @@
|
|||||||
"ru": "Version file: {file}",
|
"ru": "Version file: {file}",
|
||||||
"zh": "Version file: {file}"
|
"zh": "Version file: {file}"
|
||||||
},
|
},
|
||||||
|
"Version stays at v{version} — no version bump from git-cliff. Commits since last tag don't warrant a new release. Skipping.": {
|
||||||
|
"bg": "",
|
||||||
|
"de": "",
|
||||||
|
"en": "Version stays at v{version} — no version bump from git-cliff. Commits since last tag don't warrant a new release. Skipping.",
|
||||||
|
"pl": "",
|
||||||
|
"ru": "",
|
||||||
|
"zh": ""
|
||||||
|
},
|
||||||
"Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.": {
|
"Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.": {
|
||||||
"bg": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
|
"bg": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
|
||||||
"de": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
|
"de": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
|
||||||
@@ -1727,6 +2527,22 @@
|
|||||||
"ru": "ВНИМАНИЕ: Файл .taskid ({file_id}) устарел и не совпадает с именем ветки ({branch_id}). Удалите .taskid из репозитория — имя ветки — единственный источник истины.",
|
"ru": "ВНИМАНИЕ: Файл .taskid ({file_id}) устарел и не совпадает с именем ветки ({branch_id}). Удалите .taskid из репозитория — имя ветки — единственный источник истины.",
|
||||||
"zh": "警告:.taskid 文件 ({file_id}) 已弃用,与分支名称 ({branch_id}) 不一致。请从仓库中删除 .taskid — 分支名称是唯一的真实来源。"
|
"zh": "警告:.taskid 文件 ({file_id}) 已弃用,与分支名称 ({branch_id}) 不一致。请从仓库中删除 .taskid — 分支名称是唯一的真实来源。"
|
||||||
},
|
},
|
||||||
|
"WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.": {
|
||||||
|
"bg": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
|
||||||
|
"de": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
|
||||||
|
"en": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
|
||||||
|
"pl": "OSTRZEŻENIE: Nie można pobrać listy stron wiki po ponownych próbach. Sama synchronizacja zakończyła się sukcesem (zaktualizowano {count} stron), ale kontrola integralności nie mogła ich zweryfikować z powodu przejściowego problemu z API.",
|
||||||
|
"ru": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
|
||||||
|
"zh": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue."
|
||||||
|
},
|
||||||
|
"WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.": {
|
||||||
|
"bg": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
|
||||||
|
"de": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
|
||||||
|
"en": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
|
||||||
|
"pl": "OSTRZEŻENIE: Nie można ponownie pobrać listy stron wiki do weryfikacji. Pomijanie weryfikacji treści z powodu przejściowego problemu z API.",
|
||||||
|
"ru": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
|
||||||
|
"zh": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue."
|
||||||
|
},
|
||||||
"WARNING: VIKUNJA_TOKEN not set — skipping task existence check. Set it in .env to enable full validation.": {
|
"WARNING: VIKUNJA_TOKEN not set — skipping task existence check. Set it in .env to enable full validation.": {
|
||||||
"bg": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не е зададен — пропускане на проверката за съществуване на задача. Задайте го в .env за пълна валидация.",
|
"bg": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не е зададен — пропускане на проверката за съществуване на задача. Задайте го в .env за пълна валидация.",
|
||||||
"de": "WARNUNG: VIKUNJA_TOKEN nicht gesetzt — Task-Existenzprüfung übersprungen. In .env setzen für volle Validierung.",
|
"de": "WARNUNG: VIKUNJA_TOKEN nicht gesetzt — Task-Existenzprüfung übersprungen. In .env setzen für volle Validierung.",
|
||||||
@@ -1735,6 +2551,14 @@
|
|||||||
"ru": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не установлен — пропуск проверки существования задачи. Установите в .env для полной проверки.",
|
"ru": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не установлен — пропуск проверки существования задачи. Установите в .env для полной проверки.",
|
||||||
"zh": "警告: VIKUNJA_TOKEN 未设置 — 跳过任务存在性检查。在 .env 中设置以启用完整验证。"
|
"zh": "警告: VIKUNJA_TOKEN 未设置 — 跳过任务存在性检查。在 .env 中设置以启用完整验证。"
|
||||||
},
|
},
|
||||||
|
"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)...",
|
||||||
|
"en": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||||
|
"pl": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||||
|
"ru": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||||
|
"zh": "Waiting for CI checks to complete (timeout: {timeout}s)..."
|
||||||
|
},
|
||||||
"Warning: could not fetch tags from origin.": {
|
"Warning: could not fetch tags from origin.": {
|
||||||
"bg": "Warning: could not fetch tags from origin.",
|
"bg": "Warning: could not fetch tags from origin.",
|
||||||
"de": "Warning: could not fetch tags from origin.",
|
"de": "Warning: could not fetch tags from origin.",
|
||||||
@@ -1743,6 +2567,54 @@
|
|||||||
"ru": "Warning: could not fetch tags from origin.",
|
"ru": "Warning: could not fetch tags from origin.",
|
||||||
"zh": "Warning: could not fetch tags from origin."
|
"zh": "Warning: could not fetch tags from origin."
|
||||||
},
|
},
|
||||||
|
"Warning: instance-level runners query failed: {error}": {
|
||||||
|
"bg": "Warning: instance-level runners query failed: {error}",
|
||||||
|
"de": "Warning: instance-level runners query failed: {error}",
|
||||||
|
"en": "Warning: instance-level runners query failed: {error}",
|
||||||
|
"pl": "Warning: instance-level runners query failed: {error}",
|
||||||
|
"ru": "Warning: instance-level runners query failed: {error}",
|
||||||
|
"zh": "Warning: instance-level runners query failed: {error}"
|
||||||
|
},
|
||||||
|
"Warning: instance-level runners query returned HTTP {status}": {
|
||||||
|
"bg": "Warning: instance-level runners query returned HTTP {status}",
|
||||||
|
"de": "Warning: instance-level runners query returned HTTP {status}",
|
||||||
|
"en": "Warning: instance-level runners query returned HTTP {status}",
|
||||||
|
"pl": "Warning: instance-level runners query returned HTTP {status}",
|
||||||
|
"ru": "Warning: instance-level runners query returned HTTP {status}",
|
||||||
|
"zh": "Warning: instance-level runners query returned HTTP {status}"
|
||||||
|
},
|
||||||
|
"Warning: org-level runners query failed: {error}": {
|
||||||
|
"bg": "Warning: org-level runners query failed: {error}",
|
||||||
|
"de": "Warning: org-level runners query failed: {error}",
|
||||||
|
"en": "Warning: org-level runners query failed: {error}",
|
||||||
|
"pl": "Warning: org-level runners query failed: {error}",
|
||||||
|
"ru": "Warning: org-level runners query failed: {error}",
|
||||||
|
"zh": "Warning: org-level runners query failed: {error}"
|
||||||
|
},
|
||||||
|
"Warning: org-level runners query returned HTTP {status}": {
|
||||||
|
"bg": "Warning: org-level runners query returned HTTP {status}",
|
||||||
|
"de": "Warning: org-level runners query returned HTTP {status}",
|
||||||
|
"en": "Warning: org-level runners query returned HTTP {status}",
|
||||||
|
"pl": "Warning: org-level runners query returned HTTP {status}",
|
||||||
|
"ru": "Warning: org-level runners query returned HTTP {status}",
|
||||||
|
"zh": "Warning: org-level runners query returned HTTP {status}"
|
||||||
|
},
|
||||||
|
"Warning: repo-level runners query failed: {error}": {
|
||||||
|
"bg": "Warning: repo-level runners query failed: {error}",
|
||||||
|
"de": "Warning: repo-level runners query failed: {error}",
|
||||||
|
"en": "Warning: repo-level runners query failed: {error}",
|
||||||
|
"pl": "Warning: repo-level runners query failed: {error}",
|
||||||
|
"ru": "Warning: repo-level runners query failed: {error}",
|
||||||
|
"zh": "Warning: repo-level runners query failed: {error}"
|
||||||
|
},
|
||||||
|
"Warning: repo-level runners query returned HTTP {status}": {
|
||||||
|
"bg": "Warning: repo-level runners query returned HTTP {status}",
|
||||||
|
"de": "Warning: repo-level runners query returned HTTP {status}",
|
||||||
|
"en": "Warning: repo-level runners query returned HTTP {status}",
|
||||||
|
"pl": "Warning: repo-level runners query returned HTTP {status}",
|
||||||
|
"ru": "Warning: repo-level runners query returned HTTP {status}",
|
||||||
|
"zh": "Warning: repo-level runners query returned HTTP {status}"
|
||||||
|
},
|
||||||
"Wiki integrity check failed — {count} issue(s)": {
|
"Wiki integrity check failed — {count} issue(s)": {
|
||||||
"bg": "Wiki integrity check failed — {count} issue(s)",
|
"bg": "Wiki integrity check failed — {count} issue(s)",
|
||||||
"de": "Wiki integrity check failed — {count} issue(s)",
|
"de": "Wiki integrity check failed — {count} issue(s)",
|
||||||
@@ -1763,9 +2635,9 @@
|
|||||||
"bg": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
"bg": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||||
"de": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
"de": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||||
"en": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
"en": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||||
|
"pl": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||||
"ru": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
"ru": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||||
"zh": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
"zh": "Wrote tag {tag} to GITHUB_OUTPUT."
|
||||||
"pl": "Wrote tag {tag} to GITHUB_OUTPUT."
|
|
||||||
},
|
},
|
||||||
"[check-dep-docs] Passed: all dependencies are documented": {
|
"[check-dep-docs] Passed: all dependencies are documented": {
|
||||||
"bg": "[check-dep-docs] Passed: all dependencies are documented",
|
"bg": "[check-dep-docs] Passed: all dependencies are documented",
|
||||||
@@ -1879,6 +2751,14 @@
|
|||||||
"ru": "завершён",
|
"ru": "завершён",
|
||||||
"zh": "已完成"
|
"zh": "已完成"
|
||||||
},
|
},
|
||||||
|
"count={count}": {
|
||||||
|
"bg": "count={count}",
|
||||||
|
"de": "count={count}",
|
||||||
|
"en": "count={count}",
|
||||||
|
"pl": "count={count}",
|
||||||
|
"ru": "count={count}",
|
||||||
|
"zh": "count={count}"
|
||||||
|
},
|
||||||
"devx version mismatch across extras: {detail}": {
|
"devx version mismatch across extras: {detail}": {
|
||||||
"bg": "несъответствие на версията на devx между extras: {detail}",
|
"bg": "несъответствие на версията на devx между extras: {detail}",
|
||||||
"de": "devx-Versionskonflikt zwischen Extras: {detail}",
|
"de": "devx-Versionskonflikt zwischen Extras: {detail}",
|
||||||
@@ -1943,6 +2823,14 @@
|
|||||||
"ru": "неактивен",
|
"ru": "неактивен",
|
||||||
"zh": "未激活"
|
"zh": "未激活"
|
||||||
},
|
},
|
||||||
|
"indices={indices}": {
|
||||||
|
"bg": "indices={indices}",
|
||||||
|
"de": "indices={indices}",
|
||||||
|
"en": "indices={indices}",
|
||||||
|
"pl": "indices={indices}",
|
||||||
|
"ru": "indices={indices}",
|
||||||
|
"zh": "indices={indices}"
|
||||||
|
},
|
||||||
"mapping.json keys and values must be strings, got {k}={v}": {
|
"mapping.json keys and values must be strings, got {k}={v}": {
|
||||||
"bg": "mapping.json keys and values must be strings, got {k}={v}",
|
"bg": "mapping.json keys and values must be strings, got {k}={v}",
|
||||||
"de": "mapping.json keys and values must be strings, got {k}={v}",
|
"de": "mapping.json keys and values must be strings, got {k}={v}",
|
||||||
@@ -1975,6 +2863,22 @@
|
|||||||
"ru": "pyproject.toml не найден в текущей директории.",
|
"ru": "pyproject.toml не найден в текущей директории.",
|
||||||
"zh": "在当前目录中未找到 pyproject.toml。"
|
"zh": "在当前目录中未找到 pyproject.toml。"
|
||||||
},
|
},
|
||||||
|
"tea login '{name}' already configured.": {
|
||||||
|
"bg": "tea login '{name}' already configured.",
|
||||||
|
"de": "tea login '{name}' already configured.",
|
||||||
|
"en": "tea login '{name}' already configured.",
|
||||||
|
"pl": "tea login '{name}' already configured.",
|
||||||
|
"ru": "tea login '{name}' already configured.",
|
||||||
|
"zh": "tea login '{name}' already configured."
|
||||||
|
},
|
||||||
|
"tea not installed — skipping login configuration.": {
|
||||||
|
"bg": "tea not installed — skipping login configuration.",
|
||||||
|
"de": "tea not installed — skipping login configuration.",
|
||||||
|
"en": "tea not installed — skipping login configuration.",
|
||||||
|
"pl": "tea not installed — skipping login configuration.",
|
||||||
|
"ru": "tea not installed — skipping login configuration.",
|
||||||
|
"zh": "tea not installed — skipping login configuration."
|
||||||
|
},
|
||||||
"unknown": {
|
"unknown": {
|
||||||
"bg": "неизвестен",
|
"bg": "неизвестен",
|
||||||
"de": "unbekannt",
|
"de": "unbekannt",
|
||||||
@@ -1991,292 +2895,188 @@
|
|||||||
"ru": "{file} already exists. Use --force to overwrite.",
|
"ru": "{file} already exists. Use --force to overwrite.",
|
||||||
"zh": "{file} already exists. Use --force to overwrite."
|
"zh": "{file} already exists. Use --force to overwrite."
|
||||||
},
|
},
|
||||||
"Version stays at v{version} — no version bump from git-cliff. Commits since last tag don't warrant a new release. Skipping.": {
|
"{separator}": {
|
||||||
"bg": "",
|
"bg": "{separator}",
|
||||||
"de": "",
|
"de": "{separator}",
|
||||||
"en": "Version stays at v{version} — no version bump from git-cliff. Commits since last tag don't warrant a new release. Skipping.",
|
"en": "{separator}",
|
||||||
"pl": "",
|
"pl": "{separator}",
|
||||||
"ru": "",
|
"ru": "{separator}",
|
||||||
"zh": ""
|
"zh": "{separator}"
|
||||||
},
|
},
|
||||||
"tea not installed — skipping login configuration.": {
|
"Failed to push release commit after 3 attempts. Manual intervention required.": {
|
||||||
"bg": "tea not installed — skipping login configuration.",
|
"bg": "Failed to push release commit after 3 attempts. Manual intervention required.",
|
||||||
"de": "tea not installed — skipping login configuration.",
|
"de": "Failed to push release commit after 3 attempts. Manual intervention required.",
|
||||||
"en": "tea not installed — skipping login configuration.",
|
"en": "Failed to push release commit after 3 attempts. Manual intervention required.",
|
||||||
"pl": "tea not installed — skipping login configuration.",
|
"pl": "Failed to push release commit after 3 attempts. Manual intervention required.",
|
||||||
"ru": "tea not installed — skipping login configuration.",
|
"ru": "Failed to push release commit after 3 attempts. Manual intervention required.",
|
||||||
"zh": "tea not installed — skipping login configuration."
|
"zh": "Failed to push release commit after 3 attempts. Manual intervention required."
|
||||||
},
|
},
|
||||||
"CI_GITEA_TOKEN not set — skipping login configuration.": {
|
"Push attempt {n}/3 failed: {err}": {
|
||||||
"bg": "CI_GITEA_TOKEN not set — skipping login configuration.",
|
"bg": "Push attempt {n}/3 failed: {err}",
|
||||||
"de": "CI_GITEA_TOKEN not set — skipping login configuration.",
|
"de": "Push attempt {n}/3 failed: {err}",
|
||||||
"en": "CI_GITEA_TOKEN not set — skipping login configuration.",
|
"en": "Push attempt {n}/3 failed: {err}",
|
||||||
"pl": "CI_GITEA_TOKEN not set — skipping login configuration.",
|
"pl": "Push attempt {n}/3 failed: {err}",
|
||||||
"ru": "CI_GITEA_TOKEN not set — skipping login configuration.",
|
"ru": "Push attempt {n}/3 failed: {err}",
|
||||||
"zh": "CI_GITEA_TOKEN not set — skipping login configuration."
|
"zh": "Push attempt {n}/3 failed: {err}"
|
||||||
},
|
},
|
||||||
"tea login '{name}' already configured.": {
|
"Rebase attempt {n}/3 failed: {err}": {
|
||||||
"bg": "tea login '{name}' already configured.",
|
"bg": "Rebase attempt {n}/3 failed: {err}",
|
||||||
"de": "tea login '{name}' already configured.",
|
"de": "Rebase attempt {n}/3 failed: {err}",
|
||||||
"en": "tea login '{name}' already configured.",
|
"en": "Rebase attempt {n}/3 failed: {err}",
|
||||||
"pl": "tea login '{name}' already configured.",
|
"pl": "Rebase attempt {n}/3 failed: {err}",
|
||||||
"ru": "tea login '{name}' already configured.",
|
"ru": "Rebase attempt {n}/3 failed: {err}",
|
||||||
"zh": "tea login '{name}' already configured."
|
"zh": "Rebase attempt {n}/3 failed: {err}"
|
||||||
},
|
},
|
||||||
"Configuring tea login '{name}' for {url}...": {
|
"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": "Configuring tea login '{name}' for {url}...",
|
"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": "Configuring tea login '{name}' for {url}...",
|
"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": "Configuring tea login '{name}' for {url}...",
|
"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": "Configuring tea login '{name}' for {url}...",
|
"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": "Configuring tea login '{name}' for {url}...",
|
"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": "Configuring tea login '{name}' for {url}..."
|
"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."
|
||||||
},
|
},
|
||||||
" Could not fetch logs: {error}": {
|
"Branch is already up-to-date with origin/master.": {
|
||||||
"en": " Could not fetch logs: {error}",
|
"bg": "Branch is already up-to-date with origin/master.",
|
||||||
"bg": " Could not fetch logs: {error}",
|
"de": "Branch is already up-to-date with origin/master.",
|
||||||
"de": " Could not fetch logs: {error}",
|
"en": "Branch is already up-to-date with origin/master.",
|
||||||
"pl": " Could not fetch logs: {error}",
|
"pl": "Branch is already up-to-date with origin/master.",
|
||||||
"ru": " Could not fetch logs: {error}",
|
"ru": "Branch is already up-to-date with origin/master.",
|
||||||
"zh": " Could not fetch logs: {error}"
|
"zh": "Branch is already up-to-date with origin/master."
|
||||||
},
|
},
|
||||||
"Added label '{label}' to PR #{pr}.": {
|
"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": "Added label '{label}' to PR #{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.",
|
||||||
"bg": "Added label '{label}' to PR #{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.",
|
||||||
"de": "Added label '{label}' to PR #{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": "Added label '{label}' to PR #{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": "Added label '{label}' to PR #{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": "Added label '{label}' to PR #{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."
|
||||||
},
|
},
|
||||||
"CI checks did not complete within timeout.": {
|
"Branch is {count} commit(s) behind master. Rebasing...": {
|
||||||
"en": "CI checks did not complete within timeout.",
|
"bg": "Branch is {count} commit(s) behind master. Rebasing...",
|
||||||
"bg": "CI checks did not complete within timeout.",
|
"de": "Branch is {count} commit(s) behind master. Rebasing...",
|
||||||
"de": "CI checks did not complete within timeout.",
|
"en": "Branch is {count} commit(s) behind master. Rebasing...",
|
||||||
"pl": "CI checks did not complete within timeout.",
|
"pl": "Branch is {count} commit(s) behind master. Rebasing...",
|
||||||
"ru": "CI checks did not complete within timeout.",
|
"ru": "Branch is {count} commit(s) behind master. Rebasing...",
|
||||||
"zh": "CI checks did not complete within timeout."
|
"zh": "Branch is {count} commit(s) behind master. Rebasing..."
|
||||||
},
|
},
|
||||||
"CI checks failed.": {
|
"CI_GITEA_TOKEN is not set. Add it to .env or export it.": {
|
||||||
"en": "CI checks failed.",
|
"bg": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
|
||||||
"bg": "CI checks failed.",
|
"de": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
|
||||||
"de": "CI checks failed.",
|
"en": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
|
||||||
"pl": "CI checks failed.",
|
"pl": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
|
||||||
"ru": "CI checks failed.",
|
"ru": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
|
||||||
"zh": "CI checks failed."
|
"zh": "CI_GITEA_TOKEN is not set. Add it to .env or export it."
|
||||||
},
|
},
|
||||||
"CI_GITEA_TOKEN is not set.": {
|
"Cannot rebase: not on a branch (detached HEAD).": {
|
||||||
"en": "CI_GITEA_TOKEN is not set.",
|
"bg": "Cannot rebase: not on a branch (detached HEAD).",
|
||||||
"bg": "CI_GITEA_TOKEN is not set.",
|
"de": "Cannot rebase: not on a branch (detached HEAD).",
|
||||||
"de": "CI_GITEA_TOKEN is not set.",
|
"en": "Cannot rebase: not on a branch (detached HEAD).",
|
||||||
"pl": "CI_GITEA_TOKEN is not set.",
|
"pl": "Cannot rebase: not on a branch (detached HEAD).",
|
||||||
"ru": "CI_GITEA_TOKEN is not set.",
|
"ru": "Cannot rebase: not on a branch (detached HEAD).",
|
||||||
"zh": "CI_GITEA_TOKEN is not set."
|
"zh": "Cannot rebase: not on a branch (detached HEAD)."
|
||||||
},
|
},
|
||||||
"Checking status for PR #{pr_number}...": {
|
"Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.": {
|
||||||
"en": "Checking status for PR #{pr_number}...",
|
"bg": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
|
||||||
"bg": "Checking status for PR #{pr_number}...",
|
"de": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
|
||||||
"de": "Checking status for PR #{pr_number}...",
|
"en": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
|
||||||
"pl": "Checking status for PR #{pr_number}...",
|
"pl": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
|
||||||
"ru": "Checking status for PR #{pr_number}...",
|
"ru": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
|
||||||
"zh": "Checking status for PR #{pr_number}..."
|
"zh": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR."
|
||||||
},
|
},
|
||||||
"Commit: {sha}": {
|
"Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.": {
|
||||||
"en": "Commit: {sha}",
|
"bg": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
|
||||||
"bg": "Commit: {sha}",
|
"de": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
|
||||||
"de": "Commit: {sha}",
|
"en": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
|
||||||
"pl": "Commit: {sha}",
|
"pl": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
|
||||||
"ru": "Commit: {sha}",
|
"ru": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
|
||||||
"zh": "Commit: {sha}"
|
"zh": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables."
|
||||||
},
|
},
|
||||||
"Could not determine head SHA for PR #{pr_number}.": {
|
"Fetch failed: {error}": {
|
||||||
"en": "Could not determine head SHA for PR #{pr_number}.",
|
"bg": "Fetch failed: {error}",
|
||||||
"bg": "Could not determine head SHA for PR #{pr_number}.",
|
"de": "Fetch failed: {error}",
|
||||||
"de": "Could not determine head SHA for PR #{pr_number}.",
|
"en": "Fetch failed: {error}",
|
||||||
"pl": "Could not determine head SHA for PR #{pr_number}.",
|
"pl": "Fetch failed: {error}",
|
||||||
"ru": "Could not determine head SHA for PR #{pr_number}.",
|
"ru": "Fetch failed: {error}",
|
||||||
"zh": "Could not determine head SHA for PR #{pr_number}."
|
"zh": "Fetch failed: {error}"
|
||||||
},
|
},
|
||||||
"Fetching logs for PR #{pr_number}...": {
|
"Fetching origin/master...": {
|
||||||
"en": "Fetching logs for PR #{pr_number}...",
|
"bg": "Fetching origin/master...",
|
||||||
"bg": "Fetching logs for PR #{pr_number}...",
|
"de": "Fetching origin/master...",
|
||||||
"de": "Fetching logs for PR #{pr_number}...",
|
"en": "Fetching origin/master...",
|
||||||
"pl": "Fetching logs for PR #{pr_number}...",
|
"pl": "Fetching origin/master...",
|
||||||
"ru": "Fetching logs for PR #{pr_number}...",
|
"ru": "Fetching origin/master...",
|
||||||
"zh": "Fetching logs for PR #{pr_number}..."
|
"zh": "Fetching origin/master..."
|
||||||
},
|
},
|
||||||
"Label '{label}' already on PR #{pr}.": {
|
"Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.": {
|
||||||
"en": "Label '{label}' already on PR #{pr}.",
|
"bg": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
|
||||||
"bg": "Label '{label}' already on PR #{pr}.",
|
"de": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
|
||||||
"de": "Label '{label}' already on PR #{pr}.",
|
"en": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
|
||||||
"pl": "Label '{label}' already on PR #{pr}.",
|
"pl": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
|
||||||
"ru": "Label '{label}' already on PR #{pr}.",
|
"ru": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
|
||||||
"zh": "Label '{label}' already on PR #{pr}."
|
"zh": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again."
|
||||||
},
|
},
|
||||||
"Latest run: #{run_id} (status: {status})": {
|
"Force-pushing...": {
|
||||||
"en": "Latest run: #{run_id} (status: {status})",
|
"bg": "Force-pushing...",
|
||||||
"bg": "Latest run: #{run_id} (status: {status})",
|
"de": "Force-pushing...",
|
||||||
"de": "Latest run: #{run_id} (status: {status})",
|
"en": "Force-pushing...",
|
||||||
"pl": "Latest run: #{run_id} (status: {status})",
|
"pl": "Force-pushing...",
|
||||||
"ru": "Latest run: #{run_id} (status: {status})",
|
"ru": "Force-pushing...",
|
||||||
"zh": "Latest run: #{run_id} (status: {status})"
|
"zh": "Force-pushing..."
|
||||||
},
|
},
|
||||||
"No CI checks found for commit {sha}.": {
|
"Nothing to push.": {
|
||||||
"en": "No CI checks found for commit {sha}.",
|
"bg": "Nothing to push.",
|
||||||
"bg": "No CI checks found for commit {sha}.",
|
"de": "Nothing to push.",
|
||||||
"de": "No CI checks found for commit {sha}.",
|
"en": "Nothing to push.",
|
||||||
"pl": "No CI checks found for commit {sha}.",
|
"pl": "Nothing to push.",
|
||||||
"ru": "No CI checks found for commit {sha}.",
|
"ru": "Nothing to push.",
|
||||||
"zh": "No CI checks found for commit {sha}."
|
"zh": "Nothing to push."
|
||||||
},
|
},
|
||||||
"No failed jobs.": {
|
"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": "No failed jobs.",
|
"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.",
|
||||||
"bg": "No failed jobs.",
|
"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.",
|
||||||
"de": "No failed jobs.",
|
"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": "No failed jobs.",
|
"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": "No failed jobs.",
|
"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": "No failed jobs."
|
"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."
|
||||||
},
|
},
|
||||||
"No job matching '{job}' found.": {
|
"Pushed {branch} to origin.": {
|
||||||
"en": "No job matching '{job}' found.",
|
"bg": "Pushed {branch} to origin.",
|
||||||
"bg": "No job matching '{job}' found.",
|
"de": "Pushed {branch} to origin.",
|
||||||
"de": "No job matching '{job}' found.",
|
"en": "Pushed {branch} to origin.",
|
||||||
"pl": "No job matching '{job}' found.",
|
"pl": "Pushed {branch} to origin.",
|
||||||
"ru": "No job matching '{job}' found.",
|
"ru": "Pushed {branch} to origin.",
|
||||||
"zh": "No job matching '{job}' found."
|
"zh": "Pushed {branch} to origin."
|
||||||
},
|
},
|
||||||
"No jobs found for run #{run_id}.": {
|
"Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue": {
|
||||||
"en": "No jobs found for run #{run_id}.",
|
"bg": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
|
||||||
"bg": "No jobs found for run #{run_id}.",
|
"de": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
|
||||||
"de": "No jobs found for run #{run_id}.",
|
"en": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
|
||||||
"pl": "No jobs found for run #{run_id}.",
|
"pl": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
|
||||||
"ru": "No jobs found for run #{run_id}.",
|
"ru": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
|
||||||
"zh": "No jobs found for run #{run_id}."
|
"zh": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue"
|
||||||
},
|
},
|
||||||
"No open PR found for branch '{branch}'.": {
|
"Rebase failed with HTTP {status}: {message}": {
|
||||||
"en": "No open PR found for branch '{branch}'.",
|
"bg": "Rebase failed with HTTP {status}: {message}",
|
||||||
"bg": "No open PR found for branch '{branch}'.",
|
"de": "Rebase failed with HTTP {status}: {message}",
|
||||||
"de": "No open PR found for branch '{branch}'.",
|
"en": "Rebase failed with HTTP {status}: {message}",
|
||||||
"pl": "No open PR found for branch '{branch}'.",
|
"pl": "Rebase failed with HTTP {status}: {message}",
|
||||||
"ru": "No open PR found for branch '{branch}'.",
|
"ru": "Rebase failed with HTTP {status}: {message}",
|
||||||
"zh": "No open PR found for branch '{branch}'."
|
"zh": "Rebase failed with HTTP {status}: {message}"
|
||||||
},
|
},
|
||||||
"No workflow runs found for SHA {sha}.": {
|
"Rebase successful.": {
|
||||||
"en": "No workflow runs found for SHA {sha}.",
|
"bg": "Rebase successful.",
|
||||||
"bg": "No workflow runs found for SHA {sha}.",
|
"de": "Rebase successful.",
|
||||||
"de": "No workflow runs found for SHA {sha}.",
|
"en": "Rebase successful.",
|
||||||
"pl": "No workflow runs found for SHA {sha}.",
|
"pl": "Rebase successful.",
|
||||||
"ru": "No workflow runs found for SHA {sha}.",
|
"ru": "Rebase successful.",
|
||||||
"zh": "No workflow runs found for SHA {sha}."
|
"zh": "Rebase successful."
|
||||||
},
|
},
|
||||||
"Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.": {
|
"Rebasing PR #{pr} via Gitea API...": {
|
||||||
"en": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
"bg": "Rebasing PR #{pr} via Gitea API...",
|
||||||
"bg": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
"de": "Rebasing PR #{pr} via Gitea API...",
|
||||||
"de": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
"en": "Rebasing PR #{pr} via Gitea API...",
|
||||||
"pl": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
"pl": "Rebasing PR #{pr} via Gitea API...",
|
||||||
"ru": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
"ru": "Rebasing PR #{pr} via Gitea API...",
|
||||||
"zh": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var."
|
"zh": "Rebasing PR #{pr} via Gitea API..."
|
||||||
},
|
|
||||||
"Timeout reached after {timeout}s.": {
|
|
||||||
"en": "Timeout reached after {timeout}s.",
|
|
||||||
"bg": "Timeout reached after {timeout}s.",
|
|
||||||
"de": "Timeout reached after {timeout}s.",
|
|
||||||
"pl": "Timeout reached after {timeout}s.",
|
|
||||||
"ru": "Timeout reached after {timeout}s.",
|
|
||||||
"zh": "Timeout reached after {timeout}s."
|
|
||||||
},
|
|
||||||
"Waiting for CI checks to complete (timeout: {timeout}s)...": {
|
|
||||||
"en": "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)...",
|
|
||||||
"pl": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
|
||||||
"ru": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
|
||||||
"zh": "Waiting for CI checks to complete (timeout: {timeout}s)..."
|
|
||||||
},
|
|
||||||
"\n[check_test_coverage] Fix: add the missing test file(s) before committing.": {
|
|
||||||
"en": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
|
||||||
"bg": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
|
||||||
"de": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
|
||||||
"pl": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
|
||||||
"ru": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
|
||||||
"zh": "\n[check_test_coverage] Fix: add the missing test file(s) before committing."
|
|
||||||
},
|
|
||||||
"Package owner not specified. Use --owner or set [tool.devx] repo_owner.": {
|
|
||||||
"en": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
|
||||||
"bg": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
|
||||||
"de": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
|
||||||
"pl": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
|
||||||
"ru": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
|
||||||
"zh": "Package owner not specified. Use --owner or set [tool.devx] repo_owner."
|
|
||||||
},
|
|
||||||
"Configuration validation failed.": {
|
|
||||||
"en": "Configuration validation failed.",
|
|
||||||
"bg": "Configuration validation failed.",
|
|
||||||
"de": "Configuration validation failed.",
|
|
||||||
"pl": "Configuration validation failed.",
|
|
||||||
"ru": "Configuration validation failed.",
|
|
||||||
"zh": "Configuration validation failed."
|
|
||||||
},
|
|
||||||
"Missing tests for changed files.": {
|
|
||||||
"en": "Missing tests for changed files.",
|
|
||||||
"bg": "Missing tests for changed files.",
|
|
||||||
"de": "Missing tests for changed files.",
|
|
||||||
"pl": "Missing tests for changed files.",
|
|
||||||
"ru": "Missing tests for changed files.",
|
|
||||||
"zh": "Missing tests for changed files."
|
|
||||||
},
|
|
||||||
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.": {
|
|
||||||
"en": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
|
||||||
"bg": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
|
||||||
"de": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
|
||||||
"pl": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
|
||||||
"ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
|
||||||
"zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'."
|
|
||||||
},
|
|
||||||
"--checklist-categories must list at least 8 of 13 categories. Got {count}.": {
|
|
||||||
"en": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
|
||||||
"bg": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
|
||||||
"de": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
|
||||||
"pl": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
|
||||||
"ru": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
|
||||||
"zh": "--checklist-categories must list at least 8 of 13 categories. Got {count}."
|
|
||||||
},
|
|
||||||
"--checklist-confirmed is required for APPROVE events.": {
|
|
||||||
"en": "--checklist-confirmed is required for APPROVE events.",
|
|
||||||
"bg": "--checklist-confirmed is required for APPROVE events.",
|
|
||||||
"de": "--checklist-confirmed is required for APPROVE events.",
|
|
||||||
"pl": "--checklist-confirmed is required for APPROVE events.",
|
|
||||||
"ru": "--checklist-confirmed is required for APPROVE events.",
|
|
||||||
"zh": "--checklist-confirmed is required for APPROVE events."
|
|
||||||
},
|
|
||||||
"Invalid checklist category: {cat}. Must be numbers.": {
|
|
||||||
"en": "Invalid checklist category: {cat}. Must be numbers.",
|
|
||||||
"bg": "Invalid checklist category: {cat}. Must be numbers.",
|
|
||||||
"de": "Invalid checklist category: {cat}. Must be numbers.",
|
|
||||||
"pl": "Invalid checklist category: {cat}. Must be numbers.",
|
|
||||||
"ru": "Invalid checklist category: {cat}. Must be numbers.",
|
|
||||||
"zh": "Invalid checklist category: {cat}. Must be numbers."
|
|
||||||
},
|
|
||||||
"Items input must be a JSON array, got {type}": {
|
|
||||||
"bg": "Входните данни трябва да са JSON масив, получено {type}",
|
|
||||||
"de": "Eingabe muss ein JSON-Array sein, erhalten {type}",
|
|
||||||
"en": "Items input must be a JSON array, got {type}",
|
|
||||||
"pl": "Dane wejściowe muszą być tablicą JSON, otrzymano {type}",
|
|
||||||
"ru": "Входные данные должны быть JSON-массивом, получено {type}",
|
|
||||||
"zh": "输入必须是 JSON 数组,得到 {type}"
|
|
||||||
},
|
|
||||||
"Review body must be at least 50 characters.": {
|
|
||||||
"en": "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.",
|
|
||||||
"pl": "Review body must be at least 50 characters.",
|
|
||||||
"ru": "Review body must be at least 50 characters.",
|
|
||||||
"zh": "Review body must be at least 50 characters."
|
|
||||||
},
|
|
||||||
" - Block admin merge override: yes": {
|
|
||||||
"bg": " - Блокиране на admin merge override: да",
|
|
||||||
"de": " - Admin-Merge-Override blockieren: ja",
|
|
||||||
"en": " - Block admin merge override: yes",
|
|
||||||
"pl": " - Blokuj admin merge override: tak",
|
|
||||||
"ru": " - Блокировать admin merge override: да",
|
|
||||||
"zh": " - 阻止管理员合并覆盖:是"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,6 +233,18 @@ class TestGiteaClient:
|
|||||||
json={"Do": "squash", "MergeTitleField": "fix: bug"},
|
json={"Do": "squash", "MergeTitleField": "fix: bug"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_update_pr_branch(self) -> None:
|
||||||
|
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||||
|
client._session.request = MagicMock(return_value=_mock_response())
|
||||||
|
|
||||||
|
client.update_pr_branch(7, style="rebase")
|
||||||
|
client._session.request.assert_called_once_with(
|
||||||
|
"POST",
|
||||||
|
"https://git.example.com/repos/owner/repo/pulls/7/update",
|
||||||
|
timeout=DEFAULT_TIMEOUT,
|
||||||
|
params={"style": "rebase"},
|
||||||
|
)
|
||||||
|
|
||||||
def test_get_pr_labels(self) -> None:
|
def test_get_pr_labels(self) -> None:
|
||||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||||
client._session.request = MagicMock(return_value=_mock_response([{"name": "ready-to-merge"}]))
|
client._session.request = MagicMock(return_value=_mock_response([{"name": "ready-to-merge"}]))
|
||||||
|
|||||||
@@ -292,14 +292,13 @@ class TestMain:
|
|||||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
|
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||||
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
|
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
|
||||||
@patch("devx.ci.auto_merge.GiteaClient")
|
@patch("devx.ci.auto_merge.GiteaClient")
|
||||||
def test_merge_behind_master_raises_no_rebase(
|
def test_merge_behind_master_auto_rebases(
|
||||||
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
|
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
|
||||||
) -> None: # type: ignore[no-untyped-def]
|
) -> None: # type: ignore[no-untyped-def]
|
||||||
"""When branch is behind master, auto-merge should NOT rebase.
|
"""When branch is behind master, auto-merge rebases via Gitea API.
|
||||||
|
|
||||||
Auto-rebasing creates a feedback loop: the force-push triggers a new
|
The rebase triggers a new CI run. The next auto-merge attempt will
|
||||||
pull_request synchronize event, which starts a new CI run, which runs
|
find the branch up-to-date and merge successfully.
|
||||||
auto-merge again, which rebases again, etc.
|
|
||||||
"""
|
"""
|
||||||
monkeypatch.chdir(tmp_path)
|
monkeypatch.chdir(tmp_path)
|
||||||
|
|
||||||
@@ -315,12 +314,40 @@ class TestMain:
|
|||||||
main,
|
main,
|
||||||
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
|
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
|
||||||
)
|
)
|
||||||
assert result.exit_code != 0
|
assert result.exit_code == 0
|
||||||
assert "behind master" in result.output.lower()
|
assert "behind master" in result.output.lower()
|
||||||
assert "rebase manually" in result.output.lower()
|
assert "auto-rebasing" in result.output.lower()
|
||||||
# Must NOT have called merge_pr twice (no retry after rebase)
|
# Should have called update_pr_branch to trigger server-side rebase
|
||||||
|
mock_client.update_pr_branch.assert_called_once_with(7, style="rebase")
|
||||||
|
# Must NOT have called merge_pr twice (no immediate retry)
|
||||||
assert mock_client.merge_pr.call_count == 1
|
assert mock_client.merge_pr.call_count == 1
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||||
|
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
|
||||||
|
@patch("devx.ci.auto_merge.GiteaClient")
|
||||||
|
def test_merge_behind_master_rebase_failure_raises(
|
||||||
|
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
|
||||||
|
) -> None: # type: ignore[no-untyped-def]
|
||||||
|
"""When auto-rebase fails, raise with manual rebase instructions."""
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.get_pr_commits.return_value = [
|
||||||
|
{"commit": {"message": "fix: resolve timeout"}},
|
||||||
|
]
|
||||||
|
mock_client.merge_pr.side_effect = APIError(405, "HEAD branch is behind master")
|
||||||
|
mock_client.update_pr_branch.side_effect = APIError(409, "Conflict during rebase")
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
main,
|
||||||
|
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "auto-rebase failed" in result.output.lower()
|
||||||
|
assert "rebase manually" in result.output.lower()
|
||||||
|
|
||||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
|
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||||
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
|
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
|
||||||
@patch("devx.ci.auto_merge.GiteaClient")
|
@patch("devx.ci.auto_merge.GiteaClient")
|
||||||
@@ -386,10 +413,10 @@ class TestMain:
|
|||||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
|
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||||
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
|
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
|
||||||
@patch("devx.ci.auto_merge.GiteaClient")
|
@patch("devx.ci.auto_merge.GiteaClient")
|
||||||
def test_merge_behind_master_does_not_force_push(
|
def test_merge_behind_master_does_not_run_git_commands(
|
||||||
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
|
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
|
||||||
) -> None: # type: ignore[no-untyped-def]
|
) -> None: # type: ignore[no-untyped-def]
|
||||||
"""Verify no git commands are run when branch is behind master."""
|
"""When behind master, auto-merge uses API rebase — no local git commands."""
|
||||||
monkeypatch.chdir(tmp_path)
|
monkeypatch.chdir(tmp_path)
|
||||||
|
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
@@ -405,8 +432,8 @@ class TestMain:
|
|||||||
main,
|
main,
|
||||||
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
|
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
|
||||||
)
|
)
|
||||||
assert result.exit_code != 0
|
assert result.exit_code == 0
|
||||||
# No git commands should be run (no rebase, no push)
|
# No local git commands should be run (rebase is via API)
|
||||||
mock_run.assert_not_called()
|
mock_run.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -184,6 +184,14 @@ class TestMain:
|
|||||||
result = runner.invoke(check_translations.main, ["--translations", str(trans_file)])
|
result = runner.invoke(check_translations.main, ["--translations", str(trans_file)])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_no_translations_file_skips(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""When no translations file is found, should pass with skip message."""
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(check_translations.main, [])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "No translations file found" in result.output
|
||||||
|
|
||||||
|
|
||||||
class TestPrintResult:
|
class TestPrintResult:
|
||||||
def test_prints_all_good(self, capsys: pytest.CaptureFixture[str]) -> None:
|
def test_prints_all_good(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
|||||||
@@ -87,6 +87,13 @@ class TestCiCommands:
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
mock_run.assert_called_once_with("devx.ci.doc_coverage", [])
|
mock_run.assert_called_once_with("devx.ci.doc_coverage", [])
|
||||||
|
|
||||||
|
@patch("devx.cli._run_module")
|
||||||
|
def test_ci_lint_docs(self, mock_run: MagicMock) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["ci", "lint-docs", "--", "--root", "."])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
mock_run.assert_called_once_with("devx.ci.lint_docs", ["--root", "."])
|
||||||
|
|
||||||
@patch("devx.cli._run_module")
|
@patch("devx.cli._run_module")
|
||||||
def test_ci_notify_failure(self, mock_run: MagicMock) -> None:
|
def test_ci_notify_failure(self, mock_run: MagicMock) -> None:
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
@@ -194,6 +201,20 @@ class TestToolsCommands:
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
mock_run.assert_called_once_with("devx.tools.setup", [])
|
mock_run.assert_called_once_with("devx.tools.setup", [])
|
||||||
|
|
||||||
|
@patch("devx.cli._run_module")
|
||||||
|
def test_tools_rebase(self, mock_run: MagicMock) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["tools", "rebase", "--", "--no-push"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
mock_run.assert_called_once_with("devx.tools.rebase", ["--no-push"])
|
||||||
|
|
||||||
|
@patch("devx.cli._run_module")
|
||||||
|
def test_tools_pr_rebase(self, mock_run: MagicMock) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["tools", "pr-rebase", "--", "--pr", "42"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
mock_run.assert_called_once_with("devx.tools.pr_rebase", ["--pr", "42"])
|
||||||
|
|
||||||
|
|
||||||
class TestMoleculeCommands:
|
class TestMoleculeCommands:
|
||||||
@patch("devx.cli._run_module")
|
@patch("devx.cli._run_module")
|
||||||
|
|||||||
@@ -38,6 +38,31 @@ class TestIsReleaseCommit:
|
|||||||
assert detect_release_commit.is_release_commit("") is False
|
assert detect_release_commit.is_release_commit("") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsBadgeCommit:
|
||||||
|
def test_badge_commit(self) -> None:
|
||||||
|
assert detect_release_commit.is_badge_commit("chore: update badge URLs to commit abc123 [skip ci]") is True
|
||||||
|
|
||||||
|
def test_regular_chore(self) -> None:
|
||||||
|
assert detect_release_commit.is_badge_commit("chore: cleanup deps") is False
|
||||||
|
|
||||||
|
def test_empty(self) -> None:
|
||||||
|
assert detect_release_commit.is_badge_commit("") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsAutomatedCommit:
|
||||||
|
def test_release_is_automated(self) -> None:
|
||||||
|
assert detect_release_commit.is_automated_commit("release: v1.0.0 [skip ci]") is True
|
||||||
|
|
||||||
|
def test_badge_is_automated(self) -> None:
|
||||||
|
assert detect_release_commit.is_automated_commit("chore: update badge URLs to commit abc123 [skip ci]") is True
|
||||||
|
|
||||||
|
def test_regular_is_not_automated(self) -> None:
|
||||||
|
assert detect_release_commit.is_automated_commit("OBL-INFRA-363: fix: something") is False
|
||||||
|
|
||||||
|
def test_empty(self) -> None:
|
||||||
|
assert detect_release_commit.is_automated_commit("") is False
|
||||||
|
|
||||||
|
|
||||||
class TestWriteGithubOutput:
|
class TestWriteGithubOutput:
|
||||||
def test_write(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_write(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
gh_file = tmp_path / "output.txt"
|
gh_file = tmp_path / "output.txt"
|
||||||
@@ -62,7 +87,26 @@ class TestMain:
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Release commit" in result.output
|
assert "Release commit" in result.output
|
||||||
with open(gh_file) as f:
|
with open(gh_file) as f:
|
||||||
assert "is-release=true" in f.read()
|
content = f.read()
|
||||||
|
assert "is-release=true" in content
|
||||||
|
assert "is-automated=true" in content
|
||||||
|
|
||||||
|
def test_badge_commit(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
gh_file = tmp_path / "output.txt"
|
||||||
|
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||||
|
with patch.object(
|
||||||
|
detect_release_commit,
|
||||||
|
"get_commit_message",
|
||||||
|
return_value="chore: update badge URLs to commit abc123 [skip ci]",
|
||||||
|
):
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(detect_release_commit.main, [])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Automated CI commit" in result.output
|
||||||
|
with open(gh_file) as f:
|
||||||
|
content = f.read()
|
||||||
|
assert "is-release=false" in content
|
||||||
|
assert "is-automated=true" in content
|
||||||
|
|
||||||
def test_regular_commit(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_regular_commit(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
gh_file = tmp_path / "output.txt"
|
gh_file = tmp_path / "output.txt"
|
||||||
@@ -73,4 +117,6 @@ class TestMain:
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Regular merge commit" in result.output
|
assert "Regular merge commit" in result.output
|
||||||
with open(gh_file) as f:
|
with open(gh_file) as f:
|
||||||
assert "is-release=false" in f.read()
|
content = f.read()
|
||||||
|
assert "is-release=false" in content
|
||||||
|
assert "is-automated=false" in content
|
||||||
|
|||||||
@@ -12,10 +12,13 @@ from devx.ci.doc_coverage import (
|
|||||||
main,
|
main,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Path to devx's own source directory (for testing)
|
||||||
|
DEVX_SRC_DIR = Path(__file__).resolve().parent.parent.parent / "src" / "devx"
|
||||||
|
|
||||||
|
|
||||||
class TestExtractCliCommands:
|
class TestExtractCliCommands:
|
||||||
def test_extracts_commands(self) -> None:
|
def test_extracts_commands(self) -> None:
|
||||||
commands = extract_cli_commands()
|
commands = extract_cli_commands(DEVX_SRC_DIR)
|
||||||
# devx CLI has commands under ci, tools, and molecule groups
|
# devx CLI has commands under ci, tools, and molecule groups
|
||||||
assert "auto-merge" in commands
|
assert "auto-merge" in commands
|
||||||
assert "release" in commands
|
assert "release" in commands
|
||||||
@@ -24,39 +27,30 @@ class TestExtractCliCommands:
|
|||||||
assert "install-tools" in commands
|
assert "install-tools" in commands
|
||||||
|
|
||||||
def test_returns_list(self) -> None:
|
def test_returns_list(self) -> None:
|
||||||
commands = extract_cli_commands()
|
commands = extract_cli_commands(DEVX_SRC_DIR)
|
||||||
assert isinstance(commands, list)
|
assert isinstance(commands, list)
|
||||||
assert len(commands) > 0
|
assert len(commands) > 0
|
||||||
|
|
||||||
def test_no_cli_file(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_no_cli_file(self, tmp_path: Path) -> None:
|
||||||
"""Returns empty list when CLI file doesn't exist."""
|
"""Returns empty list when CLI file doesn't exist."""
|
||||||
from devx.ci import doc_coverage
|
commands = extract_cli_commands(tmp_path)
|
||||||
|
|
||||||
monkeypatch.setattr(doc_coverage, "CLI_FILE", Path("/nonexistent/cli.py"))
|
|
||||||
commands = extract_cli_commands()
|
|
||||||
assert commands == []
|
assert commands == []
|
||||||
|
|
||||||
def test_def_fallback_no_explicit_name(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_def_fallback_no_explicit_name(self, tmp_path: Path) -> None:
|
||||||
"""When a command decorator has no explicit name, falls back to the def name."""
|
"""When a command decorator has no explicit name, falls back to the def name."""
|
||||||
from devx.ci import doc_coverage
|
|
||||||
|
|
||||||
fake_cli = tmp_path / "cli.py"
|
fake_cli = tmp_path / "cli.py"
|
||||||
fake_cli.write_text("@click.group()\ndef cli():\n pass\n@cli.command()\ndef my_command():\n pass\n")
|
fake_cli.write_text("@click.group()\ndef cli():\n pass\n@cli.command()\ndef my_command():\n pass\n")
|
||||||
monkeypatch.setattr(doc_coverage, "CLI_FILE", fake_cli)
|
commands = extract_cli_commands(tmp_path)
|
||||||
commands = extract_cli_commands()
|
|
||||||
assert "my_command" in commands
|
assert "my_command" in commands
|
||||||
|
|
||||||
def test_command_decorator_no_def_fallback(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_command_decorator_no_def_fallback(self, tmp_path: Path) -> None:
|
||||||
"""When a command decorator has no name and no following def, it is skipped."""
|
"""When a command decorator has no name and no following def, it is skipped."""
|
||||||
from devx.ci import doc_coverage
|
|
||||||
|
|
||||||
fake_cli = tmp_path / "cli.py"
|
fake_cli = tmp_path / "cli.py"
|
||||||
# The last @cli.command() has no explicit name and no def statement after it
|
# The last @cli.command() has no explicit name and no def statement after it
|
||||||
fake_cli.write_text(
|
fake_cli.write_text(
|
||||||
"@click.group()\ndef cli():\n pass\n@cli.command()\ndef real_cmd():\n pass\n@cli.command()\npass\n"
|
"@click.group()\ndef cli():\n pass\n@cli.command()\ndef real_cmd():\n pass\n@cli.command()\npass\n"
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(doc_coverage, "CLI_FILE", fake_cli)
|
commands = extract_cli_commands(tmp_path)
|
||||||
commands = extract_cli_commands()
|
|
||||||
# real_cmd should be found via def fallback; the bare @cli.command() is skipped
|
# real_cmd should be found via def fallback; the bare @cli.command() is skipped
|
||||||
assert "real_cmd" in commands
|
assert "real_cmd" in commands
|
||||||
assert "pass" not in commands
|
assert "pass" not in commands
|
||||||
@@ -96,19 +90,29 @@ class TestMain:
|
|||||||
docs = tmp_path / "docs"
|
docs = tmp_path / "docs"
|
||||||
(docs / "user").mkdir(parents=True)
|
(docs / "user").mkdir(parents=True)
|
||||||
(docs / "tech").mkdir(parents=True)
|
(docs / "tech").mkdir(parents=True)
|
||||||
# Get actual commands from the CLI
|
src = tmp_path / "src" / "devx"
|
||||||
commands = extract_cli_commands()
|
src.mkdir(parents=True)
|
||||||
|
(src / "ci").mkdir()
|
||||||
|
(src / "__init__.py").write_text("")
|
||||||
|
(src / "ci" / "__init__.py").write_text("")
|
||||||
|
# Create a fake cli.py with some commands
|
||||||
|
(src / "cli.py").write_text(
|
||||||
|
"@click.group()\ndef cli():\n pass\n"
|
||||||
|
"@cli.command('release')\ndef release():\n pass\n"
|
||||||
|
"@cli.command('setup')\ndef setup():\n pass\n"
|
||||||
|
)
|
||||||
|
# Create a fake module and CI script
|
||||||
|
(src / "config.py").write_text("# config module")
|
||||||
|
(src / "ci" / "auto_merge.py").write_text("# auto_merge script")
|
||||||
# Write cli-commands.md with all commands
|
# Write cli-commands.md with all commands
|
||||||
cli_content = "\n".join(f"## {cmd}" for cmd in commands)
|
cli_content = "## release\n\n## setup\n"
|
||||||
(docs / "user" / "cli-commands.md").write_text(cli_content)
|
(docs / "user" / "cli-commands.md").write_text(cli_content)
|
||||||
# Write architecture.md with all modules
|
# Write architecture.md with all modules
|
||||||
from devx.ci.doc_coverage import REQUIRED_MODULES, REQUIRED_SCRIPTS
|
(docs / "tech" / "architecture.md").write_text("config.py")
|
||||||
|
|
||||||
(docs / "tech" / "architecture.md").write_text(" ".join(REQUIRED_MODULES))
|
|
||||||
# Write ci-cd-workflow.md with all scripts
|
# Write ci-cd-workflow.md with all scripts
|
||||||
(docs / "tech" / "ci-cd-workflow.md").write_text(" ".join(REQUIRED_SCRIPTS))
|
(docs / "tech" / "ci-cd-workflow.md").write_text("auto_merge.py")
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(main, ["--docs-dir", str(docs)])
|
result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src)])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "100%" in result.output
|
assert "100%" in result.output
|
||||||
|
|
||||||
@@ -117,11 +121,21 @@ class TestMain:
|
|||||||
docs = tmp_path / "docs"
|
docs = tmp_path / "docs"
|
||||||
(docs / "user").mkdir(parents=True)
|
(docs / "user").mkdir(parents=True)
|
||||||
(docs / "tech").mkdir(parents=True)
|
(docs / "tech").mkdir(parents=True)
|
||||||
|
src = tmp_path / "src" / "devx"
|
||||||
|
src.mkdir(parents=True)
|
||||||
|
(src / "ci").mkdir()
|
||||||
|
(src / "__init__.py").write_text("")
|
||||||
|
(src / "ci" / "__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")
|
||||||
|
(src / "ci" / "auto_merge.py").write_text("# auto_merge")
|
||||||
(docs / "user" / "cli-commands.md").write_text("No commands here.")
|
(docs / "user" / "cli-commands.md").write_text("No commands here.")
|
||||||
(docs / "tech" / "architecture.md").write_text("No modules here.")
|
(docs / "tech" / "architecture.md").write_text("No modules here.")
|
||||||
(docs / "tech" / "ci-cd-workflow.md").write_text("No scripts here.")
|
(docs / "tech" / "ci-cd-workflow.md").write_text("No scripts here.")
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(main, ["--docs-dir", str(docs), "--fail-on-missing"])
|
result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src), "--fail-on-missing"])
|
||||||
assert result.exit_code == 1
|
assert result.exit_code == 1
|
||||||
|
|
||||||
def test_missing_docs_warn_only(self, tmp_path: Path) -> None:
|
def test_missing_docs_warn_only(self, tmp_path: Path) -> None:
|
||||||
@@ -129,10 +143,55 @@ class TestMain:
|
|||||||
docs = tmp_path / "docs"
|
docs = tmp_path / "docs"
|
||||||
(docs / "user").mkdir(parents=True)
|
(docs / "user").mkdir(parents=True)
|
||||||
(docs / "tech").mkdir(parents=True)
|
(docs / "tech").mkdir(parents=True)
|
||||||
|
src = tmp_path / "src" / "devx"
|
||||||
|
src.mkdir(parents=True)
|
||||||
|
(src / "ci").mkdir()
|
||||||
|
(src / "__init__.py").write_text("")
|
||||||
|
(src / "ci" / "__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")
|
||||||
|
(src / "ci" / "auto_merge.py").write_text("# auto_merge")
|
||||||
(docs / "user" / "cli-commands.md").write_text("No commands here.")
|
(docs / "user" / "cli-commands.md").write_text("No commands here.")
|
||||||
(docs / "tech" / "architecture.md").write_text("No modules here.")
|
(docs / "tech" / "architecture.md").write_text("No modules here.")
|
||||||
(docs / "tech" / "ci-cd-workflow.md").write_text("No scripts here.")
|
(docs / "tech" / "ci-cd-workflow.md").write_text("No scripts here.")
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(main, ["--docs-dir", str(docs)])
|
result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src)])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "MISSING" in result.output
|
assert "MISSING" in result.output
|
||||||
|
|
||||||
|
def test_auto_detect_scripts_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""When src/ doesn't exist but scripts/ does, auto-detect it."""
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
docs = tmp_path / "docs"
|
||||||
|
(docs / "user").mkdir(parents=True)
|
||||||
|
(docs / "tech").mkdir(parents=True)
|
||||||
|
scripts = tmp_path / "scripts"
|
||||||
|
scripts.mkdir()
|
||||||
|
(scripts / "cli.py").write_text(
|
||||||
|
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
|
||||||
|
)
|
||||||
|
(scripts / "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)])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_no_source_dir_falls_back_to_required(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""When no source dir exists, falls back to REQUIRED_MODULES/SCRIPTS."""
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
docs = tmp_path / "docs"
|
||||||
|
(docs / "user").mkdir(parents=True)
|
||||||
|
(docs / "tech").mkdir(parents=True)
|
||||||
|
(docs / "user" / "cli-commands.md").write_text("")
|
||||||
|
from devx.ci.doc_coverage import REQUIRED_MODULES, REQUIRED_SCRIPTS
|
||||||
|
|
||||||
|
(docs / "tech" / "architecture.md").write_text(" ".join(REQUIRED_MODULES))
|
||||||
|
(docs / "tech" / "ci-cd-workflow.md").write_text(" ".join(REQUIRED_SCRIPTS))
|
||||||
|
runner = CliRunner()
|
||||||
|
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
|
||||||
|
|||||||
@@ -0,0 +1,436 @@
|
|||||||
|
"""Unit tests for devx.ci.lint_docs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from devx.ci.lint_docs import (
|
||||||
|
check_docs_structure,
|
||||||
|
check_duplicate_headings,
|
||||||
|
check_heading_hierarchy,
|
||||||
|
check_internal_links,
|
||||||
|
check_required_files,
|
||||||
|
check_stale_docs,
|
||||||
|
check_todo_fixme,
|
||||||
|
check_trailing_whitespace,
|
||||||
|
extract_headings,
|
||||||
|
extract_links,
|
||||||
|
main,
|
||||||
|
slugify,
|
||||||
|
strip_code_blocks,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSlugify:
|
||||||
|
def test_basic(self) -> None:
|
||||||
|
assert slugify("Hello World") == "hello-world"
|
||||||
|
|
||||||
|
def test_special_chars(self) -> None:
|
||||||
|
assert slugify("Hello, World!") == "hello-world"
|
||||||
|
|
||||||
|
def test_multiple_spaces(self) -> None:
|
||||||
|
assert slugify("Hello World") == "hello-world"
|
||||||
|
|
||||||
|
def test_trailing_dash(self) -> None:
|
||||||
|
assert slugify("Hello World -") == "hello-world--"
|
||||||
|
|
||||||
|
def test_empty(self) -> None:
|
||||||
|
assert slugify("") == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractHeadings:
|
||||||
|
def test_extracts_headings(self, tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "test.md"
|
||||||
|
f.write_text("# Title\n\n## Section\n\n### Subsection\n")
|
||||||
|
headings = extract_headings(f)
|
||||||
|
assert "title" in headings
|
||||||
|
assert headings["title"] == 1
|
||||||
|
assert "section" in headings
|
||||||
|
assert headings["section"] == 2
|
||||||
|
assert "subsection" in headings
|
||||||
|
assert headings["subsection"] == 3
|
||||||
|
|
||||||
|
def test_no_headings(self, tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "test.md"
|
||||||
|
f.write_text("Just some text.\nNo headings here.\n")
|
||||||
|
headings = extract_headings(f)
|
||||||
|
assert headings == {}
|
||||||
|
|
||||||
|
def test_ignores_headings_in_code_blocks(self, tmp_path: Path) -> None:
|
||||||
|
"""Headings inside code blocks should not be detected."""
|
||||||
|
f = tmp_path / "test.md"
|
||||||
|
f.write_text("# Title\n\n```bash\n# Not a heading\n## Also not\n```\n\n## Real Section\n")
|
||||||
|
headings = extract_headings(f)
|
||||||
|
assert "title" in headings
|
||||||
|
assert "real-section" in headings
|
||||||
|
assert "not-a-heading" not in headings
|
||||||
|
assert "also-not" not in headings
|
||||||
|
|
||||||
|
|
||||||
|
class TestStripCodeBlocks:
|
||||||
|
def test_strips_fenced_blocks(self) -> None:
|
||||||
|
content = "Before\n```bash\n# comment\n```\nAfter"
|
||||||
|
result = strip_code_blocks(content)
|
||||||
|
assert "# comment" not in result
|
||||||
|
assert "Before" in result
|
||||||
|
assert "After" in result
|
||||||
|
|
||||||
|
def test_strips_multiple_blocks(self) -> None:
|
||||||
|
content = "# Title\n```python\ncode1\n```\nText\n```yaml\ncode2\n```\nEnd"
|
||||||
|
result = strip_code_blocks(content)
|
||||||
|
assert "code1" not in result
|
||||||
|
assert "code2" not in result
|
||||||
|
assert "Text" in result
|
||||||
|
assert "End" in result
|
||||||
|
|
||||||
|
def test_no_code_blocks(self) -> None:
|
||||||
|
content = "# Title\n\nSome text."
|
||||||
|
result = strip_code_blocks(content)
|
||||||
|
assert result == content
|
||||||
|
|
||||||
|
def test_preserves_line_numbers(self) -> None:
|
||||||
|
content = "Line1\n```\nLine3\n```\nLine5"
|
||||||
|
result = strip_code_blocks(content)
|
||||||
|
lines = result.splitlines()
|
||||||
|
assert len(lines) == 5
|
||||||
|
assert lines[0] == "Line1"
|
||||||
|
assert lines[4] == "Line5"
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractLinks:
|
||||||
|
def test_extracts_internal_links(self, tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "test.md"
|
||||||
|
f.write_text("[link](other.md)\n[external](https://example.com)\n[anchor](#section)\n")
|
||||||
|
links = extract_links(f)
|
||||||
|
# Should return internal + anchor links (not http or mailto)
|
||||||
|
assert len(links) == 2
|
||||||
|
assert links[0][2] == "other.md"
|
||||||
|
assert links[1][2] == "#section"
|
||||||
|
|
||||||
|
def test_extracts_links_with_anchors(self, tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "test.md"
|
||||||
|
f.write_text("[link](other.md#section)\n")
|
||||||
|
links = extract_links(f)
|
||||||
|
assert len(links) == 1
|
||||||
|
assert links[0][2] == "other.md#section"
|
||||||
|
|
||||||
|
def test_skips_mailto(self, tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "test.md"
|
||||||
|
f.write_text("[email](mailto:test@example.com)\n")
|
||||||
|
links = extract_links(f)
|
||||||
|
assert links == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckRequiredFiles:
|
||||||
|
def test_all_present(self, tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "README.md").write_text("# README")
|
||||||
|
(tmp_path / "AGENTS.md").write_text("# AGENTS")
|
||||||
|
(tmp_path / "CHANGELOG.md").write_text("# CHANGELOG")
|
||||||
|
issues = check_required_files(tmp_path)
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
def test_missing_files(self, tmp_path: Path) -> None:
|
||||||
|
issues = check_required_files(tmp_path)
|
||||||
|
assert len(issues) == 3
|
||||||
|
assert any("README.md" in i for i in issues)
|
||||||
|
assert any("AGENTS.md" in i for i in issues)
|
||||||
|
assert any("CHANGELOG.md" in i for i in issues)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckDocsStructure:
|
||||||
|
def test_all_present(self, tmp_path: Path) -> None:
|
||||||
|
docs = tmp_path / "docs"
|
||||||
|
docs.mkdir()
|
||||||
|
(docs / "index.md").write_text("# Home")
|
||||||
|
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home"}))
|
||||||
|
issues = check_docs_structure(tmp_path, docs)
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
def test_missing_docs_dir(self, tmp_path: Path) -> None:
|
||||||
|
issues = check_docs_structure(tmp_path, tmp_path / "docs")
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert "Docs directory not found" in issues[0]
|
||||||
|
|
||||||
|
def test_missing_index(self, tmp_path: Path) -> None:
|
||||||
|
docs = tmp_path / "docs"
|
||||||
|
docs.mkdir()
|
||||||
|
issues = check_docs_structure(tmp_path, docs)
|
||||||
|
assert any("index.md" in i for i in issues)
|
||||||
|
|
||||||
|
def test_invalid_mapping_json(self, tmp_path: Path) -> None:
|
||||||
|
docs = tmp_path / "docs"
|
||||||
|
docs.mkdir()
|
||||||
|
(docs / "index.md").write_text("# Home")
|
||||||
|
(docs / "mapping.json").write_text("{invalid json")
|
||||||
|
issues = check_docs_structure(tmp_path, docs)
|
||||||
|
assert any("invalid JSON" in i for i in issues)
|
||||||
|
|
||||||
|
def test_empty_mapping(self, tmp_path: Path) -> None:
|
||||||
|
docs = tmp_path / "docs"
|
||||||
|
docs.mkdir()
|
||||||
|
(docs / "index.md").write_text("# Home")
|
||||||
|
(docs / "mapping.json").write_text("{}")
|
||||||
|
issues = check_docs_structure(tmp_path, docs)
|
||||||
|
assert any("empty" in i for i in issues)
|
||||||
|
|
||||||
|
def test_mapping_not_object(self, tmp_path: Path) -> None:
|
||||||
|
docs = tmp_path / "docs"
|
||||||
|
docs.mkdir()
|
||||||
|
(docs / "index.md").write_text("# Home")
|
||||||
|
(docs / "mapping.json").write_text("[]")
|
||||||
|
issues = check_docs_structure(tmp_path, docs)
|
||||||
|
assert any("JSON object" in i for i in issues)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckInternalLinks:
|
||||||
|
def test_valid_links(self, tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "README.md").write_text("[link](docs/guide.md)\n")
|
||||||
|
docs = tmp_path / "docs"
|
||||||
|
docs.mkdir()
|
||||||
|
(docs / "guide.md").write_text("# Guide\n")
|
||||||
|
issues = check_internal_links(tmp_path, docs)
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
def test_broken_file_link(self, tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "README.md").write_text("[link](nonexistent.md)\n")
|
||||||
|
issues = check_internal_links(tmp_path, tmp_path / "docs")
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert "file not found" in issues[0]
|
||||||
|
|
||||||
|
def test_broken_anchor(self, tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "README.md").write_text("[link](#missing-section)\n")
|
||||||
|
issues = check_internal_links(tmp_path, tmp_path / "docs")
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert "broken anchor" in issues[0]
|
||||||
|
|
||||||
|
def test_broken_anchor_in_target(self, tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "README.md").write_text("[link](guide.md#missing)\n")
|
||||||
|
(tmp_path / "guide.md").write_text("# Guide\n")
|
||||||
|
issues = check_internal_links(tmp_path, tmp_path / "docs")
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert "broken anchor" in issues[0]
|
||||||
|
|
||||||
|
def test_valid_anchor_in_target(self, tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "README.md").write_text("[link](guide.md#section)\n")
|
||||||
|
(tmp_path / "guide.md").write_text("# Section\n")
|
||||||
|
issues = check_internal_links(tmp_path, tmp_path / "docs")
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
def test_wiki_page_link_skipped(self, tmp_path: Path) -> None:
|
||||||
|
"""Links matching wiki page names in mapping.json should be skipped."""
|
||||||
|
docs = tmp_path / "docs"
|
||||||
|
docs.mkdir()
|
||||||
|
(docs / "index.md").write_text("[Architecture](Architecture)\n")
|
||||||
|
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home", "tech/architecture.md": "Architecture"}))
|
||||||
|
issues = check_internal_links(tmp_path, docs)
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
def test_non_wiki_page_no_extension_skipped(self, tmp_path: Path) -> None:
|
||||||
|
"""Links without file extension and no slash should be skipped (can't verify)."""
|
||||||
|
(tmp_path / "README.md").write_text("[SomePage](SomePage)\n")
|
||||||
|
issues = check_internal_links(tmp_path, tmp_path / "docs")
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
def test_broken_anchor_in_target_with_content(self, tmp_path: Path) -> None:
|
||||||
|
"""Broken anchor in an existing target file should be flagged."""
|
||||||
|
(tmp_path / "README.md").write_text("[link](guide.md#missing)\n")
|
||||||
|
(tmp_path / "guide.md").write_text("# Real Title\n\nSome content here.\n")
|
||||||
|
issues = check_internal_links(tmp_path, tmp_path / "docs")
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert "broken anchor" in issues[0]
|
||||||
|
|
||||||
|
def test_valid_anchor_in_target_with_content(self, tmp_path: Path) -> None:
|
||||||
|
"""Valid anchor in an existing target file should pass."""
|
||||||
|
(tmp_path / "README.md").write_text("[link](guide.md#real-title)\n")
|
||||||
|
(tmp_path / "guide.md").write_text("# Real Title\n\nSome content.\n")
|
||||||
|
issues = check_internal_links(tmp_path, tmp_path / "docs")
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
def test_invalid_mapping_json_ignored(self, tmp_path: Path) -> None:
|
||||||
|
"""Invalid mapping.json should not crash link checking."""
|
||||||
|
docs = tmp_path / "docs"
|
||||||
|
docs.mkdir()
|
||||||
|
(docs / "index.md").write_text("[link](guide.md)\n")
|
||||||
|
(docs / "guide.md").write_text("# Guide\n")
|
||||||
|
(docs / "mapping.json").write_text("{invalid json")
|
||||||
|
issues = check_internal_links(tmp_path, docs)
|
||||||
|
# Should still work — just without wiki page mappings
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckHeadingHierarchy:
|
||||||
|
def test_valid_hierarchy(self, tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "README.md").write_text("# Title\n## Section\n### Sub\n")
|
||||||
|
issues = check_heading_hierarchy(tmp_path)
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
def test_skipped_level(self, tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "README.md").write_text("# Title\n### Sub\n")
|
||||||
|
issues = check_heading_hierarchy(tmp_path)
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert "hierarchy skip" in issues[0]
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckTodoFixme:
|
||||||
|
def test_no_todo(self, tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "README.md").write_text("Just some text.\n")
|
||||||
|
issues = check_todo_fixme(tmp_path)
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
def test_found_todo(self, tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "README.md").write_text("TODO: fix this later\n")
|
||||||
|
issues = check_todo_fixme(tmp_path)
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert "TODO" in issues[0]
|
||||||
|
|
||||||
|
def test_found_fixme(self, tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "README.md").write_text("FIXME: broken code\n")
|
||||||
|
issues = check_todo_fixme(tmp_path)
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert "FIXME" in issues[0]
|
||||||
|
|
||||||
|
def test_ignores_todo_in_rules(self, tmp_path: Path) -> None:
|
||||||
|
"""References to 'TODO' in rules docs should not be flagged."""
|
||||||
|
(tmp_path / "README.md").write_text("Best practices (no `print()`, no `TODO`/`FIXME`)\n")
|
||||||
|
issues = check_todo_fixme(tmp_path)
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
def test_ignores_todo_without_colon(self, tmp_path: Path) -> None:
|
||||||
|
"""'TODO' without a colon should not be flagged."""
|
||||||
|
(tmp_path / "README.md").write_text("The TODO list is empty\n")
|
||||||
|
issues = check_todo_fixme(tmp_path)
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckTrailingWhitespace:
|
||||||
|
def test_no_trailing(self, tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "README.md").write_text("No trailing whitespace here\n")
|
||||||
|
issues = check_trailing_whitespace(tmp_path)
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
def test_trailing_spaces(self, tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "README.md").write_text("Trailing spaces \n")
|
||||||
|
issues = check_trailing_whitespace(tmp_path)
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert "trailing whitespace" in issues[0]
|
||||||
|
|
||||||
|
def test_trailing_tabs(self, tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "README.md").write_text("Trailing tabs\t\n")
|
||||||
|
issues = check_trailing_whitespace(tmp_path)
|
||||||
|
assert len(issues) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckStaleDocs:
|
||||||
|
def test_fresh_doc(self, tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "README.md").write_text("Fresh content\n")
|
||||||
|
issues = check_stale_docs(tmp_path)
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
def test_stale_doc(self, tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "README.md"
|
||||||
|
f.write_text("Old content\n")
|
||||||
|
# Set mtime to 200 days ago
|
||||||
|
old_time = (datetime.now() - timedelta(days=200)).timestamp()
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.utime(f, (old_time, old_time))
|
||||||
|
issues = check_stale_docs(tmp_path)
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert "stale" in issues[0]
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckDuplicateHeadings:
|
||||||
|
def test_no_duplicates(self, tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "README.md").write_text("# Title\n## Section\n")
|
||||||
|
issues = check_duplicate_headings(tmp_path)
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
def test_duplicates(self, tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "README.md").write_text("# Title\n# Title\n")
|
||||||
|
issues = check_duplicate_headings(tmp_path)
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert "duplicate heading" in issues[0]
|
||||||
|
|
||||||
|
def test_changelog_excluded(self, tmp_path: Path) -> None:
|
||||||
|
"""CHANGELOG.md should be excluded from duplicate heading checks."""
|
||||||
|
(tmp_path / "CHANGELOG.md").write_text("# Features\n# Features\n# Features\n")
|
||||||
|
issues = check_duplicate_headings(tmp_path)
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestMain:
|
||||||
|
def test_passes_clean_repo(self, tmp_path: Path) -> None:
|
||||||
|
"""A clean repo with all files should pass."""
|
||||||
|
(tmp_path / "README.md").write_text("# Title\n\nContent here.\n")
|
||||||
|
(tmp_path / "AGENTS.md").write_text("# AGENTS\n\nContent here.\n")
|
||||||
|
(tmp_path / "CHANGELOG.md").write_text("# Changelog\n\nContent here.\n")
|
||||||
|
docs = tmp_path / "docs"
|
||||||
|
docs.mkdir()
|
||||||
|
(docs / "index.md").write_text("# Home\n")
|
||||||
|
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home"}))
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--root", str(tmp_path)])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "PASS" in result.output
|
||||||
|
|
||||||
|
def test_fails_on_missing_files(self, tmp_path: Path) -> None:
|
||||||
|
"""Missing required files should fail."""
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--root", str(tmp_path)])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "FAIL" in result.output
|
||||||
|
|
||||||
|
def test_fix_trailing_whitespace(self, tmp_path: Path) -> None:
|
||||||
|
"""--fix should auto-fix trailing whitespace."""
|
||||||
|
(tmp_path / "README.md").write_text("# Title\n\nContent here. \n")
|
||||||
|
(tmp_path / "AGENTS.md").write_text("# AGENTS\n\nContent here.\n")
|
||||||
|
(tmp_path / "CHANGELOG.md").write_text("# Changelog\n\nContent here.\n")
|
||||||
|
docs = tmp_path / "docs"
|
||||||
|
docs.mkdir()
|
||||||
|
(docs / "index.md").write_text("# Home\n")
|
||||||
|
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home"}))
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--root", str(tmp_path), "--fix"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
# Verify whitespace was fixed
|
||||||
|
content = (tmp_path / "README.md").read_text()
|
||||||
|
assert "Content here. \n" not in content
|
||||||
|
assert "Content here.\n" in content
|
||||||
|
|
||||||
|
def test_no_check_links(self, tmp_path: Path) -> None:
|
||||||
|
"""--no-check-links should skip link checking."""
|
||||||
|
(tmp_path / "README.md").write_text("# Title\n[broken](nonexistent.md)\n")
|
||||||
|
(tmp_path / "AGENTS.md").write_text("# AGENTS\n")
|
||||||
|
(tmp_path / "CHANGELOG.md").write_text("# Changelog\n")
|
||||||
|
docs = tmp_path / "docs"
|
||||||
|
docs.mkdir()
|
||||||
|
(docs / "index.md").write_text("# Home\n")
|
||||||
|
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home"}))
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--root", str(tmp_path), "--no-check-links"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_stale_docs_warning(self, tmp_path: Path) -> None:
|
||||||
|
"""--check-stale should warn but not fail."""
|
||||||
|
(tmp_path / "README.md").write_text("# Title\n")
|
||||||
|
(tmp_path / "AGENTS.md").write_text("# AGENTS\n")
|
||||||
|
(tmp_path / "CHANGELOG.md").write_text("# Changelog\n")
|
||||||
|
docs = tmp_path / "docs"
|
||||||
|
docs.mkdir()
|
||||||
|
(docs / "index.md").write_text("# Home\n")
|
||||||
|
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home"}))
|
||||||
|
# Make README stale
|
||||||
|
import os
|
||||||
|
|
||||||
|
f = tmp_path / "README.md"
|
||||||
|
old_time = (datetime.now() - timedelta(days=200)).timestamp()
|
||||||
|
os.utime(f, (old_time, old_time))
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--root", str(tmp_path), "--check-stale"])
|
||||||
|
# Stale docs are warnings, not errors
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "stale" in result.output
|
||||||
@@ -516,6 +516,36 @@ class TestCheckDocumentation:
|
|||||||
check_documentation(files, result)
|
check_documentation(files, result)
|
||||||
assert any("Documentation: OK" in s for s in result.summary)
|
assert any("Documentation: OK" in s for s in result.summary)
|
||||||
|
|
||||||
|
def test_tofu_changes_without_docs_warns(self) -> None:
|
||||||
|
result = ReviewResult()
|
||||||
|
files = [{"filename": "tofu/modules/hetzner-vm/main.tf"}]
|
||||||
|
check_documentation(files, result)
|
||||||
|
assert any("WARNING" in s for s in result.summary)
|
||||||
|
|
||||||
|
def test_workflow_changes_info(self) -> None:
|
||||||
|
result = ReviewResult()
|
||||||
|
files = [{"filename": ".gitea/workflows/ci.yml"}]
|
||||||
|
check_documentation(files, result)
|
||||||
|
assert any("INFO" in s for s in result.summary)
|
||||||
|
|
||||||
|
def test_todo_in_doc_patch_warns(self) -> None:
|
||||||
|
result = ReviewResult()
|
||||||
|
files = [{"filename": "docs/guide.md", "patch": "+TODO: fix this later\n+Some content\n"}]
|
||||||
|
check_documentation(files, result)
|
||||||
|
assert any("TODO" in s for s in result.summary)
|
||||||
|
|
||||||
|
def test_todo_in_readme_patch_warns(self) -> None:
|
||||||
|
result = ReviewResult()
|
||||||
|
files = [{"filename": "README.md", "patch": "+FIXME: broken\n"}]
|
||||||
|
check_documentation(files, result)
|
||||||
|
assert any("FIXME" in s for s in result.summary)
|
||||||
|
|
||||||
|
def test_no_todo_in_doc_patch_ok(self) -> None:
|
||||||
|
result = ReviewResult()
|
||||||
|
files = [{"filename": "docs/guide.md", "patch": "+Some content\n"}]
|
||||||
|
check_documentation(files, result)
|
||||||
|
assert not any("TODO" in s for s in result.summary)
|
||||||
|
|
||||||
|
|
||||||
class TestCheckTestCoverage:
|
class TestCheckTestCoverage:
|
||||||
def test_src_changes_without_tests_warns(self) -> None:
|
def test_src_changes_without_tests_warns(self) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
"""Tests for devx.tools.rebase, devx.tools.pr_rebase, and detect_pr_number."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from devx.tools.pr_rebase import main as pr_rebase_main
|
||||||
|
from devx.tools.rebase import main as rebase_main
|
||||||
|
|
||||||
|
_FULL_ENV = {
|
||||||
|
"CI_GITEA_TOKEN": "tok",
|
||||||
|
"DEVX_REPO_OWNER": "owner",
|
||||||
|
"DEVX_REPO_NAME": "repo",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunGitHelper:
|
||||||
|
"""Tests for the _run_git helper function."""
|
||||||
|
|
||||||
|
@patch("devx.tools.rebase.subprocess.run")
|
||||||
|
def test_run_git_with_check(self, mock_run: MagicMock) -> None:
|
||||||
|
"""_run_git passes check=True by default."""
|
||||||
|
from devx.tools.rebase import _run_git
|
||||||
|
|
||||||
|
mock_run.return_value = MagicMock(stdout="ok\n", returncode=0)
|
||||||
|
result = _run_git(["status"])
|
||||||
|
mock_run.assert_called_once_with(
|
||||||
|
["git", "status"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
assert result.stdout == "ok\n"
|
||||||
|
|
||||||
|
@patch("devx.tools.rebase.subprocess.run")
|
||||||
|
def test_run_git_without_check(self, mock_run: MagicMock) -> None:
|
||||||
|
"""_run_git passes check=False when specified."""
|
||||||
|
from devx.tools.rebase import _run_git
|
||||||
|
|
||||||
|
mock_run.return_value = MagicMock(stdout="", stderr="err", returncode=1)
|
||||||
|
result = _run_git(["rebase", "origin/master"], check=False)
|
||||||
|
mock_run.assert_called_once_with(
|
||||||
|
["git", "rebase", "origin/master"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
assert result.returncode == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestDetectPrNumber:
|
||||||
|
"""Tests for the detect_pr_number helper in _shared."""
|
||||||
|
|
||||||
|
@patch("devx.tools._shared.subprocess.run")
|
||||||
|
@patch.dict("os.environ", _FULL_ENV, clear=True)
|
||||||
|
@patch("devx.api_clients.GiteaClient")
|
||||||
|
def test_detect_pr_found(self, mock_client_cls: MagicMock, mock_run: MagicMock) -> None:
|
||||||
|
"""detect_pr_number returns PR number when branch has an open PR."""
|
||||||
|
from devx.tools._shared import detect_pr_number
|
||||||
|
|
||||||
|
mock_run.return_value = MagicMock(stdout="feature-branch\n", returncode=0)
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.list_prs.return_value = [
|
||||||
|
{"number": 42, "head": {"ref": "feature-branch"}},
|
||||||
|
{"number": 99, "head": {"ref": "other-branch"}},
|
||||||
|
]
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
result = detect_pr_number()
|
||||||
|
assert result == 42
|
||||||
|
|
||||||
|
@patch("devx.tools._shared.subprocess.run")
|
||||||
|
@patch.dict("os.environ", _FULL_ENV, clear=True)
|
||||||
|
@patch("devx.api_clients.GiteaClient")
|
||||||
|
def test_detect_pr_not_found(self, mock_client_cls: MagicMock, mock_run: MagicMock) -> None:
|
||||||
|
"""detect_pr_number returns None when no open PR matches branch."""
|
||||||
|
from devx.tools._shared import detect_pr_number
|
||||||
|
|
||||||
|
mock_run.return_value = MagicMock(stdout="no-pr-branch\n", returncode=0)
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.list_prs.return_value = [
|
||||||
|
{"number": 42, "head": {"ref": "other-branch"}},
|
||||||
|
]
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
result = detect_pr_number()
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@patch("devx.tools._shared.subprocess.run")
|
||||||
|
def test_detect_pr_detached_head(self, mock_run: MagicMock) -> None:
|
||||||
|
"""detect_pr_number returns None on detached HEAD."""
|
||||||
|
from devx.tools._shared import detect_pr_number
|
||||||
|
|
||||||
|
mock_run.return_value = MagicMock(stdout="HEAD\n", returncode=0)
|
||||||
|
result = detect_pr_number()
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@patch("devx.tools._shared.subprocess.run")
|
||||||
|
def test_detect_pr_git_failure(self, mock_run: MagicMock) -> None:
|
||||||
|
"""detect_pr_number returns None when git command fails."""
|
||||||
|
from devx.tools._shared import detect_pr_number
|
||||||
|
|
||||||
|
mock_run.return_value = MagicMock(stdout="", stderr="error", returncode=1)
|
||||||
|
result = detect_pr_number()
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@patch("devx.tools._shared.subprocess.run")
|
||||||
|
@patch.dict("os.environ", {}, clear=True)
|
||||||
|
def test_detect_pr_no_token(self, mock_run: MagicMock) -> None:
|
||||||
|
"""detect_pr_number returns None when CI_GITEA_TOKEN is not set."""
|
||||||
|
from devx.tools._shared import detect_pr_number
|
||||||
|
|
||||||
|
mock_run.return_value = MagicMock(stdout="feature\n", returncode=0)
|
||||||
|
result = detect_pr_number()
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@patch("devx.tools._shared.subprocess.run")
|
||||||
|
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "GITHUB_REPOSITORY": "owner/repo"}, clear=True)
|
||||||
|
@patch("devx.api_clients.GiteaClient")
|
||||||
|
def test_detect_pr_github_repo_fallback(self, mock_client_cls: MagicMock, mock_run: MagicMock) -> None:
|
||||||
|
"""detect_pr_number uses GITHUB_REPOSITORY as fallback for owner/repo."""
|
||||||
|
from devx.tools._shared import detect_pr_number
|
||||||
|
|
||||||
|
mock_run.return_value = MagicMock(stdout="feature\n", returncode=0)
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.list_prs.return_value = [{"number": 7, "head": {"ref": "feature"}}]
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
result = detect_pr_number()
|
||||||
|
assert result == 7
|
||||||
|
|
||||||
|
@patch("devx.tools._shared.subprocess.run")
|
||||||
|
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "GITHUB_REPOSITORY": "invalid-no-slash"}, clear=True)
|
||||||
|
def test_detect_pr_github_repo_no_slash(self, mock_run: MagicMock) -> None:
|
||||||
|
"""GITHUB_REPOSITORY without slash is ignored, returns None."""
|
||||||
|
from devx.tools._shared import detect_pr_number
|
||||||
|
|
||||||
|
mock_run.return_value = MagicMock(stdout="feature\n", returncode=0)
|
||||||
|
result = detect_pr_number()
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@patch("devx.tools._shared.subprocess.run")
|
||||||
|
@patch.dict("os.environ", _FULL_ENV, clear=True)
|
||||||
|
@patch("devx.api_clients.GiteaClient")
|
||||||
|
def test_detect_pr_api_error_returns_none(self, mock_client_cls: MagicMock, mock_run: MagicMock) -> None:
|
||||||
|
"""detect_pr_number returns None when API call fails (best-effort)."""
|
||||||
|
from devx.api_clients import APIError
|
||||||
|
from devx.tools._shared import detect_pr_number
|
||||||
|
|
||||||
|
mock_run.return_value = MagicMock(stdout="feature\n", returncode=0)
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.list_prs.side_effect = APIError(401, "Unauthorized")
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
result = detect_pr_number()
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestRebaseTool:
|
||||||
|
"""Tests for the local rebase tool (devx.tools.rebase)."""
|
||||||
|
|
||||||
|
@patch("devx.tools.rebase._run_git")
|
||||||
|
def test_rebase_already_up_to_date(self, mock_run_git: MagicMock) -> None:
|
||||||
|
"""When branch is up-to-date, no rebase or push happens."""
|
||||||
|
mock_run_git.side_effect = [
|
||||||
|
MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse
|
||||||
|
MagicMock(stdout="", returncode=0), # fetch
|
||||||
|
MagicMock(stdout="0\n", returncode=0), # rev-list --count
|
||||||
|
]
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(rebase_main, [])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "already up-to-date" in result.output.lower()
|
||||||
|
|
||||||
|
@patch("devx.tools.rebase._run_git")
|
||||||
|
def test_rebase_behind_master_success(self, mock_run_git: MagicMock) -> None:
|
||||||
|
"""When behind master, rebase and force-push."""
|
||||||
|
mock_run_git.side_effect = [
|
||||||
|
MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse
|
||||||
|
MagicMock(stdout="", returncode=0), # fetch
|
||||||
|
MagicMock(stdout="2\n", returncode=0), # rev-list --count (behind by 2)
|
||||||
|
MagicMock(stdout="", stderr="", returncode=0), # rebase
|
||||||
|
MagicMock(stdout="", stderr="", returncode=0), # push
|
||||||
|
]
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(rebase_main, [])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "2 commit(s) behind" in result.output
|
||||||
|
assert "rebase successful" in result.output.lower()
|
||||||
|
assert "pushed" in result.output.lower()
|
||||||
|
|
||||||
|
@patch("devx.tools.rebase._run_git")
|
||||||
|
def test_rebase_no_push_flag(self, mock_run_git: MagicMock) -> None:
|
||||||
|
"""With --no-push, rebase happens but no push."""
|
||||||
|
mock_run_git.side_effect = [
|
||||||
|
MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse
|
||||||
|
MagicMock(stdout="", returncode=0), # fetch
|
||||||
|
MagicMock(stdout="1\n", returncode=0), # rev-list --count
|
||||||
|
MagicMock(stdout="", stderr="", returncode=0), # rebase
|
||||||
|
]
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(rebase_main, ["--no-push"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "rebase successful" in result.output.lower()
|
||||||
|
# Only 4 git calls (no push)
|
||||||
|
assert mock_run_git.call_count == 4
|
||||||
|
|
||||||
|
@patch("devx.tools.rebase._run_git")
|
||||||
|
def test_rebase_detached_head_fails(self, mock_run_git: MagicMock) -> None:
|
||||||
|
"""Detached HEAD should fail immediately."""
|
||||||
|
mock_run_git.return_value = MagicMock(stdout="HEAD\n", returncode=0)
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(rebase_main, [])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "detached" in result.output.lower()
|
||||||
|
|
||||||
|
@patch("devx.tools.rebase._run_git")
|
||||||
|
def test_rebase_branch_detection_failure(self, mock_run_git: MagicMock) -> None:
|
||||||
|
"""Git rev-parse failure should exit with error."""
|
||||||
|
mock_run_git.return_value = MagicMock(stdout="", stderr="fatal: not a repo", returncode=1)
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(rebase_main, [])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "could not detect" in result.output.lower()
|
||||||
|
|
||||||
|
@patch("devx.tools.rebase._run_git")
|
||||||
|
def test_rebase_conflict_fails(self, mock_run_git: MagicMock) -> None:
|
||||||
|
"""Rebase conflict should exit with error."""
|
||||||
|
mock_run_git.side_effect = [
|
||||||
|
MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse
|
||||||
|
MagicMock(stdout="", returncode=0), # fetch
|
||||||
|
MagicMock(stdout="1\n", returncode=0), # rev-list --count
|
||||||
|
MagicMock(stdout="", stderr="CONFLICT", returncode=1), # rebase fails
|
||||||
|
]
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(rebase_main, [])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "rebase failed" in result.output.lower()
|
||||||
|
|
||||||
|
@patch("devx.tools.rebase._run_git")
|
||||||
|
def test_rebase_fetch_failure(self, mock_run_git: MagicMock) -> None:
|
||||||
|
"""Fetch failure should exit with error."""
|
||||||
|
mock_run_git.side_effect = [
|
||||||
|
MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse
|
||||||
|
MagicMock(stdout="", stderr="network error", returncode=1), # fetch fails
|
||||||
|
]
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(rebase_main, [])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "fetch failed" in result.output.lower()
|
||||||
|
|
||||||
|
@patch("devx.tools.rebase._run_git")
|
||||||
|
def test_rebase_push_failure(self, mock_run_git: MagicMock) -> None:
|
||||||
|
"""Force-push rejection should exit with error."""
|
||||||
|
mock_run_git.side_effect = [
|
||||||
|
MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse
|
||||||
|
MagicMock(stdout="", returncode=0), # fetch
|
||||||
|
MagicMock(stdout="1\n", returncode=0), # rev-list --count
|
||||||
|
MagicMock(stdout="", stderr="", returncode=0), # rebase
|
||||||
|
MagicMock(stdout="", stderr="rejected", returncode=1), # push fails
|
||||||
|
]
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(rebase_main, [])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "force-push failed" in result.output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestPrRebaseTool:
|
||||||
|
"""Tests for the server-side PR rebase tool (devx.tools.pr_rebase)."""
|
||||||
|
|
||||||
|
@patch.dict("os.environ", _FULL_ENV, clear=True)
|
||||||
|
@patch("devx.tools.pr_rebase.GiteaClient")
|
||||||
|
def test_pr_rebase_success(self, mock_client_cls: MagicMock) -> None:
|
||||||
|
"""Successful API rebase prints confirmation."""
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(pr_rebase_main, ["--pr", "42"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "rebased successfully" in result.output.lower()
|
||||||
|
mock_client.update_pr_branch.assert_called_once_with(42, style="rebase")
|
||||||
|
|
||||||
|
@patch.dict("os.environ", _FULL_ENV, clear=True)
|
||||||
|
@patch("devx.tools.pr_rebase.GiteaClient")
|
||||||
|
def test_pr_rebase_api_error(self, mock_client_cls: MagicMock) -> None:
|
||||||
|
"""API error during rebase exits with error."""
|
||||||
|
from devx.api_clients import APIError
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.update_pr_branch.side_effect = APIError(409, "Conflict")
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(pr_rebase_main, ["--pr", "42"])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "rebase failed" in result.output.lower()
|
||||||
|
|
||||||
|
@patch("devx.tools.pr_rebase.load_dotenv")
|
||||||
|
@patch.dict("os.environ", {}, clear=True)
|
||||||
|
def test_pr_rebase_no_token(self, _mock_load: MagicMock) -> None:
|
||||||
|
"""Missing CI_GITEA_TOKEN should fail."""
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(pr_rebase_main, ["--pr", "42"])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "CI_GITEA_TOKEN" in result.output
|
||||||
|
|
||||||
|
@patch.dict("os.environ", _FULL_ENV, clear=True)
|
||||||
|
@patch("devx.tools.pr_rebase.detect_pr_number", return_value=None)
|
||||||
|
def test_pr_rebase_no_pr_detected(self, _mock_detect: MagicMock) -> None:
|
||||||
|
"""When PR number can't be auto-detected, fail with instructions."""
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(pr_rebase_main, [])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "could not detect" in result.output.lower()
|
||||||
|
|
||||||
|
@patch("devx.tools.pr_rebase.load_dotenv")
|
||||||
|
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||||
|
@patch("devx.tools.pr_rebase.GiteaClient")
|
||||||
|
def test_pr_rebase_no_repo_env(self, _mock_client: MagicMock, _mock_load: MagicMock) -> None:
|
||||||
|
"""Missing repo env vars should fail."""
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(pr_rebase_main, ["--pr", "42"])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "DEVX_REPO_OWNER" in result.output
|
||||||
|
|
||||||
|
@patch("devx.tools.pr_rebase.load_dotenv")
|
||||||
|
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "GITHUB_REPOSITORY": "owner/repo"}, clear=True)
|
||||||
|
@patch("devx.tools.pr_rebase.GiteaClient")
|
||||||
|
def test_pr_rebase_github_repo_fallback(self, mock_client_cls: MagicMock, _mock_load: MagicMock) -> None:
|
||||||
|
"""GITHUB_REPOSITORY env var is used as fallback for owner/repo."""
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(pr_rebase_main, ["--pr", "42"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
mock_client.update_pr_branch.assert_called_once_with(42, style="rebase")
|
||||||
@@ -1234,6 +1234,153 @@ class TestMain:
|
|||||||
assert "already existed" in result.output
|
assert "already existed" in result.output
|
||||||
mock_tag.assert_called_once_with("0.2.0", "changelog", False)
|
mock_tag.assert_called_once_with("0.2.0", "changelog", False)
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {})
|
||||||
|
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||||
|
@patch("devx.ci.release.fetch_tags")
|
||||||
|
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
|
||||||
|
@patch("devx.ci.release.run_tests")
|
||||||
|
@patch("devx.ci.release.create_and_push_tag", return_value=True)
|
||||||
|
@patch("devx.ci.release.commit_release_changes", return_value=True)
|
||||||
|
@patch("devx.ci.release.update_changelog")
|
||||||
|
@patch("devx.ci.release.update_init_version")
|
||||||
|
@patch("devx.ci.release.get_changelog", return_value="changelog")
|
||||||
|
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
|
||||||
|
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
|
||||||
|
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
|
||||||
|
@patch("devx.ci.release.time.sleep")
|
||||||
|
@patch("devx.ci.release.run_cmd")
|
||||||
|
def test_push_retry_succeeds_after_rebase_failure(
|
||||||
|
self,
|
||||||
|
mock_run_cmd: MagicMock,
|
||||||
|
mock_sleep: MagicMock,
|
||||||
|
mock_has: MagicMock,
|
||||||
|
mock_bumped: MagicMock,
|
||||||
|
mock_latest: MagicMock,
|
||||||
|
mock_changelog: MagicMock,
|
||||||
|
mock_update_init: MagicMock,
|
||||||
|
mock_update_changelog: MagicMock,
|
||||||
|
mock_commit: MagicMock,
|
||||||
|
mock_tag: MagicMock,
|
||||||
|
mock_run_tests: MagicMock,
|
||||||
|
mock_user: MagicMock,
|
||||||
|
mock_ft: MagicMock,
|
||||||
|
mock_vtc: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Push should retry after rebase failure and succeed on second attempt."""
|
||||||
|
ok = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||||
|
rebase_fail = MagicMock(returncode=1, stdout="", stderr="conflict")
|
||||||
|
rebase_abort = MagicMock(returncode=0, stdout="", stderr="")
|
||||||
|
push_ok = MagicMock(returncode=0, stdout="", stderr="")
|
||||||
|
# git rev-parse → ok, git log -1 → ok (non-release msg)
|
||||||
|
# pull --rebase → fail, rebase --abort → ok
|
||||||
|
# pull --rebase → ok, push → ok
|
||||||
|
mock_run_cmd.side_effect = [ok, ok, rebase_fail, rebase_abort, ok, push_ok]
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, [])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Rebase attempt 1/3 failed" in result.output
|
||||||
|
assert "Pushed release commit to master" in result.output
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {})
|
||||||
|
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||||
|
@patch("devx.ci.release.fetch_tags")
|
||||||
|
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
|
||||||
|
@patch("devx.ci.release.run_tests")
|
||||||
|
@patch("devx.ci.release.create_and_push_tag", return_value=True)
|
||||||
|
@patch("devx.ci.release.commit_release_changes", return_value=True)
|
||||||
|
@patch("devx.ci.release.update_changelog")
|
||||||
|
@patch("devx.ci.release.update_init_version")
|
||||||
|
@patch("devx.ci.release.get_changelog", return_value="changelog")
|
||||||
|
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
|
||||||
|
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
|
||||||
|
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
|
||||||
|
@patch("devx.ci.release.time.sleep")
|
||||||
|
@patch("devx.ci.release.run_cmd")
|
||||||
|
def test_push_fails_after_all_retries(
|
||||||
|
self,
|
||||||
|
mock_run_cmd: MagicMock,
|
||||||
|
mock_sleep: MagicMock,
|
||||||
|
mock_has: MagicMock,
|
||||||
|
mock_bumped: MagicMock,
|
||||||
|
mock_latest: MagicMock,
|
||||||
|
mock_changelog: MagicMock,
|
||||||
|
mock_update_init: MagicMock,
|
||||||
|
mock_update_changelog: MagicMock,
|
||||||
|
mock_commit: MagicMock,
|
||||||
|
mock_tag: MagicMock,
|
||||||
|
mock_run_tests: MagicMock,
|
||||||
|
mock_user: MagicMock,
|
||||||
|
mock_ft: MagicMock,
|
||||||
|
mock_vtc: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Push should fail after 3 unsuccessful rebase attempts."""
|
||||||
|
ok = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||||
|
rebase_fail = MagicMock(returncode=1, stdout="", stderr="conflict")
|
||||||
|
rebase_abort = MagicMock(returncode=0, stdout="", stderr="")
|
||||||
|
# git rev-parse → ok, git log -1 → ok
|
||||||
|
# 3 attempts: pull --rebase → fail, rebase --abort → ok
|
||||||
|
mock_run_cmd.side_effect = [
|
||||||
|
ok,
|
||||||
|
ok,
|
||||||
|
rebase_fail,
|
||||||
|
rebase_abort, # attempt 1
|
||||||
|
rebase_fail,
|
||||||
|
rebase_abort, # attempt 2
|
||||||
|
rebase_fail,
|
||||||
|
rebase_abort, # attempt 3
|
||||||
|
]
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, [])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "Failed to push release commit after 3 attempts" in result.output
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {})
|
||||||
|
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||||
|
@patch("devx.ci.release.fetch_tags")
|
||||||
|
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
|
||||||
|
@patch("devx.ci.release.run_tests")
|
||||||
|
@patch("devx.ci.release.create_and_push_tag", return_value=True)
|
||||||
|
@patch("devx.ci.release.commit_release_changes", return_value=True)
|
||||||
|
@patch("devx.ci.release.update_changelog")
|
||||||
|
@patch("devx.ci.release.update_init_version")
|
||||||
|
@patch("devx.ci.release.get_changelog", return_value="changelog")
|
||||||
|
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
|
||||||
|
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
|
||||||
|
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
|
||||||
|
@patch("devx.ci.release.time.sleep")
|
||||||
|
@patch("devx.ci.release.run_cmd")
|
||||||
|
def test_push_retry_succeeds_after_push_failure(
|
||||||
|
self,
|
||||||
|
mock_run_cmd: MagicMock,
|
||||||
|
mock_sleep: MagicMock,
|
||||||
|
mock_has: MagicMock,
|
||||||
|
mock_bumped: MagicMock,
|
||||||
|
mock_latest: MagicMock,
|
||||||
|
mock_changelog: MagicMock,
|
||||||
|
mock_update_init: MagicMock,
|
||||||
|
mock_update_changelog: MagicMock,
|
||||||
|
mock_commit: MagicMock,
|
||||||
|
mock_tag: MagicMock,
|
||||||
|
mock_run_tests: MagicMock,
|
||||||
|
mock_user: MagicMock,
|
||||||
|
mock_ft: MagicMock,
|
||||||
|
mock_vtc: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Push should retry after push rejection and succeed on second attempt."""
|
||||||
|
ok = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||||
|
rebase_ok = MagicMock(returncode=0, stdout="", stderr="")
|
||||||
|
push_fail = MagicMock(returncode=1, stdout="", stderr="non-fast-forward")
|
||||||
|
push_ok = MagicMock(returncode=0, stdout="", stderr="")
|
||||||
|
# git rev-parse → ok, git log -1 → ok
|
||||||
|
# attempt 1: pull --rebase → ok, push → fail
|
||||||
|
# attempt 2: pull --rebase → ok, push → ok
|
||||||
|
mock_run_cmd.side_effect = [ok, ok, rebase_ok, push_fail, rebase_ok, push_ok]
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, [])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Push attempt 1/3 failed" in result.output
|
||||||
|
assert "Pushed release commit to master" in result.output
|
||||||
|
|
||||||
@patch.dict("os.environ", {})
|
@patch.dict("os.environ", {})
|
||||||
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||||
@patch("devx.ci.release.fetch_tags")
|
@patch("devx.ci.release.fetch_tags")
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from devx.ci.sync_wiki import (
|
|||||||
verify_wiki_integrity,
|
verify_wiki_integrity,
|
||||||
verify_wiki_page,
|
verify_wiki_page,
|
||||||
)
|
)
|
||||||
|
from devx.exceptions import APIError
|
||||||
|
|
||||||
|
|
||||||
class TestEncodeContent:
|
class TestEncodeContent:
|
||||||
@@ -97,13 +98,11 @@ class TestReadDocContent:
|
|||||||
|
|
||||||
|
|
||||||
class TestListWikiPages:
|
class TestListWikiPages:
|
||||||
def test_returns_empty_on_api_error(self) -> None:
|
def test_raises_on_api_error(self) -> None:
|
||||||
from devx.exceptions import APIError
|
|
||||||
|
|
||||||
client = MagicMock()
|
client = MagicMock()
|
||||||
client._request.side_effect = APIError(404, "not found")
|
client._request.side_effect = APIError(404, "not found")
|
||||||
result = list_wiki_pages(client)
|
with pytest.raises(APIError):
|
||||||
assert result == {}
|
list_wiki_pages(client)
|
||||||
|
|
||||||
def test_returns_page_dict(self) -> None:
|
def test_returns_page_dict(self) -> None:
|
||||||
client = MagicMock()
|
client = MagicMock()
|
||||||
@@ -284,6 +283,43 @@ class TestVerifyWikiIntegrity:
|
|||||||
failures = verify_wiki_integrity(client, mapping, synced)
|
failures = verify_wiki_integrity(client, mapping, synced)
|
||||||
assert len(failures) >= 3 # count mismatch, missing FAQ, stale Stale, empty Home
|
assert len(failures) >= 3 # count mismatch, missing FAQ, stale Stale, empty Home
|
||||||
|
|
||||||
|
def test_transient_api_failure_returns_empty(self) -> None:
|
||||||
|
"""When the wiki API is unavailable after retries, integrity check
|
||||||
|
should return no failures (sync already succeeded)."""
|
||||||
|
client = MagicMock()
|
||||||
|
|
||||||
|
# _list_wiki_pages_with_retry raises APIError (retries exhausted)
|
||||||
|
with patch("devx.ci.sync_wiki._list_wiki_pages_with_retry", side_effect=APIError(0, "timeout")):
|
||||||
|
mapping = {"index.md": "Home", "faq.md": "FAQ"}
|
||||||
|
synced = {"Home": "# Home", "FAQ": "# FAQ"}
|
||||||
|
failures = verify_wiki_integrity(client, mapping, synced)
|
||||||
|
assert failures == []
|
||||||
|
|
||||||
|
def test_transient_api_failure_recovers_on_retry(self) -> None:
|
||||||
|
"""When the wiki API recovers after a retry, integrity check proceeds normally."""
|
||||||
|
client = MagicMock()
|
||||||
|
pages = {"Home": "Home", "FAQ": "FAQ"}
|
||||||
|
contents = {"Home": "# Home", "FAQ": "# FAQ"}
|
||||||
|
|
||||||
|
def mock_request(method, path, **kwargs):
|
||||||
|
resp = MagicMock()
|
||||||
|
if path == "/wiki/pages":
|
||||||
|
page_list = [{"title": t, "sub_url": s} for t, s in pages.items()]
|
||||||
|
resp.json.return_value = page_list
|
||||||
|
elif path.startswith("/wiki/page/"):
|
||||||
|
sub_url = path.replace("/wiki/page/", "")
|
||||||
|
content = contents.get(sub_url, "")
|
||||||
|
encoded = base64.b64encode(content.encode()).decode("ascii") if content else ""
|
||||||
|
resp.json.return_value = {"content_base64": encoded}
|
||||||
|
return resp
|
||||||
|
|
||||||
|
client._request.side_effect = mock_request
|
||||||
|
|
||||||
|
mapping = {"index.md": "Home", "faq.md": "FAQ"}
|
||||||
|
synced = {"Home": "# Home", "FAQ": "# FAQ"}
|
||||||
|
failures = verify_wiki_integrity(client, mapping, synced)
|
||||||
|
assert failures == []
|
||||||
|
|
||||||
|
|
||||||
class TestMain:
|
class TestMain:
|
||||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||||
@@ -498,3 +534,41 @@ class TestMain:
|
|||||||
result = runner.invoke(main, ["--dry-run", "--strict", "--repo", "owner/repo"])
|
result = runner.invoke(main, ["--dry-run", "--strict", "--repo", "owner/repo"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Integrity check" not in result.output
|
assert "Integrity check" not in result.output
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||||
|
@patch("devx.ci.sync_wiki.GiteaClient")
|
||||||
|
def test_initial_list_api_error_treated_as_empty(self, mock_client_cls: MagicMock) -> None:
|
||||||
|
"""When the initial page list fails, sync proceeds treating wiki as empty."""
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||||
|
mock_mapping.exists.return_value = True
|
||||||
|
with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
||||||
|
with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"):
|
||||||
|
with patch("devx.ci.sync_wiki.list_wiki_pages", side_effect=APIError(0, "timeout")):
|
||||||
|
with patch("devx.ci.sync_wiki.sync_page", return_value="created"):
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--repo", "owner/repo"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Created: Home" in result.output
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||||
|
@patch("devx.ci.sync_wiki.GiteaClient")
|
||||||
|
def test_verify_skips_when_refetch_fails(self, mock_client_cls: MagicMock) -> None:
|
||||||
|
"""When --verify re-fetch fails after retries, verification is skipped gracefully."""
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||||
|
mock_mapping.exists.return_value = True
|
||||||
|
with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
||||||
|
with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"):
|
||||||
|
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
|
||||||
|
with patch("devx.ci.sync_wiki.sync_page", return_value="updated"):
|
||||||
|
with patch(
|
||||||
|
"devx.ci.sync_wiki._list_wiki_pages_with_retry",
|
||||||
|
side_effect=APIError(0, "timeout"),
|
||||||
|
):
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--repo", "owner/repo", "--verify"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Skipping content verification" in result.output
|
||||||
|
|||||||
Reference in New Issue
Block a user