Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d7ed62b34 | ||
|
|
ee80c27631 | ||
|
|
98b1659579 | ||
|
|
c12d9abc6d | ||
|
|
40a65cb0a6 | ||
|
|
1a89738dd4 |
@@ -38,6 +38,12 @@ jobs:
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
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
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
|
||||
@@ -71,7 +71,8 @@ src/devx/
|
||||
│ ├── distribute_items.py # Distribute generic items (VMs, hosts) across parallel runners (LPT)
|
||||
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast
|
||||
│ ├── 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)
|
||||
│ ├── setup.py # Environment setup (venv, deps, hooks)
|
||||
│ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea, hadolint
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.27.0] - 2026-06-28
|
||||
|
||||
### Features
|
||||
|
||||
- Add lint_docs tool, fix doc_coverage/check_translations for any repo
|
||||
|
||||
## [0.26.4] - 2026-06-28
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Wrap all user-facing strings with _() for i18n completeness
|
||||
|
||||
## [0.26.3] - 2026-06-28
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -16,12 +16,12 @@ quality badges.
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Why devx?
|
||||
|
||||
@@ -158,6 +158,9 @@ python -m devx.ci.check_translations --translations path/to/translations.json
|
||||
# Documentation coverage check
|
||||
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
|
||||
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/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -158,6 +158,7 @@ for the full configuration reference, PR workflow, and project conventions.
|
||||
## Wiki pages
|
||||
|
||||
- [Home](Home) — This page
|
||||
- [Getting Started](Getting-Started) — Installation, configuration, and quick start guide
|
||||
- [CLI Commands](CLI-Commands) — Full CLI command documentation with examples
|
||||
- [Architecture](Architecture) — Package structure, module descriptions, design principles
|
||||
- [CI/CD Workflow](CI-CD-Workflow) — Pipeline documentation, workflows, and CI scripts
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"index.md": "Home",
|
||||
"user/getting-started.md": "Getting-Started",
|
||||
"user/cli-commands.md": "CLI-Commands",
|
||||
"tech/architecture.md": "Architecture",
|
||||
"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.
|
||||
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
|
||||
`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
|
||||
version). Other jobs skip. The tag push triggers `publish.yml`.
|
||||
|
||||
### Jobs
|
||||
### Post-merge jobs
|
||||
|
||||
#### `detect-type`
|
||||
|
||||
|
||||
@@ -127,14 +127,37 @@ Click commands from `cli.py` and checks if each has documentation in
|
||||
|
||||
```bash
|
||||
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:
|
||||
- `--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
|
||||
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`
|
||||
|
||||
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."""
|
||||
|
||||
__version__ = "0.26.3"
|
||||
__version__ = "0.27.0"
|
||||
|
||||
@@ -31,11 +31,11 @@ from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
||||
REPO_ROOT = Path.cwd()
|
||||
|
||||
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_SRC_DIR = REPO_ROOT / "src" / "devx"
|
||||
|
||||
@@ -169,20 +169,44 @@ def print_result(result: TranslationCheckResult) -> None:
|
||||
"translations",
|
||||
multiple=True,
|
||||
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."""
|
||||
results: list[TranslationCheckResult] = []
|
||||
if not translations:
|
||||
# Default: check the devx package's own translations
|
||||
results = [
|
||||
check_translation_set("devx", DEFAULT_SRC_DIR, DEFAULT_TRANS_FILE),
|
||||
# Auto-detect translations file in the current repo
|
||||
root = Path.cwd()
|
||||
# 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:
|
||||
results = []
|
||||
for trans_file in translations:
|
||||
# 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
|
||||
results.append(check_translation_set(name, src_dir, trans_file))
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import subprocess # nosec B404
|
||||
import click
|
||||
|
||||
from devx.ci._shared import write_github_output
|
||||
from devx.i18n import _
|
||||
|
||||
RELEASE_RE = re.compile(r"^release: v\d+\.\d+\.\d+")
|
||||
|
||||
@@ -44,13 +45,13 @@ def is_release_commit(message: str) -> bool:
|
||||
def main() -> None:
|
||||
"""Detect if the latest commit is a release commit and set GITHUB_OUTPUT."""
|
||||
msg = get_commit_message()
|
||||
click.echo(f"Commit message: {msg}")
|
||||
click.echo(_("Commit message: {msg}", msg=msg))
|
||||
is_release = is_release_commit(msg)
|
||||
write_github_output("is-release", "true" if is_release else "false")
|
||||
if is_release:
|
||||
click.echo("Release commit — skipping all post-merge jobs.")
|
||||
click.echo(_("Release commit — skipping all post-merge jobs."))
|
||||
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
|
||||
|
||||
@@ -30,6 +30,7 @@ import click
|
||||
import requests
|
||||
|
||||
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
|
||||
from devx.i18n import _
|
||||
|
||||
DEFAULT_MAX_RUNNERS = 3
|
||||
|
||||
@@ -55,9 +56,9 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
|
||||
data = r.json()
|
||||
total += data.get("total_count", 0)
|
||||
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:
|
||||
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
|
||||
try:
|
||||
@@ -70,9 +71,9 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
|
||||
data = r.json()
|
||||
total += data.get("total_count", 0)
|
||||
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:
|
||||
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)
|
||||
try:
|
||||
@@ -85,9 +86,12 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
|
||||
data = r.json()
|
||||
total += data.get("total_count", 0)
|
||||
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:
|
||||
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
|
||||
|
||||
@@ -165,8 +169,8 @@ def main(
|
||||
with open(gh_output, "a", encoding="utf-8") as f: # noqa: PTH123
|
||||
f.write(f"runner-count={count}\n")
|
||||
f.write(f"runner-indices={json.dumps(indices)}\n")
|
||||
click.echo(f"Runner count: {count}")
|
||||
click.echo(f"Runner indices: {indices}")
|
||||
click.echo(_("Runner count: {count}", count=count))
|
||||
click.echo(_("Runner indices: {indices}", indices=indices))
|
||||
return
|
||||
|
||||
if output_count:
|
||||
@@ -178,8 +182,8 @@ def main(
|
||||
return
|
||||
|
||||
# Default: output both as key=value pairs for CI consumption
|
||||
click.echo(f"count={count}")
|
||||
click.echo(f"indices={json.dumps(indices)}")
|
||||
click.echo(_("count={count}", count=count))
|
||||
click.echo(_("indices={indices}", indices=json.dumps(indices)))
|
||||
|
||||
|
||||
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)
|
||||
for i, group in enumerate(groups):
|
||||
labels = " ".join(group) if group else "(none)"
|
||||
click.echo(f"Runner {i}: {labels}")
|
||||
click.echo(_("Runner {i}: {labels}", i=i, labels=labels))
|
||||
return
|
||||
|
||||
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("SKIP", "true")
|
||||
return
|
||||
|
||||
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
|
||||
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:
|
||||
write_github_env("ASSIGNED_FILES", encoded)
|
||||
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
|
||||
|
||||
click.echo(encoded)
|
||||
|
||||
@@ -162,17 +162,25 @@ def main(
|
||||
groups = distribute(items, weights, max_runners)
|
||||
for i, group in enumerate(groups):
|
||||
labels = " ".join(group) if group else "(none)"
|
||||
click.echo(f"Runner {i}: {labels}")
|
||||
click.echo(_("Runner {i}: {labels}", i=i, labels=labels))
|
||||
return
|
||||
|
||||
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("SKIP", "true")
|
||||
return
|
||||
|
||||
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
|
||||
assigned = items_for_runner(items, weights, zero_based, max_runners)
|
||||
@@ -181,7 +189,14 @@ def main(
|
||||
if github_env:
|
||||
write_github_env("ASSIGNED_ITEMS", encoded)
|
||||
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
|
||||
|
||||
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
|
||||
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:
|
||||
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
|
||||
@@ -19,11 +23,12 @@ import click
|
||||
|
||||
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"
|
||||
CLI_FILE = REPO_ROOT / "src" / "devx" / "cli.py"
|
||||
|
||||
# Major modules that should be documented in tech/architecture.md
|
||||
# These are devx-specific; when checking other repos, use --source-dir
|
||||
REQUIRED_MODULES = [
|
||||
"cli.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."""
|
||||
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 []
|
||||
content = CLI_FILE.read_text()
|
||||
content = cli_file.read_text()
|
||||
commands: list[str] = []
|
||||
# Find all @<group>.command("name") occurrences in the CLI source
|
||||
# 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.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(
|
||||
"--fail-on-missing",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Exit with non-zero status if any documentation is missing.",
|
||||
)
|
||||
def main(docs_dir: str, fail_on_missing: bool) -> None:
|
||||
docs_path = Path(docs_dir)
|
||||
def main(docs_dir: str | None, source_dir: str | None, fail_on_missing: bool) -> None:
|
||||
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"
|
||||
architecture_file = docs_path / "tech" / "architecture.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
|
||||
click.echo(_("Checking CLI command documentation..."))
|
||||
commands = extract_cli_commands()
|
||||
commands = extract_cli_commands(src_path)
|
||||
total += len(commands)
|
||||
cli_docs = cli_commands_file.read_text() if cli_commands_file.exists() else ""
|
||||
for cmd in commands:
|
||||
if check_command_documented(cmd, cli_docs):
|
||||
click.echo(_(" OK: devx {cmd}", cmd=cmd))
|
||||
click.echo(_(" OK: {cmd}", cmd=cmd))
|
||||
else:
|
||||
click.echo(_(" MISSING: devx {cmd}", cmd=cmd))
|
||||
missing.append(f"CLI command: devx {cmd}")
|
||||
click.echo(_(" MISSING: {cmd}", cmd=cmd))
|
||||
missing.append(f"CLI command: {cmd}")
|
||||
|
||||
# Check modules in architecture.md
|
||||
# Auto-detect modules from source directory (top-level only, exclude subdirs)
|
||||
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 ""
|
||||
for module in REQUIRED_MODULES:
|
||||
for module in detected_modules:
|
||||
if check_module_documented(module, arch_docs):
|
||||
click.echo(_(" OK: {module}", module=module))
|
||||
else:
|
||||
@@ -134,10 +166,16 @@ def main(docs_dir: str, fail_on_missing: bool) -> None:
|
||||
missing.append(f"Module: {module}")
|
||||
|
||||
# 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..."))
|
||||
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 ""
|
||||
for script in REQUIRED_SCRIPTS:
|
||||
for script in detected_scripts:
|
||||
if check_module_documented(script, ci_docs):
|
||||
click.echo(_(" OK: {script}", script=script))
|
||||
else:
|
||||
|
||||
@@ -86,7 +86,7 @@ def cli(pytest_args: tuple[str, ...]) -> None:
|
||||
cmd = [sys.executable, "-m", "pytest"]
|
||||
cmd.extend(pytest_args)
|
||||
|
||||
click.echo(f"Running: {' '.join(cmd)}")
|
||||
click.echo(_("Running: {cmd}", cmd=" ".join(cmd)))
|
||||
|
||||
process = subprocess.Popen( # nosec B603
|
||||
cmd,
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
#!/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)
|
||||
DUPLICATE_HEADING_EXCLUDES = {"CHANGELOG.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)
|
||||
|
||||
|
||||
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 {".venv", ".git", "node_modules", "__pycache__", ".pytest_cache"} 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 {".venv", ".git", "node_modules", "__pycache__", ".pytest_cache"} 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 {".venv", ".git", "node_modules", "__pycache__", ".pytest_cache"} 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 {".venv", ".git", "node_modules", "__pycache__", ".pytest_cache"} 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 {".venv", ".git", "node_modules", "__pycache__", ".pytest_cache"} 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 {".venv", ".git", "node_modules", "__pycache__", ".pytest_cache"} 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 {".venv", ".git", "node_modules", "__pycache__", ".pytest_cache"} 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
|
||||
)
|
||||
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:
|
||||
result.add_summary("- Documentation: WARNING — source files changed but no docs updated")
|
||||
elif has_ansible_changes and not has_doc_changes:
|
||||
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:
|
||||
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:
|
||||
"""Check that tests are updated for source changes."""
|
||||
|
||||
+22
-11
@@ -28,6 +28,8 @@ from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
from devx.i18n import _
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
"""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", "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:
|
||||
@@ -76,8 +78,8 @@ def generate_badges(output_dir: str) -> None:
|
||||
_run([sys.executable, "-m", "devx.tools.generate_badges", "--output-dir", output_dir])
|
||||
badges = list(Path(output_dir).glob("*.svg"))
|
||||
if not badges:
|
||||
raise click.ClickException("No badge SVG files generated")
|
||||
click.echo(f"Generated {len(badges)} badge files")
|
||||
raise click.ClickException(_("No badge SVG files generated"))
|
||||
click.echo(_("Generated {count} badge files", count=len(badges)))
|
||||
|
||||
|
||||
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", "commit", "--no-verify", "-m", "Update badges [skip ci]"]) # 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
|
||||
result = _run_capture(["git", "rev-parse", "HEAD"]) # nosec B607
|
||||
sha = result.stdout.strip()
|
||||
click.echo(f"Badges commit SHA: {sha}")
|
||||
click.echo(_("Badges commit SHA: {sha}", sha=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)
|
||||
if new_content != 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
|
||||
|
||||
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
|
||||
|
||||
_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
|
||||
_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()
|
||||
@@ -193,13 +195,22 @@ def main(output_dir: str, branch: str, no_readme_update: bool, retries: int) ->
|
||||
except (subprocess.CalledProcessError, RuntimeError) as exc:
|
||||
last_error = exc
|
||||
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)
|
||||
with contextlib.suppress(subprocess.CalledProcessError):
|
||||
fetch_latest_master(branch)
|
||||
else:
|
||||
click.echo(f"Badge push failed after {retries} attempts: {exc}")
|
||||
raise click.ClickException(f"Badge push failed after {retries} attempts: {last_error}")
|
||||
click.echo(_("Badge push failed after {retries} attempts: {error}", retries=retries, error=exc))
|
||||
raise click.ClickException(
|
||||
_("Badge push failed after {retries} attempts: {error}", retries=retries, error=last_error)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -95,6 +95,13 @@ def ci_doc_coverage(args: tuple[str, ...]) -> None:
|
||||
_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")
|
||||
@click.argument("args", nargs=-1)
|
||||
def ci_notify_failure(args: tuple[str, ...]) -> None:
|
||||
|
||||
@@ -200,9 +200,9 @@ def main(
|
||||
total_kept = 0
|
||||
total_failed = 0
|
||||
for name in names:
|
||||
click.echo(f"\n{'=' * 60}")
|
||||
click.echo(f"Package: {owner}/{name}")
|
||||
click.echo(f"{'=' * 60}")
|
||||
click.echo(_("\n{separator}", separator="=" * 60))
|
||||
click.echo(_("Package: {owner}/{name}", owner=owner, name=name))
|
||||
click.echo(_("{separator}", separator="=" * 60))
|
||||
try:
|
||||
versions = list_package_versions(base_url, owner, name, token)
|
||||
except requests.RequestException as exc:
|
||||
@@ -217,17 +217,19 @@ def main(
|
||||
click.echo(_("No versions found."))
|
||||
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):
|
||||
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)
|
||||
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:
|
||||
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
|
||||
continue
|
||||
|
||||
@@ -236,17 +238,24 @@ def main(
|
||||
for v in to_delete:
|
||||
version = str(v.get("version", ""))
|
||||
if delete_package_version(base_url, owner, name, version, token):
|
||||
click.echo(f" Deleted: {version}")
|
||||
click.echo(_(" Deleted: {version}", version=version))
|
||||
deleted_count += 1
|
||||
else:
|
||||
click.echo(f" FAILED to delete: {version}", err=True)
|
||||
click.echo(_(" FAILED to delete: {version}", version=version), err=True)
|
||||
failed_count += 1
|
||||
|
||||
total_deleted += deleted_count
|
||||
total_kept += kept_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:
|
||||
raise click.ClickException(_("Failed to delete {count} image version(s)", count=total_failed))
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from devx.i18n import _
|
||||
|
||||
# Coverage regex matches "TOTAL ... NN%" or "TOTAL ... NN.NN%"
|
||||
_COVERAGE_RE = re.compile(r"TOTAL.*?(\d+(?:\.\d+)?)%")
|
||||
_PASSED_RE = re.compile(r"(\d+) passed")
|
||||
@@ -197,17 +199,19 @@ def read_version(repo_root: Path) -> str:
|
||||
"""
|
||||
pkg = detect_package_name(repo_root)
|
||||
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"
|
||||
init_file = repo_root / "src" / pkg / "__init__.py"
|
||||
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"
|
||||
content = init_file.read_text()
|
||||
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
|
||||
if match:
|
||||
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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
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")
|
||||
|
||||
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 = [
|
||||
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:
|
||||
cov_badge = make_badge("coverage", f"{coverage:.0f}%", coverage_color(coverage))
|
||||
else:
|
||||
click.echo(f" WARNING: Could not extract coverage from pytest output (rc={rc})")
|
||||
click.echo(f" pytest stdout (last 300 chars): {stdout.strip()[-300:]}")
|
||||
click.echo(f" pytest stderr (last 300 chars): {stderr.strip()[-300:]}")
|
||||
click.echo(_(" WARNING: Could not extract coverage from pytest output (rc={rc})", rc=rc))
|
||||
click.echo(_(" pytest stdout (last 300 chars): {stdout}", stdout=stdout.strip()[-300:]))
|
||||
click.echo(_(" pytest stderr (last 300 chars): {stderr}", stderr=stderr.strip()[-300:]))
|
||||
cov_badge = make_badge("coverage", "unknown", "red")
|
||||
|
||||
test_count = extract_test_count(combined)
|
||||
if test_count is not None:
|
||||
tests_badge = make_badge("tests", f"{test_count} passing", "brightgreen" if rc == 0 else "red")
|
||||
else:
|
||||
click.echo(f" WARNING: Could not extract test count from pytest output (rc={rc})")
|
||||
click.echo(f" pytest stdout (last 300 chars): {stdout.strip()[-300:]}")
|
||||
click.echo(f" pytest stderr (last 300 chars): {stderr.strip()[-300:]}")
|
||||
click.echo(_(" WARNING: Could not extract test count from pytest output (rc={rc})", rc=rc))
|
||||
click.echo(_(" pytest stdout (last 300 chars): {stdout}", stdout=stdout.strip()[-300:]))
|
||||
click.echo(_(" pytest stderr (last 300 chars): {stderr}", stderr=stderr.strip()[-300:]))
|
||||
tests_badge = make_badge("tests", "unknown", "red")
|
||||
|
||||
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)
|
||||
if doc_pct is not None:
|
||||
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(f" stderr: {stderr.strip()[:200]}")
|
||||
click.echo(_(" WARNING: Could not extract doc coverage (rc={rc})", rc=rc))
|
||||
click.echo(_(" stderr: {stderr}", stderr=stderr.strip()[:200]))
|
||||
return make_badge("docs", "unknown", "red")
|
||||
|
||||
|
||||
@@ -356,16 +360,16 @@ def collect_quality(repo_root: Path) -> dict[str, str | int]:
|
||||
results.append(False)
|
||||
# Distinguish "tool not installed" from "tool found issues"
|
||||
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
|
||||
tool_names.append(f"{name}: not installed (skipped)")
|
||||
else:
|
||||
tool_names.append(f"{name}: FAIL")
|
||||
click.echo(f" WARNING: {name} failed (rc={rc})")
|
||||
click.echo(f" stderr: {stderr.strip()[:200]}")
|
||||
click.echo(_(" WARNING: {name} failed (rc={rc})", name=name, rc=rc))
|
||||
click.echo(_(" stderr: {stderr}", stderr=stderr.strip()[:200]))
|
||||
|
||||
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")
|
||||
|
||||
|
||||
@@ -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).
|
||||
"""
|
||||
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)
|
||||
click.echo(f" Package: {pkg or 'none'}")
|
||||
click.echo(_(" Package: {pkg}", pkg=pkg or "none"))
|
||||
|
||||
badges: dict[str, dict[str, str | int]] = {}
|
||||
|
||||
# 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)
|
||||
badges["coverage"] = cov_badge
|
||||
badges["tests"] = tests_badge
|
||||
|
||||
# 2. Documentation coverage
|
||||
click.echo(" Collecting doc coverage...")
|
||||
click.echo(_(" Collecting doc coverage..."))
|
||||
badges["docs"] = collect_doc_coverage(root)
|
||||
|
||||
# 3. Code quality (ruff + pyright + bandit)
|
||||
click.echo(" Collecting code quality...")
|
||||
click.echo(_(" Collecting code quality..."))
|
||||
badges["quality"] = collect_quality(root)
|
||||
|
||||
# 4. Version
|
||||
click.echo(" Collecting version...")
|
||||
click.echo(_(" Collecting version..."))
|
||||
version = read_version(root)
|
||||
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"]))
|
||||
path = output_dir / f"{name}.svg"
|
||||
path.write_text(svg)
|
||||
click.echo(f" Generated: {path}")
|
||||
click.echo(_(" Generated: {path}", path=path))
|
||||
|
||||
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."""
|
||||
out = Path(output_dir)
|
||||
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)
|
||||
click.echo(f"\nGenerated {len(badges)} badges:")
|
||||
click.echo(_("\nGenerated {count} badges:", count=len(badges)))
|
||||
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
|
||||
|
||||
+943
-335
@@ -55,6 +55,14 @@
|
||||
"ru": "\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.": {
|
||||
"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.",
|
||||
@@ -71,6 +79,14 @@
|
||||
"ru": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.",
|
||||
"zh": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report."
|
||||
},
|
||||
"\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):": {
|
||||
"bg": "\nIntegrity check FAILED ({count} issues):",
|
||||
"de": "\nIntegrity check FAILED ({count} issues):",
|
||||
@@ -87,6 +103,14 @@
|
||||
"ru": "\nIntegrity check passed — all {count} pages verified.",
|
||||
"zh": "\nIntegrity check passed — all {count} pages verified."
|
||||
},
|
||||
"\nKeeping {kept}, would delete {count}": {
|
||||
"bg": "\nKeeping {kept}, would delete {count}",
|
||||
"de": "\nKeeping {kept}, would delete {count}",
|
||||
"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}": {
|
||||
"bg": "\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).",
|
||||
"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...": {
|
||||
"bg": "\nRunning full wiki integrity check...",
|
||||
"de": "\nRunning full wiki integrity check...",
|
||||
@@ -183,6 +215,14 @@
|
||||
"ru": "\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}": {
|
||||
"bg": "\n[dry-run] Changelog:\n{changelog}",
|
||||
"de": "\n[dry-run] Changelog:\n{changelog}",
|
||||
@@ -199,6 +239,14 @@
|
||||
"ru": "\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}):": {
|
||||
"bg": "\n{tag} files ({count}):",
|
||||
"de": "\n{tag} files ({count}):",
|
||||
@@ -207,6 +255,38 @@
|
||||
"ru": "\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": {
|
||||
"bg": " - Автоматично изтриване на клон след сливане: да",
|
||||
"de": " - Branch nach Merge automatisch löschen: ja",
|
||||
@@ -215,6 +295,14 @@
|
||||
"ru": " - Автоудаление ветки после слияния: да",
|
||||
"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": {
|
||||
"bg": " - Блокиране на остарели клонове: да",
|
||||
"de": " - Veraltete Branches blockieren: ja",
|
||||
@@ -263,6 +351,46 @@
|
||||
"ru": " - Требуемые проверки статуса: {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}": {
|
||||
"bg": " Created: {title}",
|
||||
"de": " Created: {title}",
|
||||
@@ -271,6 +399,14 @@
|
||||
"ru": " 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!": {
|
||||
"bg": " FAIL: {title} — content mismatch or empty!",
|
||||
"de": " FAIL: {title} — content mismatch or empty!",
|
||||
@@ -279,13 +415,29 @@
|
||||
"ru": " FAIL: {title} — content mismatch or empty!",
|
||||
"zh": " FAIL: {title} — content mismatch or empty!"
|
||||
},
|
||||
" MISSING: devx {cmd}": {
|
||||
"bg": " ЛИПСВА: devx {cmd}",
|
||||
"de": " FEHLT: devx {cmd}",
|
||||
"en": " MISSING: devx {cmd}",
|
||||
"pl": " BRAK: devx {cmd}",
|
||||
"ru": " ОТСУТСТВУЕТ: devx {cmd}",
|
||||
"zh": " 缺失: devx {cmd}"
|
||||
" FAILED to delete: {version}": {
|
||||
"bg": " FAILED to delete: {version}",
|
||||
"de": " FAILED to delete: {version}",
|
||||
"en": " FAILED to delete: {version}",
|
||||
"pl": " FAILED to delete: {version}",
|
||||
"ru": " FAILED to delete: {version}",
|
||||
"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}": {
|
||||
"bg": " MISSING: {module}",
|
||||
@@ -303,13 +455,13 @@
|
||||
"ru": " MISSING: {script}",
|
||||
"zh": " MISSING: {script}"
|
||||
},
|
||||
" OK: devx {cmd}": {
|
||||
"bg": " ОК: devx {cmd}",
|
||||
"de": " OK: devx {cmd}",
|
||||
"en": " OK: devx {cmd}",
|
||||
"pl": " OK: devx {cmd}",
|
||||
"ru": " ОК: devx {cmd}",
|
||||
"zh": " 正常: devx {cmd}"
|
||||
" OK: {cmd}": {
|
||||
"bg": " OK: {cmd}",
|
||||
"de": " OK: {cmd}",
|
||||
"en": " OK: {cmd}",
|
||||
"pl": " OK: {cmd}",
|
||||
"ru": " OK: {cmd}",
|
||||
"zh": " OK: {cmd}"
|
||||
},
|
||||
" OK: {module}": {
|
||||
"bg": " OK: {module}",
|
||||
@@ -335,6 +487,38 @@
|
||||
"ru": " 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}": {
|
||||
"bg": " Updated: {title}",
|
||||
"de": " Updated: {title}",
|
||||
@@ -343,6 +527,126 @@
|
||||
"ru": " 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": {
|
||||
"bg": "--push requires --registry",
|
||||
"de": "--push requires --registry",
|
||||
@@ -375,6 +679,14 @@
|
||||
"ru": "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.": {
|
||||
"bg": "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.",
|
||||
"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": {
|
||||
"bg": "Клон '{branch}' не съдържа ID на задача.\n Очакван формат: {prefix}-N-кратко-описание",
|
||||
"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}",
|
||||
"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...": {
|
||||
"bg": "Checking CLI command documentation...",
|
||||
"de": "Checking CLI command documentation...",
|
||||
@@ -471,6 +879,78 @@
|
||||
"ru": "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}": {
|
||||
"bg": "Command failed ({cmd}): {stderr}",
|
||||
"de": "Command failed ({cmd}): {stderr}",
|
||||
@@ -479,6 +959,22 @@
|
||||
"ru": "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)": {
|
||||
"bg": "Comparing {base}..{head} ({count} files changed)",
|
||||
"de": "Comparing {base}..{head} ({count} files changed)",
|
||||
@@ -495,6 +991,14 @@
|
||||
"ru": "Конфигурация OK: [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}...": {
|
||||
"bg": "Конфигуриране на защита на клона {branch}...",
|
||||
"de": "Konfiguriere Branch-Schutz für {branch}...",
|
||||
@@ -511,6 +1015,14 @@
|
||||
"ru": "Настройка параметров репозитория...",
|
||||
"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}": {
|
||||
"bg": "Не може да се определи текущия клон: {error}",
|
||||
"de": "Aktueller Branch konnte nicht erkannt werden: {error}",
|
||||
@@ -519,6 +1031,14 @@
|
||||
"ru": "Не удалось определить текущую ветку: {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.": {
|
||||
"bg": "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}",
|
||||
"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.": {
|
||||
"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.",
|
||||
@@ -695,6 +1207,22 @@
|
||||
"ru": "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": {
|
||||
"bg": "FAILED: {count} undocumented dependency/ies",
|
||||
"de": "FAILED: {count} undocumented dependency/ies",
|
||||
@@ -727,6 +1255,14 @@
|
||||
"ru": "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}": {
|
||||
"bg": "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}",
|
||||
"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.": {
|
||||
"bg": "Found {count} existing wiki pages.",
|
||||
"de": "Found {count} existing wiki pages.",
|
||||
@@ -759,6 +1303,14 @@
|
||||
"ru": "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.": {
|
||||
"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.",
|
||||
@@ -767,6 +1319,14 @@
|
||||
"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."
|
||||
},
|
||||
"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}'.": {
|
||||
"bg": "Generated {file} with prefix '{prefix}'.",
|
||||
"de": "Generated {file} with prefix '{prefix}'.",
|
||||
@@ -775,6 +1335,14 @@
|
||||
"ru": "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.": {
|
||||
"bg": "Gitea PyPI registry: {tag} вече е публикуван — продължава.",
|
||||
"de": "Gitea PyPI-Registry: {tag} bereits veröffentlicht — wird fortgesetzt.",
|
||||
@@ -903,6 +1471,38 @@
|
||||
"ru": "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}": {
|
||||
"bg": "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.",
|
||||
"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}": {
|
||||
"bg": "Manifest file not found: {path}",
|
||||
"de": "Manifest file not found: {path}",
|
||||
@@ -959,6 +1567,14 @@
|
||||
"ru": "Слияние не удалось: 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": {
|
||||
"bg": "Модул {mod} няма функция main()",
|
||||
"de": "Modul {mod} hat keine main()-Funktion",
|
||||
@@ -1015,6 +1631,30 @@
|
||||
"ru": "Отлично! Задача 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}.": {
|
||||
"bg": "No changes between {base} and {head}.",
|
||||
"de": "No changes between {base} and {head}.",
|
||||
@@ -1023,6 +1663,38 @@
|
||||
"ru": "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.": {
|
||||
"bg": "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.",
|
||||
"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.": {
|
||||
"bg": "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}",
|
||||
"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}": {
|
||||
"bg": "PASSED: {pair}",
|
||||
"de": "PASSED: {pair}",
|
||||
@@ -1263,6 +1951,22 @@
|
||||
"ru": "PYPI_TOKEN не задан и URL registry не настроен — пропускаем публикацию в 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": {
|
||||
"bg": "Разбор на owner={owner}, repo={repo} от DEVX_REPO_NAME",
|
||||
"de": "Owner={owner}, repo={repo} aus DEVX_REPO_NAME analysiert",
|
||||
@@ -1359,6 +2063,14 @@
|
||||
"ru": "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.": {
|
||||
"bg": "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).",
|
||||
"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": {
|
||||
"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",
|
||||
@@ -1431,6 +2119,22 @@
|
||||
"ru": "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}": {
|
||||
"bg": "Release creation failed: {error}",
|
||||
"de": "Release creation failed: {error}",
|
||||
@@ -1471,6 +2175,14 @@
|
||||
"ru": "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.": {
|
||||
"bg": "Собственикът на хранилището не е зададен. Използвайте --owner или 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.",
|
||||
"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}": {
|
||||
"bg": "Roles directory not found: {path}",
|
||||
"de": "Roles directory not found: {path}",
|
||||
@@ -1487,6 +2207,14 @@
|
||||
"ru": "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})": {
|
||||
"bg": "Индексът на runner {index} е извън диапазона (0..{max})",
|
||||
"de": "Runner-Index {index} außerhalb des Bereichs (0..{max})",
|
||||
@@ -1495,6 +2223,30 @@
|
||||
"ru": "Индекс 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...": {
|
||||
"bg": "Running lint checks...",
|
||||
"de": "Running lint checks...",
|
||||
@@ -1511,6 +2263,14 @@
|
||||
"ru": "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}": {
|
||||
"bg": "Running: {scenario} on {platform}",
|
||||
"de": "Running: {scenario} on {platform}",
|
||||
@@ -1543,6 +2303,22 @@
|
||||
"ru": "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...": {
|
||||
"bg": "Syncing {count} documentation pages to wiki...",
|
||||
"de": "Syncing {count} documentation pages to wiki...",
|
||||
@@ -1623,6 +2399,14 @@
|
||||
"ru": "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).": {
|
||||
"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).",
|
||||
@@ -1647,6 +2431,14 @@
|
||||
"ru": "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}": {
|
||||
"bg": "Updated version in {init}",
|
||||
"de": "Updated version in {init}",
|
||||
@@ -1695,6 +2487,14 @@
|
||||
"ru": "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.": {
|
||||
"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.",
|
||||
@@ -1735,6 +2535,14 @@
|
||||
"ru": "ПРЕДУПРЕЖДЕНИЕ: 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.": {
|
||||
"bg": "Warning: could not fetch tags from origin.",
|
||||
"de": "Warning: could not fetch tags from origin.",
|
||||
@@ -1743,6 +2551,54 @@
|
||||
"ru": "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)": {
|
||||
"bg": "Wiki integrity check failed — {count} issue(s)",
|
||||
"de": "Wiki integrity check failed — {count} issue(s)",
|
||||
@@ -1763,9 +2619,9 @@
|
||||
"bg": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||
"de": "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.",
|
||||
"zh": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||
"pl": "Wrote tag {tag} to GITHUB_OUTPUT."
|
||||
"zh": "Wrote tag {tag} to GITHUB_OUTPUT."
|
||||
},
|
||||
"[check-dep-docs] Passed: all dependencies are documented": {
|
||||
"bg": "[check-dep-docs] Passed: all dependencies are documented",
|
||||
@@ -1879,6 +2735,14 @@
|
||||
"ru": "завершён",
|
||||
"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}": {
|
||||
"bg": "несъответствие на версията на devx между extras: {detail}",
|
||||
"de": "devx-Versionskonflikt zwischen Extras: {detail}",
|
||||
@@ -1943,6 +2807,14 @@
|
||||
"ru": "неактивен",
|
||||
"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}": {
|
||||
"bg": "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 +2847,22 @@
|
||||
"ru": "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": {
|
||||
"bg": "неизвестен",
|
||||
"de": "unbekannt",
|
||||
@@ -1991,292 +2879,12 @@
|
||||
"ru": "{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.": {
|
||||
"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": ""
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"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": " - 阻止管理员合并覆盖:是"
|
||||
"{separator}": {
|
||||
"bg": "{separator}",
|
||||
"de": "{separator}",
|
||||
"en": "{separator}",
|
||||
"pl": "{separator}",
|
||||
"ru": "{separator}",
|
||||
"zh": "{separator}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,6 +184,14 @@ class TestMain:
|
||||
result = runner.invoke(check_translations.main, ["--translations", str(trans_file)])
|
||||
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:
|
||||
def test_prints_all_good(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
|
||||
@@ -87,6 +87,13 @@ class TestCiCommands:
|
||||
assert result.exit_code == 0
|
||||
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")
|
||||
def test_ci_notify_failure(self, mock_run: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
@@ -12,10 +12,13 @@ from devx.ci.doc_coverage import (
|
||||
main,
|
||||
)
|
||||
|
||||
# Path to devx's own source directory (for testing)
|
||||
DEVX_SRC_DIR = Path(__file__).resolve().parent.parent.parent / "src" / "devx"
|
||||
|
||||
|
||||
class TestExtractCliCommands:
|
||||
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
|
||||
assert "auto-merge" in commands
|
||||
assert "release" in commands
|
||||
@@ -24,39 +27,30 @@ class TestExtractCliCommands:
|
||||
assert "install-tools" in commands
|
||||
|
||||
def test_returns_list(self) -> None:
|
||||
commands = extract_cli_commands()
|
||||
commands = extract_cli_commands(DEVX_SRC_DIR)
|
||||
assert isinstance(commands, list)
|
||||
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."""
|
||||
from devx.ci import doc_coverage
|
||||
|
||||
monkeypatch.setattr(doc_coverage, "CLI_FILE", Path("/nonexistent/cli.py"))
|
||||
commands = extract_cli_commands()
|
||||
commands = extract_cli_commands(tmp_path)
|
||||
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."""
|
||||
from devx.ci import doc_coverage
|
||||
|
||||
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")
|
||||
monkeypatch.setattr(doc_coverage, "CLI_FILE", fake_cli)
|
||||
commands = extract_cli_commands()
|
||||
commands = extract_cli_commands(tmp_path)
|
||||
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."""
|
||||
from devx.ci import doc_coverage
|
||||
|
||||
fake_cli = tmp_path / "cli.py"
|
||||
# The last @cli.command() has no explicit name and no def statement after it
|
||||
fake_cli.write_text(
|
||||
"@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()
|
||||
commands = extract_cli_commands(tmp_path)
|
||||
# real_cmd should be found via def fallback; the bare @cli.command() is skipped
|
||||
assert "real_cmd" in commands
|
||||
assert "pass" not in commands
|
||||
@@ -96,19 +90,29 @@ class TestMain:
|
||||
docs = tmp_path / "docs"
|
||||
(docs / "user").mkdir(parents=True)
|
||||
(docs / "tech").mkdir(parents=True)
|
||||
# Get actual commands from the CLI
|
||||
commands = extract_cli_commands()
|
||||
src = tmp_path / "src" / "devx"
|
||||
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
|
||||
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)
|
||||
# Write architecture.md with all modules
|
||||
from devx.ci.doc_coverage import REQUIRED_MODULES, REQUIRED_SCRIPTS
|
||||
|
||||
(docs / "tech" / "architecture.md").write_text(" ".join(REQUIRED_MODULES))
|
||||
(docs / "tech" / "architecture.md").write_text("config.py")
|
||||
# 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()
|
||||
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 "100%" in result.output
|
||||
|
||||
@@ -117,11 +121,21 @@ class TestMain:
|
||||
docs = tmp_path / "docs"
|
||||
(docs / "user").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 / "tech" / "architecture.md").write_text("No modules here.")
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text("No scripts here.")
|
||||
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
|
||||
|
||||
def test_missing_docs_warn_only(self, tmp_path: Path) -> None:
|
||||
@@ -129,10 +143,55 @@ class TestMain:
|
||||
docs = tmp_path / "docs"
|
||||
(docs / "user").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 / "tech" / "architecture.md").write_text("No modules here.")
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text("No scripts here.")
|
||||
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 "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)
|
||||
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:
|
||||
def test_src_changes_without_tests_warns(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user