Compare commits

...
12 Commits
Author SHA1 Message Date
devx-ci-bot 587d3a6ca4 release: v0.27.3 [skip ci] 2026-06-30 05:33:54 +00:00
emil 9d75e408ae DEVX-14: fix: retry wiki integrity check on transient API timeout
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 8s
Build Images / detect-type (push) Successful in 42s
Post-merge / sync-wiki (push) Successful in 30s
Post-merge / vikunja (push) Successful in 16s
Post-merge / release (push) Successful in 44s
Post-merge / configure-repo (push) Successful in 17s
Post-merge / badges (push) Successful in 43s
Post-merge / publish (push) Successful in 20s
Build Images / build-and-push (push) Successful in 3m12s
Build Images / cleanup (push) Successful in 2m37s
2026-06-30 05:33:00 +00:00
gitea-actions-bot 412bbea01d chore: update badge URLs to commit 8d35f5dd [skip ci] 2026-06-29 11:30:18 +00:00
devx-ci-bot ce5ce33a12 release: v0.27.2 [skip ci] 2026-06-29 11:30:13 +00:00
emil 0fae419584 DEVX-100: fix: retry release push on non-fast-forward with rebase loop
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 12s
Post-merge / configure-repo (push) Successful in 11s
Post-merge / sync-wiki (push) Successful in 23s
Post-merge / release (push) Successful in 29s
Post-merge / badges (push) Successful in 30s
Build Images / detect-type (push) Successful in 1m0s
Post-merge / publish (push) Successful in 16s
Build Images / build-and-push (push) Successful in 3m57s
Build Images / cleanup (push) Successful in 2m56s
2026-06-29 11:29:31 +00:00
gitea-actions-bot 70b011d4a6 chore: update badge URLs to commit 53e15be6 [skip ci] 2026-06-28 17:02:18 +00:00
devx-ci-bot a5c16a92df release: v0.27.1 [skip ci] 2026-06-28 17:02:12 +00:00
emil e6f022ae96 DEVX-99: fix: exclude .devin/.terraform dirs from lint_docs, add duplicate heading excludes
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 8s
Post-merge / vikunja (push) Successful in 12s
Post-merge / configure-repo (push) Successful in 10s
Post-merge / sync-wiki (push) Successful in 19s
Build Images / detect-type (push) Successful in 35s
Post-merge / release (push) Successful in 25s
Post-merge / badges (push) Successful in 28s
Post-merge / publish (push) Successful in 15s
Build Images / build-and-push (push) Successful in 2m51s
Build Images / cleanup (push) Successful in 2m18s
2026-06-28 17:01:37 +00:00
gitea-actions-bot 1a27983750 chore: update badge URLs to commit 8f2186a4 [skip ci] 2026-06-28 16:36:47 +00:00
devx-ci-bot 5d7ed62b34 release: v0.27.0 [skip ci] 2026-06-28 16:36:35 +00:00
emil ee80c27631 DEVX-98: feat: add lint_docs tool, fix doc_coverage/check_translations for any repo
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 8s
Post-merge / vikunja (push) Successful in 13s
Post-merge / configure-repo (push) Successful in 11s
Post-merge / sync-wiki (push) Successful in 21s
Post-merge / release (push) Successful in 25s
Build Images / detect-type (push) Successful in 38s
Post-merge / badges (push) Successful in 34s
Post-merge / publish (push) Successful in 33s
Build Images / build-and-push (push) Successful in 3m20s
Build Images / cleanup (push) Successful in 1m57s
2026-06-28 16:35:59 +00:00
gitea-actions-bot 98b1659579 chore: update badge URLs to commit f308b9f8 [skip ci] 2026-06-28 15:02:09 +00:00
26 changed files with 1850 additions and 131 deletions
+6
View File
@@ -38,6 +38,12 @@ jobs:
run: | run: |
. .venv/bin/activate . .venv/bin/activate
python3 -m devx.ci.doc_coverage --fail-on-missing python3 -m devx.ci.doc_coverage --fail-on-missing
- name: Documentation lint check
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
python3 -m devx.ci.lint_docs --root .
- name: Translation completeness check - name: Translation completeness check
env: env:
PYTHONPATH: src PYTHONPATH: src
+2 -1
View File
@@ -71,7 +71,8 @@ src/devx/
│ ├── distribute_items.py # Distribute generic items (VMs, hosts) across parallel runners (LPT) │ ├── distribute_items.py # Distribute generic items (VMs, hosts) across parallel runners (LPT)
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast │ ├── integration_guard.py # Run pytest with cross-runner fail-fast
│ ├── check_translations.py # Translation completeness check │ ├── check_translations.py # Translation completeness check
── doc_coverage.py # Documentation coverage check ── doc_coverage.py # Documentation coverage check
│ └── lint_docs.py # Documentation linter (structure, links, headings)
├── tools/ # Developer tooling modules (run locally or by CI) ├── tools/ # Developer tooling modules (run locally or by CI)
│ ├── setup.py # Environment setup (venv, deps, hooks) │ ├── setup.py # Environment setup (venv, deps, hooks)
│ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea, hadolint │ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea, hadolint
+24
View File
@@ -2,6 +2,30 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [0.27.3] - 2026-06-30
### Bug Fixes
- Retry wiki integrity check on transient API timeout
## [0.27.2] - 2026-06-29
### Bug Fixes
- Retry release push on non-fast-forward with rebase loop
## [0.27.1] - 2026-06-28
### Bug Fixes
- Exclude .devin/.terraform dirs from lint_docs, add duplicate heading excludes
## [0.27.0] - 2026-06-28
### Features
- Add lint_docs tool, fix doc_coverage/check_translations for any repo
## [0.26.4] - 2026-06-28 ## [0.26.4] - 2026-06-28
### Bug Fixes ### Bug Fixes
+9 -6
View File
@@ -16,12 +16,12 @@ quality badges.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE) [![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8d35f5dd12fe7167c4b1a7f03b92e28ecfbd3599/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8d35f5dd12fe7167c4b1a7f03b92e28ecfbd3599/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) [![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8d35f5dd12fe7167c4b1a7f03b92e28ecfbd3599/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8d35f5dd12fe7167c4b1a7f03b92e28ecfbd3599/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) [![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8d35f5dd12fe7167c4b1a7f03b92e28ecfbd3599/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8d35f5dd12fe7167c4b1a7f03b92e28ecfbd3599/python.svg)](https://www.python.org/downloads/)
## Why devx? ## Why devx?
@@ -158,6 +158,9 @@ python -m devx.ci.check_translations --translations path/to/translations.json
# Documentation coverage check # Documentation coverage check
python -m devx.ci.doc_coverage --fail-on-missing python -m devx.ci.doc_coverage --fail-on-missing
# Documentation lint (structure, links, headings, TODOs)
python -m devx.ci.lint_docs --root .
# Validate a commit message # Validate a commit message
python -m devx.ci.validate_commit_msg commit-msg.txt --branch master python -m devx.ci.validate_commit_msg commit-msg.txt --branch master
+7 -6
View File
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE) [![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8d35f5dd12fe7167c4b1a7f03b92e28ecfbd3599/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8d35f5dd12fe7167c4b1a7f03b92e28ecfbd3599/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) [![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8d35f5dd12fe7167c4b1a7f03b92e28ecfbd3599/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8d35f5dd12fe7167c4b1a7f03b92e28ecfbd3599/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) [![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8d35f5dd12fe7167c4b1a7f03b92e28ecfbd3599/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8d35f5dd12fe7167c4b1a7f03b92e28ecfbd3599/python.svg)](https://www.python.org/downloads/)
## Overview ## Overview
@@ -158,6 +158,7 @@ for the full configuration reference, PR workflow, and project conventions.
## Wiki pages ## Wiki pages
- [Home](Home) — This page - [Home](Home) — This page
- [Getting Started](Getting-Started) — Installation, configuration, and quick start guide
- [CLI Commands](CLI-Commands) — Full CLI command documentation with examples - [CLI Commands](CLI-Commands) — Full CLI command documentation with examples
- [Architecture](Architecture) — Package structure, module descriptions, design principles - [Architecture](Architecture) — Package structure, module descriptions, design principles
- [CI/CD Workflow](CI-CD-Workflow) — Pipeline documentation, workflows, and CI scripts - [CI/CD Workflow](CI-CD-Workflow) — Pipeline documentation, workflows, and CI scripts
+1
View File
@@ -1,5 +1,6 @@
{ {
"index.md": "Home", "index.md": "Home",
"user/getting-started.md": "Getting-Started",
"user/cli-commands.md": "CLI-Commands", "user/cli-commands.md": "CLI-Commands",
"tech/architecture.md": "Architecture", "tech/architecture.md": "Architecture",
"tech/ci-cd-workflow.md": "CI-CD-Workflow" "tech/ci-cd-workflow.md": "CI-CD-Workflow"
+1 -1
View File
@@ -382,7 +382,7 @@ single-role (4-part) and multi-role (5-part) pair encoding.
Runs all molecule scenarios on all supported OS platforms sequentially. Runs all molecule scenarios on all supported OS platforms sequentially.
Intended for local development; CI uses the parallel matrix instead. Intended for local development; CI uses the parallel matrix instead.
### `discover_runners.py` ### `molecule/discover_runners.py`
Discovers available Gitea Actions runners for molecule tests. Same logic as Discovers available Gitea Actions runners for molecule tests. Same logic as
`devx.ci.discover_runners` but intended for molecule-specific workflows. `devx.ci.discover_runners` but intended for molecule-specific workflows.
+1 -1
View File
@@ -166,7 +166,7 @@ When `release` creates a `release: vX.Y.Z` commit, the release commit's
post-merge run still updates badges (the version badge picks up the new post-merge run still updates badges (the version badge picks up the new
version). Other jobs skip. The tag push triggers `publish.yml`. version). Other jobs skip. The tag push triggers `publish.yml`.
### Jobs ### Post-merge jobs
#### `detect-type` #### `detect-type`
+24 -1
View File
@@ -127,14 +127,37 @@ Click commands from `cli.py` and checks if each has documentation in
```bash ```bash
devx ci doc-coverage devx ci doc-coverage
devx ci doc-coverage --docs-dir docs/ --fail-on-missing devx ci doc-coverage --docs-dir docs/ --source-dir src/ --fail-on-missing
``` ```
Options: Options:
- `--docs-dir <dir>` — path to the docs directory (default: `docs/`) - `--docs-dir <dir>` — path to the docs directory (default: `docs/`)
- `--source-dir <dir>` — path to the source directory (default: auto-detect)
- `--fail-on-missing` — exit with non-zero status if any documentation is - `--fail-on-missing` — exit with non-zero status if any documentation is
missing missing
### `devx ci lint-docs`
Lint documentation files for structure, broken links, heading hierarchy,
duplicate headings, TODO/FIXME markers, and trailing whitespace.
```bash
devx ci lint-docs
devx ci lint-docs --root . --fix
devx ci lint-docs --no-check-links --no-check-stale
```
Options:
- `--root <dir>` — repository root directory (default: `.`)
- `--docs-dir <dir>` — docs directory (default: `<root>/docs`)
- `--check-links/--no-check-links` — check internal links (default: yes)
- `--check-headings/--no-check-headings` — check heading hierarchy (default: yes)
- `--check-todo/--no-check-todo` — check for TODO/FIXME markers (default: yes)
- `--check-stale/--no-check-stale` — check for stale docs (default: no)
- `--check-trailing/--no-check-trailing` — check trailing whitespace (default: yes)
- `--check-duplicates/--no-check-duplicates` — check duplicate headings (default: yes)
- `--fix` — auto-fix trailing whitespace
### `devx ci integration-guard` ### `devx ci integration-guard`
Run pytest with cross-runner failure detection. If any Run pytest with cross-runner failure detection. If any
+161
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects.""" """devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.26.4" __version__ = "0.27.3"
+33 -9
View File
@@ -31,11 +31,11 @@ from pathlib import Path
import click import click
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent REPO_ROOT = Path.cwd()
SUPPORTED_LANGS = ("en", "bg", "de", "ru", "zh", "pl") SUPPORTED_LANGS = ("en", "bg", "de", "ru", "zh", "pl")
# Default translation set: devx package itself # Default translation set: look for translations.json in the current repo
DEFAULT_TRANS_FILE = REPO_ROOT / "src" / "devx" / "translations.json" DEFAULT_TRANS_FILE = REPO_ROOT / "src" / "devx" / "translations.json"
DEFAULT_SRC_DIR = REPO_ROOT / "src" / "devx" DEFAULT_SRC_DIR = REPO_ROOT / "src" / "devx"
@@ -169,20 +169,44 @@ def print_result(result: TranslationCheckResult) -> None:
"translations", "translations",
multiple=True, multiple=True,
type=click.Path(exists=False, path_type=Path), type=click.Path(exists=False, path_type=Path),
help="Path to a translations JSON file to check (can be repeated). Defaults to src/devx/translations.json.", help="Path to a translations JSON file to check (can be repeated). Auto-detects by default.",
) )
def main(translations: tuple[Path, ...]) -> None: @click.option(
"--source-dir",
default=None,
help="Source directory to scan for _() calls (default: auto-detect).",
)
def main(translations: tuple[Path, ...], source_dir: str | None) -> None:
"""Check translation files for gaps, dead keys, and missing languages.""" """Check translation files for gaps, dead keys, and missing languages."""
results: list[TranslationCheckResult] = []
if not translations: if not translations:
# Default: check the devx package's own translations # Auto-detect translations file in the current repo
results = [ root = Path.cwd()
check_translation_set("devx", DEFAULT_SRC_DIR, DEFAULT_TRANS_FILE), # Try common locations
candidates = [
root / "src" / "devx" / "translations.json",
root / "src" / "gitea_runner_manager" / "translations.json",
] ]
# Also search for any translations.json in src/
for match in root.glob("src/*/translations.json"):
candidates.append(match)
found = False
for candidate in candidates:
if candidate.exists():
src_dir = Path(source_dir) if source_dir else candidate.parent
results.append(check_translation_set(candidate.parent.name, src_dir, candidate))
found = True
break
if not found:
# No translations file found — this repo doesn't use i18n
click.echo("PASS: No translations file found — skipping (repo does not use i18n).")
return
else: else:
results = []
for trans_file in translations: for trans_file in translations:
# Infer source directory as the parent of the translations file # Infer source directory as the parent of the translations file
src_dir = trans_file.parent src_dir = Path(source_dir) if source_dir else trans_file.parent
name = trans_file.parent.name name = trans_file.parent.name
results.append(check_translation_set(name, src_dir, trans_file)) results.append(check_translation_set(name, src_dir, trans_file))
+55 -17
View File
@@ -5,8 +5,12 @@ Parses Click commands from the CLI source code and checks if each command
has corresponding documentation in the wiki/docs. Reports missing has corresponding documentation in the wiki/docs. Reports missing
documentation as warnings and exits with non-zero if coverage is below 100%. documentation as warnings and exits with non-zero if coverage is below 100%.
By default, checks the current repository's own source and docs directories.
When run from the devx package itself (development mode), it checks devx's
own files. When installed as a package, it checks the consuming repo's files.
Usage: Usage:
python3 -m devx.ci.doc_coverage [--docs-dir docs/] [--fail-on-missing] python3 -m devx.ci.doc_coverage [--docs-dir docs/] [--source-dir src/] [--fail-on-missing]
""" """
from __future__ import annotations from __future__ import annotations
@@ -19,11 +23,12 @@ import click
from devx.i18n import _ from devx.i18n import _
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent # Default to the current working directory (consuming repo's root)
REPO_ROOT = Path.cwd()
DOCS_DIR = REPO_ROOT / "docs" DOCS_DIR = REPO_ROOT / "docs"
CLI_FILE = REPO_ROOT / "src" / "devx" / "cli.py"
# Major modules that should be documented in tech/architecture.md # Major modules that should be documented in tech/architecture.md
# These are devx-specific; when checking other repos, use --source-dir
REQUIRED_MODULES = [ REQUIRED_MODULES = [
"cli.py", "cli.py",
"i18n.py", "i18n.py",
@@ -51,11 +56,16 @@ REQUIRED_SCRIPTS = [
] ]
def extract_cli_commands() -> list[str]: def extract_cli_commands(source_dir: Path) -> list[str]:
"""Extract command names from the CLI source file.""" """Extract command names from the CLI source file."""
if not CLI_FILE.exists(): # Try to find the CLI file in the source directory
cli_file = None
for candidate in source_dir.rglob("cli.py"):
cli_file = candidate
break
if cli_file is None or not cli_file.exists():
return [] return []
content = CLI_FILE.read_text() content = cli_file.read_text()
commands: list[str] = [] commands: list[str] = []
# Find all @<group>.command("name") occurrences in the CLI source # Find all @<group>.command("name") occurrences in the CLI source
# Matches @cli.command, @ci.command, @tools.command, @molecule.command # Matches @cli.command, @ci.command, @tools.command, @molecule.command
@@ -94,15 +104,30 @@ def check_module_documented(module: str, docs_content: str) -> bool:
@click.command() @click.command()
@click.option("--docs-dir", default=str(DOCS_DIR), help="Path to the docs directory.") @click.option("--docs-dir", default=None, help="Path to the docs directory (default: ./docs).")
@click.option("--source-dir", default=None, help="Path to the source directory (default: auto-detect from src/).")
@click.option( @click.option(
"--fail-on-missing", "--fail-on-missing",
is_flag=True, is_flag=True,
default=False, default=False,
help="Exit with non-zero status if any documentation is missing.", help="Exit with non-zero status if any documentation is missing.",
) )
def main(docs_dir: str, fail_on_missing: bool) -> None: def main(docs_dir: str | None, source_dir: str | None, fail_on_missing: bool) -> None:
docs_path = Path(docs_dir) root = Path.cwd()
docs_path = Path(docs_dir) if docs_dir else root / "docs"
# Auto-detect source directory
if source_dir:
src_path = Path(source_dir)
else:
# Try common source directories
for candidate in [root / "src", root / "scripts"]:
if candidate.exists():
src_path = candidate
break
else:
src_path = root / "src"
cli_commands_file = docs_path / "user" / "cli-commands.md" cli_commands_file = docs_path / "user" / "cli-commands.md"
architecture_file = docs_path / "tech" / "architecture.md" architecture_file = docs_path / "tech" / "architecture.md"
ci_cd_file = docs_path / "tech" / "ci-cd-workflow.md" ci_cd_file = docs_path / "tech" / "ci-cd-workflow.md"
@@ -112,21 +137,28 @@ def main(docs_dir: str, fail_on_missing: bool) -> None:
# Check CLI commands # Check CLI commands
click.echo(_("Checking CLI command documentation...")) click.echo(_("Checking CLI command documentation..."))
commands = extract_cli_commands() commands = extract_cli_commands(src_path)
total += len(commands) total += len(commands)
cli_docs = cli_commands_file.read_text() if cli_commands_file.exists() else "" cli_docs = cli_commands_file.read_text() if cli_commands_file.exists() else ""
for cmd in commands: for cmd in commands:
if check_command_documented(cmd, cli_docs): if check_command_documented(cmd, cli_docs):
click.echo(_(" OK: devx {cmd}", cmd=cmd)) click.echo(_(" OK: {cmd}", cmd=cmd))
else: else:
click.echo(_(" MISSING: devx {cmd}", cmd=cmd)) click.echo(_(" MISSING: {cmd}", cmd=cmd))
missing.append(f"CLI command: devx {cmd}") missing.append(f"CLI command: {cmd}")
# Check modules in architecture.md # Check modules in architecture.md
# Auto-detect modules from source directory (top-level only, exclude subdirs)
click.echo(_("\nChecking module documentation in architecture.md...")) click.echo(_("\nChecking module documentation in architecture.md..."))
total += len(REQUIRED_MODULES) if src_path.exists():
detected_modules = sorted(
f.name for f in src_path.glob("*.py") if f.name != "__init__.py" and f.name != "cli.py"
)
else:
detected_modules = REQUIRED_MODULES
total += len(detected_modules)
arch_docs = architecture_file.read_text() if architecture_file.exists() else "" arch_docs = architecture_file.read_text() if architecture_file.exists() else ""
for module in REQUIRED_MODULES: for module in detected_modules:
if check_module_documented(module, arch_docs): if check_module_documented(module, arch_docs):
click.echo(_(" OK: {module}", module=module)) click.echo(_(" OK: {module}", module=module))
else: else:
@@ -134,10 +166,16 @@ def main(docs_dir: str, fail_on_missing: bool) -> None:
missing.append(f"Module: {module}") missing.append(f"Module: {module}")
# Check CI scripts in ci-cd-workflow.md # Check CI scripts in ci-cd-workflow.md
# Auto-detect CI scripts from ci/ subdirectory
click.echo(_("\nChecking CI script documentation in ci-cd-workflow.md...")) click.echo(_("\nChecking CI script documentation in ci-cd-workflow.md..."))
total += len(REQUIRED_SCRIPTS) ci_dir = src_path / "ci" if src_path.name != "ci" else src_path
if ci_dir.exists():
detected_scripts = sorted(f.name for f in ci_dir.glob("*.py") if f.name != "__init__.py")
else:
detected_scripts = REQUIRED_SCRIPTS
total += len(detected_scripts)
ci_docs = ci_cd_file.read_text() if ci_cd_file.exists() else "" ci_docs = ci_cd_file.read_text() if ci_cd_file.exists() else ""
for script in REQUIRED_SCRIPTS: for script in detected_scripts:
if check_module_documented(script, ci_docs): if check_module_documented(script, ci_docs):
click.echo(_(" OK: {script}", script=script)) click.echo(_(" OK: {script}", script=script))
else: else:
+415
View File
@@ -0,0 +1,415 @@
#!/usr/bin/env python3
"""Lint documentation files for structure, links, and quality.
Checks performed (all configurable via pyproject.toml ``[tool.devx.docs]``):
- **Required files**: README.md, AGENTS.md, CHANGELOG.md must exist.
- **Docs structure**: ``docs/index.md`` and ``docs/mapping.json`` must exist.
- **Broken internal links**: relative paths and anchors in markdown files
must resolve to actual files and headings.
- **Heading hierarchy**: no skipping heading levels (e.g., ``#`` → ``###``).
- **TODO/FIXME**: flags leftover TODO/FIXME markers in documentation.
- **Stale docs**: files not modified in >180 days (warning only).
- **Trailing whitespace**: lines should not end with whitespace.
- **Blank line before headings**: headings should have a blank line before them.
Usage::
python3 -m devx.ci.lint_docs
python3 -m devx.ci.lint_docs --docs-dir docs/ --root .
python3 -m devx.ci.lint_docs --fix # auto-fix trailing whitespace
"""
from __future__ import annotations
import json
import re
import sys
from datetime import datetime, timedelta
from pathlib import Path
import click
from devx.i18n import _
# Heading slug pattern (GitHub-style)
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$", re.MULTILINE)
# Markdown link pattern: [text](url)
_LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)]+)\)")
# Trailing whitespace
_TRAILING_WS_RE = re.compile(r"[ \t]+$")
# Heading without blank line before
_HEADING_NO_BLANK_RE = re.compile(r"([^\n])\n(#{1,6}\s)")
# Files that must exist in every project
REQUIRED_FILES = ["README.md", "AGENTS.md", "CHANGELOG.md"]
# Files that must exist in docs/
REQUIRED_DOC_FILES = ["index.md"]
# Maximum age for docs before they're considered stale (days)
STALE_THRESHOLD_DAYS = 180
# Files excluded from duplicate heading checks (auto-generated or structured
# with repeated subsections under different parent sections)
DUPLICATE_HEADING_EXCLUDES = {
"CHANGELOG.md",
"incident-response-sso.md",
"role-sync-design.md",
}
# TODO/FIXME pattern — matches "TODO:" or "FIXME:" at start of line/after whitespace
# Does NOT match references to the word "TODO" in rules/documentation
_TODO_RE = re.compile(r"(?m)^\s*(?:>>>?\s*)?(TODO|FIXME|HACK|XXX)\s*:", re.IGNORECASE)
# Directories excluded from markdown file scanning
_EXCLUDE_DIRS = {
".venv",
".git",
"node_modules",
"__pycache__",
".pytest_cache",
".devin",
".terraform",
"site-packages",
"dist-info",
}
def slugify(text: str) -> str:
"""Convert heading text to a GitHub-style slug."""
slug = text.lower().strip()
slug = re.sub(r"[^\w\s-]", "", slug)
slug = re.sub(r"[\s]+", "-", slug)
return slug
def strip_code_blocks(content: str) -> str:
"""Remove fenced code blocks from markdown content.
Replaces ```...``` blocks with empty lines so heading detection
doesn't pick up # comments inside code blocks.
"""
result: list[str] = []
in_code_block = False
for line in content.splitlines():
if line.strip().startswith("```"):
in_code_block = not in_code_block
result.append("")
continue
if in_code_block:
result.append("")
continue
result.append(line)
return "\n".join(result)
def extract_headings(filepath: Path) -> dict[str, int]:
"""Extract all headings from a markdown file.
Returns a dict mapping slug heading level.
"""
content = strip_code_blocks(filepath.read_text(encoding="utf-8"))
headings: dict[str, int] = {}
for match in _HEADING_RE.finditer(content):
level = len(match.group(1))
text = match.group(2)
slug = slugify(text)
headings[slug] = level
return headings
def extract_links(filepath: Path) -> list[tuple[int, str, str]]:
"""Extract all markdown links from a file.
Returns a list of (line_number, link_text, url) tuples.
Includes anchor-only links (#section) for validation.
Skips external links (http/https) and mailto.
"""
content = filepath.read_text(encoding="utf-8")
links: list[tuple[int, str, str]] = []
for match in _LINK_RE.finditer(content):
url = match.group(2).strip()
# Skip external links and mailto
if url.startswith(("http://", "https://", "mailto:")):
continue
line_num = content[: match.start()].count("\n") + 1
links.append((line_num, match.group(1), url))
return links
def check_required_files(root: Path) -> list[str]:
"""Check that required files exist."""
issues: list[str] = []
for filename in REQUIRED_FILES:
if not (root / filename).exists():
issues.append(f"Missing required file: {filename}")
return issues
def check_docs_structure(root: Path, docs_dir: Path) -> list[str]:
"""Check that docs directory has required structure."""
issues: list[str] = []
if not docs_dir.exists():
issues.append(f"Docs directory not found: {docs_dir}")
return issues
for filename in REQUIRED_DOC_FILES:
if not (docs_dir / filename).exists():
issues.append(f"Missing required doc file: docs/{filename}")
mapping_file = docs_dir / "mapping.json"
if mapping_file.exists():
try:
mapping = json.loads(mapping_file.read_text(encoding="utf-8"))
if not isinstance(mapping, dict):
issues.append("docs/mapping.json must be a JSON object")
elif not mapping:
issues.append("docs/mapping.json is empty")
except json.JSONDecodeError as e:
issues.append(f"docs/mapping.json is invalid JSON: {e}")
return issues
def check_internal_links(root: Path, docs_dir: Path) -> list[str]:
"""Check that all internal links in markdown files resolve."""
issues: list[str] = []
md_files = list(root.rglob("*.md"))
# Exclude .venv, .git, node_modules
md_files = [f for f in md_files if not any(part in _EXCLUDE_DIRS for part in f.parts)]
# Load wiki page names from mapping.json — these are valid link targets
wiki_pages: set[str] = set()
mapping_file = docs_dir / "mapping.json"
if mapping_file.exists():
try:
mapping = json.loads(mapping_file.read_text(encoding="utf-8"))
wiki_pages = set(mapping.values())
except (json.JSONDecodeError, AttributeError):
pass
for md_file in md_files:
rel_path = md_file.relative_to(root)
links = extract_links(md_file)
headings = extract_headings(md_file)
for line_num, _link_text, url in links:
# Split into path and anchor
if "#" in url:
path_part, anchor = url.split("#", 1)
else:
path_part, anchor = url, ""
# Skip wiki page references (no file extension, no /, matches mapping.json values)
if path_part and "." not in path_part and "/" not in path_part:
if path_part in wiki_pages:
continue
# Also skip if it looks like a wiki page name (CamelCase or hyphenated)
# without a file extension — can't verify these locally
if not any(c in path_part for c in "/\\"):
continue
# Resolve relative path
if path_part:
target = (md_file.parent / path_part).resolve()
if not target.exists():
issues.append(f"{rel_path}:{line_num}: broken link '{url}' — file not found: {path_part}")
continue
# Check anchor in target file
if anchor:
target_headings = extract_headings(target)
target_slug = slugify(anchor)
if target_slug not in target_headings:
issues.append(f"{rel_path}:{line_num}: broken anchor '#{anchor}' in {path_part}")
elif anchor:
# Anchor-only link — check in current file
anchor_slug = slugify(anchor)
if anchor_slug not in headings:
issues.append(f"{rel_path}:{line_num}: broken anchor '#{anchor}'")
return issues
def check_heading_hierarchy(root: Path) -> list[str]:
"""Check that headings don't skip levels."""
issues: list[str] = []
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
for md_file in md_files:
rel_path = md_file.relative_to(root)
content = strip_code_blocks(md_file.read_text(encoding="utf-8"))
prev_level = 0
for match in _HEADING_RE.finditer(content):
level = len(match.group(1))
if prev_level > 0 and level > prev_level + 1:
issues.append(f"{rel_path}: heading hierarchy skip — H{prev_level} → H{level}: '{match.group(2)}'")
prev_level = level
return issues
def check_todo_fixme(root: Path) -> list[str]:
"""Check for TODO/FIXME/HACK/XXX markers in documentation.
Only flags actual TODO/FIXME markers (e.g., "TODO: fix this"), not
references to the word "TODO" in rules or documentation about TODOs.
"""
issues: list[str] = []
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
for md_file in md_files:
rel_path = md_file.relative_to(root)
content = md_file.read_text(encoding="utf-8")
for match in _TODO_RE.finditer(content):
line_num = content[: match.start()].count("\n") + 1
line = content.splitlines()[line_num - 1] if line_num <= len(content.splitlines()) else ""
issues.append(f"{rel_path}:{line_num}: TODO/FIXME found: {line.strip()}")
return issues
def check_trailing_whitespace(root: Path) -> list[str]:
"""Check for trailing whitespace in markdown files."""
issues: list[str] = []
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
for md_file in md_files:
rel_path = md_file.relative_to(root)
content = md_file.read_text(encoding="utf-8")
for i, line in enumerate(content.splitlines(), 1):
if _TRAILING_WS_RE.search(line):
issues.append(f"{rel_path}:{i}: trailing whitespace")
return issues
def check_stale_docs(root: Path) -> list[str]:
"""Check for stale documentation (not modified in >180 days)."""
issues: list[str] = []
threshold = datetime.now() - timedelta(days=STALE_THRESHOLD_DAYS)
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
for md_file in md_files:
rel_path = md_file.relative_to(root)
mtime = datetime.fromtimestamp(md_file.stat().st_mtime)
if mtime < threshold:
days_old = (datetime.now() - mtime).days
issues.append(f"{rel_path}: stale doc — not modified in {days_old} days")
return issues
def check_duplicate_headings(root: Path) -> list[str]:
"""Check for duplicate headings within the same file."""
issues: list[str] = []
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
for md_file in md_files:
rel_path = md_file.relative_to(root)
# Skip auto-generated files like CHANGELOG.md
if md_file.name in DUPLICATE_HEADING_EXCLUDES:
continue
content = strip_code_blocks(md_file.read_text(encoding="utf-8"))
seen: dict[str, int] = {}
for match in _HEADING_RE.finditer(content):
text = match.group(2)
slug = slugify(text)
if slug in seen:
issues.append(f"{rel_path}: duplicate heading '{text}'")
seen[slug] = 1
return issues
@click.command()
@click.option("--root", default=".", help="Repository root directory.")
@click.option("--docs-dir", default=None, help="Docs directory (default: <root>/docs).")
@click.option("--check-links/--no-check-links", default=True, help="Check internal links.")
@click.option("--check-headings/--no-check-headings", default=True, help="Check heading hierarchy.")
@click.option("--check-todo/--no-check-todo", default=True, help="Check for TODO/FIXME.")
@click.option("--check-stale/--no-check-stale", default=False, help="Check for stale docs.")
@click.option("--check-trailing/--no-check-trailing", default=True, help="Check trailing whitespace.")
@click.option("--check-duplicates/--no-check-duplicates", default=True, help="Check duplicate headings.")
@click.option("--fix", is_flag=True, default=False, help="Auto-fix trailing whitespace.")
def main(
root: str,
docs_dir: str | None,
check_links: bool,
check_headings: bool,
check_todo: bool,
check_stale: bool,
check_trailing: bool,
check_duplicates: bool,
fix: bool,
) -> None:
"""Lint documentation files for structure, links, and quality."""
root_path = Path(root).resolve()
docs_path = Path(docs_dir) if docs_dir else root_path / "docs"
click.echo(_("Linting documentation in {root}...", root=str(root_path)))
all_issues: list[str] = []
# Structure checks
click.echo(_("Checking required files..."))
all_issues.extend(check_required_files(root_path))
click.echo(_("Checking docs structure..."))
all_issues.extend(check_docs_structure(root_path, docs_path))
# Link checks
if check_links:
click.echo(_("Checking internal links..."))
all_issues.extend(check_internal_links(root_path, docs_path))
# Heading hierarchy
if check_headings:
click.echo(_("Checking heading hierarchy..."))
all_issues.extend(check_heading_hierarchy(root_path))
# Duplicate headings
if check_duplicates:
click.echo(_("Checking duplicate headings..."))
all_issues.extend(check_duplicate_headings(root_path))
# TODO/FIXME
if check_todo:
click.echo(_("Checking for TODO/FIXME markers..."))
all_issues.extend(check_todo_fixme(root_path))
# Trailing whitespace
if check_trailing:
click.echo(_("Checking trailing whitespace..."))
ws_issues = check_trailing_whitespace(root_path)
if fix and ws_issues:
fixed = 0
md_files = [f for f in root_path.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
for md_file in md_files:
content = md_file.read_text(encoding="utf-8")
fixed_content = _TRAILING_WS_RE.sub("", content)
if content != fixed_content:
md_file.write_text(fixed_content, encoding="utf-8")
fixed += 1
click.echo(_(" Auto-fixed trailing whitespace in {n} files", n=fixed))
else:
all_issues.extend(ws_issues)
# Stale docs
if check_stale:
click.echo(_("Checking for stale docs..."))
stale = check_stale_docs(root_path)
for issue in stale:
click.echo(f" WARN: {issue}")
# Stale docs are warnings, not errors
click.echo(_(" {n} stale docs found (warnings only)", n=len(stale)))
# Report
click.echo(f"\n{'=' * 60}")
if all_issues:
click.echo(_("FAIL: {n} documentation issues found:", n=len(all_issues)))
for issue in all_issues:
click.echo(f" - {issue}")
sys.exit(1)
else:
click.echo(_("PASS: All documentation checks passed!"))
if __name__ == "__main__": # pragma: no cover
main()
+20
View File
@@ -387,14 +387,34 @@ def check_documentation(files: list[dict[str, Any]], result: ReviewResult) -> No
for f in files for f in files
) )
has_ansible_changes = any(f.get("filename", "").startswith("ansible/") for f in files) has_ansible_changes = any(f.get("filename", "").startswith("ansible/") for f in files)
has_tofu_changes = any(f.get("filename", "").startswith("tofu/") for f in files)
has_workflow_changes = any(f.get("filename", "").startswith(".gitea/") for f in files)
# Check for TODO/FIXME in changed docs
todo_issues: list[str] = []
for f in files:
filename = f.get("filename", "")
if filename.endswith(".md") and filename.startswith(("docs/", "README", "AGENTS")):
# Can't check file content from PR API easily, but flag if patch adds TODO
patch = f.get("patch", "")
if patch and re.search(r"^\+.*\b(TODO|FIXME|HACK|XXX)\b", patch, re.IGNORECASE):
todo_issues.append(f"{filename}: new TODO/FIXME added in documentation")
if has_src_changes and not has_doc_changes: if has_src_changes and not has_doc_changes:
result.add_summary("- Documentation: WARNING — source files changed but no docs updated") result.add_summary("- Documentation: WARNING — source files changed but no docs updated")
elif has_ansible_changes and not has_doc_changes: elif has_ansible_changes and not has_doc_changes:
result.add_summary("- Documentation: WARNING — Ansible role changed but no docs updated") result.add_summary("- Documentation: WARNING — Ansible role changed but no docs updated")
elif has_tofu_changes and not has_doc_changes:
result.add_summary("- Documentation: WARNING — OpenTofu changes but no docs updated")
elif has_workflow_changes and not has_doc_changes:
result.add_summary("- Documentation: INFO — workflow changes (consider updating CI docs if behavior changed)")
else: else:
result.add_summary("- Documentation: OK") result.add_summary("- Documentation: OK")
if todo_issues:
for issue in todo_issues:
result.add_summary(f"- Documentation: WARNING — {issue}")
def check_test_coverage(files: list[dict[str, Any]], result: ReviewResult) -> None: def check_test_coverage(files: list[dict[str, Any]], result: ReviewResult) -> None:
"""Check that tests are updated for source changes.""" """Check that tests are updated for source changes."""
+34 -3
View File
@@ -38,6 +38,7 @@ from __future__ import annotations
import os import os
import re import re
import sys import sys
import time
import click import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
@@ -709,9 +710,39 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
click.echo(_("Created release commit.")) click.echo(_("Created release commit."))
# Pull --rebase before push to handle the case where master # Pull --rebase before push to handle the case where master
# advanced between checkout and commit (e.g., another merge). # advanced between checkout and commit (e.g., another merge).
run_cmd(["git", "pull", "--rebase", "origin", "master"], check=False) # Retry up to 3 times to handle concurrent pushes.
# Use refs/heads/master to avoid ambiguity with a 'master' tag push_succeeded = False
run_cmd(["git", "push", "origin", "refs/heads/master:refs/heads/master"]) for attempt in range(3):
rebase = run_cmd(["git", "pull", "--rebase", "origin", "master"], check=False)
if rebase.returncode != 0:
# Rebase failed (likely conflicts). Abort and retry.
click.echo(
_(
"Rebase attempt {n}/3 failed: {err}",
n=attempt + 1,
err=rebase.stderr.strip() if rebase.stderr else rebase.stdout.strip(),
)
)
run_cmd(["git", "rebase", "--abort"], check=False)
# Brief delay before retry to let concurrent pushes settle.
time.sleep(5)
continue
push = run_cmd(["git", "push", "origin", "refs/heads/master:refs/heads/master"], check=False)
if push.returncode == 0:
push_succeeded = True
break
click.echo(
_(
"Push attempt {n}/3 failed: {err}",
n=attempt + 1,
err=push.stderr.strip() if push.stderr else push.stdout.strip(),
)
)
time.sleep(5)
if not push_succeeded:
raise click.ClickException(
_("Failed to push release commit after 3 attempts. Manual intervention required.")
)
click.echo(_("Pushed release commit to master.")) click.echo(_("Pushed release commit to master."))
else: else:
click.echo(_("Skipping commit push — no staged changes.")) click.echo(_("Skipping commit push — no staged changes."))
+67 -8
View File
@@ -21,11 +21,19 @@ from __future__ import annotations
import base64 import base64
import json import json
import logging
import os import os
from pathlib import Path from pathlib import Path
import click import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from tenacity import (
before_sleep_log,
retry,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
from devx.api_clients import GiteaClient from devx.api_clients import GiteaClient
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
@@ -86,11 +94,12 @@ def decode_content(content_b64: str) -> str:
def list_wiki_pages(client: GiteaClient) -> dict[str, str]: def list_wiki_pages(client: GiteaClient) -> dict[str, str]:
"""List existing wiki pages, returning {title: sub_url}.""" """List existing wiki pages, returning {title: sub_url}.
try:
pages = client._request("GET", "/wiki/pages").json() Raises :class:`APIError` if the wiki API is unavailable the caller
except APIError: is responsible for retrying or handling the failure.
return {} """
pages = client._request("GET", "/wiki/pages").json()
return {page.get("title", ""): page.get("sub_url", page.get("title", "")) for page in pages} return {page.get("title", ""): page.get("sub_url", page.get("title", "")) for page in pages}
@@ -161,6 +170,28 @@ def verify_wiki_page(
return actual.strip() == expected_content.strip() return actual.strip() == expected_content.strip()
def _list_wiki_pages_with_retry(client: GiteaClient) -> dict[str, str]:
"""List wiki pages with tenacity retry on APIError.
The Gitea API can be briefly unavailable right after a batch of wiki
page updates. Uses the same tenacity pattern as ``api_clients`` for
exponential backoff.
"""
_logger = logging.getLogger("sync_wiki")
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=2, min=2, max=8),
retry=retry_if_exception_type(APIError),
before_sleep=before_sleep_log(_logger, logging.WARNING),
reraise=True,
)
def _do_list() -> dict[str, str]:
return list_wiki_pages(client)
return _do_list()
def verify_wiki_integrity( def verify_wiki_integrity(
client: GiteaClient, client: GiteaClient,
mapping: dict[str, str], mapping: dict[str, str],
@@ -176,9 +207,25 @@ def verify_wiki_integrity(
5. Page count matches 5. Page count matches
Returns a list of failure messages (empty if all checks pass). Returns a list of failure messages (empty if all checks pass).
If the wiki API is temporarily unavailable (all retry attempts
fail), returns an empty list with a warning the sync itself
already succeeded, so a transient API outage should not fail the job.
""" """
failures: list[str] = [] failures: list[str] = []
existing_pages = list_wiki_pages(client)
try:
existing_pages = _list_wiki_pages_with_retry(client)
except APIError:
click.echo(
_(
"WARNING: Could not fetch wiki page list after retries. "
"The sync itself succeeded ({count} pages updated), but the "
"integrity check could not verify them due to a transient API issue.",
count=len(synced),
)
)
return []
expected_titles = set(mapping.values()) expected_titles = set(mapping.values())
# Check 1: Page count # Check 1: Page count
@@ -243,7 +290,10 @@ def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
click.echo(_("Syncing {count} documentation pages to wiki...", count=len(mapping))) click.echo(_("Syncing {count} documentation pages to wiki...", count=len(mapping)))
existing_pages = list_wiki_pages(client) try:
existing_pages = list_wiki_pages(client)
except APIError:
existing_pages = {}
if existing_pages: if existing_pages:
click.echo(_("Found {count} existing wiki pages.", count=len(existing_pages))) click.echo(_("Found {count} existing wiki pages.", count=len(existing_pages)))
@@ -302,7 +352,16 @@ def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
else: else:
click.echo(_("\nVerifying wiki pages have content...")) click.echo(_("\nVerifying wiki pages have content..."))
# Re-fetch the page list to get updated sub_urls # Re-fetch the page list to get updated sub_urls
existing_pages = list_wiki_pages(client) try:
existing_pages = _list_wiki_pages_with_retry(client)
except APIError:
click.echo(
_(
"WARNING: Could not re-fetch wiki page list for verification. "
"Skipping content verification due to transient API issue."
)
)
return
failures = 0 failures = 0
for page_title, expected_content in sorted(synced.items()): for page_title, expected_content in sorted(synced.items()):
ok = verify_wiki_page(client, page_title, expected_content, existing_pages) ok = verify_wiki_page(client, page_title, expected_content, existing_pages)
+7
View File
@@ -95,6 +95,13 @@ def ci_doc_coverage(args: tuple[str, ...]) -> None:
_run_module("devx.ci.doc_coverage", list(args)) _run_module("devx.ci.doc_coverage", list(args))
@ci.command("lint-docs")
@click.argument("args", nargs=-1)
def ci_lint_docs(args: tuple[str, ...]) -> None:
"""Lint documentation files for structure, links, and quality."""
_run_module("devx.ci.lint_docs", list(args))
@ci.command("notify-failure") @ci.command("notify-failure")
@click.argument("args", nargs=-1) @click.argument("args", nargs=-1)
def ci_notify_failure(args: tuple[str, ...]) -> None: def ci_notify_failure(args: tuple[str, ...]) -> None:
+189 -45
View File
@@ -144,9 +144,9 @@
"zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments)." "zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments)."
}, },
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.": { "\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}'.", "bg": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
"de": "\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}'.", "pl": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
"ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.", "ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
"zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'." "zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'."
@@ -216,9 +216,9 @@
"zh": "\nWorkflow-only changes ({count}):" "zh": "\nWorkflow-only changes ({count}):"
}, },
"\n[check_test_coverage] Fix: add the missing test file(s) before committing.": { "\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.", "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.", "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.", "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.", "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." "zh": "\n[check_test_coverage] Fix: add the missing test file(s) before committing."
@@ -256,9 +256,9 @@
"zh": "\n{tag} files ({count}):" "zh": "\n{tag} files ({count}):"
}, },
" Could not fetch logs: {error}": { " Could not fetch logs: {error}": {
"en": " Could not fetch logs: {error}",
"bg": " Could not fetch logs: {error}", "bg": " Could not fetch logs: {error}",
"de": " Could not fetch logs: {error}", "de": " Could not fetch logs: {error}",
"en": " Could not fetch logs: {error}",
"pl": " Could not fetch logs: {error}", "pl": " Could not fetch logs: {error}",
"ru": " Could not fetch logs: {error}", "ru": " Could not fetch logs: {error}",
"zh": " Could not fetch logs: {error}" "zh": " Could not fetch logs: {error}"
@@ -351,6 +351,14 @@
"ru": " - Требуемые проверки статуса: {checks}", "ru": " - Требуемые проверки статуса: {checks}",
"zh": " - 必需状态检查: {checks}" "zh": " - 必需状态检查: {checks}"
}, },
" Auto-fixed trailing whitespace in {n} files": {
"bg": " Auto-fixed trailing whitespace in {n} files",
"de": " Auto-fixed trailing whitespace in {n} files",
"en": " Auto-fixed trailing whitespace in {n} files",
"pl": " Auto-fixed trailing whitespace in {n} files",
"ru": " Auto-fixed trailing whitespace in {n} files",
"zh": " Auto-fixed trailing whitespace in {n} files"
},
" Collecting code quality...": { " Collecting code quality...": {
"bg": " Collecting code quality...", "bg": " Collecting code quality...",
"de": " Collecting code quality...", "de": " Collecting code quality...",
@@ -423,13 +431,13 @@
"ru": " Generated: {path}", "ru": " Generated: {path}",
"zh": " Generated: {path}" "zh": " Generated: {path}"
}, },
" MISSING: devx {cmd}": { " MISSING: {cmd}": {
"bg": " ЛИПСВА: devx {cmd}", "bg": " MISSING: {cmd}",
"de": " FEHLT: devx {cmd}", "de": " MISSING: {cmd}",
"en": " MISSING: devx {cmd}", "en": " MISSING: {cmd}",
"pl": " BRAK: devx {cmd}", "pl": " MISSING: {cmd}",
"ru": " ОТСУТСТВУЕТ: devx {cmd}", "ru": " MISSING: {cmd}",
"zh": " 缺失: devx {cmd}" "zh": " MISSING: {cmd}"
}, },
" MISSING: {module}": { " MISSING: {module}": {
"bg": " MISSING: {module}", "bg": " MISSING: {module}",
@@ -447,13 +455,13 @@
"ru": " MISSING: {script}", "ru": " MISSING: {script}",
"zh": " MISSING: {script}" "zh": " MISSING: {script}"
}, },
" OK: devx {cmd}": { " OK: {cmd}": {
"bg": " ОК: devx {cmd}", "bg": " OK: {cmd}",
"de": " OK: devx {cmd}", "de": " OK: {cmd}",
"en": " OK: devx {cmd}", "en": " OK: {cmd}",
"pl": " OK: devx {cmd}", "pl": " OK: {cmd}",
"ru": " ОК: devx {cmd}", "ru": " OK: {cmd}",
"zh": " 正常: devx {cmd}" "zh": " OK: {cmd}"
}, },
" OK: {module}": { " OK: {module}": {
"bg": " OK: {module}", "bg": " OK: {module}",
@@ -607,6 +615,14 @@
"ru": " {name}: {label}={message} ({color})", "ru": " {name}: {label}={message} ({color})",
"zh": " {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})": { " {version} (created: {created})": {
"bg": " {version} (created: {created})", "bg": " {version} (created: {created})",
"de": " {version} (created: {created})", "de": " {version} (created: {created})",
@@ -616,17 +632,17 @@
"zh": " {version} (created: {created})" "zh": " {version} (created: {created})"
}, },
"--checklist-categories must list at least 8 of 13 categories. Got {count}.": { "--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}.", "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}.", "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}.", "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}.", "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}." "zh": "--checklist-categories must list at least 8 of 13 categories. Got {count}."
}, },
"--checklist-confirmed is required for APPROVE events.": { "--checklist-confirmed is required for APPROVE events.": {
"en": "--checklist-confirmed is required for APPROVE events.",
"bg": "--checklist-confirmed is required for APPROVE events.", "bg": "--checklist-confirmed is required for APPROVE events.",
"de": "--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.", "pl": "--checklist-confirmed is required for APPROVE events.",
"ru": "--checklist-confirmed is required for APPROVE events.", "ru": "--checklist-confirmed is required for APPROVE events.",
"zh": "--checklist-confirmed is required for APPROVE events." "zh": "--checklist-confirmed is required for APPROVE events."
@@ -664,9 +680,9 @@
"zh": "API poll warning: {exc}" "zh": "API poll warning: {exc}"
}, },
"Added label '{label}' to PR #{pr}.": { "Added label '{label}' to PR #{pr}.": {
"en": "Added label '{label}' to PR #{pr}.",
"bg": "Added label '{label}' to PR #{pr}.", "bg": "Added label '{label}' to PR #{pr}.",
"de": "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}.", "pl": "Added label '{label}' to PR #{pr}.",
"ru": "Added label '{label}' to PR #{pr}.", "ru": "Added label '{label}' to PR #{pr}.",
"zh": "Added label '{label}' to PR #{pr}." "zh": "Added label '{label}' to PR #{pr}."
@@ -808,17 +824,17 @@
"zh": "Bumping version: {current} -> v{new_version}" "zh": "Bumping version: {current} -> v{new_version}"
}, },
"CI checks did not complete within timeout.": { "CI checks did not complete within timeout.": {
"en": "CI checks did not complete within timeout.",
"bg": "CI checks did not complete within timeout.", "bg": "CI checks did not complete within timeout.",
"de": "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.", "pl": "CI checks did not complete within timeout.",
"ru": "CI checks did not complete within timeout.", "ru": "CI checks did not complete within timeout.",
"zh": "CI checks did not complete within timeout." "zh": "CI checks did not complete within timeout."
}, },
"CI checks failed.": { "CI checks failed.": {
"en": "CI checks failed.",
"bg": "CI checks failed.", "bg": "CI checks failed.",
"de": "CI checks failed.", "de": "CI checks failed.",
"en": "CI checks failed.",
"pl": "CI checks failed.", "pl": "CI checks failed.",
"ru": "CI checks failed.", "ru": "CI checks failed.",
"zh": "CI checks failed." "zh": "CI checks failed."
@@ -832,9 +848,9 @@
"zh": "CI_GITEA_TOKEN environment variable required" "zh": "CI_GITEA_TOKEN environment variable required"
}, },
"CI_GITEA_TOKEN is not set.": { "CI_GITEA_TOKEN is not set.": {
"en": "CI_GITEA_TOKEN is not set.",
"bg": "CI_GITEA_TOKEN is not set.", "bg": "CI_GITEA_TOKEN is not set.",
"de": "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.", "pl": "CI_GITEA_TOKEN is not set.",
"ru": "CI_GITEA_TOKEN is not set.", "ru": "CI_GITEA_TOKEN is not set.",
"zh": "CI_GITEA_TOKEN is not set." "zh": "CI_GITEA_TOKEN is not set."
@@ -863,14 +879,78 @@
"ru": "Checking CLI command documentation...", "ru": "Checking CLI command documentation...",
"zh": "Checking CLI command documentation..." "zh": "Checking CLI command documentation..."
}, },
"Checking docs structure...": {
"bg": "Checking docs structure...",
"de": "Checking docs structure...",
"en": "Checking docs structure...",
"pl": "Checking docs structure...",
"ru": "Checking docs structure...",
"zh": "Checking docs structure..."
},
"Checking duplicate headings...": {
"bg": "Checking duplicate headings...",
"de": "Checking duplicate headings...",
"en": "Checking duplicate headings...",
"pl": "Checking duplicate headings...",
"ru": "Checking duplicate headings...",
"zh": "Checking duplicate headings..."
},
"Checking for TODO/FIXME markers...": {
"bg": "Checking for TODO/FIXME markers...",
"de": "Checking for TODO/FIXME markers...",
"en": "Checking for TODO/FIXME markers...",
"pl": "Checking for TODO/FIXME markers...",
"ru": "Checking for TODO/FIXME markers...",
"zh": "Checking for TODO/FIXME markers..."
},
"Checking for stale docs...": {
"bg": "Checking for stale docs...",
"de": "Checking for stale docs...",
"en": "Checking for stale docs...",
"pl": "Checking for stale docs...",
"ru": "Checking for stale docs...",
"zh": "Checking for stale docs..."
},
"Checking heading hierarchy...": {
"bg": "Checking heading hierarchy...",
"de": "Checking heading hierarchy...",
"en": "Checking heading hierarchy...",
"pl": "Checking heading hierarchy...",
"ru": "Checking heading hierarchy...",
"zh": "Checking heading hierarchy..."
},
"Checking internal links...": {
"bg": "Checking internal links...",
"de": "Checking internal links...",
"en": "Checking internal links...",
"pl": "Checking internal links...",
"ru": "Checking internal links...",
"zh": "Checking internal links..."
},
"Checking required files...": {
"bg": "Checking required files...",
"de": "Checking required files...",
"en": "Checking required files...",
"pl": "Checking required files...",
"ru": "Checking required files...",
"zh": "Checking required files..."
},
"Checking status for PR #{pr_number}...": { "Checking status for PR #{pr_number}...": {
"en": "Checking status for PR #{pr_number}...",
"bg": "Checking status for PR #{pr_number}...", "bg": "Checking status for PR #{pr_number}...",
"de": "Checking status for PR #{pr_number}...", "de": "Checking status for PR #{pr_number}...",
"en": "Checking status for PR #{pr_number}...",
"pl": "Checking status for PR #{pr_number}...", "pl": "Checking status for PR #{pr_number}...",
"ru": "Checking status for PR #{pr_number}...", "ru": "Checking status for PR #{pr_number}...",
"zh": "Checking status for PR #{pr_number}..." "zh": "Checking status for PR #{pr_number}..."
}, },
"Checking trailing whitespace...": {
"bg": "Checking trailing whitespace...",
"de": "Checking trailing whitespace...",
"en": "Checking trailing whitespace...",
"pl": "Checking trailing whitespace...",
"ru": "Checking trailing whitespace...",
"zh": "Checking trailing whitespace..."
},
"Command failed ({cmd}): {stderr}": { "Command failed ({cmd}): {stderr}": {
"bg": "Command failed ({cmd}): {stderr}", "bg": "Command failed ({cmd}): {stderr}",
"de": "Command failed ({cmd}): {stderr}", "de": "Command failed ({cmd}): {stderr}",
@@ -888,9 +968,9 @@
"zh": "Commit message: {msg}" "zh": "Commit message: {msg}"
}, },
"Commit: {sha}": { "Commit: {sha}": {
"en": "Commit: {sha}",
"bg": "Commit: {sha}", "bg": "Commit: {sha}",
"de": "Commit: {sha}", "de": "Commit: {sha}",
"en": "Commit: {sha}",
"pl": "Commit: {sha}", "pl": "Commit: {sha}",
"ru": "Commit: {sha}", "ru": "Commit: {sha}",
"zh": "Commit: {sha}" "zh": "Commit: {sha}"
@@ -912,9 +992,9 @@
"zh": "配置正常: [tool.devx] 已存在, devx 版本一致。" "zh": "配置正常: [tool.devx] 已存在, devx 版本一致。"
}, },
"Configuration validation failed.": { "Configuration validation failed.": {
"en": "Configuration validation failed.",
"bg": "Configuration validation failed.", "bg": "Configuration validation failed.",
"de": "Configuration validation failed.", "de": "Configuration validation failed.",
"en": "Configuration validation failed.",
"pl": "Configuration validation failed.", "pl": "Configuration validation failed.",
"ru": "Configuration validation failed.", "ru": "Configuration validation failed.",
"zh": "Configuration validation failed." "zh": "Configuration validation failed."
@@ -952,9 +1032,9 @@
"zh": "无法检测当前分支: {error}" "zh": "无法检测当前分支: {error}"
}, },
"Could not determine head SHA for PR #{pr_number}.": { "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}.", "bg": "Could not determine head SHA for PR #{pr_number}.",
"de": "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}.", "pl": "Could not determine head SHA for PR #{pr_number}.",
"ru": "Could not determine head SHA for PR #{pr_number}.", "ru": "Could not determine head SHA for PR #{pr_number}.",
"zh": "Could not determine head SHA for PR #{pr_number}." "zh": "Could not determine head SHA for PR #{pr_number}."
@@ -1135,6 +1215,14 @@
"ru": "Каждый элемент должен быть строкой или объектом с 'id', получено {type}", "ru": "Каждый элемент должен быть строкой или объектом с 'id', получено {type}",
"zh": "每个元素必须是字符串或带有 'id' 的对象,得到 {type}" "zh": "每个元素必须是字符串或带有 'id' 的对象,得到 {type}"
}, },
"FAIL: {n} documentation issues found:": {
"bg": "FAIL: {n} documentation issues found:",
"de": "FAIL: {n} documentation issues found:",
"en": "FAIL: {n} documentation issues found:",
"pl": "FAIL: {n} documentation issues found:",
"ru": "FAIL: {n} documentation issues found:",
"zh": "FAIL: {n} documentation issues found:"
},
"FAILED: {count} undocumented dependency/ies": { "FAILED: {count} undocumented dependency/ies": {
"bg": "FAILED: {count} undocumented dependency/ies", "bg": "FAILED: {count} undocumented dependency/ies",
"de": "FAILED: {count} undocumented dependency/ies", "de": "FAILED: {count} undocumented dependency/ies",
@@ -1184,9 +1272,9 @@
"zh": "Failed to list versions for {name}: {error}" "zh": "Failed to list versions for {name}: {error}"
}, },
"Fetching logs for PR #{pr_number}...": { "Fetching logs for PR #{pr_number}...": {
"en": "Fetching logs for PR #{pr_number}...",
"bg": "Fetching logs for PR #{pr_number}...", "bg": "Fetching logs for PR #{pr_number}...",
"de": "Fetching logs for PR #{pr_number}...", "de": "Fetching logs for PR #{pr_number}...",
"en": "Fetching logs for PR #{pr_number}...",
"pl": "Fetching logs for PR #{pr_number}...", "pl": "Fetching logs for PR #{pr_number}...",
"ru": "Fetching logs for PR #{pr_number}...", "ru": "Fetching logs for PR #{pr_number}...",
"zh": "Fetching logs for PR #{pr_number}..." "zh": "Fetching logs for PR #{pr_number}..."
@@ -1384,9 +1472,9 @@
"zh": "Integration tests passed." "zh": "Integration tests passed."
}, },
"Invalid checklist category: {cat}. Must be numbers.": { "Invalid checklist category: {cat}. Must be numbers.": {
"en": "Invalid checklist category: {cat}. Must be numbers.",
"bg": "Invalid checklist category: {cat}. Must be numbers.", "bg": "Invalid checklist category: {cat}. Must be numbers.",
"de": "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.", "pl": "Invalid checklist category: {cat}. Must be numbers.",
"ru": "Invalid checklist category: {cat}. Must be numbers.", "ru": "Invalid checklist category: {cat}. Must be numbers.",
"zh": "Invalid checklist category: {cat}. Must be numbers." "zh": "Invalid checklist category: {cat}. Must be numbers."
@@ -1400,17 +1488,17 @@
"zh": "输入必须是 JSON 数组,得到 {type}" "zh": "输入必须是 JSON 数组,得到 {type}"
}, },
"Label '{label}' already on PR #{pr}.": { "Label '{label}' already on PR #{pr}.": {
"en": "Label '{label}' already on PR #{pr}.",
"bg": "Label '{label}' already on PR #{pr}.", "bg": "Label '{label}' already on PR #{pr}.",
"de": "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}.", "pl": "Label '{label}' already on PR #{pr}.",
"ru": "Label '{label}' already on PR #{pr}.", "ru": "Label '{label}' already on PR #{pr}.",
"zh": "Label '{label}' already on PR #{pr}." "zh": "Label '{label}' already on PR #{pr}."
}, },
"Latest run: #{run_id} (status: {status})": { "Latest run: #{run_id} (status: {status})": {
"en": "Latest run: #{run_id} (status: {status})",
"bg": "Latest run: #{run_id} (status: {status})", "bg": "Latest run: #{run_id} (status: {status})",
"de": "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})", "pl": "Latest run: #{run_id} (status: {status})",
"ru": "Latest run: #{run_id} (status: {status})", "ru": "Latest run: #{run_id} (status: {status})",
"zh": "Latest run: #{run_id} (status: {status})" "zh": "Latest run: #{run_id} (status: {status})"
@@ -1431,6 +1519,14 @@
"ru": "Lint passed.", "ru": "Lint passed.",
"zh": "Lint passed." "zh": "Lint passed."
}, },
"Linting documentation in {root}...": {
"bg": "Linting documentation in {root}...",
"de": "Linting documentation in {root}...",
"en": "Linting documentation in {root}...",
"pl": "Linting documentation in {root}...",
"ru": "Linting documentation in {root}...",
"zh": "Linting documentation in {root}..."
},
"Manifest file not found: {path}": { "Manifest file not found: {path}": {
"bg": "Manifest file not found: {path}", "bg": "Manifest file not found: {path}",
"de": "Manifest file not found: {path}", "de": "Manifest file not found: {path}",
@@ -1472,9 +1568,9 @@
"zh": "合并失败: HTTP {status}: {message}\n请检查 PR 是否准备就绪且您具有合并权限。" "zh": "合并失败: HTTP {status}: {message}\n请检查 PR 是否准备就绪且您具有合并权限。"
}, },
"Missing tests for changed files.": { "Missing tests for changed files.": {
"en": "Missing tests for changed files.",
"bg": "Missing tests for changed files.", "bg": "Missing tests for changed files.",
"de": "Missing tests for changed files.", "de": "Missing tests for changed files.",
"en": "Missing tests for changed files.",
"pl": "Missing tests for changed files.", "pl": "Missing tests for changed files.",
"ru": "Missing tests for changed files.", "ru": "Missing tests for changed files.",
"zh": "Missing tests for changed files." "zh": "Missing tests for changed files."
@@ -1536,9 +1632,9 @@
"zh": "不错!Vikunja 任务 {task_id} (ID {vikunja_id}) 已更新并标记为完成。" "zh": "不错!Vikunja 任务 {task_id} (ID {vikunja_id}) 已更新并标记为完成。"
}, },
"No CI checks found for commit {sha}.": { "No CI checks found for commit {sha}.": {
"en": "No CI checks found for commit {sha}.",
"bg": "No CI checks found for commit {sha}.", "bg": "No CI checks found for commit {sha}.",
"de": "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}.", "pl": "No CI checks found for commit {sha}.",
"ru": "No CI checks found for commit {sha}.", "ru": "No CI checks found for commit {sha}.",
"zh": "No CI checks found for commit {sha}." "zh": "No CI checks found for commit {sha}."
@@ -1568,33 +1664,33 @@
"zh": "No changes between {base} and {head}." "zh": "No changes between {base} and {head}."
}, },
"No failed jobs.": { "No failed jobs.": {
"en": "No failed jobs.",
"bg": "No failed jobs.", "bg": "No failed jobs.",
"de": "No failed jobs.", "de": "No failed jobs.",
"en": "No failed jobs.",
"pl": "No failed jobs.", "pl": "No failed jobs.",
"ru": "No failed jobs.", "ru": "No failed jobs.",
"zh": "No failed jobs." "zh": "No failed jobs."
}, },
"No job matching '{job}' found.": { "No job matching '{job}' found.": {
"en": "No job matching '{job}' found.",
"bg": "No job matching '{job}' found.", "bg": "No job matching '{job}' found.",
"de": "No job matching '{job}' found.", "de": "No job matching '{job}' found.",
"en": "No job matching '{job}' found.",
"pl": "No job matching '{job}' found.", "pl": "No job matching '{job}' found.",
"ru": "No job matching '{job}' found.", "ru": "No job matching '{job}' found.",
"zh": "No job matching '{job}' found." "zh": "No job matching '{job}' found."
}, },
"No jobs found for run #{run_id}.": { "No jobs found for run #{run_id}.": {
"en": "No jobs found for run #{run_id}.",
"bg": "No jobs found for run #{run_id}.", "bg": "No jobs found for run #{run_id}.",
"de": "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}.", "pl": "No jobs found for run #{run_id}.",
"ru": "No jobs found for run #{run_id}.", "ru": "No jobs found for run #{run_id}.",
"zh": "No jobs found for run #{run_id}." "zh": "No jobs found for run #{run_id}."
}, },
"No open PR found for branch '{branch}'.": { "No open PR found for branch '{branch}'.": {
"en": "No open PR found for branch '{branch}'.",
"bg": "No open PR found for branch '{branch}'.", "bg": "No open PR found for branch '{branch}'.",
"de": "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}'.", "pl": "No open PR found for branch '{branch}'.",
"ru": "No open PR found for branch '{branch}'.", "ru": "No open PR found for branch '{branch}'.",
"zh": "No open PR found for branch '{branch}'." "zh": "No open PR found for branch '{branch}'."
@@ -1664,9 +1760,9 @@
"zh": "No versions found." "zh": "No versions found."
}, },
"No workflow runs found for SHA {sha}.": { "No workflow runs found for SHA {sha}.": {
"en": "No workflow runs found for SHA {sha}.",
"bg": "No workflow runs found for SHA {sha}.", "bg": "No workflow runs found for SHA {sha}.",
"de": "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}.", "pl": "No workflow runs found for SHA {sha}.",
"ru": "No workflow runs found for SHA {sha}.", "ru": "No workflow runs found for SHA {sha}.",
"zh": "No workflow runs found for SHA {sha}." "zh": "No workflow runs found for SHA {sha}."
@@ -1767,6 +1863,14 @@
"ru": "Ой! Публикация в PyPI не удалась:\n{stderr}", "ru": "Ой! Публикация в PyPI не удалась:\n{stderr}",
"zh": "哎呀!PyPI 发布失败:\n{stderr}" "zh": "哎呀!PyPI 发布失败:\n{stderr}"
}, },
"PASS: All documentation checks passed!": {
"bg": "PASS: All documentation checks passed!",
"de": "PASS: All documentation checks passed!",
"en": "PASS: All documentation checks passed!",
"pl": "PASS: All documentation checks passed!",
"ru": "PASS: All documentation checks passed!",
"zh": "PASS: All documentation checks passed!"
},
"PASSED: {pair}": { "PASSED: {pair}": {
"bg": "PASSED: {pair}", "bg": "PASSED: {pair}",
"de": "PASSED: {pair}", "de": "PASSED: {pair}",
@@ -1848,9 +1952,9 @@
"zh": "未设置 PYPI_TOKEN 且未配置 registry URL — 跳过 PyPI 发布。别担心,我们直接创建 Gitea release。" "zh": "未设置 PYPI_TOKEN 且未配置 registry URL — 跳过 PyPI 发布。别担心,我们直接创建 Gitea release。"
}, },
"Package owner not specified. Use --owner or set [tool.devx] repo_owner.": { "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.", "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.", "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.", "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.", "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." "zh": "Package owner not specified. Use --owner or set [tool.devx] repo_owner."
@@ -2072,9 +2176,9 @@
"zh": "Repository in owner/name format" "zh": "Repository in owner/name format"
}, },
"Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.": { "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.", "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.", "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.", "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.", "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." "zh": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var."
@@ -2088,9 +2192,9 @@
"zh": "仓库所有者未设置。使用 --owner 或 DEVX_REPO_OWNER 环境变量。" "zh": "仓库所有者未设置。使用 --owner 或 DEVX_REPO_OWNER 环境变量。"
}, },
"Review body must be at least 50 characters.": { "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.", "bg": "Review body must be at least 50 characters.",
"de": "Review body must be at least 50 characters.", "de": "Review body must be at least 50 characters.",
"en": "Review body must be at least 50 characters.",
"pl": "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.", "ru": "Review body must be at least 50 characters.",
"zh": "Review body must be at least 50 characters." "zh": "Review body must be at least 50 characters."
@@ -2296,9 +2400,9 @@
"zh": "Tests passed." "zh": "Tests passed."
}, },
"Timeout reached after {timeout}s.": { "Timeout reached after {timeout}s.": {
"en": "Timeout reached after {timeout}s.",
"bg": "Timeout reached after {timeout}s.", "bg": "Timeout reached after {timeout}s.",
"de": "Timeout reached after {timeout}s.", "de": "Timeout reached after {timeout}s.",
"en": "Timeout reached after {timeout}s.",
"pl": "Timeout reached after {timeout}s.", "pl": "Timeout reached after {timeout}s.",
"ru": "Timeout reached after {timeout}s.", "ru": "Timeout reached after {timeout}s.",
"zh": "Timeout reached after {timeout}s." "zh": "Timeout reached after {timeout}s."
@@ -2423,6 +2527,22 @@
"ru": "ВНИМАНИЕ: Файл .taskid ({file_id}) устарел и не совпадает с именем ветки ({branch_id}). Удалите .taskid из репозитория — имя ветки — единственный источник истины.", "ru": "ВНИМАНИЕ: Файл .taskid ({file_id}) устарел и не совпадает с именем ветки ({branch_id}). Удалите .taskid из репозитория — имя ветки — единственный источник истины.",
"zh": "警告:.taskid 文件 ({file_id}) 已弃用,与分支名称 ({branch_id}) 不一致。请从仓库中删除 .taskid — 分支名称是唯一的真实来源。" "zh": "警告:.taskid 文件 ({file_id}) 已弃用,与分支名称 ({branch_id}) 不一致。请从仓库中删除 .taskid — 分支名称是唯一的真实来源。"
}, },
"WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.": {
"bg": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
"de": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
"en": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
"pl": "OSTRZEŻENIE: Nie można pobrać listy stron wiki po ponownych próbach. Sama synchronizacja zakończyła się sukcesem (zaktualizowano {count} stron), ale kontrola integralności nie mogła ich zweryfikować z powodu przejściowego problemu z API.",
"ru": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
"zh": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue."
},
"WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.": {
"bg": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
"de": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
"en": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
"pl": "OSTRZEŻENIE: Nie można ponownie pobrać listy stron wiki do weryfikacji. Pomijanie weryfikacji treści z powodu przejściowego problemu z API.",
"ru": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
"zh": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue."
},
"WARNING: VIKUNJA_TOKEN not set — skipping task existence check. Set it in .env to enable full validation.": { "WARNING: VIKUNJA_TOKEN not set — skipping task existence check. Set it in .env to enable full validation.": {
"bg": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не е зададен — пропускане на проверката за съществуване на задача. Задайте го в .env за пълна валидация.", "bg": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не е зададен — пропускане на проверката за съществуване на задача. Задайте го в .env за пълна валидация.",
"de": "WARNUNG: VIKUNJA_TOKEN nicht gesetzt — Task-Existenzprüfung übersprungen. In .env setzen für volle Validierung.", "de": "WARNUNG: VIKUNJA_TOKEN nicht gesetzt — Task-Existenzprüfung übersprungen. In .env setzen für volle Validierung.",
@@ -2432,9 +2552,9 @@
"zh": "警告: VIKUNJA_TOKEN 未设置 — 跳过任务存在性检查。在 .env 中设置以启用完整验证。" "zh": "警告: VIKUNJA_TOKEN 未设置 — 跳过任务存在性检查。在 .env 中设置以启用完整验证。"
}, },
"Waiting for CI checks to complete (timeout: {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)...", "bg": "Waiting for CI checks to complete (timeout: {timeout}s)...",
"de": "Waiting for CI checks to complete (timeout: {timeout}s)...", "de": "Waiting for CI checks to complete (timeout: {timeout}s)...",
"en": "Waiting for CI checks to complete (timeout: {timeout}s)...",
"pl": "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)...", "ru": "Waiting for CI checks to complete (timeout: {timeout}s)...",
"zh": "Waiting for CI checks to complete (timeout: {timeout}s)..." "zh": "Waiting for CI checks to complete (timeout: {timeout}s)..."
@@ -2515,9 +2635,9 @@
"bg": "Wrote tag {tag} to GITHUB_OUTPUT.", "bg": "Wrote tag {tag} to GITHUB_OUTPUT.",
"de": "Wrote tag {tag} to GITHUB_OUTPUT.", "de": "Wrote tag {tag} to GITHUB_OUTPUT.",
"en": "Wrote tag {tag} to GITHUB_OUTPUT.", "en": "Wrote tag {tag} to GITHUB_OUTPUT.",
"pl": "Wrote tag {tag} to GITHUB_OUTPUT.",
"ru": "Wrote tag {tag} to GITHUB_OUTPUT.", "ru": "Wrote tag {tag} to GITHUB_OUTPUT.",
"zh": "Wrote tag {tag} to GITHUB_OUTPUT.", "zh": "Wrote tag {tag} to GITHUB_OUTPUT."
"pl": "Wrote tag {tag} to GITHUB_OUTPUT."
}, },
"[check-dep-docs] Passed: all dependencies are documented": { "[check-dep-docs] Passed: all dependencies are documented": {
"bg": "[check-dep-docs] Passed: all dependencies are documented", "bg": "[check-dep-docs] Passed: all dependencies are documented",
@@ -2782,5 +2902,29 @@
"pl": "{separator}", "pl": "{separator}",
"ru": "{separator}", "ru": "{separator}",
"zh": "{separator}" "zh": "{separator}"
},
"Failed to push release commit after 3 attempts. Manual intervention required.": {
"bg": "Failed to push release commit after 3 attempts. Manual intervention required.",
"de": "Failed to push release commit after 3 attempts. Manual intervention required.",
"en": "Failed to push release commit after 3 attempts. Manual intervention required.",
"pl": "Failed to push release commit after 3 attempts. Manual intervention required.",
"ru": "Failed to push release commit after 3 attempts. Manual intervention required.",
"zh": "Failed to push release commit after 3 attempts. Manual intervention required."
},
"Push attempt {n}/3 failed: {err}": {
"bg": "Push attempt {n}/3 failed: {err}",
"de": "Push attempt {n}/3 failed: {err}",
"en": "Push attempt {n}/3 failed: {err}",
"pl": "Push attempt {n}/3 failed: {err}",
"ru": "Push attempt {n}/3 failed: {err}",
"zh": "Push attempt {n}/3 failed: {err}"
},
"Rebase attempt {n}/3 failed: {err}": {
"bg": "Rebase attempt {n}/3 failed: {err}",
"de": "Rebase attempt {n}/3 failed: {err}",
"en": "Rebase attempt {n}/3 failed: {err}",
"pl": "Rebase attempt {n}/3 failed: {err}",
"ru": "Rebase attempt {n}/3 failed: {err}",
"zh": "Rebase attempt {n}/3 failed: {err}"
} }
} }
+8
View File
@@ -184,6 +184,14 @@ class TestMain:
result = runner.invoke(check_translations.main, ["--translations", str(trans_file)]) result = runner.invoke(check_translations.main, ["--translations", str(trans_file)])
assert result.exit_code == 0 assert result.exit_code == 0
def test_no_translations_file_skips(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When no translations file is found, should pass with skip message."""
monkeypatch.chdir(tmp_path)
runner = CliRunner()
result = runner.invoke(check_translations.main, [])
assert result.exit_code == 0
assert "No translations file found" in result.output
class TestPrintResult: class TestPrintResult:
def test_prints_all_good(self, capsys: pytest.CaptureFixture[str]) -> None: def test_prints_all_good(self, capsys: pytest.CaptureFixture[str]) -> None:
+7
View File
@@ -87,6 +87,13 @@ class TestCiCommands:
assert result.exit_code == 0 assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.doc_coverage", []) mock_run.assert_called_once_with("devx.ci.doc_coverage", [])
@patch("devx.cli._run_module")
def test_ci_lint_docs(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "lint-docs", "--", "--root", "."])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.lint_docs", ["--root", "."])
@patch("devx.cli._run_module") @patch("devx.cli._run_module")
def test_ci_notify_failure(self, mock_run: MagicMock) -> None: def test_ci_notify_failure(self, mock_run: MagicMock) -> None:
runner = CliRunner() runner = CliRunner()
+86 -27
View File
@@ -12,10 +12,13 @@ from devx.ci.doc_coverage import (
main, main,
) )
# Path to devx's own source directory (for testing)
DEVX_SRC_DIR = Path(__file__).resolve().parent.parent.parent / "src" / "devx"
class TestExtractCliCommands: class TestExtractCliCommands:
def test_extracts_commands(self) -> None: def test_extracts_commands(self) -> None:
commands = extract_cli_commands() commands = extract_cli_commands(DEVX_SRC_DIR)
# devx CLI has commands under ci, tools, and molecule groups # devx CLI has commands under ci, tools, and molecule groups
assert "auto-merge" in commands assert "auto-merge" in commands
assert "release" in commands assert "release" in commands
@@ -24,39 +27,30 @@ class TestExtractCliCommands:
assert "install-tools" in commands assert "install-tools" in commands
def test_returns_list(self) -> None: def test_returns_list(self) -> None:
commands = extract_cli_commands() commands = extract_cli_commands(DEVX_SRC_DIR)
assert isinstance(commands, list) assert isinstance(commands, list)
assert len(commands) > 0 assert len(commands) > 0
def test_no_cli_file(self, monkeypatch: pytest.MonkeyPatch) -> None: def test_no_cli_file(self, tmp_path: Path) -> None:
"""Returns empty list when CLI file doesn't exist.""" """Returns empty list when CLI file doesn't exist."""
from devx.ci import doc_coverage commands = extract_cli_commands(tmp_path)
monkeypatch.setattr(doc_coverage, "CLI_FILE", Path("/nonexistent/cli.py"))
commands = extract_cli_commands()
assert commands == [] assert commands == []
def test_def_fallback_no_explicit_name(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: def test_def_fallback_no_explicit_name(self, tmp_path: Path) -> None:
"""When a command decorator has no explicit name, falls back to the def name.""" """When a command decorator has no explicit name, falls back to the def name."""
from devx.ci import doc_coverage
fake_cli = tmp_path / "cli.py" fake_cli = tmp_path / "cli.py"
fake_cli.write_text("@click.group()\ndef cli():\n pass\n@cli.command()\ndef my_command():\n pass\n") fake_cli.write_text("@click.group()\ndef cli():\n pass\n@cli.command()\ndef my_command():\n pass\n")
monkeypatch.setattr(doc_coverage, "CLI_FILE", fake_cli) commands = extract_cli_commands(tmp_path)
commands = extract_cli_commands()
assert "my_command" in commands assert "my_command" in commands
def test_command_decorator_no_def_fallback(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: def test_command_decorator_no_def_fallback(self, tmp_path: Path) -> None:
"""When a command decorator has no name and no following def, it is skipped.""" """When a command decorator has no name and no following def, it is skipped."""
from devx.ci import doc_coverage
fake_cli = tmp_path / "cli.py" fake_cli = tmp_path / "cli.py"
# The last @cli.command() has no explicit name and no def statement after it # The last @cli.command() has no explicit name and no def statement after it
fake_cli.write_text( fake_cli.write_text(
"@click.group()\ndef cli():\n pass\n@cli.command()\ndef real_cmd():\n pass\n@cli.command()\npass\n" "@click.group()\ndef cli():\n pass\n@cli.command()\ndef real_cmd():\n pass\n@cli.command()\npass\n"
) )
monkeypatch.setattr(doc_coverage, "CLI_FILE", fake_cli) commands = extract_cli_commands(tmp_path)
commands = extract_cli_commands()
# real_cmd should be found via def fallback; the bare @cli.command() is skipped # real_cmd should be found via def fallback; the bare @cli.command() is skipped
assert "real_cmd" in commands assert "real_cmd" in commands
assert "pass" not in commands assert "pass" not in commands
@@ -96,19 +90,29 @@ class TestMain:
docs = tmp_path / "docs" docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True) (docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True) (docs / "tech").mkdir(parents=True)
# Get actual commands from the CLI src = tmp_path / "src" / "devx"
commands = extract_cli_commands() src.mkdir(parents=True)
(src / "ci").mkdir()
(src / "__init__.py").write_text("")
(src / "ci" / "__init__.py").write_text("")
# Create a fake cli.py with some commands
(src / "cli.py").write_text(
"@click.group()\ndef cli():\n pass\n"
"@cli.command('release')\ndef release():\n pass\n"
"@cli.command('setup')\ndef setup():\n pass\n"
)
# Create a fake module and CI script
(src / "config.py").write_text("# config module")
(src / "ci" / "auto_merge.py").write_text("# auto_merge script")
# Write cli-commands.md with all commands # Write cli-commands.md with all commands
cli_content = "\n".join(f"## {cmd}" for cmd in commands) cli_content = "## release\n\n## setup\n"
(docs / "user" / "cli-commands.md").write_text(cli_content) (docs / "user" / "cli-commands.md").write_text(cli_content)
# Write architecture.md with all modules # Write architecture.md with all modules
from devx.ci.doc_coverage import REQUIRED_MODULES, REQUIRED_SCRIPTS (docs / "tech" / "architecture.md").write_text("config.py")
(docs / "tech" / "architecture.md").write_text(" ".join(REQUIRED_MODULES))
# Write ci-cd-workflow.md with all scripts # Write ci-cd-workflow.md with all scripts
(docs / "tech" / "ci-cd-workflow.md").write_text(" ".join(REQUIRED_SCRIPTS)) (docs / "tech" / "ci-cd-workflow.md").write_text("auto_merge.py")
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["--docs-dir", str(docs)]) result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src)])
assert result.exit_code == 0 assert result.exit_code == 0
assert "100%" in result.output assert "100%" in result.output
@@ -117,11 +121,21 @@ class TestMain:
docs = tmp_path / "docs" docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True) (docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True) (docs / "tech").mkdir(parents=True)
src = tmp_path / "src" / "devx"
src.mkdir(parents=True)
(src / "ci").mkdir()
(src / "__init__.py").write_text("")
(src / "ci" / "__init__.py").write_text("")
(src / "cli.py").write_text(
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
)
(src / "config.py").write_text("# config")
(src / "ci" / "auto_merge.py").write_text("# auto_merge")
(docs / "user" / "cli-commands.md").write_text("No commands here.") (docs / "user" / "cli-commands.md").write_text("No commands here.")
(docs / "tech" / "architecture.md").write_text("No modules here.") (docs / "tech" / "architecture.md").write_text("No modules here.")
(docs / "tech" / "ci-cd-workflow.md").write_text("No scripts here.") (docs / "tech" / "ci-cd-workflow.md").write_text("No scripts here.")
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["--docs-dir", str(docs), "--fail-on-missing"]) result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src), "--fail-on-missing"])
assert result.exit_code == 1 assert result.exit_code == 1
def test_missing_docs_warn_only(self, tmp_path: Path) -> None: def test_missing_docs_warn_only(self, tmp_path: Path) -> None:
@@ -129,10 +143,55 @@ class TestMain:
docs = tmp_path / "docs" docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True) (docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True) (docs / "tech").mkdir(parents=True)
src = tmp_path / "src" / "devx"
src.mkdir(parents=True)
(src / "ci").mkdir()
(src / "__init__.py").write_text("")
(src / "ci" / "__init__.py").write_text("")
(src / "cli.py").write_text(
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
)
(src / "config.py").write_text("# config")
(src / "ci" / "auto_merge.py").write_text("# auto_merge")
(docs / "user" / "cli-commands.md").write_text("No commands here.") (docs / "user" / "cli-commands.md").write_text("No commands here.")
(docs / "tech" / "architecture.md").write_text("No modules here.") (docs / "tech" / "architecture.md").write_text("No modules here.")
(docs / "tech" / "ci-cd-workflow.md").write_text("No scripts here.") (docs / "tech" / "ci-cd-workflow.md").write_text("No scripts here.")
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["--docs-dir", str(docs)]) result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src)])
assert result.exit_code == 0 assert result.exit_code == 0
assert "MISSING" in result.output assert "MISSING" in result.output
def test_auto_detect_scripts_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When src/ doesn't exist but scripts/ does, auto-detect it."""
monkeypatch.chdir(tmp_path)
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
scripts = tmp_path / "scripts"
scripts.mkdir()
(scripts / "cli.py").write_text(
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
)
(scripts / "config.py").write_text("# config")
(docs / "user" / "cli-commands.md").write_text("## release\n")
(docs / "tech" / "architecture.md").write_text("config.py")
(docs / "tech" / "ci-cd-workflow.md").write_text("")
runner = CliRunner()
result = runner.invoke(main, ["--docs-dir", str(docs)])
assert result.exit_code == 0
def test_no_source_dir_falls_back_to_required(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When no source dir exists, falls back to REQUIRED_MODULES/SCRIPTS."""
monkeypatch.chdir(tmp_path)
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
(docs / "user" / "cli-commands.md").write_text("")
from devx.ci.doc_coverage import REQUIRED_MODULES, REQUIRED_SCRIPTS
(docs / "tech" / "architecture.md").write_text(" ".join(REQUIRED_MODULES))
(docs / "tech" / "ci-cd-workflow.md").write_text(" ".join(REQUIRED_SCRIPTS))
runner = CliRunner()
result = runner.invoke(main, ["--docs-dir", str(docs)])
# No source dir found, so no CLI commands, but modules/scripts from REQUIRED lists
assert result.exit_code == 0
+436
View File
@@ -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
+30
View File
@@ -516,6 +516,36 @@ class TestCheckDocumentation:
check_documentation(files, result) check_documentation(files, result)
assert any("Documentation: OK" in s for s in result.summary) assert any("Documentation: OK" in s for s in result.summary)
def test_tofu_changes_without_docs_warns(self) -> None:
result = ReviewResult()
files = [{"filename": "tofu/modules/hetzner-vm/main.tf"}]
check_documentation(files, result)
assert any("WARNING" in s for s in result.summary)
def test_workflow_changes_info(self) -> None:
result = ReviewResult()
files = [{"filename": ".gitea/workflows/ci.yml"}]
check_documentation(files, result)
assert any("INFO" in s for s in result.summary)
def test_todo_in_doc_patch_warns(self) -> None:
result = ReviewResult()
files = [{"filename": "docs/guide.md", "patch": "+TODO: fix this later\n+Some content\n"}]
check_documentation(files, result)
assert any("TODO" in s for s in result.summary)
def test_todo_in_readme_patch_warns(self) -> None:
result = ReviewResult()
files = [{"filename": "README.md", "patch": "+FIXME: broken\n"}]
check_documentation(files, result)
assert any("FIXME" in s for s in result.summary)
def test_no_todo_in_doc_patch_ok(self) -> None:
result = ReviewResult()
files = [{"filename": "docs/guide.md", "patch": "+Some content\n"}]
check_documentation(files, result)
assert not any("TODO" in s for s in result.summary)
class TestCheckTestCoverage: class TestCheckTestCoverage:
def test_src_changes_without_tests_warns(self) -> None: def test_src_changes_without_tests_warns(self) -> None:
+147
View File
@@ -1234,6 +1234,153 @@ class TestMain:
assert "already existed" in result.output assert "already existed" in result.output
mock_tag.assert_called_once_with("0.2.0", "changelog", False) mock_tag.assert_called_once_with("0.2.0", "changelog", False)
@patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.fetch_tags")
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.run_tests")
@patch("devx.ci.release.create_and_push_tag", return_value=True)
@patch("devx.ci.release.commit_release_changes", return_value=True)
@patch("devx.ci.release.update_changelog")
@patch("devx.ci.release.update_init_version")
@patch("devx.ci.release.get_changelog", return_value="changelog")
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
@patch("devx.ci.release.time.sleep")
@patch("devx.ci.release.run_cmd")
def test_push_retry_succeeds_after_rebase_failure(
self,
mock_run_cmd: MagicMock,
mock_sleep: MagicMock,
mock_has: MagicMock,
mock_bumped: MagicMock,
mock_latest: MagicMock,
mock_changelog: MagicMock,
mock_update_init: MagicMock,
mock_update_changelog: MagicMock,
mock_commit: MagicMock,
mock_tag: MagicMock,
mock_run_tests: MagicMock,
mock_user: MagicMock,
mock_ft: MagicMock,
mock_vtc: MagicMock,
) -> None:
"""Push should retry after rebase failure and succeed on second attempt."""
ok = MagicMock(returncode=0, stdout="master\n", stderr="")
rebase_fail = MagicMock(returncode=1, stdout="", stderr="conflict")
rebase_abort = MagicMock(returncode=0, stdout="", stderr="")
push_ok = MagicMock(returncode=0, stdout="", stderr="")
# git rev-parse → ok, git log -1 → ok (non-release msg)
# pull --rebase → fail, rebase --abort → ok
# pull --rebase → ok, push → ok
mock_run_cmd.side_effect = [ok, ok, rebase_fail, rebase_abort, ok, push_ok]
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "Rebase attempt 1/3 failed" in result.output
assert "Pushed release commit to master" in result.output
@patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.fetch_tags")
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.run_tests")
@patch("devx.ci.release.create_and_push_tag", return_value=True)
@patch("devx.ci.release.commit_release_changes", return_value=True)
@patch("devx.ci.release.update_changelog")
@patch("devx.ci.release.update_init_version")
@patch("devx.ci.release.get_changelog", return_value="changelog")
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
@patch("devx.ci.release.time.sleep")
@patch("devx.ci.release.run_cmd")
def test_push_fails_after_all_retries(
self,
mock_run_cmd: MagicMock,
mock_sleep: MagicMock,
mock_has: MagicMock,
mock_bumped: MagicMock,
mock_latest: MagicMock,
mock_changelog: MagicMock,
mock_update_init: MagicMock,
mock_update_changelog: MagicMock,
mock_commit: MagicMock,
mock_tag: MagicMock,
mock_run_tests: MagicMock,
mock_user: MagicMock,
mock_ft: MagicMock,
mock_vtc: MagicMock,
) -> None:
"""Push should fail after 3 unsuccessful rebase attempts."""
ok = MagicMock(returncode=0, stdout="master\n", stderr="")
rebase_fail = MagicMock(returncode=1, stdout="", stderr="conflict")
rebase_abort = MagicMock(returncode=0, stdout="", stderr="")
# git rev-parse → ok, git log -1 → ok
# 3 attempts: pull --rebase → fail, rebase --abort → ok
mock_run_cmd.side_effect = [
ok,
ok,
rebase_fail,
rebase_abort, # attempt 1
rebase_fail,
rebase_abort, # attempt 2
rebase_fail,
rebase_abort, # attempt 3
]
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code != 0
assert "Failed to push release commit after 3 attempts" in result.output
@patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.fetch_tags")
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.run_tests")
@patch("devx.ci.release.create_and_push_tag", return_value=True)
@patch("devx.ci.release.commit_release_changes", return_value=True)
@patch("devx.ci.release.update_changelog")
@patch("devx.ci.release.update_init_version")
@patch("devx.ci.release.get_changelog", return_value="changelog")
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
@patch("devx.ci.release.time.sleep")
@patch("devx.ci.release.run_cmd")
def test_push_retry_succeeds_after_push_failure(
self,
mock_run_cmd: MagicMock,
mock_sleep: MagicMock,
mock_has: MagicMock,
mock_bumped: MagicMock,
mock_latest: MagicMock,
mock_changelog: MagicMock,
mock_update_init: MagicMock,
mock_update_changelog: MagicMock,
mock_commit: MagicMock,
mock_tag: MagicMock,
mock_run_tests: MagicMock,
mock_user: MagicMock,
mock_ft: MagicMock,
mock_vtc: MagicMock,
) -> None:
"""Push should retry after push rejection and succeed on second attempt."""
ok = MagicMock(returncode=0, stdout="master\n", stderr="")
rebase_ok = MagicMock(returncode=0, stdout="", stderr="")
push_fail = MagicMock(returncode=1, stdout="", stderr="non-fast-forward")
push_ok = MagicMock(returncode=0, stdout="", stderr="")
# git rev-parse → ok, git log -1 → ok
# attempt 1: pull --rebase → ok, push → fail
# attempt 2: pull --rebase → ok, push → ok
mock_run_cmd.side_effect = [ok, ok, rebase_ok, push_fail, rebase_ok, push_ok]
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "Push attempt 1/3 failed" in result.output
assert "Pushed release commit to master" in result.output
@patch.dict("os.environ", {}) @patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[]) @patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.fetch_tags") @patch("devx.ci.release.fetch_tags")
+79 -5
View File
@@ -21,6 +21,7 @@ from devx.ci.sync_wiki import (
verify_wiki_integrity, verify_wiki_integrity,
verify_wiki_page, verify_wiki_page,
) )
from devx.exceptions import APIError
class TestEncodeContent: class TestEncodeContent:
@@ -97,13 +98,11 @@ class TestReadDocContent:
class TestListWikiPages: class TestListWikiPages:
def test_returns_empty_on_api_error(self) -> None: def test_raises_on_api_error(self) -> None:
from devx.exceptions import APIError
client = MagicMock() client = MagicMock()
client._request.side_effect = APIError(404, "not found") client._request.side_effect = APIError(404, "not found")
result = list_wiki_pages(client) with pytest.raises(APIError):
assert result == {} list_wiki_pages(client)
def test_returns_page_dict(self) -> None: def test_returns_page_dict(self) -> None:
client = MagicMock() client = MagicMock()
@@ -284,6 +283,43 @@ class TestVerifyWikiIntegrity:
failures = verify_wiki_integrity(client, mapping, synced) failures = verify_wiki_integrity(client, mapping, synced)
assert len(failures) >= 3 # count mismatch, missing FAQ, stale Stale, empty Home assert len(failures) >= 3 # count mismatch, missing FAQ, stale Stale, empty Home
def test_transient_api_failure_returns_empty(self) -> None:
"""When the wiki API is unavailable after retries, integrity check
should return no failures (sync already succeeded)."""
client = MagicMock()
# _list_wiki_pages_with_retry raises APIError (retries exhausted)
with patch("devx.ci.sync_wiki._list_wiki_pages_with_retry", side_effect=APIError(0, "timeout")):
mapping = {"index.md": "Home", "faq.md": "FAQ"}
synced = {"Home": "# Home", "FAQ": "# FAQ"}
failures = verify_wiki_integrity(client, mapping, synced)
assert failures == []
def test_transient_api_failure_recovers_on_retry(self) -> None:
"""When the wiki API recovers after a retry, integrity check proceeds normally."""
client = MagicMock()
pages = {"Home": "Home", "FAQ": "FAQ"}
contents = {"Home": "# Home", "FAQ": "# FAQ"}
def mock_request(method, path, **kwargs):
resp = MagicMock()
if path == "/wiki/pages":
page_list = [{"title": t, "sub_url": s} for t, s in pages.items()]
resp.json.return_value = page_list
elif path.startswith("/wiki/page/"):
sub_url = path.replace("/wiki/page/", "")
content = contents.get(sub_url, "")
encoded = base64.b64encode(content.encode()).decode("ascii") if content else ""
resp.json.return_value = {"content_base64": encoded}
return resp
client._request.side_effect = mock_request
mapping = {"index.md": "Home", "faq.md": "FAQ"}
synced = {"Home": "# Home", "FAQ": "# FAQ"}
failures = verify_wiki_integrity(client, mapping, synced)
assert failures == []
class TestMain: class TestMain:
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@@ -498,3 +534,41 @@ class TestMain:
result = runner.invoke(main, ["--dry-run", "--strict", "--repo", "owner/repo"]) result = runner.invoke(main, ["--dry-run", "--strict", "--repo", "owner/repo"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "Integrity check" not in result.output assert "Integrity check" not in result.output
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient")
def test_initial_list_api_error_treated_as_empty(self, mock_client_cls: MagicMock) -> None:
"""When the initial page list fails, sync proceeds treating wiki as empty."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"):
with patch("devx.ci.sync_wiki.list_wiki_pages", side_effect=APIError(0, "timeout")):
with patch("devx.ci.sync_wiki.sync_page", return_value="created"):
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo"])
assert result.exit_code == 0
assert "Created: Home" in result.output
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient")
def test_verify_skips_when_refetch_fails(self, mock_client_cls: MagicMock) -> None:
"""When --verify re-fetch fails after retries, verification is skipped gracefully."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"):
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
with patch("devx.ci.sync_wiki.sync_page", return_value="updated"):
with patch(
"devx.ci.sync_wiki._list_wiki_pages_with_retry",
side_effect=APIError(0, "timeout"),
):
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo", "--verify"])
assert result.exit_code == 0
assert "Skipping content verification" in result.output