Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
587d3a6ca4 | ||
|
|
9d75e408ae | ||
|
|
412bbea01d | ||
|
|
ce5ce33a12 | ||
|
|
0fae419584 | ||
|
|
70b011d4a6 | ||
|
|
a5c16a92df | ||
|
|
e6f022ae96 | ||
|
|
1a27983750 | ||
|
|
5d7ed62b34 | ||
|
|
ee80c27631 | ||
|
|
98b1659579 | ||
|
|
c12d9abc6d | ||
|
|
40a65cb0a6 | ||
|
|
1a89738dd4 |
@@ -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
|
||||||
|
|||||||
@@ -2,6 +2,36 @@
|
|||||||
|
|
||||||
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.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
|
## [0.26.3] - 2026-06-28
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ quality badges.
|
|||||||
|
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/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
|
||||||
|
|
||||||
|
|||||||
+7
-6
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
|
|||||||
|
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.3"
|
__version__ = "0.27.3"
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ 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+")
|
||||||
|
|
||||||
@@ -44,13 +45,13 @@ def is_release_commit(message: str) -> bool:
|
|||||||
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 a release 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)
|
||||||
write_github_output("is-release", "true" if is_release else "false")
|
write_github_output("is-release", "true" if is_release 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."))
|
||||||
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:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
+980
-332
@@ -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",
|
||||||
@@ -463,6 +823,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 +879,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 +959,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 +991,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 +1015,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 +1031,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 +1159,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 +1207,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 +1255,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 +1271,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 +1303,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 +1319,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 +1335,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 +1471,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 +1519,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 +1567,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 +1631,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 +1663,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 +1759,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 +1863,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 +1951,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 +2063,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 +2095,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 +2119,22 @@
|
|||||||
"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."
|
||||||
|
},
|
||||||
"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,36 @@
|
|||||||
"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}...": {
|
|
||||||
"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 fetch logs: {error}": {
|
|
||||||
"en": " Could not fetch logs: {error}",
|
|
||||||
"bg": " Could not fetch logs: {error}",
|
|
||||||
"de": " Could not fetch logs: {error}",
|
|
||||||
"pl": " Could not fetch logs: {error}",
|
|
||||||
"ru": " Could not fetch logs: {error}",
|
|
||||||
"zh": " Could not fetch logs: {error}"
|
|
||||||
},
|
|
||||||
"Added label '{label}' to PR #{pr}.": {
|
|
||||||
"en": "Added label '{label}' to PR #{pr}.",
|
|
||||||
"bg": "Added label '{label}' to PR #{pr}.",
|
|
||||||
"de": "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}."
|
|
||||||
},
|
|
||||||
"CI checks did not complete within timeout.": {
|
|
||||||
"en": "CI checks did not complete within timeout.",
|
|
||||||
"bg": "CI checks did not complete within timeout.",
|
|
||||||
"de": "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.": {
|
|
||||||
"en": "CI checks failed.",
|
|
||||||
"bg": "CI checks failed.",
|
|
||||||
"de": "CI checks failed.",
|
|
||||||
"pl": "CI checks failed.",
|
|
||||||
"ru": "CI checks failed.",
|
|
||||||
"zh": "CI checks failed."
|
|
||||||
},
|
|
||||||
"CI_GITEA_TOKEN is not set.": {
|
|
||||||
"en": "CI_GITEA_TOKEN is not set.",
|
|
||||||
"bg": "CI_GITEA_TOKEN is not set.",
|
|
||||||
"de": "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."
|
|
||||||
},
|
|
||||||
"Checking status for PR #{pr_number}...": {
|
|
||||||
"en": "Checking status for PR #{pr_number}...",
|
|
||||||
"bg": "Checking status for PR #{pr_number}...",
|
|
||||||
"de": "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}..."
|
|
||||||
},
|
|
||||||
"Commit: {sha}": {
|
|
||||||
"en": "Commit: {sha}",
|
|
||||||
"bg": "Commit: {sha}",
|
|
||||||
"de": "Commit: {sha}",
|
|
||||||
"pl": "Commit: {sha}",
|
|
||||||
"ru": "Commit: {sha}",
|
|
||||||
"zh": "Commit: {sha}"
|
|
||||||
},
|
|
||||||
"Could not determine head SHA for PR #{pr_number}.": {
|
|
||||||
"en": "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}.",
|
|
||||||
"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}."
|
|
||||||
},
|
|
||||||
"Fetching logs for PR #{pr_number}...": {
|
|
||||||
"en": "Fetching logs for PR #{pr_number}...",
|
|
||||||
"bg": "Fetching logs for PR #{pr_number}...",
|
|
||||||
"de": "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}..."
|
|
||||||
},
|
|
||||||
"Label '{label}' already on PR #{pr}.": {
|
|
||||||
"en": "Label '{label}' already on PR #{pr}.",
|
|
||||||
"bg": "Label '{label}' already on PR #{pr}.",
|
|
||||||
"de": "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})": {
|
|
||||||
"en": "Latest run: #{run_id} (status: {status})",
|
|
||||||
"bg": "Latest run: #{run_id} (status: {status})",
|
|
||||||
"de": "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})"
|
|
||||||
},
|
|
||||||
"No CI checks found for commit {sha}.": {
|
|
||||||
"en": "No CI checks found for commit {sha}.",
|
|
||||||
"bg": "No CI checks found for commit {sha}.",
|
|
||||||
"de": "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 failed jobs.": {
|
|
||||||
"en": "No failed jobs.",
|
|
||||||
"bg": "No failed jobs.",
|
|
||||||
"de": "No failed jobs.",
|
|
||||||
"pl": "No failed jobs.",
|
|
||||||
"ru": "No failed jobs.",
|
|
||||||
"zh": "No failed jobs."
|
|
||||||
},
|
|
||||||
"No job matching '{job}' found.": {
|
|
||||||
"en": "No job matching '{job}' found.",
|
|
||||||
"bg": "No job matching '{job}' found.",
|
|
||||||
"de": "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}.": {
|
|
||||||
"en": "No jobs found for run #{run_id}.",
|
|
||||||
"bg": "No jobs found for run #{run_id}.",
|
|
||||||
"de": "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}'.": {
|
|
||||||
"en": "No open PR found for branch '{branch}'.",
|
|
||||||
"bg": "No open PR found for branch '{branch}'.",
|
|
||||||
"de": "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 workflow runs found for SHA {sha}.": {
|
|
||||||
"en": "No workflow runs found for SHA {sha}.",
|
|
||||||
"bg": "No workflow runs found for SHA {sha}.",
|
|
||||||
"de": "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}."
|
|
||||||
},
|
|
||||||
"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.",
|
|
||||||
"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.",
|
|
||||||
"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."
|
|
||||||
},
|
|
||||||
"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": " - 阻止管理员合并覆盖:是"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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