Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68a01d1bda | ||
|
|
621b051793 | ||
|
|
66554657f2 | ||
|
|
587d3a6ca4 | ||
|
|
9d75e408ae | ||
|
|
412bbea01d | ||
|
|
ce5ce33a12 | ||
|
|
0fae419584 | ||
|
|
70b011d4a6 | ||
|
|
a5c16a92df | ||
|
|
e6f022ae96 | ||
|
|
1a27983750 | ||
|
|
5d7ed62b34 | ||
|
|
ee80c27631 | ||
|
|
98b1659579 |
@@ -38,6 +38,12 @@ jobs:
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 -m devx.ci.doc_coverage --fail-on-missing
|
||||
- name: Documentation lint check
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 -m devx.ci.lint_docs --root .
|
||||
- name: Translation completeness check
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
|
||||
@@ -71,7 +71,8 @@ src/devx/
|
||||
│ ├── distribute_items.py # Distribute generic items (VMs, hosts) across parallel runners (LPT)
|
||||
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast
|
||||
│ ├── check_translations.py # Translation completeness check
|
||||
│ └── doc_coverage.py # Documentation coverage check
|
||||
│ ├── doc_coverage.py # Documentation coverage check
|
||||
│ └── lint_docs.py # Documentation linter (structure, links, headings)
|
||||
├── tools/ # Developer tooling modules (run locally or by CI)
|
||||
│ ├── setup.py # Environment setup (venv, deps, hooks)
|
||||
│ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea, hadolint
|
||||
@@ -89,7 +90,9 @@ src/devx/
|
||||
│ ├── create_pr.py # Create PRs with auto-derived title from Vikunja
|
||||
│ ├── pr_status.py # Check CI status for a PR/commit (--wait polls)
|
||||
│ ├── pr_logs.py # Fetch logs for failed CI jobs
|
||||
│ └── pr_label.py # Add labels to PRs (idempotent)
|
||||
│ ├── pr_label.py # Add labels to PRs (idempotent)
|
||||
│ ├── rebase.py # Rebase current branch onto origin/master + force-push
|
||||
│ └── pr_rebase.py # Rebase a PR's head branch via Gitea API (server-side)
|
||||
├── opentofu.py # OpenTofu output helpers (get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field)
|
||||
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
|
||||
├── discover_runners.py # Dynamic Gitea runner discovery
|
||||
@@ -180,6 +183,12 @@ the PR. Then add the `ready-to-merge` label. The auto-merge workflow will:
|
||||
5. The post-merge workflow marks the Vikunja task as done
|
||||
6. The release workflow automatically versions, tags, and publishes
|
||||
|
||||
**If the branch is behind master** (another PR merged first), auto-merge
|
||||
automatically rebases the PR's head branch via the Gitea API
|
||||
(`POST /pulls/{index}/update?style=rebase`). This triggers a new CI run.
|
||||
The next auto-merge attempt will find the branch up-to-date and merge
|
||||
successfully. No manual intervention needed.
|
||||
|
||||
> **IMPORTANT**: Never manually merge PRs via the API. Always use the auto-merge
|
||||
> workflow by adding the `ready-to-merge` label.
|
||||
|
||||
@@ -408,6 +417,8 @@ projects.
|
||||
| `devx-pr-logs` | Fetch logs for failed CI jobs (`PR=`, `JOB=`, `TAIL=`) |
|
||||
| `devx-pr-label` | Add a label to a PR (`PR=`, `LABEL=ready-to-merge`) |
|
||||
| `devx-pr-review` | Post a review on a PR (`PR=`, `EVENT=`, `BODY=`, `CHECKLIST=`) |
|
||||
| `devx-rebase` | Rebase current branch onto origin/master + force-push (`NO_PUSH=1` for local only) |
|
||||
| `devx-pr-rebase` | Rebase a PR's head branch via Gitea API — server-side, no local git needed (`PR=`) |
|
||||
| `devx-check-config` | Validate devx configuration |
|
||||
| `devx-configure-gitea-pypi` | Configure Gitea private PyPI registry |
|
||||
| `devx-env` | Create .env from .env.example |
|
||||
|
||||
@@ -2,6 +2,36 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.28.0] - 2026-07-01
|
||||
|
||||
### Features
|
||||
|
||||
- Auto-rebase in auto-merge, new rebase tools, CLI registration
|
||||
|
||||
## [0.27.3] - 2026-06-30
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Retry wiki integrity check on transient API timeout
|
||||
|
||||
## [0.27.2] - 2026-06-29
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Retry release push on non-fast-forward with rebase loop
|
||||
|
||||
## [0.27.1] - 2026-06-28
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Exclude .devin/.terraform dirs from lint_docs, add duplicate heading excludes
|
||||
|
||||
## [0.27.0] - 2026-06-28
|
||||
|
||||
### Features
|
||||
|
||||
- Add lint_docs tool, fix doc_coverage/check_translations for any repo
|
||||
|
||||
## [0.26.4] - 2026-06-28
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -98,6 +98,8 @@ create-task: devx-create-task
|
||||
create-pr: devx-create-pr
|
||||
push-with-pr: devx-push-with-pr
|
||||
git-push: devx-push
|
||||
rebase: devx-rebase
|
||||
pr-rebase: devx-pr-rebase
|
||||
|
||||
lint-all: lint workflow-lint lint-dockerfiles
|
||||
@echo "[lint-all] All linting checks passed."
|
||||
|
||||
@@ -16,12 +16,12 @@ quality badges.
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Why devx?
|
||||
|
||||
@@ -158,6 +158,9 @@ python -m devx.ci.check_translations --translations path/to/translations.json
|
||||
# Documentation coverage check
|
||||
python -m devx.ci.doc_coverage --fail-on-missing
|
||||
|
||||
# Documentation lint (structure, links, headings, TODOs)
|
||||
python -m devx.ci.lint_docs --root .
|
||||
|
||||
# Validate a commit message
|
||||
python -m devx.ci.validate_commit_msg commit-msg.txt --branch master
|
||||
|
||||
|
||||
+8
-7
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -131,7 +131,7 @@ wiki sync details.
|
||||
devx provides a `devx` CLI with three command groups:
|
||||
|
||||
- `devx ci <command>` — CI/CD automation (17 commands)
|
||||
- `devx tools <command>` — Developer tools (7 commands)
|
||||
- `devx tools <command>` — Developer tools (9 commands)
|
||||
- `devx molecule <command>` — Molecule testing (4 commands, optional)
|
||||
|
||||
See [CLI Commands](CLI-Commands) for full command documentation with examples.
|
||||
@@ -158,6 +158,7 @@ for the full configuration reference, PR workflow, and project conventions.
|
||||
## Wiki pages
|
||||
|
||||
- [Home](Home) — This page
|
||||
- [Getting Started](Getting-Started) — Installation, configuration, and quick start guide
|
||||
- [CLI Commands](CLI-Commands) — Full CLI command documentation with examples
|
||||
- [Architecture](Architecture) — Package structure, module descriptions, design principles
|
||||
- [CI/CD Workflow](CI-CD-Workflow) — Pipeline documentation, workflows, and CI scripts
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"index.md": "Home",
|
||||
"user/getting-started.md": "Getting-Started",
|
||||
"user/cli-commands.md": "CLI-Commands",
|
||||
"tech/architecture.md": "Architecture",
|
||||
"tech/ci-cd-workflow.md": "CI-CD-Workflow"
|
||||
|
||||
@@ -382,7 +382,7 @@ single-role (4-part) and multi-role (5-part) pair encoding.
|
||||
Runs all molecule scenarios on all supported OS platforms sequentially.
|
||||
Intended for local development; CI uses the parallel matrix instead.
|
||||
|
||||
### `discover_runners.py`
|
||||
### `molecule/discover_runners.py`
|
||||
|
||||
Discovers available Gitea Actions runners for molecule tests. Same logic as
|
||||
`devx.ci.discover_runners` but intended for molecule-specific workflows.
|
||||
|
||||
@@ -166,7 +166,7 @@ When `release` creates a `release: vX.Y.Z` commit, the release commit's
|
||||
post-merge run still updates badges (the version badge picks up the new
|
||||
version). Other jobs skip. The tag push triggers `publish.yml`.
|
||||
|
||||
### Jobs
|
||||
### Post-merge jobs
|
||||
|
||||
#### `detect-type`
|
||||
|
||||
|
||||
@@ -127,14 +127,37 @@ Click commands from `cli.py` and checks if each has documentation in
|
||||
|
||||
```bash
|
||||
devx ci doc-coverage
|
||||
devx ci doc-coverage --docs-dir docs/ --fail-on-missing
|
||||
devx ci doc-coverage --docs-dir docs/ --source-dir src/ --fail-on-missing
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--docs-dir <dir>` — path to the docs directory (default: `docs/`)
|
||||
- `--source-dir <dir>` — path to the source directory (default: auto-detect)
|
||||
- `--fail-on-missing` — exit with non-zero status if any documentation is
|
||||
missing
|
||||
|
||||
### `devx ci lint-docs`
|
||||
|
||||
Lint documentation files for structure, broken links, heading hierarchy,
|
||||
duplicate headings, TODO/FIXME markers, and trailing whitespace.
|
||||
|
||||
```bash
|
||||
devx ci lint-docs
|
||||
devx ci lint-docs --root . --fix
|
||||
devx ci lint-docs --no-check-links --no-check-stale
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--root <dir>` — repository root directory (default: `.`)
|
||||
- `--docs-dir <dir>` — docs directory (default: `<root>/docs`)
|
||||
- `--check-links/--no-check-links` — check internal links (default: yes)
|
||||
- `--check-headings/--no-check-headings` — check heading hierarchy (default: yes)
|
||||
- `--check-todo/--no-check-todo` — check for TODO/FIXME markers (default: yes)
|
||||
- `--check-stale/--no-check-stale` — check for stale docs (default: no)
|
||||
- `--check-trailing/--no-check-trailing` — check trailing whitespace (default: yes)
|
||||
- `--check-duplicates/--no-check-duplicates` — check duplicate headings (default: yes)
|
||||
- `--fix` — auto-fix trailing whitespace
|
||||
|
||||
### `devx ci integration-guard`
|
||||
|
||||
Run pytest with cross-runner failure detection. If any
|
||||
@@ -398,6 +421,35 @@ Options:
|
||||
- `--no-pre-commit` — skip pre-commit hook installation
|
||||
- `--no-tea-login` — skip tea CLI login configuration
|
||||
|
||||
### `devx tools rebase`
|
||||
|
||||
Rebase the current branch onto `origin/master` and force-push with
|
||||
`--force-with-lease`. Checks if the branch is behind master first —
|
||||
if up-to-date, exits without doing anything.
|
||||
|
||||
```bash
|
||||
devx tools rebase # rebase + force-push
|
||||
devx tools rebase -- --no-push # rebase locally only
|
||||
```
|
||||
|
||||
Options (pass after `--`):
|
||||
- `--no-push` — rebase locally without pushing
|
||||
|
||||
### `devx tools pr-rebase`
|
||||
|
||||
Rebase a pull request's head branch onto master via the Gitea API
|
||||
(server-side). This triggers a new `pull_request synchronize` event,
|
||||
which starts a new CI run. Useful when you don't have the branch
|
||||
checked out locally.
|
||||
|
||||
```bash
|
||||
devx tools pr-rebase -- --pr 42 # rebase PR #42
|
||||
devx tools pr-rebase # auto-detect PR from current branch
|
||||
```
|
||||
|
||||
Options (pass after `--`):
|
||||
- `--pr <N>` — PR number (auto-detected from current branch if omitted)
|
||||
|
||||
## Molecule Commands
|
||||
|
||||
Molecule commands require the `molecule` extra (`pip install devx[molecule]`).
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
# Getting Started with devx
|
||||
|
||||
This guide walks you through installing devx, configuring it for your project,
|
||||
and setting up a complete CI/CD pipeline.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Python 3.12+**
|
||||
- **A Gitea instance** with Actions enabled
|
||||
- **A Gitea API token** with repo, workflow, and organization scopes
|
||||
- **(Optional) Vikunja API token** for task tracking integration
|
||||
|
||||
## Installation
|
||||
|
||||
devx is published to the Gitea PyPI registry. Configure pip to use it:
|
||||
|
||||
```bash
|
||||
# Configure Gitea PyPI registry
|
||||
pip config set global.extra-index-url https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
|
||||
|
||||
# Install devx
|
||||
pip install devx
|
||||
```
|
||||
|
||||
Or install from source:
|
||||
|
||||
```bash
|
||||
git clone https://git.oblachno.oblachno.fyi/oblachno-oss/devx.git
|
||||
cd devx
|
||||
make setup
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Configure environment variables
|
||||
|
||||
Create a `.env` file in your project root:
|
||||
|
||||
```bash
|
||||
CI_GITEA_TOKEN=your_gitea_api_token
|
||||
VIKUNJA_TOKEN=your_vikunja_api_token # optional
|
||||
```
|
||||
|
||||
### 2. Add devx to your project
|
||||
|
||||
Add devx to your `pyproject.toml`:
|
||||
|
||||
```toml
|
||||
[project]
|
||||
dependencies = [
|
||||
"devx>=0.26.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"devx[dev]>=0.26.0",
|
||||
]
|
||||
```
|
||||
|
||||
### 3. Set up the Makefile
|
||||
|
||||
devx provides a shared Makefile fragment. Add this to your `Makefile`:
|
||||
|
||||
```makefile
|
||||
include devx.mak
|
||||
```
|
||||
|
||||
Run `devx tools setup` to install all development tools (actionlint, git-cliff,
|
||||
tea CLI, etc.) and configure pre-commit hooks.
|
||||
|
||||
### 4. Create the docs structure
|
||||
|
||||
devx expects a `docs/` directory with at minimum:
|
||||
|
||||
```
|
||||
docs/
|
||||
├── index.md # Documentation home page
|
||||
├── mapping.json # Wiki page title mappings
|
||||
├── user/ # User-facing documentation
|
||||
│ └── cli-commands.md
|
||||
└── tech/ # Technical documentation
|
||||
├── architecture.md
|
||||
└── ci-cd-workflow.md
|
||||
```
|
||||
|
||||
Example `docs/mapping.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"index.md": "Home",
|
||||
"user/cli-commands.md": "CLI-Commands",
|
||||
"tech/architecture.md": "Architecture",
|
||||
"tech/ci-cd-workflow.md": "CI-CD-Workflow"
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Set up CI workflows
|
||||
|
||||
Create `.gitea/workflows/ci.yml` and `.gitea/workflows/post-merge.yml` in your
|
||||
project. See the [CI/CD Workflow guide](../tech/ci-cd-workflow.md) for details.
|
||||
|
||||
### 6. Configure release settings
|
||||
|
||||
Add a `cliff.toml` for git-cliff-based versioning:
|
||||
|
||||
```bash
|
||||
devx tools generate-cliff-config
|
||||
```
|
||||
|
||||
Add `[tool.devx]` section to `pyproject.toml` for project-specific config:
|
||||
|
||||
```toml
|
||||
[tool.devx]
|
||||
# Vikunja project ID for task tracking
|
||||
vikunja_project_id = 6
|
||||
|
||||
[tool.devx.classify]
|
||||
# File patterns that are workflow-only (no release needed)
|
||||
workflow_only = [
|
||||
".gitea/**",
|
||||
"docs/**",
|
||||
"tests/**",
|
||||
"AGENTS.md",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
]
|
||||
```
|
||||
|
||||
## Available Tools
|
||||
|
||||
### CI/CD Automation (`devx.ci.*`)
|
||||
|
||||
- `devx.ci.release` — Automated semver versioning and tagging
|
||||
- `devx.ci.publish` — Package publishing to Gitea PyPI registry
|
||||
- `devx.ci.auto_merge` — Squash-merge automation with task ID validation
|
||||
- `devx.ci.pr_review` — Automated PR review with inline comments
|
||||
- `devx.ci.classify_changes` — User-facing vs workflow-only change detection
|
||||
- `devx.ci.sync_wiki` — Push docs/ to Gitea wiki
|
||||
- `devx.ci.doc_coverage` — Documentation coverage checker
|
||||
- `devx.ci.lint_docs` — Documentation linter (structure, links, headings)
|
||||
- `devx.ci.check_translations` — i18n translation completeness checker
|
||||
- `devx.ci.notify_failure` — Create Gitea issues on CI failures
|
||||
- `devx.ci.distribute_files` — Parallel test file distribution
|
||||
- `devx.ci.distribute_items` — Parallel item distribution across runners
|
||||
- `devx.ci.discover_runners` — Dynamic runner discovery via Gitea API
|
||||
|
||||
### Development Tools (`devx.tools.*`)
|
||||
|
||||
- `devx.tools.setup` — Environment setup (venv, deps, hooks, tools)
|
||||
- `devx.tools.install_tools` — Install CI/CD tools (actionlint, git-cliff, tea)
|
||||
- `devx.tools.create_task` — Create Vikunja tasks
|
||||
- `devx.tools.create_pr` — Create Gitea PRs with task ID in title
|
||||
- `devx.tools.configure_repo` — Configure branch protection and labels
|
||||
- `devx.tools.generate_badges` — Generate quality badge SVGs
|
||||
- `devx.tools.check_test_speed` — Enforce test execution speed limits
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Read the [CLI Commands reference](cli-commands.md) for all available commands
|
||||
- Read the [Architecture guide](../tech/architecture.md) to understand internals
|
||||
- Read the [CI/CD Workflow guide](../tech/ci-cd-workflow.md) for pipeline details
|
||||
@@ -1,3 +1,3 @@
|
||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||
|
||||
__version__ = "0.26.4"
|
||||
__version__ = "0.28.0"
|
||||
|
||||
@@ -194,6 +194,19 @@ class GiteaClient:
|
||||
payload = {"Do": "squash", "MergeTitleField": merge_title}
|
||||
self._request("POST", f"/pulls/{pr_number}/merge", json=payload)
|
||||
|
||||
def update_pr_branch(self, pr_number: str | int, style: str = "rebase") -> None:
|
||||
"""Update PR head branch by merging/rebasing the base branch into it.
|
||||
|
||||
Uses the Gitea API ``POST /pulls/{index}/update?style=rebase`` endpoint.
|
||||
This rebases the PR's head branch onto the latest base branch server-side,
|
||||
triggering a ``pull_request synchronize`` event that starts a new CI run.
|
||||
|
||||
Args:
|
||||
pr_number: PR number.
|
||||
style: Update method — ``"rebase"`` (default) or ``"merge"``.
|
||||
"""
|
||||
self._request("POST", f"/pulls/{pr_number}/update", params={"style": style})
|
||||
|
||||
def get_commit_status(self, sha: str) -> list[dict[str, Any]]:
|
||||
"""Fetch all status check contexts reported for a commit.
|
||||
|
||||
|
||||
@@ -231,17 +231,35 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
|
||||
client.merge_pr(pr_num, merge_title)
|
||||
except APIError as e:
|
||||
if e.status == 405 and "behind" in e.message.lower():
|
||||
# Head branch is behind master — do NOT auto-rebase.
|
||||
# Auto-rebasing creates a feedback loop: the force-push triggers
|
||||
# a new pull_request synchronize event, which starts a new CI run,
|
||||
# which runs auto-merge again, which rebases again, etc.
|
||||
raise click.ClickException(
|
||||
# Head branch is behind master. Auto-rebase via Gitea API.
|
||||
# This triggers a new pull_request synchronize event → new CI run.
|
||||
# The next auto-merge attempt will find the branch up-to-date and
|
||||
# merge successfully. This is NOT an infinite loop: the rebase
|
||||
# resolves the "behind" condition, so the next run merges.
|
||||
# If another PR merges in between, the branch may fall behind
|
||||
# again, but the process converges as PRs stop merging.
|
||||
click.echo(
|
||||
_(
|
||||
"Branch is behind master. Rebase manually:\n"
|
||||
" git fetch origin master && git rebase origin/master && git push --force-with-lease\n"
|
||||
"Then re-add the ready-to-merge label.",
|
||||
"Branch is behind master. Auto-rebasing via Gitea API...\n"
|
||||
"A new CI run will start automatically after the rebase.\n"
|
||||
"The next auto-merge attempt will merge this PR.",
|
||||
)
|
||||
) from None
|
||||
)
|
||||
try:
|
||||
client.update_pr_branch(pr_num, style="rebase")
|
||||
except APIError as rebase_err:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Auto-rebase failed with HTTP {status}: {message}\n"
|
||||
"Rebase manually:\n"
|
||||
" git fetch origin master && git rebase origin/master && git push --force-with-lease\n"
|
||||
"Then re-add the ready-to-merge label.",
|
||||
status=rebase_err.status,
|
||||
message=rebase_err.message,
|
||||
)
|
||||
) from None
|
||||
# Exit cleanly — the rebase triggers a new CI run that will retry.
|
||||
return
|
||||
else:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
|
||||
@@ -31,11 +31,11 @@ from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
||||
REPO_ROOT = Path.cwd()
|
||||
|
||||
SUPPORTED_LANGS = ("en", "bg", "de", "ru", "zh", "pl")
|
||||
|
||||
# Default translation set: devx package itself
|
||||
# Default translation set: look for translations.json in the current repo
|
||||
DEFAULT_TRANS_FILE = REPO_ROOT / "src" / "devx" / "translations.json"
|
||||
DEFAULT_SRC_DIR = REPO_ROOT / "src" / "devx"
|
||||
|
||||
@@ -169,20 +169,44 @@ def print_result(result: TranslationCheckResult) -> None:
|
||||
"translations",
|
||||
multiple=True,
|
||||
type=click.Path(exists=False, path_type=Path),
|
||||
help="Path to a translations JSON file to check (can be repeated). Defaults to src/devx/translations.json.",
|
||||
help="Path to a translations JSON file to check (can be repeated). Auto-detects by default.",
|
||||
)
|
||||
def main(translations: tuple[Path, ...]) -> None:
|
||||
@click.option(
|
||||
"--source-dir",
|
||||
default=None,
|
||||
help="Source directory to scan for _() calls (default: auto-detect).",
|
||||
)
|
||||
def main(translations: tuple[Path, ...], source_dir: str | None) -> None:
|
||||
"""Check translation files for gaps, dead keys, and missing languages."""
|
||||
results: list[TranslationCheckResult] = []
|
||||
if not translations:
|
||||
# Default: check the devx package's own translations
|
||||
results = [
|
||||
check_translation_set("devx", DEFAULT_SRC_DIR, DEFAULT_TRANS_FILE),
|
||||
# Auto-detect translations file in the current repo
|
||||
root = Path.cwd()
|
||||
# Try common locations
|
||||
candidates = [
|
||||
root / "src" / "devx" / "translations.json",
|
||||
root / "src" / "gitea_runner_manager" / "translations.json",
|
||||
]
|
||||
# Also search for any translations.json in src/
|
||||
for match in root.glob("src/*/translations.json"):
|
||||
candidates.append(match)
|
||||
|
||||
found = False
|
||||
for candidate in candidates:
|
||||
if candidate.exists():
|
||||
src_dir = Path(source_dir) if source_dir else candidate.parent
|
||||
results.append(check_translation_set(candidate.parent.name, src_dir, candidate))
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
# No translations file found — this repo doesn't use i18n
|
||||
click.echo("PASS: No translations file found — skipping (repo does not use i18n).")
|
||||
return
|
||||
else:
|
||||
results = []
|
||||
for trans_file in translations:
|
||||
# Infer source directory as the parent of the translations file
|
||||
src_dir = trans_file.parent
|
||||
src_dir = Path(source_dir) if source_dir else trans_file.parent
|
||||
name = trans_file.parent.name
|
||||
results.append(check_translation_set(name, src_dir, trans_file))
|
||||
|
||||
|
||||
+55
-17
@@ -5,8 +5,12 @@ Parses Click commands from the CLI source code and checks if each command
|
||||
has corresponding documentation in the wiki/docs. Reports missing
|
||||
documentation as warnings and exits with non-zero if coverage is below 100%.
|
||||
|
||||
By default, checks the current repository's own source and docs directories.
|
||||
When run from the devx package itself (development mode), it checks devx's
|
||||
own files. When installed as a package, it checks the consuming repo's files.
|
||||
|
||||
Usage:
|
||||
python3 -m devx.ci.doc_coverage [--docs-dir docs/] [--fail-on-missing]
|
||||
python3 -m devx.ci.doc_coverage [--docs-dir docs/] [--source-dir src/] [--fail-on-missing]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -19,11 +23,12 @@ import click
|
||||
|
||||
from devx.i18n import _
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
||||
# Default to the current working directory (consuming repo's root)
|
||||
REPO_ROOT = Path.cwd()
|
||||
DOCS_DIR = REPO_ROOT / "docs"
|
||||
CLI_FILE = REPO_ROOT / "src" / "devx" / "cli.py"
|
||||
|
||||
# Major modules that should be documented in tech/architecture.md
|
||||
# These are devx-specific; when checking other repos, use --source-dir
|
||||
REQUIRED_MODULES = [
|
||||
"cli.py",
|
||||
"i18n.py",
|
||||
@@ -51,11 +56,16 @@ REQUIRED_SCRIPTS = [
|
||||
]
|
||||
|
||||
|
||||
def extract_cli_commands() -> list[str]:
|
||||
def extract_cli_commands(source_dir: Path) -> list[str]:
|
||||
"""Extract command names from the CLI source file."""
|
||||
if not CLI_FILE.exists():
|
||||
# Try to find the CLI file in the source directory
|
||||
cli_file = None
|
||||
for candidate in source_dir.rglob("cli.py"):
|
||||
cli_file = candidate
|
||||
break
|
||||
if cli_file is None or not cli_file.exists():
|
||||
return []
|
||||
content = CLI_FILE.read_text()
|
||||
content = cli_file.read_text()
|
||||
commands: list[str] = []
|
||||
# Find all @<group>.command("name") occurrences in the CLI source
|
||||
# Matches @cli.command, @ci.command, @tools.command, @molecule.command
|
||||
@@ -94,15 +104,30 @@ def check_module_documented(module: str, docs_content: str) -> bool:
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--docs-dir", default=str(DOCS_DIR), help="Path to the docs directory.")
|
||||
@click.option("--docs-dir", default=None, help="Path to the docs directory (default: ./docs).")
|
||||
@click.option("--source-dir", default=None, help="Path to the source directory (default: auto-detect from src/).")
|
||||
@click.option(
|
||||
"--fail-on-missing",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Exit with non-zero status if any documentation is missing.",
|
||||
)
|
||||
def main(docs_dir: str, fail_on_missing: bool) -> None:
|
||||
docs_path = Path(docs_dir)
|
||||
def main(docs_dir: str | None, source_dir: str | None, fail_on_missing: bool) -> None:
|
||||
root = Path.cwd()
|
||||
docs_path = Path(docs_dir) if docs_dir else root / "docs"
|
||||
|
||||
# Auto-detect source directory
|
||||
if source_dir:
|
||||
src_path = Path(source_dir)
|
||||
else:
|
||||
# Try common source directories
|
||||
for candidate in [root / "src", root / "scripts"]:
|
||||
if candidate.exists():
|
||||
src_path = candidate
|
||||
break
|
||||
else:
|
||||
src_path = root / "src"
|
||||
|
||||
cli_commands_file = docs_path / "user" / "cli-commands.md"
|
||||
architecture_file = docs_path / "tech" / "architecture.md"
|
||||
ci_cd_file = docs_path / "tech" / "ci-cd-workflow.md"
|
||||
@@ -112,21 +137,28 @@ def main(docs_dir: str, fail_on_missing: bool) -> None:
|
||||
|
||||
# Check CLI commands
|
||||
click.echo(_("Checking CLI command documentation..."))
|
||||
commands = extract_cli_commands()
|
||||
commands = extract_cli_commands(src_path)
|
||||
total += len(commands)
|
||||
cli_docs = cli_commands_file.read_text() if cli_commands_file.exists() else ""
|
||||
for cmd in commands:
|
||||
if check_command_documented(cmd, cli_docs):
|
||||
click.echo(_(" OK: devx {cmd}", cmd=cmd))
|
||||
click.echo(_(" OK: {cmd}", cmd=cmd))
|
||||
else:
|
||||
click.echo(_(" MISSING: devx {cmd}", cmd=cmd))
|
||||
missing.append(f"CLI command: devx {cmd}")
|
||||
click.echo(_(" MISSING: {cmd}", cmd=cmd))
|
||||
missing.append(f"CLI command: {cmd}")
|
||||
|
||||
# Check modules in architecture.md
|
||||
# Auto-detect modules from source directory (top-level only, exclude subdirs)
|
||||
click.echo(_("\nChecking module documentation in architecture.md..."))
|
||||
total += len(REQUIRED_MODULES)
|
||||
if src_path.exists():
|
||||
detected_modules = sorted(
|
||||
f.name for f in src_path.glob("*.py") if f.name != "__init__.py" and f.name != "cli.py"
|
||||
)
|
||||
else:
|
||||
detected_modules = REQUIRED_MODULES
|
||||
total += len(detected_modules)
|
||||
arch_docs = architecture_file.read_text() if architecture_file.exists() else ""
|
||||
for module in REQUIRED_MODULES:
|
||||
for module in detected_modules:
|
||||
if check_module_documented(module, arch_docs):
|
||||
click.echo(_(" OK: {module}", module=module))
|
||||
else:
|
||||
@@ -134,10 +166,16 @@ def main(docs_dir: str, fail_on_missing: bool) -> None:
|
||||
missing.append(f"Module: {module}")
|
||||
|
||||
# Check CI scripts in ci-cd-workflow.md
|
||||
# Auto-detect CI scripts from ci/ subdirectory
|
||||
click.echo(_("\nChecking CI script documentation in ci-cd-workflow.md..."))
|
||||
total += len(REQUIRED_SCRIPTS)
|
||||
ci_dir = src_path / "ci" if src_path.name != "ci" else src_path
|
||||
if ci_dir.exists():
|
||||
detected_scripts = sorted(f.name for f in ci_dir.glob("*.py") if f.name != "__init__.py")
|
||||
else:
|
||||
detected_scripts = REQUIRED_SCRIPTS
|
||||
total += len(detected_scripts)
|
||||
ci_docs = ci_cd_file.read_text() if ci_cd_file.exists() else ""
|
||||
for script in REQUIRED_SCRIPTS:
|
||||
for script in detected_scripts:
|
||||
if check_module_documented(script, ci_docs):
|
||||
click.echo(_(" OK: {script}", script=script))
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Lint documentation files for structure, links, and quality.
|
||||
|
||||
Checks performed (all configurable via pyproject.toml ``[tool.devx.docs]``):
|
||||
- **Required files**: README.md, AGENTS.md, CHANGELOG.md must exist.
|
||||
- **Docs structure**: ``docs/index.md`` and ``docs/mapping.json`` must exist.
|
||||
- **Broken internal links**: relative paths and anchors in markdown files
|
||||
must resolve to actual files and headings.
|
||||
- **Heading hierarchy**: no skipping heading levels (e.g., ``#`` → ``###``).
|
||||
- **TODO/FIXME**: flags leftover TODO/FIXME markers in documentation.
|
||||
- **Stale docs**: files not modified in >180 days (warning only).
|
||||
- **Trailing whitespace**: lines should not end with whitespace.
|
||||
- **Blank line before headings**: headings should have a blank line before them.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.ci.lint_docs
|
||||
python3 -m devx.ci.lint_docs --docs-dir docs/ --root .
|
||||
python3 -m devx.ci.lint_docs --fix # auto-fix trailing whitespace
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from devx.i18n import _
|
||||
|
||||
# Heading slug pattern (GitHub-style)
|
||||
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$", re.MULTILINE)
|
||||
# Markdown link pattern: [text](url)
|
||||
_LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)]+)\)")
|
||||
# Trailing whitespace
|
||||
_TRAILING_WS_RE = re.compile(r"[ \t]+$")
|
||||
# Heading without blank line before
|
||||
_HEADING_NO_BLANK_RE = re.compile(r"([^\n])\n(#{1,6}\s)")
|
||||
|
||||
# Files that must exist in every project
|
||||
REQUIRED_FILES = ["README.md", "AGENTS.md", "CHANGELOG.md"]
|
||||
|
||||
# Files that must exist in docs/
|
||||
REQUIRED_DOC_FILES = ["index.md"]
|
||||
|
||||
# Maximum age for docs before they're considered stale (days)
|
||||
STALE_THRESHOLD_DAYS = 180
|
||||
|
||||
# Files excluded from duplicate heading checks (auto-generated or structured
|
||||
# with repeated subsections under different parent sections)
|
||||
DUPLICATE_HEADING_EXCLUDES = {
|
||||
"CHANGELOG.md",
|
||||
"incident-response-sso.md",
|
||||
"role-sync-design.md",
|
||||
}
|
||||
|
||||
# TODO/FIXME pattern — matches "TODO:" or "FIXME:" at start of line/after whitespace
|
||||
# Does NOT match references to the word "TODO" in rules/documentation
|
||||
_TODO_RE = re.compile(r"(?m)^\s*(?:>>>?\s*)?(TODO|FIXME|HACK|XXX)\s*:", re.IGNORECASE)
|
||||
|
||||
# Directories excluded from markdown file scanning
|
||||
_EXCLUDE_DIRS = {
|
||||
".venv",
|
||||
".git",
|
||||
"node_modules",
|
||||
"__pycache__",
|
||||
".pytest_cache",
|
||||
".devin",
|
||||
".terraform",
|
||||
"site-packages",
|
||||
"dist-info",
|
||||
}
|
||||
|
||||
|
||||
def slugify(text: str) -> str:
|
||||
"""Convert heading text to a GitHub-style slug."""
|
||||
slug = text.lower().strip()
|
||||
slug = re.sub(r"[^\w\s-]", "", slug)
|
||||
slug = re.sub(r"[\s]+", "-", slug)
|
||||
return slug
|
||||
|
||||
|
||||
def strip_code_blocks(content: str) -> str:
|
||||
"""Remove fenced code blocks from markdown content.
|
||||
|
||||
Replaces ```...``` blocks with empty lines so heading detection
|
||||
doesn't pick up # comments inside code blocks.
|
||||
"""
|
||||
result: list[str] = []
|
||||
in_code_block = False
|
||||
for line in content.splitlines():
|
||||
if line.strip().startswith("```"):
|
||||
in_code_block = not in_code_block
|
||||
result.append("")
|
||||
continue
|
||||
if in_code_block:
|
||||
result.append("")
|
||||
continue
|
||||
result.append(line)
|
||||
return "\n".join(result)
|
||||
|
||||
|
||||
def extract_headings(filepath: Path) -> dict[str, int]:
|
||||
"""Extract all headings from a markdown file.
|
||||
|
||||
Returns a dict mapping slug → heading level.
|
||||
"""
|
||||
content = strip_code_blocks(filepath.read_text(encoding="utf-8"))
|
||||
headings: dict[str, int] = {}
|
||||
for match in _HEADING_RE.finditer(content):
|
||||
level = len(match.group(1))
|
||||
text = match.group(2)
|
||||
slug = slugify(text)
|
||||
headings[slug] = level
|
||||
return headings
|
||||
|
||||
|
||||
def extract_links(filepath: Path) -> list[tuple[int, str, str]]:
|
||||
"""Extract all markdown links from a file.
|
||||
|
||||
Returns a list of (line_number, link_text, url) tuples.
|
||||
Includes anchor-only links (#section) for validation.
|
||||
Skips external links (http/https) and mailto.
|
||||
"""
|
||||
content = filepath.read_text(encoding="utf-8")
|
||||
links: list[tuple[int, str, str]] = []
|
||||
for match in _LINK_RE.finditer(content):
|
||||
url = match.group(2).strip()
|
||||
# Skip external links and mailto
|
||||
if url.startswith(("http://", "https://", "mailto:")):
|
||||
continue
|
||||
line_num = content[: match.start()].count("\n") + 1
|
||||
links.append((line_num, match.group(1), url))
|
||||
return links
|
||||
|
||||
|
||||
def check_required_files(root: Path) -> list[str]:
|
||||
"""Check that required files exist."""
|
||||
issues: list[str] = []
|
||||
for filename in REQUIRED_FILES:
|
||||
if not (root / filename).exists():
|
||||
issues.append(f"Missing required file: {filename}")
|
||||
return issues
|
||||
|
||||
|
||||
def check_docs_structure(root: Path, docs_dir: Path) -> list[str]:
|
||||
"""Check that docs directory has required structure."""
|
||||
issues: list[str] = []
|
||||
if not docs_dir.exists():
|
||||
issues.append(f"Docs directory not found: {docs_dir}")
|
||||
return issues
|
||||
for filename in REQUIRED_DOC_FILES:
|
||||
if not (docs_dir / filename).exists():
|
||||
issues.append(f"Missing required doc file: docs/{filename}")
|
||||
mapping_file = docs_dir / "mapping.json"
|
||||
if mapping_file.exists():
|
||||
try:
|
||||
mapping = json.loads(mapping_file.read_text(encoding="utf-8"))
|
||||
if not isinstance(mapping, dict):
|
||||
issues.append("docs/mapping.json must be a JSON object")
|
||||
elif not mapping:
|
||||
issues.append("docs/mapping.json is empty")
|
||||
except json.JSONDecodeError as e:
|
||||
issues.append(f"docs/mapping.json is invalid JSON: {e}")
|
||||
return issues
|
||||
|
||||
|
||||
def check_internal_links(root: Path, docs_dir: Path) -> list[str]:
|
||||
"""Check that all internal links in markdown files resolve."""
|
||||
issues: list[str] = []
|
||||
md_files = list(root.rglob("*.md"))
|
||||
# Exclude .venv, .git, node_modules
|
||||
md_files = [f for f in md_files if not any(part in _EXCLUDE_DIRS for part in f.parts)]
|
||||
|
||||
# Load wiki page names from mapping.json — these are valid link targets
|
||||
wiki_pages: set[str] = set()
|
||||
mapping_file = docs_dir / "mapping.json"
|
||||
if mapping_file.exists():
|
||||
try:
|
||||
mapping = json.loads(mapping_file.read_text(encoding="utf-8"))
|
||||
wiki_pages = set(mapping.values())
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
|
||||
for md_file in md_files:
|
||||
rel_path = md_file.relative_to(root)
|
||||
links = extract_links(md_file)
|
||||
headings = extract_headings(md_file)
|
||||
|
||||
for line_num, _link_text, url in links:
|
||||
# Split into path and anchor
|
||||
if "#" in url:
|
||||
path_part, anchor = url.split("#", 1)
|
||||
else:
|
||||
path_part, anchor = url, ""
|
||||
|
||||
# Skip wiki page references (no file extension, no /, matches mapping.json values)
|
||||
if path_part and "." not in path_part and "/" not in path_part:
|
||||
if path_part in wiki_pages:
|
||||
continue
|
||||
# Also skip if it looks like a wiki page name (CamelCase or hyphenated)
|
||||
# without a file extension — can't verify these locally
|
||||
if not any(c in path_part for c in "/\\"):
|
||||
continue
|
||||
|
||||
# Resolve relative path
|
||||
if path_part:
|
||||
target = (md_file.parent / path_part).resolve()
|
||||
if not target.exists():
|
||||
issues.append(f"{rel_path}:{line_num}: broken link '{url}' — file not found: {path_part}")
|
||||
continue
|
||||
# Check anchor in target file
|
||||
if anchor:
|
||||
target_headings = extract_headings(target)
|
||||
target_slug = slugify(anchor)
|
||||
if target_slug not in target_headings:
|
||||
issues.append(f"{rel_path}:{line_num}: broken anchor '#{anchor}' in {path_part}")
|
||||
elif anchor:
|
||||
# Anchor-only link — check in current file
|
||||
anchor_slug = slugify(anchor)
|
||||
if anchor_slug not in headings:
|
||||
issues.append(f"{rel_path}:{line_num}: broken anchor '#{anchor}'")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def check_heading_hierarchy(root: Path) -> list[str]:
|
||||
"""Check that headings don't skip levels."""
|
||||
issues: list[str] = []
|
||||
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
|
||||
|
||||
for md_file in md_files:
|
||||
rel_path = md_file.relative_to(root)
|
||||
content = strip_code_blocks(md_file.read_text(encoding="utf-8"))
|
||||
prev_level = 0
|
||||
for match in _HEADING_RE.finditer(content):
|
||||
level = len(match.group(1))
|
||||
if prev_level > 0 and level > prev_level + 1:
|
||||
issues.append(f"{rel_path}: heading hierarchy skip — H{prev_level} → H{level}: '{match.group(2)}'")
|
||||
prev_level = level
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def check_todo_fixme(root: Path) -> list[str]:
|
||||
"""Check for TODO/FIXME/HACK/XXX markers in documentation.
|
||||
|
||||
Only flags actual TODO/FIXME markers (e.g., "TODO: fix this"), not
|
||||
references to the word "TODO" in rules or documentation about TODOs.
|
||||
"""
|
||||
issues: list[str] = []
|
||||
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
|
||||
|
||||
for md_file in md_files:
|
||||
rel_path = md_file.relative_to(root)
|
||||
content = md_file.read_text(encoding="utf-8")
|
||||
for match in _TODO_RE.finditer(content):
|
||||
line_num = content[: match.start()].count("\n") + 1
|
||||
line = content.splitlines()[line_num - 1] if line_num <= len(content.splitlines()) else ""
|
||||
issues.append(f"{rel_path}:{line_num}: TODO/FIXME found: {line.strip()}")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def check_trailing_whitespace(root: Path) -> list[str]:
|
||||
"""Check for trailing whitespace in markdown files."""
|
||||
issues: list[str] = []
|
||||
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
|
||||
|
||||
for md_file in md_files:
|
||||
rel_path = md_file.relative_to(root)
|
||||
content = md_file.read_text(encoding="utf-8")
|
||||
for i, line in enumerate(content.splitlines(), 1):
|
||||
if _TRAILING_WS_RE.search(line):
|
||||
issues.append(f"{rel_path}:{i}: trailing whitespace")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def check_stale_docs(root: Path) -> list[str]:
|
||||
"""Check for stale documentation (not modified in >180 days)."""
|
||||
issues: list[str] = []
|
||||
threshold = datetime.now() - timedelta(days=STALE_THRESHOLD_DAYS)
|
||||
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
|
||||
|
||||
for md_file in md_files:
|
||||
rel_path = md_file.relative_to(root)
|
||||
mtime = datetime.fromtimestamp(md_file.stat().st_mtime)
|
||||
if mtime < threshold:
|
||||
days_old = (datetime.now() - mtime).days
|
||||
issues.append(f"{rel_path}: stale doc — not modified in {days_old} days")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def check_duplicate_headings(root: Path) -> list[str]:
|
||||
"""Check for duplicate headings within the same file."""
|
||||
issues: list[str] = []
|
||||
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
|
||||
|
||||
for md_file in md_files:
|
||||
rel_path = md_file.relative_to(root)
|
||||
# Skip auto-generated files like CHANGELOG.md
|
||||
if md_file.name in DUPLICATE_HEADING_EXCLUDES:
|
||||
continue
|
||||
content = strip_code_blocks(md_file.read_text(encoding="utf-8"))
|
||||
seen: dict[str, int] = {}
|
||||
for match in _HEADING_RE.finditer(content):
|
||||
text = match.group(2)
|
||||
slug = slugify(text)
|
||||
if slug in seen:
|
||||
issues.append(f"{rel_path}: duplicate heading '{text}'")
|
||||
seen[slug] = 1
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--root", default=".", help="Repository root directory.")
|
||||
@click.option("--docs-dir", default=None, help="Docs directory (default: <root>/docs).")
|
||||
@click.option("--check-links/--no-check-links", default=True, help="Check internal links.")
|
||||
@click.option("--check-headings/--no-check-headings", default=True, help="Check heading hierarchy.")
|
||||
@click.option("--check-todo/--no-check-todo", default=True, help="Check for TODO/FIXME.")
|
||||
@click.option("--check-stale/--no-check-stale", default=False, help="Check for stale docs.")
|
||||
@click.option("--check-trailing/--no-check-trailing", default=True, help="Check trailing whitespace.")
|
||||
@click.option("--check-duplicates/--no-check-duplicates", default=True, help="Check duplicate headings.")
|
||||
@click.option("--fix", is_flag=True, default=False, help="Auto-fix trailing whitespace.")
|
||||
def main(
|
||||
root: str,
|
||||
docs_dir: str | None,
|
||||
check_links: bool,
|
||||
check_headings: bool,
|
||||
check_todo: bool,
|
||||
check_stale: bool,
|
||||
check_trailing: bool,
|
||||
check_duplicates: bool,
|
||||
fix: bool,
|
||||
) -> None:
|
||||
"""Lint documentation files for structure, links, and quality."""
|
||||
root_path = Path(root).resolve()
|
||||
docs_path = Path(docs_dir) if docs_dir else root_path / "docs"
|
||||
|
||||
click.echo(_("Linting documentation in {root}...", root=str(root_path)))
|
||||
|
||||
all_issues: list[str] = []
|
||||
|
||||
# Structure checks
|
||||
click.echo(_("Checking required files..."))
|
||||
all_issues.extend(check_required_files(root_path))
|
||||
|
||||
click.echo(_("Checking docs structure..."))
|
||||
all_issues.extend(check_docs_structure(root_path, docs_path))
|
||||
|
||||
# Link checks
|
||||
if check_links:
|
||||
click.echo(_("Checking internal links..."))
|
||||
all_issues.extend(check_internal_links(root_path, docs_path))
|
||||
|
||||
# Heading hierarchy
|
||||
if check_headings:
|
||||
click.echo(_("Checking heading hierarchy..."))
|
||||
all_issues.extend(check_heading_hierarchy(root_path))
|
||||
|
||||
# Duplicate headings
|
||||
if check_duplicates:
|
||||
click.echo(_("Checking duplicate headings..."))
|
||||
all_issues.extend(check_duplicate_headings(root_path))
|
||||
|
||||
# TODO/FIXME
|
||||
if check_todo:
|
||||
click.echo(_("Checking for TODO/FIXME markers..."))
|
||||
all_issues.extend(check_todo_fixme(root_path))
|
||||
|
||||
# Trailing whitespace
|
||||
if check_trailing:
|
||||
click.echo(_("Checking trailing whitespace..."))
|
||||
ws_issues = check_trailing_whitespace(root_path)
|
||||
if fix and ws_issues:
|
||||
fixed = 0
|
||||
md_files = [f for f in root_path.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
|
||||
for md_file in md_files:
|
||||
content = md_file.read_text(encoding="utf-8")
|
||||
fixed_content = _TRAILING_WS_RE.sub("", content)
|
||||
if content != fixed_content:
|
||||
md_file.write_text(fixed_content, encoding="utf-8")
|
||||
fixed += 1
|
||||
click.echo(_(" Auto-fixed trailing whitespace in {n} files", n=fixed))
|
||||
else:
|
||||
all_issues.extend(ws_issues)
|
||||
|
||||
# Stale docs
|
||||
if check_stale:
|
||||
click.echo(_("Checking for stale docs..."))
|
||||
stale = check_stale_docs(root_path)
|
||||
for issue in stale:
|
||||
click.echo(f" WARN: {issue}")
|
||||
# Stale docs are warnings, not errors
|
||||
click.echo(_(" {n} stale docs found (warnings only)", n=len(stale)))
|
||||
|
||||
# Report
|
||||
click.echo(f"\n{'=' * 60}")
|
||||
if all_issues:
|
||||
click.echo(_("FAIL: {n} documentation issues found:", n=len(all_issues)))
|
||||
for issue in all_issues:
|
||||
click.echo(f" - {issue}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
click.echo(_("PASS: All documentation checks passed!"))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -387,14 +387,34 @@ def check_documentation(files: list[dict[str, Any]], result: ReviewResult) -> No
|
||||
for f in files
|
||||
)
|
||||
has_ansible_changes = any(f.get("filename", "").startswith("ansible/") for f in files)
|
||||
has_tofu_changes = any(f.get("filename", "").startswith("tofu/") for f in files)
|
||||
has_workflow_changes = any(f.get("filename", "").startswith(".gitea/") for f in files)
|
||||
|
||||
# Check for TODO/FIXME in changed docs
|
||||
todo_issues: list[str] = []
|
||||
for f in files:
|
||||
filename = f.get("filename", "")
|
||||
if filename.endswith(".md") and filename.startswith(("docs/", "README", "AGENTS")):
|
||||
# Can't check file content from PR API easily, but flag if patch adds TODO
|
||||
patch = f.get("patch", "")
|
||||
if patch and re.search(r"^\+.*\b(TODO|FIXME|HACK|XXX)\b", patch, re.IGNORECASE):
|
||||
todo_issues.append(f"{filename}: new TODO/FIXME added in documentation")
|
||||
|
||||
if has_src_changes and not has_doc_changes:
|
||||
result.add_summary("- Documentation: WARNING — source files changed but no docs updated")
|
||||
elif has_ansible_changes and not has_doc_changes:
|
||||
result.add_summary("- Documentation: WARNING — Ansible role changed but no docs updated")
|
||||
elif has_tofu_changes and not has_doc_changes:
|
||||
result.add_summary("- Documentation: WARNING — OpenTofu changes but no docs updated")
|
||||
elif has_workflow_changes and not has_doc_changes:
|
||||
result.add_summary("- Documentation: INFO — workflow changes (consider updating CI docs if behavior changed)")
|
||||
else:
|
||||
result.add_summary("- Documentation: OK")
|
||||
|
||||
if todo_issues:
|
||||
for issue in todo_issues:
|
||||
result.add_summary(f"- Documentation: WARNING — {issue}")
|
||||
|
||||
|
||||
def check_test_coverage(files: list[dict[str, Any]], result: ReviewResult) -> None:
|
||||
"""Check that tests are updated for source changes."""
|
||||
|
||||
+34
-3
@@ -38,6 +38,7 @@ from __future__ import annotations
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
import click
|
||||
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."))
|
||||
# Pull --rebase before push to handle the case where master
|
||||
# advanced between checkout and commit (e.g., another merge).
|
||||
run_cmd(["git", "pull", "--rebase", "origin", "master"], check=False)
|
||||
# Use refs/heads/master to avoid ambiguity with a 'master' tag
|
||||
run_cmd(["git", "push", "origin", "refs/heads/master:refs/heads/master"])
|
||||
# Retry up to 3 times to handle concurrent pushes.
|
||||
push_succeeded = False
|
||||
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."))
|
||||
else:
|
||||
click.echo(_("Skipping commit push — no staged changes."))
|
||||
|
||||
@@ -21,11 +21,19 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
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.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]:
|
||||
"""List existing wiki pages, returning {title: sub_url}."""
|
||||
try:
|
||||
pages = client._request("GET", "/wiki/pages").json()
|
||||
except APIError:
|
||||
return {}
|
||||
"""List existing wiki pages, returning {title: sub_url}.
|
||||
|
||||
Raises :class:`APIError` if the wiki API is unavailable — the caller
|
||||
is responsible for retrying or handling the failure.
|
||||
"""
|
||||
pages = client._request("GET", "/wiki/pages").json()
|
||||
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()
|
||||
|
||||
|
||||
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(
|
||||
client: GiteaClient,
|
||||
mapping: dict[str, str],
|
||||
@@ -176,9 +207,25 @@ def verify_wiki_integrity(
|
||||
5. Page count matches
|
||||
|
||||
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] = []
|
||||
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())
|
||||
|
||||
# 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)))
|
||||
|
||||
existing_pages = list_wiki_pages(client)
|
||||
try:
|
||||
existing_pages = list_wiki_pages(client)
|
||||
except APIError:
|
||||
existing_pages = {}
|
||||
if 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:
|
||||
click.echo(_("\nVerifying wiki pages have content..."))
|
||||
# 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
|
||||
for page_title, expected_content in sorted(synced.items()):
|
||||
ok = verify_wiki_page(client, page_title, expected_content, existing_pages)
|
||||
|
||||
@@ -95,6 +95,13 @@ def ci_doc_coverage(args: tuple[str, ...]) -> None:
|
||||
_run_module("devx.ci.doc_coverage", list(args))
|
||||
|
||||
|
||||
@ci.command("lint-docs")
|
||||
@click.argument("args", nargs=-1)
|
||||
def ci_lint_docs(args: tuple[str, ...]) -> None:
|
||||
"""Lint documentation files for structure, links, and quality."""
|
||||
_run_module("devx.ci.lint_docs", list(args))
|
||||
|
||||
|
||||
@ci.command("notify-failure")
|
||||
@click.argument("args", nargs=-1)
|
||||
def ci_notify_failure(args: tuple[str, ...]) -> None:
|
||||
@@ -219,6 +226,20 @@ def tools_setup(args: tuple[str, ...]) -> None:
|
||||
_run_module("devx.tools.setup", list(args))
|
||||
|
||||
|
||||
@tools.command("rebase")
|
||||
@click.argument("args", nargs=-1)
|
||||
def tools_rebase(args: tuple[str, ...]) -> None:
|
||||
"""Rebase current branch onto origin/master and force-push."""
|
||||
_run_module("devx.tools.rebase", list(args))
|
||||
|
||||
|
||||
@tools.command("pr-rebase")
|
||||
@click.argument("args", nargs=-1)
|
||||
def tools_pr_rebase(args: tuple[str, ...]) -> None:
|
||||
"""Rebase a PR's head branch onto master via Gitea API (server-side)."""
|
||||
_run_module("devx.tools.pr_rebase", list(args))
|
||||
|
||||
|
||||
@cli.group()
|
||||
def molecule() -> None:
|
||||
"""Molecule testing commands (requires devx[molecule])."""
|
||||
|
||||
+15
-1
@@ -63,7 +63,7 @@ DEVX_PIP_INSTALL := if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi;
|
||||
$(DEVX_BIN)/pip
|
||||
|
||||
.PHONY: devx-create-task devx-create-pr devx-push devx-push-with-pr devx-check-config
|
||||
.PHONY: devx-pr-status devx-pr-logs devx-pr-label devx-pr-review
|
||||
.PHONY: devx-pr-status devx-pr-logs devx-pr-label devx-pr-review devx-rebase devx-pr-rebase
|
||||
.PHONY: devx-configure-gitea-pypi devx-install-tools devx-install-checkmake devx-checkmake
|
||||
.PHONY: devx-workflow-lint devx-workflow-dryrun devx-workflow-dryrun-safe devx-workflow-check
|
||||
.PHONY: devx-notify-failure devx-install-hooks devx-activate-scripts
|
||||
@@ -134,6 +134,20 @@ devx-pr-review:
|
||||
$(if $(BODY),--body "$(BODY)") \
|
||||
$(if $(CHECKLIST),--checklist-confirmed --checklist-categories $(CHECKLIST))
|
||||
|
||||
# Rebase current branch onto origin/master and force-push
|
||||
# Usage: make devx-rebase
|
||||
# make devx-rebase NO_PUSH=1
|
||||
devx-rebase:
|
||||
@$(DEVX_PYTHON) -m devx.tools.rebase \
|
||||
$(if $(NO_PUSH),--no-push)
|
||||
|
||||
# Rebase a PR's head branch via Gitea API (server-side, no local git needed)
|
||||
# Usage: make devx-pr-rebase
|
||||
# make devx-pr-rebase PR=42
|
||||
devx-pr-rebase:
|
||||
@$(DEVX_PYTHON) -m devx.tools.pr_rebase \
|
||||
$(if $(PR),--pr $(PR))
|
||||
|
||||
# ── Environment setup ─────────────────────────────────────────────────────────
|
||||
|
||||
# Configure Gitea private PyPI registry so pip can find devx and other
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
|
||||
@@ -22,3 +24,52 @@ def arch_string() -> str:
|
||||
if machine in {"aarch64", "arm64"}:
|
||||
return "arm64"
|
||||
raise click.ClickException(f"Unsupported architecture: {machine}")
|
||||
|
||||
|
||||
def detect_pr_number() -> int | None:
|
||||
"""Detect the PR number for the current git branch.
|
||||
|
||||
Returns the PR number if the current branch has an open PR, or None
|
||||
if no PR is found. Does NOT raise — callers decide how to handle None.
|
||||
Best-effort: returns None on any failure (no token, API down, etc.).
|
||||
"""
|
||||
result = subprocess.run( # nosec B603, B607
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
branch = result.stdout.strip()
|
||||
if branch == "HEAD":
|
||||
return None
|
||||
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token:
|
||||
return None
|
||||
|
||||
owner = os.environ.get("DEVX_REPO_OWNER", "")
|
||||
repo = os.environ.get("DEVX_REPO_NAME", "")
|
||||
if not owner or not repo:
|
||||
github_repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||
if "/" in github_repo:
|
||||
owner, repo = github_repo.split("/", 1)
|
||||
|
||||
if not owner or not repo:
|
||||
return None
|
||||
|
||||
# Lazy import to avoid circular dependency
|
||||
from devx.api_clients import APIError, GiteaClient # noqa: PLC0415
|
||||
from devx.config import GITEA_API_URL # noqa: PLC0415
|
||||
|
||||
client = GiteaClient(GITEA_API_URL, token, owner, repo)
|
||||
try:
|
||||
prs = client.list_prs(state="open")
|
||||
except APIError:
|
||||
# Best-effort: API down or auth failure → no PR detected
|
||||
return None
|
||||
for pr in prs:
|
||||
if pr.get("head", {}).get("ref") == branch:
|
||||
return int(pr["number"])
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rebase a pull request's head branch onto master via Gitea API.
|
||||
|
||||
Uses the Gitea ``POST /pulls/{index}/update?style=rebase`` endpoint to
|
||||
rebase the PR's head branch server-side. This triggers a new
|
||||
``pull_request synchronize`` event, which starts a new CI run.
|
||||
|
||||
This is useful when:
|
||||
- You don't have the branch checked out locally
|
||||
- You want to rebase a PR from another machine
|
||||
- You want to trigger the auto-merge retry without local git operations
|
||||
|
||||
Usage::
|
||||
|
||||
# Rebase PR #42
|
||||
python -m devx.tools.pr_rebase --pr 42
|
||||
|
||||
# Rebase current branch's PR (auto-detected)
|
||||
python -m devx.tools.pr_rebase
|
||||
|
||||
The repository is auto-detected from ``DEVX_REPO_OWNER`` /
|
||||
``DEVX_REPO_NAME`` or ``GITHUB_REPOSITORY`` environment variables.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from devx.api_clients import APIError, GiteaClient
|
||||
from devx.config import GITEA_API_URL
|
||||
from devx.i18n import _
|
||||
from devx.tools._shared import detect_pr_number
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--pr", type=int, help="PR number (auto-detected if omitted).")
|
||||
def main(pr: int | None) -> None:
|
||||
"""Rebase a pull request's head branch onto master via Gitea API."""
|
||||
load_dotenv()
|
||||
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("CI_GITEA_TOKEN is not set. Add it to .env or export it."))
|
||||
|
||||
pr_num = pr or detect_pr_number()
|
||||
if not pr_num:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Could not detect PR number. Use --pr to specify it explicitly,\n"
|
||||
"or run this command from a branch with an open PR.",
|
||||
)
|
||||
)
|
||||
|
||||
owner = os.environ.get("DEVX_REPO_OWNER", "")
|
||||
repo = os.environ.get("DEVX_REPO_NAME", "")
|
||||
if not owner or not repo:
|
||||
github_repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||
if "/" in github_repo:
|
||||
owner, repo = github_repo.split("/", 1)
|
||||
|
||||
if not owner or not repo:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\n"
|
||||
"or GITHUB_REPOSITORY environment variables.",
|
||||
)
|
||||
)
|
||||
|
||||
client = GiteaClient(GITEA_API_URL, token, owner, repo)
|
||||
|
||||
click.echo(_("Rebasing PR #{pr} via Gitea API...", pr=pr_num))
|
||||
try:
|
||||
client.update_pr_branch(pr_num, style="rebase")
|
||||
except APIError as e:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Rebase failed with HTTP {status}: {message}",
|
||||
status=e.status,
|
||||
message=e.message,
|
||||
)
|
||||
) from None
|
||||
|
||||
click.echo(
|
||||
_(
|
||||
"PR #{pr} rebased successfully. A new CI run will start automatically.\n"
|
||||
"If auto-merge is enabled (ready-to-merge label), the next CI run\n"
|
||||
"will attempt to merge this PR.",
|
||||
pr=pr_num,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rebase current branch onto origin/master and force-push.
|
||||
|
||||
Fetches origin/master, rebases the current branch, and force-pushes with
|
||||
``--force-with-lease``. This is the manual equivalent of what
|
||||
``auto_merge.py`` does automatically via the Gitea API.
|
||||
|
||||
Usage::
|
||||
|
||||
# Rebase current branch onto master and force-push
|
||||
python -m devx.tools.rebase
|
||||
|
||||
# Rebase without pushing (local only)
|
||||
python -m devx.tools.rebase --no-push
|
||||
|
||||
The tool fails if:
|
||||
- The rebase encounters conflicts (exits with rebase in progress)
|
||||
- The force-push is rejected (remote has unexpected commits)
|
||||
- Not on a branch (detached HEAD)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
|
||||
from devx.i18n import _
|
||||
|
||||
|
||||
def _run_git(args: list[str], check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
"""Run a git command and return the result."""
|
||||
return subprocess.run( # nosec B603, B607
|
||||
["git", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=check,
|
||||
)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--no-push", is_flag=True, help="Rebase locally without pushing.")
|
||||
def main(no_push: bool) -> None:
|
||||
"""Rebase current branch onto origin/master and force-push."""
|
||||
# Ensure we're on a branch (check=False — we handle errors ourselves)
|
||||
branch_result = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], check=False)
|
||||
if branch_result.returncode != 0:
|
||||
raise click.ClickException(_("Could not detect current branch: {error}", error=branch_result.stderr.strip()))
|
||||
branch = branch_result.stdout.strip()
|
||||
if branch == "HEAD":
|
||||
raise click.ClickException(_("Cannot rebase: not on a branch (detached HEAD)."))
|
||||
|
||||
click.echo(_("Fetching origin/master..."))
|
||||
fetch = _run_git(["fetch", "origin", "master"], check=False)
|
||||
if fetch.returncode != 0:
|
||||
raise click.ClickException(_("Fetch failed: {error}", error=fetch.stderr.strip()))
|
||||
|
||||
# Check if behind master
|
||||
behind = _run_git(
|
||||
["rev-list", "--count", "HEAD..origin/master"],
|
||||
check=False,
|
||||
)
|
||||
behind_count = int(behind.stdout.strip()) if behind.stdout.strip().isdigit() else 0
|
||||
|
||||
if behind_count == 0:
|
||||
click.echo(_("Branch is already up-to-date with origin/master."))
|
||||
if not no_push:
|
||||
click.echo(_("Nothing to push."))
|
||||
return
|
||||
|
||||
click.echo(_("Branch is {count} commit(s) behind master. Rebasing...", count=behind_count))
|
||||
rebase = _run_git(["rebase", "origin/master"], check=False)
|
||||
if rebase.returncode != 0:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
|
||||
error=rebase.stderr.strip() or rebase.stdout.strip(),
|
||||
)
|
||||
)
|
||||
|
||||
click.echo(_("Rebase successful."))
|
||||
|
||||
if not no_push:
|
||||
click.echo(_("Force-pushing..."))
|
||||
push = _run_git(["push", "--force-with-lease", "origin", branch], check=False)
|
||||
if push.returncode != 0:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
|
||||
error=push.stderr.strip(),
|
||||
)
|
||||
)
|
||||
click.echo(_("Pushed {branch} to origin.", branch=branch))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
+341
-53
@@ -144,9 +144,9 @@
|
||||
"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}'.": {
|
||||
"en": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||
"bg": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||
"de": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||
"en": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||
"pl": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||
"ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||
"zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'."
|
||||
@@ -216,9 +216,9 @@
|
||||
"zh": "\nWorkflow-only changes ({count}):"
|
||||
},
|
||||
"\n[check_test_coverage] Fix: add the missing test file(s) before committing.": {
|
||||
"en": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||
"bg": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||
"de": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||
"en": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||
"pl": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||
"ru": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||
"zh": "\n[check_test_coverage] Fix: add the missing test file(s) before committing."
|
||||
@@ -256,9 +256,9 @@
|
||||
"zh": "\n{tag} files ({count}):"
|
||||
},
|
||||
" Could not fetch logs: {error}": {
|
||||
"en": " Could not fetch logs: {error}",
|
||||
"bg": " Could not fetch logs: {error}",
|
||||
"de": " Could not fetch logs: {error}",
|
||||
"en": " Could not fetch logs: {error}",
|
||||
"pl": " Could not fetch logs: {error}",
|
||||
"ru": " Could not fetch logs: {error}",
|
||||
"zh": " Could not fetch logs: {error}"
|
||||
@@ -351,6 +351,14 @@
|
||||
"ru": " - Требуемые проверки статуса: {checks}",
|
||||
"zh": " - 必需状态检查: {checks}"
|
||||
},
|
||||
" Auto-fixed trailing whitespace in {n} files": {
|
||||
"bg": " Auto-fixed trailing whitespace in {n} files",
|
||||
"de": " Auto-fixed trailing whitespace in {n} files",
|
||||
"en": " Auto-fixed trailing whitespace in {n} files",
|
||||
"pl": " Auto-fixed trailing whitespace in {n} files",
|
||||
"ru": " Auto-fixed trailing whitespace in {n} files",
|
||||
"zh": " Auto-fixed trailing whitespace in {n} files"
|
||||
},
|
||||
" Collecting code quality...": {
|
||||
"bg": " Collecting code quality...",
|
||||
"de": " Collecting code quality...",
|
||||
@@ -423,13 +431,13 @@
|
||||
"ru": " Generated: {path}",
|
||||
"zh": " Generated: {path}"
|
||||
},
|
||||
" MISSING: devx {cmd}": {
|
||||
"bg": " ЛИПСВА: devx {cmd}",
|
||||
"de": " FEHLT: devx {cmd}",
|
||||
"en": " MISSING: devx {cmd}",
|
||||
"pl": " BRAK: devx {cmd}",
|
||||
"ru": " ОТСУТСТВУЕТ: devx {cmd}",
|
||||
"zh": " 缺失: devx {cmd}"
|
||||
" MISSING: {cmd}": {
|
||||
"bg": " MISSING: {cmd}",
|
||||
"de": " MISSING: {cmd}",
|
||||
"en": " MISSING: {cmd}",
|
||||
"pl": " MISSING: {cmd}",
|
||||
"ru": " MISSING: {cmd}",
|
||||
"zh": " MISSING: {cmd}"
|
||||
},
|
||||
" MISSING: {module}": {
|
||||
"bg": " MISSING: {module}",
|
||||
@@ -447,13 +455,13 @@
|
||||
"ru": " MISSING: {script}",
|
||||
"zh": " MISSING: {script}"
|
||||
},
|
||||
" OK: devx {cmd}": {
|
||||
"bg": " ОК: devx {cmd}",
|
||||
"de": " OK: devx {cmd}",
|
||||
"en": " OK: devx {cmd}",
|
||||
"pl": " OK: devx {cmd}",
|
||||
"ru": " ОК: devx {cmd}",
|
||||
"zh": " 正常: devx {cmd}"
|
||||
" OK: {cmd}": {
|
||||
"bg": " OK: {cmd}",
|
||||
"de": " OK: {cmd}",
|
||||
"en": " OK: {cmd}",
|
||||
"pl": " OK: {cmd}",
|
||||
"ru": " OK: {cmd}",
|
||||
"zh": " OK: {cmd}"
|
||||
},
|
||||
" OK: {module}": {
|
||||
"bg": " OK: {module}",
|
||||
@@ -607,6 +615,14 @@
|
||||
"ru": " {name}: {label}={message} ({color})",
|
||||
"zh": " {name}: {label}={message} ({color})"
|
||||
},
|
||||
" {n} stale docs found (warnings only)": {
|
||||
"bg": " {n} stale docs found (warnings only)",
|
||||
"de": " {n} stale docs found (warnings only)",
|
||||
"en": " {n} stale docs found (warnings only)",
|
||||
"pl": " {n} stale docs found (warnings only)",
|
||||
"ru": " {n} stale docs found (warnings only)",
|
||||
"zh": " {n} stale docs found (warnings only)"
|
||||
},
|
||||
" {version} (created: {created})": {
|
||||
"bg": " {version} (created: {created})",
|
||||
"de": " {version} (created: {created})",
|
||||
@@ -616,17 +632,17 @@
|
||||
"zh": " {version} (created: {created})"
|
||||
},
|
||||
"--checklist-categories must list at least 8 of 13 categories. Got {count}.": {
|
||||
"en": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
||||
"bg": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
||||
"de": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
||||
"en": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
||||
"pl": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
||||
"ru": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
||||
"zh": "--checklist-categories must list at least 8 of 13 categories. Got {count}."
|
||||
},
|
||||
"--checklist-confirmed is required for APPROVE events.": {
|
||||
"en": "--checklist-confirmed is required for APPROVE events.",
|
||||
"bg": "--checklist-confirmed is required for APPROVE events.",
|
||||
"de": "--checklist-confirmed is required for APPROVE events.",
|
||||
"en": "--checklist-confirmed is required for APPROVE events.",
|
||||
"pl": "--checklist-confirmed is required for APPROVE events.",
|
||||
"ru": "--checklist-confirmed is required for APPROVE events.",
|
||||
"zh": "--checklist-confirmed is required for APPROVE events."
|
||||
@@ -664,9 +680,9 @@
|
||||
"zh": "API poll warning: {exc}"
|
||||
},
|
||||
"Added label '{label}' to PR #{pr}.": {
|
||||
"en": "Added label '{label}' to PR #{pr}.",
|
||||
"bg": "Added label '{label}' to PR #{pr}.",
|
||||
"de": "Added label '{label}' to PR #{pr}.",
|
||||
"en": "Added label '{label}' to PR #{pr}.",
|
||||
"pl": "Added label '{label}' to PR #{pr}.",
|
||||
"ru": "Added label '{label}' to PR #{pr}.",
|
||||
"zh": "Added label '{label}' to PR #{pr}."
|
||||
@@ -759,14 +775,6 @@
|
||||
"ru": "Ветка '{branch}' не содержит ID задачи.\n Ожидаемый формат: {prefix}-N-краткое-описание\n Пример: {prefix}-42-add-feature\n Исправление: переименуйте ветку или создайте задачу Vikunja:\n python -m devx.tools.create_task --title \"Заголовок задачи\"",
|
||||
"zh": "分支 '{branch}' 不包含任务 ID。\n 预期格式: {prefix}-N-简短描述\n 示例: {prefix}-42-add-feature\n 修复: 重命名分支或先创建 Vikunja 任务:\n python -m devx.tools.create_task --title \"任务标题\""
|
||||
},
|
||||
"Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.": {
|
||||
"bg": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
||||
"de": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
||||
"en": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
||||
"pl": "Gałąź jest w tyle za master. Wykonaj rebase ręcznie:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nNastępnie dodaj ponownie etykietę ready-to-merge.",
|
||||
"ru": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
||||
"zh": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label."
|
||||
},
|
||||
"Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master": {
|
||||
"bg": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
|
||||
"de": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
|
||||
@@ -808,17 +816,17 @@
|
||||
"zh": "Bumping version: {current} -> v{new_version}"
|
||||
},
|
||||
"CI checks did not complete within timeout.": {
|
||||
"en": "CI checks did not complete within timeout.",
|
||||
"bg": "CI checks did not complete within timeout.",
|
||||
"de": "CI checks did not complete within timeout.",
|
||||
"en": "CI checks did not complete within timeout.",
|
||||
"pl": "CI checks did not complete within timeout.",
|
||||
"ru": "CI checks did not complete within timeout.",
|
||||
"zh": "CI checks did not complete within timeout."
|
||||
},
|
||||
"CI checks failed.": {
|
||||
"en": "CI checks failed.",
|
||||
"bg": "CI checks failed.",
|
||||
"de": "CI checks failed.",
|
||||
"en": "CI checks failed.",
|
||||
"pl": "CI checks failed.",
|
||||
"ru": "CI checks failed.",
|
||||
"zh": "CI checks failed."
|
||||
@@ -832,9 +840,9 @@
|
||||
"zh": "CI_GITEA_TOKEN environment variable required"
|
||||
},
|
||||
"CI_GITEA_TOKEN is not set.": {
|
||||
"en": "CI_GITEA_TOKEN is not set.",
|
||||
"bg": "CI_GITEA_TOKEN is not set.",
|
||||
"de": "CI_GITEA_TOKEN is not set.",
|
||||
"en": "CI_GITEA_TOKEN is not set.",
|
||||
"pl": "CI_GITEA_TOKEN is not set.",
|
||||
"ru": "CI_GITEA_TOKEN is not set.",
|
||||
"zh": "CI_GITEA_TOKEN is not set."
|
||||
@@ -863,14 +871,78 @@
|
||||
"ru": "Checking CLI command documentation...",
|
||||
"zh": "Checking CLI command documentation..."
|
||||
},
|
||||
"Checking docs structure...": {
|
||||
"bg": "Checking docs structure...",
|
||||
"de": "Checking docs structure...",
|
||||
"en": "Checking docs structure...",
|
||||
"pl": "Checking docs structure...",
|
||||
"ru": "Checking docs structure...",
|
||||
"zh": "Checking docs structure..."
|
||||
},
|
||||
"Checking duplicate headings...": {
|
||||
"bg": "Checking duplicate headings...",
|
||||
"de": "Checking duplicate headings...",
|
||||
"en": "Checking duplicate headings...",
|
||||
"pl": "Checking duplicate headings...",
|
||||
"ru": "Checking duplicate headings...",
|
||||
"zh": "Checking duplicate headings..."
|
||||
},
|
||||
"Checking for TODO/FIXME markers...": {
|
||||
"bg": "Checking for TODO/FIXME markers...",
|
||||
"de": "Checking for TODO/FIXME markers...",
|
||||
"en": "Checking for TODO/FIXME markers...",
|
||||
"pl": "Checking for TODO/FIXME markers...",
|
||||
"ru": "Checking for TODO/FIXME markers...",
|
||||
"zh": "Checking for TODO/FIXME markers..."
|
||||
},
|
||||
"Checking for stale docs...": {
|
||||
"bg": "Checking for stale docs...",
|
||||
"de": "Checking for stale docs...",
|
||||
"en": "Checking for stale docs...",
|
||||
"pl": "Checking for stale docs...",
|
||||
"ru": "Checking for stale docs...",
|
||||
"zh": "Checking for stale docs..."
|
||||
},
|
||||
"Checking heading hierarchy...": {
|
||||
"bg": "Checking heading hierarchy...",
|
||||
"de": "Checking heading hierarchy...",
|
||||
"en": "Checking heading hierarchy...",
|
||||
"pl": "Checking heading hierarchy...",
|
||||
"ru": "Checking heading hierarchy...",
|
||||
"zh": "Checking heading hierarchy..."
|
||||
},
|
||||
"Checking internal links...": {
|
||||
"bg": "Checking internal links...",
|
||||
"de": "Checking internal links...",
|
||||
"en": "Checking internal links...",
|
||||
"pl": "Checking internal links...",
|
||||
"ru": "Checking internal links...",
|
||||
"zh": "Checking internal links..."
|
||||
},
|
||||
"Checking required files...": {
|
||||
"bg": "Checking required files...",
|
||||
"de": "Checking required files...",
|
||||
"en": "Checking required files...",
|
||||
"pl": "Checking required files...",
|
||||
"ru": "Checking required files...",
|
||||
"zh": "Checking required files..."
|
||||
},
|
||||
"Checking status for PR #{pr_number}...": {
|
||||
"en": "Checking status for PR #{pr_number}...",
|
||||
"bg": "Checking status for PR #{pr_number}...",
|
||||
"de": "Checking status for PR #{pr_number}...",
|
||||
"en": "Checking status for PR #{pr_number}...",
|
||||
"pl": "Checking status for PR #{pr_number}...",
|
||||
"ru": "Checking status for PR #{pr_number}...",
|
||||
"zh": "Checking status for PR #{pr_number}..."
|
||||
},
|
||||
"Checking trailing whitespace...": {
|
||||
"bg": "Checking trailing whitespace...",
|
||||
"de": "Checking trailing whitespace...",
|
||||
"en": "Checking trailing whitespace...",
|
||||
"pl": "Checking trailing whitespace...",
|
||||
"ru": "Checking trailing whitespace...",
|
||||
"zh": "Checking trailing whitespace..."
|
||||
},
|
||||
"Command failed ({cmd}): {stderr}": {
|
||||
"bg": "Command failed ({cmd}): {stderr}",
|
||||
"de": "Command failed ({cmd}): {stderr}",
|
||||
@@ -888,9 +960,9 @@
|
||||
"zh": "Commit message: {msg}"
|
||||
},
|
||||
"Commit: {sha}": {
|
||||
"en": "Commit: {sha}",
|
||||
"bg": "Commit: {sha}",
|
||||
"de": "Commit: {sha}",
|
||||
"en": "Commit: {sha}",
|
||||
"pl": "Commit: {sha}",
|
||||
"ru": "Commit: {sha}",
|
||||
"zh": "Commit: {sha}"
|
||||
@@ -912,9 +984,9 @@
|
||||
"zh": "配置正常: [tool.devx] 已存在, devx 版本一致。"
|
||||
},
|
||||
"Configuration validation failed.": {
|
||||
"en": "Configuration validation failed.",
|
||||
"bg": "Configuration validation failed.",
|
||||
"de": "Configuration validation failed.",
|
||||
"en": "Configuration validation failed.",
|
||||
"pl": "Configuration validation failed.",
|
||||
"ru": "Configuration validation failed.",
|
||||
"zh": "Configuration validation failed."
|
||||
@@ -952,9 +1024,9 @@
|
||||
"zh": "无法检测当前分支: {error}"
|
||||
},
|
||||
"Could not determine head SHA for PR #{pr_number}.": {
|
||||
"en": "Could not determine head SHA for PR #{pr_number}.",
|
||||
"bg": "Could not determine head SHA for PR #{pr_number}.",
|
||||
"de": "Could not determine head SHA for PR #{pr_number}.",
|
||||
"en": "Could not determine head SHA for PR #{pr_number}.",
|
||||
"pl": "Could not determine head SHA for PR #{pr_number}.",
|
||||
"ru": "Could not determine head SHA for PR #{pr_number}.",
|
||||
"zh": "Could not determine head SHA for PR #{pr_number}."
|
||||
@@ -1135,6 +1207,14 @@
|
||||
"ru": "Каждый элемент должен быть строкой или объектом с 'id', получено {type}",
|
||||
"zh": "每个元素必须是字符串或带有 'id' 的对象,得到 {type}"
|
||||
},
|
||||
"FAIL: {n} documentation issues found:": {
|
||||
"bg": "FAIL: {n} documentation issues found:",
|
||||
"de": "FAIL: {n} documentation issues found:",
|
||||
"en": "FAIL: {n} documentation issues found:",
|
||||
"pl": "FAIL: {n} documentation issues found:",
|
||||
"ru": "FAIL: {n} documentation issues found:",
|
||||
"zh": "FAIL: {n} documentation issues found:"
|
||||
},
|
||||
"FAILED: {count} undocumented dependency/ies": {
|
||||
"bg": "FAILED: {count} undocumented dependency/ies",
|
||||
"de": "FAILED: {count} undocumented dependency/ies",
|
||||
@@ -1184,9 +1264,9 @@
|
||||
"zh": "Failed to list versions for {name}: {error}"
|
||||
},
|
||||
"Fetching logs for PR #{pr_number}...": {
|
||||
"en": "Fetching logs for PR #{pr_number}...",
|
||||
"bg": "Fetching logs for PR #{pr_number}...",
|
||||
"de": "Fetching logs for PR #{pr_number}...",
|
||||
"en": "Fetching logs for PR #{pr_number}...",
|
||||
"pl": "Fetching logs for PR #{pr_number}...",
|
||||
"ru": "Fetching logs for PR #{pr_number}...",
|
||||
"zh": "Fetching logs for PR #{pr_number}..."
|
||||
@@ -1384,9 +1464,9 @@
|
||||
"zh": "Integration tests passed."
|
||||
},
|
||||
"Invalid checklist category: {cat}. Must be numbers.": {
|
||||
"en": "Invalid checklist category: {cat}. Must be numbers.",
|
||||
"bg": "Invalid checklist category: {cat}. Must be numbers.",
|
||||
"de": "Invalid checklist category: {cat}. Must be numbers.",
|
||||
"en": "Invalid checklist category: {cat}. Must be numbers.",
|
||||
"pl": "Invalid checklist category: {cat}. Must be numbers.",
|
||||
"ru": "Invalid checklist category: {cat}. Must be numbers.",
|
||||
"zh": "Invalid checklist category: {cat}. Must be numbers."
|
||||
@@ -1400,17 +1480,17 @@
|
||||
"zh": "输入必须是 JSON 数组,得到 {type}"
|
||||
},
|
||||
"Label '{label}' already on PR #{pr}.": {
|
||||
"en": "Label '{label}' already on PR #{pr}.",
|
||||
"bg": "Label '{label}' already on PR #{pr}.",
|
||||
"de": "Label '{label}' already on PR #{pr}.",
|
||||
"en": "Label '{label}' already on PR #{pr}.",
|
||||
"pl": "Label '{label}' already on PR #{pr}.",
|
||||
"ru": "Label '{label}' already on PR #{pr}.",
|
||||
"zh": "Label '{label}' already on PR #{pr}."
|
||||
},
|
||||
"Latest run: #{run_id} (status: {status})": {
|
||||
"en": "Latest run: #{run_id} (status: {status})",
|
||||
"bg": "Latest run: #{run_id} (status: {status})",
|
||||
"de": "Latest run: #{run_id} (status: {status})",
|
||||
"en": "Latest run: #{run_id} (status: {status})",
|
||||
"pl": "Latest run: #{run_id} (status: {status})",
|
||||
"ru": "Latest run: #{run_id} (status: {status})",
|
||||
"zh": "Latest run: #{run_id} (status: {status})"
|
||||
@@ -1431,6 +1511,14 @@
|
||||
"ru": "Lint passed.",
|
||||
"zh": "Lint passed."
|
||||
},
|
||||
"Linting documentation in {root}...": {
|
||||
"bg": "Linting documentation in {root}...",
|
||||
"de": "Linting documentation in {root}...",
|
||||
"en": "Linting documentation in {root}...",
|
||||
"pl": "Linting documentation in {root}...",
|
||||
"ru": "Linting documentation in {root}...",
|
||||
"zh": "Linting documentation in {root}..."
|
||||
},
|
||||
"Manifest file not found: {path}": {
|
||||
"bg": "Manifest file not found: {path}",
|
||||
"de": "Manifest file not found: {path}",
|
||||
@@ -1472,9 +1560,9 @@
|
||||
"zh": "合并失败: HTTP {status}: {message}\n请检查 PR 是否准备就绪且您具有合并权限。"
|
||||
},
|
||||
"Missing tests for changed files.": {
|
||||
"en": "Missing tests for changed files.",
|
||||
"bg": "Missing tests for changed files.",
|
||||
"de": "Missing tests for changed files.",
|
||||
"en": "Missing tests for changed files.",
|
||||
"pl": "Missing tests for changed files.",
|
||||
"ru": "Missing tests for changed files.",
|
||||
"zh": "Missing tests for changed files."
|
||||
@@ -1536,9 +1624,9 @@
|
||||
"zh": "不错!Vikunja 任务 {task_id} (ID {vikunja_id}) 已更新并标记为完成。"
|
||||
},
|
||||
"No CI checks found for commit {sha}.": {
|
||||
"en": "No CI checks found for commit {sha}.",
|
||||
"bg": "No CI checks found for commit {sha}.",
|
||||
"de": "No CI checks found for commit {sha}.",
|
||||
"en": "No CI checks found for commit {sha}.",
|
||||
"pl": "No CI checks found for commit {sha}.",
|
||||
"ru": "No CI checks found for commit {sha}.",
|
||||
"zh": "No CI checks found for commit {sha}."
|
||||
@@ -1568,33 +1656,33 @@
|
||||
"zh": "No changes between {base} and {head}."
|
||||
},
|
||||
"No failed jobs.": {
|
||||
"en": "No failed jobs.",
|
||||
"bg": "No failed jobs.",
|
||||
"de": "No failed jobs.",
|
||||
"en": "No failed jobs.",
|
||||
"pl": "No failed jobs.",
|
||||
"ru": "No failed jobs.",
|
||||
"zh": "No failed jobs."
|
||||
},
|
||||
"No job matching '{job}' found.": {
|
||||
"en": "No job matching '{job}' found.",
|
||||
"bg": "No job matching '{job}' found.",
|
||||
"de": "No job matching '{job}' found.",
|
||||
"en": "No job matching '{job}' found.",
|
||||
"pl": "No job matching '{job}' found.",
|
||||
"ru": "No job matching '{job}' found.",
|
||||
"zh": "No job matching '{job}' found."
|
||||
},
|
||||
"No jobs found for run #{run_id}.": {
|
||||
"en": "No jobs found for run #{run_id}.",
|
||||
"bg": "No jobs found for run #{run_id}.",
|
||||
"de": "No jobs found for run #{run_id}.",
|
||||
"en": "No jobs found for run #{run_id}.",
|
||||
"pl": "No jobs found for run #{run_id}.",
|
||||
"ru": "No jobs found for run #{run_id}.",
|
||||
"zh": "No jobs found for run #{run_id}."
|
||||
},
|
||||
"No open PR found for branch '{branch}'.": {
|
||||
"en": "No open PR found for branch '{branch}'.",
|
||||
"bg": "No open PR found for branch '{branch}'.",
|
||||
"de": "No open PR found for branch '{branch}'.",
|
||||
"en": "No open PR found for branch '{branch}'.",
|
||||
"pl": "No open PR found for branch '{branch}'.",
|
||||
"ru": "No open PR found for branch '{branch}'.",
|
||||
"zh": "No open PR found for branch '{branch}'."
|
||||
@@ -1664,9 +1752,9 @@
|
||||
"zh": "No versions found."
|
||||
},
|
||||
"No workflow runs found for SHA {sha}.": {
|
||||
"en": "No workflow runs found for SHA {sha}.",
|
||||
"bg": "No workflow runs found for SHA {sha}.",
|
||||
"de": "No workflow runs found for SHA {sha}.",
|
||||
"en": "No workflow runs found for SHA {sha}.",
|
||||
"pl": "No workflow runs found for SHA {sha}.",
|
||||
"ru": "No workflow runs found for SHA {sha}.",
|
||||
"zh": "No workflow runs found for SHA {sha}."
|
||||
@@ -1767,6 +1855,14 @@
|
||||
"ru": "Ой! Публикация в PyPI не удалась:\n{stderr}",
|
||||
"zh": "哎呀!PyPI 发布失败:\n{stderr}"
|
||||
},
|
||||
"PASS: All documentation checks passed!": {
|
||||
"bg": "PASS: All documentation checks passed!",
|
||||
"de": "PASS: All documentation checks passed!",
|
||||
"en": "PASS: All documentation checks passed!",
|
||||
"pl": "PASS: All documentation checks passed!",
|
||||
"ru": "PASS: All documentation checks passed!",
|
||||
"zh": "PASS: All documentation checks passed!"
|
||||
},
|
||||
"PASSED: {pair}": {
|
||||
"bg": "PASSED: {pair}",
|
||||
"de": "PASSED: {pair}",
|
||||
@@ -1848,9 +1944,9 @@
|
||||
"zh": "未设置 PYPI_TOKEN 且未配置 registry URL — 跳过 PyPI 发布。别担心,我们直接创建 Gitea release。"
|
||||
},
|
||||
"Package owner not specified. Use --owner or set [tool.devx] repo_owner.": {
|
||||
"en": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
||||
"bg": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
||||
"de": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
||||
"en": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
||||
"pl": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
||||
"ru": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
||||
"zh": "Package owner not specified. Use --owner or set [tool.devx] repo_owner."
|
||||
@@ -2072,9 +2168,9 @@
|
||||
"zh": "Repository in owner/name format"
|
||||
},
|
||||
"Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.": {
|
||||
"en": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||
"bg": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||
"de": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||
"en": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||
"pl": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||
"ru": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||
"zh": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var."
|
||||
@@ -2088,9 +2184,9 @@
|
||||
"zh": "仓库所有者未设置。使用 --owner 或 DEVX_REPO_OWNER 环境变量。"
|
||||
},
|
||||
"Review body must be at least 50 characters.": {
|
||||
"en": "Review body must be at least 50 characters.",
|
||||
"bg": "Review body must be at least 50 characters.",
|
||||
"de": "Review body must be at least 50 characters.",
|
||||
"en": "Review body must be at least 50 characters.",
|
||||
"pl": "Review body must be at least 50 characters.",
|
||||
"ru": "Review body must be at least 50 characters.",
|
||||
"zh": "Review body must be at least 50 characters."
|
||||
@@ -2296,9 +2392,9 @@
|
||||
"zh": "Tests passed."
|
||||
},
|
||||
"Timeout reached after {timeout}s.": {
|
||||
"en": "Timeout reached after {timeout}s.",
|
||||
"bg": "Timeout reached after {timeout}s.",
|
||||
"de": "Timeout reached after {timeout}s.",
|
||||
"en": "Timeout reached after {timeout}s.",
|
||||
"pl": "Timeout reached after {timeout}s.",
|
||||
"ru": "Timeout reached after {timeout}s.",
|
||||
"zh": "Timeout reached after {timeout}s."
|
||||
@@ -2423,6 +2519,22 @@
|
||||
"ru": "ВНИМАНИЕ: Файл .taskid ({file_id}) устарел и не совпадает с именем ветки ({branch_id}). Удалите .taskid из репозитория — имя ветки — единственный источник истины.",
|
||||
"zh": "警告:.taskid 文件 ({file_id}) 已弃用,与分支名称 ({branch_id}) 不一致。请从仓库中删除 .taskid — 分支名称是唯一的真实来源。"
|
||||
},
|
||||
"WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.": {
|
||||
"bg": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
|
||||
"de": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
|
||||
"en": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
|
||||
"pl": "OSTRZEŻENIE: Nie można pobrać listy stron wiki po ponownych próbach. Sama synchronizacja zakończyła się sukcesem (zaktualizowano {count} stron), ale kontrola integralności nie mogła ich zweryfikować z powodu przejściowego problemu z API.",
|
||||
"ru": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
|
||||
"zh": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue."
|
||||
},
|
||||
"WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.": {
|
||||
"bg": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
|
||||
"de": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
|
||||
"en": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
|
||||
"pl": "OSTRZEŻENIE: Nie można ponownie pobrać listy stron wiki do weryfikacji. Pomijanie weryfikacji treści z powodu przejściowego problemu z API.",
|
||||
"ru": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
|
||||
"zh": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue."
|
||||
},
|
||||
"WARNING: VIKUNJA_TOKEN not set — skipping task existence check. Set it in .env to enable full validation.": {
|
||||
"bg": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не е зададен — пропускане на проверката за съществуване на задача. Задайте го в .env за пълна валидация.",
|
||||
"de": "WARNUNG: VIKUNJA_TOKEN nicht gesetzt — Task-Existenzprüfung übersprungen. In .env setzen für volle Validierung.",
|
||||
@@ -2432,9 +2544,9 @@
|
||||
"zh": "警告: VIKUNJA_TOKEN 未设置 — 跳过任务存在性检查。在 .env 中设置以启用完整验证。"
|
||||
},
|
||||
"Waiting for CI checks to complete (timeout: {timeout}s)...": {
|
||||
"en": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||
"bg": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||
"de": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||
"en": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||
"pl": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||
"ru": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||
"zh": "Waiting for CI checks to complete (timeout: {timeout}s)..."
|
||||
@@ -2515,9 +2627,9 @@
|
||||
"bg": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||
"de": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||
"en": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||
"pl": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||
"ru": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||
"zh": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||
"pl": "Wrote tag {tag} to GITHUB_OUTPUT."
|
||||
"zh": "Wrote tag {tag} to GITHUB_OUTPUT."
|
||||
},
|
||||
"[check-dep-docs] Passed: all dependencies are documented": {
|
||||
"bg": "[check-dep-docs] Passed: all dependencies are documented",
|
||||
@@ -2782,5 +2894,181 @@
|
||||
"pl": "{separator}",
|
||||
"ru": "{separator}",
|
||||
"zh": "{separator}"
|
||||
},
|
||||
"Failed to push release commit after 3 attempts. Manual intervention required.": {
|
||||
"bg": "Failed to push release commit after 3 attempts. Manual intervention required.",
|
||||
"de": "Failed to push release commit after 3 attempts. Manual intervention required.",
|
||||
"en": "Failed to push release commit after 3 attempts. Manual intervention required.",
|
||||
"pl": "Failed to push release commit after 3 attempts. Manual intervention required.",
|
||||
"ru": "Failed to push release commit after 3 attempts. Manual intervention required.",
|
||||
"zh": "Failed to push release commit after 3 attempts. Manual intervention required."
|
||||
},
|
||||
"Push attempt {n}/3 failed: {err}": {
|
||||
"bg": "Push attempt {n}/3 failed: {err}",
|
||||
"de": "Push attempt {n}/3 failed: {err}",
|
||||
"en": "Push attempt {n}/3 failed: {err}",
|
||||
"pl": "Push attempt {n}/3 failed: {err}",
|
||||
"ru": "Push attempt {n}/3 failed: {err}",
|
||||
"zh": "Push attempt {n}/3 failed: {err}"
|
||||
},
|
||||
"Rebase attempt {n}/3 failed: {err}": {
|
||||
"bg": "Rebase attempt {n}/3 failed: {err}",
|
||||
"de": "Rebase attempt {n}/3 failed: {err}",
|
||||
"en": "Rebase attempt {n}/3 failed: {err}",
|
||||
"pl": "Rebase attempt {n}/3 failed: {err}",
|
||||
"ru": "Rebase attempt {n}/3 failed: {err}",
|
||||
"zh": "Rebase attempt {n}/3 failed: {err}"
|
||||
},
|
||||
"Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.": {
|
||||
"bg": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
||||
"de": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
||||
"en": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
||||
"pl": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
||||
"ru": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
||||
"zh": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label."
|
||||
},
|
||||
"Branch is already up-to-date with origin/master.": {
|
||||
"bg": "Branch is already up-to-date with origin/master.",
|
||||
"de": "Branch is already up-to-date with origin/master.",
|
||||
"en": "Branch is already up-to-date with origin/master.",
|
||||
"pl": "Branch is already up-to-date with origin/master.",
|
||||
"ru": "Branch is already up-to-date with origin/master.",
|
||||
"zh": "Branch is already up-to-date with origin/master."
|
||||
},
|
||||
"Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.": {
|
||||
"bg": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.",
|
||||
"de": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.",
|
||||
"en": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.",
|
||||
"pl": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.",
|
||||
"ru": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.",
|
||||
"zh": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR."
|
||||
},
|
||||
"Branch is {count} commit(s) behind master. Rebasing...": {
|
||||
"bg": "Branch is {count} commit(s) behind master. Rebasing...",
|
||||
"de": "Branch is {count} commit(s) behind master. Rebasing...",
|
||||
"en": "Branch is {count} commit(s) behind master. Rebasing...",
|
||||
"pl": "Branch is {count} commit(s) behind master. Rebasing...",
|
||||
"ru": "Branch is {count} commit(s) behind master. Rebasing...",
|
||||
"zh": "Branch is {count} commit(s) behind master. Rebasing..."
|
||||
},
|
||||
"CI_GITEA_TOKEN is not set. Add it to .env or export it.": {
|
||||
"bg": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
|
||||
"de": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
|
||||
"en": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
|
||||
"pl": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
|
||||
"ru": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
|
||||
"zh": "CI_GITEA_TOKEN is not set. Add it to .env or export it."
|
||||
},
|
||||
"Cannot rebase: not on a branch (detached HEAD).": {
|
||||
"bg": "Cannot rebase: not on a branch (detached HEAD).",
|
||||
"de": "Cannot rebase: not on a branch (detached HEAD).",
|
||||
"en": "Cannot rebase: not on a branch (detached HEAD).",
|
||||
"pl": "Cannot rebase: not on a branch (detached HEAD).",
|
||||
"ru": "Cannot rebase: not on a branch (detached HEAD).",
|
||||
"zh": "Cannot rebase: not on a branch (detached HEAD)."
|
||||
},
|
||||
"Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.": {
|
||||
"bg": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
|
||||
"de": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
|
||||
"en": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
|
||||
"pl": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
|
||||
"ru": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
|
||||
"zh": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR."
|
||||
},
|
||||
"Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.": {
|
||||
"bg": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
|
||||
"de": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
|
||||
"en": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
|
||||
"pl": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
|
||||
"ru": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
|
||||
"zh": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables."
|
||||
},
|
||||
"Fetch failed: {error}": {
|
||||
"bg": "Fetch failed: {error}",
|
||||
"de": "Fetch failed: {error}",
|
||||
"en": "Fetch failed: {error}",
|
||||
"pl": "Fetch failed: {error}",
|
||||
"ru": "Fetch failed: {error}",
|
||||
"zh": "Fetch failed: {error}"
|
||||
},
|
||||
"Fetching origin/master...": {
|
||||
"bg": "Fetching origin/master...",
|
||||
"de": "Fetching origin/master...",
|
||||
"en": "Fetching origin/master...",
|
||||
"pl": "Fetching origin/master...",
|
||||
"ru": "Fetching origin/master...",
|
||||
"zh": "Fetching origin/master..."
|
||||
},
|
||||
"Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.": {
|
||||
"bg": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
|
||||
"de": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
|
||||
"en": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
|
||||
"pl": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
|
||||
"ru": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
|
||||
"zh": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again."
|
||||
},
|
||||
"Force-pushing...": {
|
||||
"bg": "Force-pushing...",
|
||||
"de": "Force-pushing...",
|
||||
"en": "Force-pushing...",
|
||||
"pl": "Force-pushing...",
|
||||
"ru": "Force-pushing...",
|
||||
"zh": "Force-pushing..."
|
||||
},
|
||||
"Nothing to push.": {
|
||||
"bg": "Nothing to push.",
|
||||
"de": "Nothing to push.",
|
||||
"en": "Nothing to push.",
|
||||
"pl": "Nothing to push.",
|
||||
"ru": "Nothing to push.",
|
||||
"zh": "Nothing to push."
|
||||
},
|
||||
"PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.": {
|
||||
"bg": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
|
||||
"de": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
|
||||
"en": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
|
||||
"pl": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
|
||||
"ru": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
|
||||
"zh": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR."
|
||||
},
|
||||
"Pushed {branch} to origin.": {
|
||||
"bg": "Pushed {branch} to origin.",
|
||||
"de": "Pushed {branch} to origin.",
|
||||
"en": "Pushed {branch} to origin.",
|
||||
"pl": "Pushed {branch} to origin.",
|
||||
"ru": "Pushed {branch} to origin.",
|
||||
"zh": "Pushed {branch} to origin."
|
||||
},
|
||||
"Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue": {
|
||||
"bg": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
|
||||
"de": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
|
||||
"en": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
|
||||
"pl": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
|
||||
"ru": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
|
||||
"zh": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue"
|
||||
},
|
||||
"Rebase failed with HTTP {status}: {message}": {
|
||||
"bg": "Rebase failed with HTTP {status}: {message}",
|
||||
"de": "Rebase failed with HTTP {status}: {message}",
|
||||
"en": "Rebase failed with HTTP {status}: {message}",
|
||||
"pl": "Rebase failed with HTTP {status}: {message}",
|
||||
"ru": "Rebase failed with HTTP {status}: {message}",
|
||||
"zh": "Rebase failed with HTTP {status}: {message}"
|
||||
},
|
||||
"Rebase successful.": {
|
||||
"bg": "Rebase successful.",
|
||||
"de": "Rebase successful.",
|
||||
"en": "Rebase successful.",
|
||||
"pl": "Rebase successful.",
|
||||
"ru": "Rebase successful.",
|
||||
"zh": "Rebase successful."
|
||||
},
|
||||
"Rebasing PR #{pr} via Gitea API...": {
|
||||
"bg": "Rebasing PR #{pr} via Gitea API...",
|
||||
"de": "Rebasing PR #{pr} via Gitea API...",
|
||||
"en": "Rebasing PR #{pr} via Gitea API...",
|
||||
"pl": "Rebasing PR #{pr} via Gitea API...",
|
||||
"ru": "Rebasing PR #{pr} via Gitea API...",
|
||||
"zh": "Rebasing PR #{pr} via Gitea API..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,6 +233,18 @@ class TestGiteaClient:
|
||||
json={"Do": "squash", "MergeTitleField": "fix: bug"},
|
||||
)
|
||||
|
||||
def test_update_pr_branch(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response())
|
||||
|
||||
client.update_pr_branch(7, style="rebase")
|
||||
client._session.request.assert_called_once_with(
|
||||
"POST",
|
||||
"https://git.example.com/repos/owner/repo/pulls/7/update",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
params={"style": "rebase"},
|
||||
)
|
||||
|
||||
def test_get_pr_labels(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response([{"name": "ready-to-merge"}]))
|
||||
|
||||
@@ -292,14 +292,13 @@ class TestMain:
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("devx.ci.auto_merge.GiteaClient")
|
||||
def test_merge_behind_master_raises_no_rebase(
|
||||
def test_merge_behind_master_auto_rebases(
|
||||
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
|
||||
) -> None: # type: ignore[no-untyped-def]
|
||||
"""When branch is behind master, auto-merge should NOT rebase.
|
||||
"""When branch is behind master, auto-merge rebases via Gitea API.
|
||||
|
||||
Auto-rebasing creates a feedback loop: the force-push triggers a new
|
||||
pull_request synchronize event, which starts a new CI run, which runs
|
||||
auto-merge again, which rebases again, etc.
|
||||
The rebase triggers a new CI run. The next auto-merge attempt will
|
||||
find the branch up-to-date and merge successfully.
|
||||
"""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
@@ -315,12 +314,40 @@ class TestMain:
|
||||
main,
|
||||
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert result.exit_code == 0
|
||||
assert "behind master" in result.output.lower()
|
||||
assert "rebase manually" in result.output.lower()
|
||||
# Must NOT have called merge_pr twice (no retry after rebase)
|
||||
assert "auto-rebasing" in result.output.lower()
|
||||
# Should have called update_pr_branch to trigger server-side rebase
|
||||
mock_client.update_pr_branch.assert_called_once_with(7, style="rebase")
|
||||
# Must NOT have called merge_pr twice (no immediate retry)
|
||||
assert mock_client.merge_pr.call_count == 1
|
||||
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("devx.ci.auto_merge.GiteaClient")
|
||||
def test_merge_behind_master_rebase_failure_raises(
|
||||
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
|
||||
) -> None: # type: ignore[no-untyped-def]
|
||||
"""When auto-rebase fails, raise with manual rebase instructions."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_commits.return_value = [
|
||||
{"commit": {"message": "fix: resolve timeout"}},
|
||||
]
|
||||
mock_client.merge_pr.side_effect = APIError(405, "HEAD branch is behind master")
|
||||
mock_client.update_pr_branch.side_effect = APIError(409, "Conflict during rebase")
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "auto-rebase failed" in result.output.lower()
|
||||
assert "rebase manually" in result.output.lower()
|
||||
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("devx.ci.auto_merge.GiteaClient")
|
||||
@@ -386,10 +413,10 @@ class TestMain:
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("devx.ci.auto_merge.GiteaClient")
|
||||
def test_merge_behind_master_does_not_force_push(
|
||||
def test_merge_behind_master_does_not_run_git_commands(
|
||||
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
|
||||
) -> None: # type: ignore[no-untyped-def]
|
||||
"""Verify no git commands are run when branch is behind master."""
|
||||
"""When behind master, auto-merge uses API rebase — no local git commands."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
mock_client = MagicMock()
|
||||
@@ -405,8 +432,8 @@ class TestMain:
|
||||
main,
|
||||
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
# No git commands should be run (no rebase, no push)
|
||||
assert result.exit_code == 0
|
||||
# No local git commands should be run (rebase is via API)
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
|
||||
@@ -184,6 +184,14 @@ class TestMain:
|
||||
result = runner.invoke(check_translations.main, ["--translations", str(trans_file)])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_no_translations_file_skips(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When no translations file is found, should pass with skip message."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(check_translations.main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "No translations file found" in result.output
|
||||
|
||||
|
||||
class TestPrintResult:
|
||||
def test_prints_all_good(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
|
||||
@@ -87,6 +87,13 @@ class TestCiCommands:
|
||||
assert result.exit_code == 0
|
||||
mock_run.assert_called_once_with("devx.ci.doc_coverage", [])
|
||||
|
||||
@patch("devx.cli._run_module")
|
||||
def test_ci_lint_docs(self, mock_run: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["ci", "lint-docs", "--", "--root", "."])
|
||||
assert result.exit_code == 0
|
||||
mock_run.assert_called_once_with("devx.ci.lint_docs", ["--root", "."])
|
||||
|
||||
@patch("devx.cli._run_module")
|
||||
def test_ci_notify_failure(self, mock_run: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
@@ -194,6 +201,20 @@ class TestToolsCommands:
|
||||
assert result.exit_code == 0
|
||||
mock_run.assert_called_once_with("devx.tools.setup", [])
|
||||
|
||||
@patch("devx.cli._run_module")
|
||||
def test_tools_rebase(self, mock_run: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["tools", "rebase", "--", "--no-push"])
|
||||
assert result.exit_code == 0
|
||||
mock_run.assert_called_once_with("devx.tools.rebase", ["--no-push"])
|
||||
|
||||
@patch("devx.cli._run_module")
|
||||
def test_tools_pr_rebase(self, mock_run: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["tools", "pr-rebase", "--", "--pr", "42"])
|
||||
assert result.exit_code == 0
|
||||
mock_run.assert_called_once_with("devx.tools.pr_rebase", ["--pr", "42"])
|
||||
|
||||
|
||||
class TestMoleculeCommands:
|
||||
@patch("devx.cli._run_module")
|
||||
|
||||
@@ -12,10 +12,13 @@ from devx.ci.doc_coverage import (
|
||||
main,
|
||||
)
|
||||
|
||||
# Path to devx's own source directory (for testing)
|
||||
DEVX_SRC_DIR = Path(__file__).resolve().parent.parent.parent / "src" / "devx"
|
||||
|
||||
|
||||
class TestExtractCliCommands:
|
||||
def test_extracts_commands(self) -> None:
|
||||
commands = extract_cli_commands()
|
||||
commands = extract_cli_commands(DEVX_SRC_DIR)
|
||||
# devx CLI has commands under ci, tools, and molecule groups
|
||||
assert "auto-merge" in commands
|
||||
assert "release" in commands
|
||||
@@ -24,39 +27,30 @@ class TestExtractCliCommands:
|
||||
assert "install-tools" in commands
|
||||
|
||||
def test_returns_list(self) -> None:
|
||||
commands = extract_cli_commands()
|
||||
commands = extract_cli_commands(DEVX_SRC_DIR)
|
||||
assert isinstance(commands, list)
|
||||
assert len(commands) > 0
|
||||
|
||||
def test_no_cli_file(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_no_cli_file(self, tmp_path: Path) -> None:
|
||||
"""Returns empty list when CLI file doesn't exist."""
|
||||
from devx.ci import doc_coverage
|
||||
|
||||
monkeypatch.setattr(doc_coverage, "CLI_FILE", Path("/nonexistent/cli.py"))
|
||||
commands = extract_cli_commands()
|
||||
commands = extract_cli_commands(tmp_path)
|
||||
assert commands == []
|
||||
|
||||
def test_def_fallback_no_explicit_name(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_def_fallback_no_explicit_name(self, tmp_path: Path) -> None:
|
||||
"""When a command decorator has no explicit name, falls back to the def name."""
|
||||
from devx.ci import doc_coverage
|
||||
|
||||
fake_cli = tmp_path / "cli.py"
|
||||
fake_cli.write_text("@click.group()\ndef cli():\n pass\n@cli.command()\ndef my_command():\n pass\n")
|
||||
monkeypatch.setattr(doc_coverage, "CLI_FILE", fake_cli)
|
||||
commands = extract_cli_commands()
|
||||
commands = extract_cli_commands(tmp_path)
|
||||
assert "my_command" in commands
|
||||
|
||||
def test_command_decorator_no_def_fallback(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_command_decorator_no_def_fallback(self, tmp_path: Path) -> None:
|
||||
"""When a command decorator has no name and no following def, it is skipped."""
|
||||
from devx.ci import doc_coverage
|
||||
|
||||
fake_cli = tmp_path / "cli.py"
|
||||
# The last @cli.command() has no explicit name and no def statement after it
|
||||
fake_cli.write_text(
|
||||
"@click.group()\ndef cli():\n pass\n@cli.command()\ndef real_cmd():\n pass\n@cli.command()\npass\n"
|
||||
)
|
||||
monkeypatch.setattr(doc_coverage, "CLI_FILE", fake_cli)
|
||||
commands = extract_cli_commands()
|
||||
commands = extract_cli_commands(tmp_path)
|
||||
# real_cmd should be found via def fallback; the bare @cli.command() is skipped
|
||||
assert "real_cmd" in commands
|
||||
assert "pass" not in commands
|
||||
@@ -96,19 +90,29 @@ class TestMain:
|
||||
docs = tmp_path / "docs"
|
||||
(docs / "user").mkdir(parents=True)
|
||||
(docs / "tech").mkdir(parents=True)
|
||||
# Get actual commands from the CLI
|
||||
commands = extract_cli_commands()
|
||||
src = tmp_path / "src" / "devx"
|
||||
src.mkdir(parents=True)
|
||||
(src / "ci").mkdir()
|
||||
(src / "__init__.py").write_text("")
|
||||
(src / "ci" / "__init__.py").write_text("")
|
||||
# Create a fake cli.py with some commands
|
||||
(src / "cli.py").write_text(
|
||||
"@click.group()\ndef cli():\n pass\n"
|
||||
"@cli.command('release')\ndef release():\n pass\n"
|
||||
"@cli.command('setup')\ndef setup():\n pass\n"
|
||||
)
|
||||
# Create a fake module and CI script
|
||||
(src / "config.py").write_text("# config module")
|
||||
(src / "ci" / "auto_merge.py").write_text("# auto_merge script")
|
||||
# Write cli-commands.md with all commands
|
||||
cli_content = "\n".join(f"## {cmd}" for cmd in commands)
|
||||
cli_content = "## release\n\n## setup\n"
|
||||
(docs / "user" / "cli-commands.md").write_text(cli_content)
|
||||
# Write architecture.md with all modules
|
||||
from devx.ci.doc_coverage import REQUIRED_MODULES, REQUIRED_SCRIPTS
|
||||
|
||||
(docs / "tech" / "architecture.md").write_text(" ".join(REQUIRED_MODULES))
|
||||
(docs / "tech" / "architecture.md").write_text("config.py")
|
||||
# Write ci-cd-workflow.md with all scripts
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text(" ".join(REQUIRED_SCRIPTS))
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text("auto_merge.py")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs)])
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src)])
|
||||
assert result.exit_code == 0
|
||||
assert "100%" in result.output
|
||||
|
||||
@@ -117,11 +121,21 @@ class TestMain:
|
||||
docs = tmp_path / "docs"
|
||||
(docs / "user").mkdir(parents=True)
|
||||
(docs / "tech").mkdir(parents=True)
|
||||
src = tmp_path / "src" / "devx"
|
||||
src.mkdir(parents=True)
|
||||
(src / "ci").mkdir()
|
||||
(src / "__init__.py").write_text("")
|
||||
(src / "ci" / "__init__.py").write_text("")
|
||||
(src / "cli.py").write_text(
|
||||
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
|
||||
)
|
||||
(src / "config.py").write_text("# config")
|
||||
(src / "ci" / "auto_merge.py").write_text("# auto_merge")
|
||||
(docs / "user" / "cli-commands.md").write_text("No commands here.")
|
||||
(docs / "tech" / "architecture.md").write_text("No modules here.")
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text("No scripts here.")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs), "--fail-on-missing"])
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src), "--fail-on-missing"])
|
||||
assert result.exit_code == 1
|
||||
|
||||
def test_missing_docs_warn_only(self, tmp_path: Path) -> None:
|
||||
@@ -129,10 +143,55 @@ class TestMain:
|
||||
docs = tmp_path / "docs"
|
||||
(docs / "user").mkdir(parents=True)
|
||||
(docs / "tech").mkdir(parents=True)
|
||||
src = tmp_path / "src" / "devx"
|
||||
src.mkdir(parents=True)
|
||||
(src / "ci").mkdir()
|
||||
(src / "__init__.py").write_text("")
|
||||
(src / "ci" / "__init__.py").write_text("")
|
||||
(src / "cli.py").write_text(
|
||||
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
|
||||
)
|
||||
(src / "config.py").write_text("# config")
|
||||
(src / "ci" / "auto_merge.py").write_text("# auto_merge")
|
||||
(docs / "user" / "cli-commands.md").write_text("No commands here.")
|
||||
(docs / "tech" / "architecture.md").write_text("No modules here.")
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text("No scripts here.")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs)])
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src)])
|
||||
assert result.exit_code == 0
|
||||
assert "MISSING" in result.output
|
||||
|
||||
def test_auto_detect_scripts_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When src/ doesn't exist but scripts/ does, auto-detect it."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
docs = tmp_path / "docs"
|
||||
(docs / "user").mkdir(parents=True)
|
||||
(docs / "tech").mkdir(parents=True)
|
||||
scripts = tmp_path / "scripts"
|
||||
scripts.mkdir()
|
||||
(scripts / "cli.py").write_text(
|
||||
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
|
||||
)
|
||||
(scripts / "config.py").write_text("# config")
|
||||
(docs / "user" / "cli-commands.md").write_text("## release\n")
|
||||
(docs / "tech" / "architecture.md").write_text("config.py")
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text("")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs)])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_no_source_dir_falls_back_to_required(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When no source dir exists, falls back to REQUIRED_MODULES/SCRIPTS."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
docs = tmp_path / "docs"
|
||||
(docs / "user").mkdir(parents=True)
|
||||
(docs / "tech").mkdir(parents=True)
|
||||
(docs / "user" / "cli-commands.md").write_text("")
|
||||
from devx.ci.doc_coverage import REQUIRED_MODULES, REQUIRED_SCRIPTS
|
||||
|
||||
(docs / "tech" / "architecture.md").write_text(" ".join(REQUIRED_MODULES))
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text(" ".join(REQUIRED_SCRIPTS))
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs)])
|
||||
# No source dir found, so no CLI commands, but modules/scripts from REQUIRED lists
|
||||
assert result.exit_code == 0
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
"""Unit tests for devx.ci.lint_docs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.ci.lint_docs import (
|
||||
check_docs_structure,
|
||||
check_duplicate_headings,
|
||||
check_heading_hierarchy,
|
||||
check_internal_links,
|
||||
check_required_files,
|
||||
check_stale_docs,
|
||||
check_todo_fixme,
|
||||
check_trailing_whitespace,
|
||||
extract_headings,
|
||||
extract_links,
|
||||
main,
|
||||
slugify,
|
||||
strip_code_blocks,
|
||||
)
|
||||
|
||||
|
||||
class TestSlugify:
|
||||
def test_basic(self) -> None:
|
||||
assert slugify("Hello World") == "hello-world"
|
||||
|
||||
def test_special_chars(self) -> None:
|
||||
assert slugify("Hello, World!") == "hello-world"
|
||||
|
||||
def test_multiple_spaces(self) -> None:
|
||||
assert slugify("Hello World") == "hello-world"
|
||||
|
||||
def test_trailing_dash(self) -> None:
|
||||
assert slugify("Hello World -") == "hello-world--"
|
||||
|
||||
def test_empty(self) -> None:
|
||||
assert slugify("") == ""
|
||||
|
||||
|
||||
class TestExtractHeadings:
|
||||
def test_extracts_headings(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "test.md"
|
||||
f.write_text("# Title\n\n## Section\n\n### Subsection\n")
|
||||
headings = extract_headings(f)
|
||||
assert "title" in headings
|
||||
assert headings["title"] == 1
|
||||
assert "section" in headings
|
||||
assert headings["section"] == 2
|
||||
assert "subsection" in headings
|
||||
assert headings["subsection"] == 3
|
||||
|
||||
def test_no_headings(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "test.md"
|
||||
f.write_text("Just some text.\nNo headings here.\n")
|
||||
headings = extract_headings(f)
|
||||
assert headings == {}
|
||||
|
||||
def test_ignores_headings_in_code_blocks(self, tmp_path: Path) -> None:
|
||||
"""Headings inside code blocks should not be detected."""
|
||||
f = tmp_path / "test.md"
|
||||
f.write_text("# Title\n\n```bash\n# Not a heading\n## Also not\n```\n\n## Real Section\n")
|
||||
headings = extract_headings(f)
|
||||
assert "title" in headings
|
||||
assert "real-section" in headings
|
||||
assert "not-a-heading" not in headings
|
||||
assert "also-not" not in headings
|
||||
|
||||
|
||||
class TestStripCodeBlocks:
|
||||
def test_strips_fenced_blocks(self) -> None:
|
||||
content = "Before\n```bash\n# comment\n```\nAfter"
|
||||
result = strip_code_blocks(content)
|
||||
assert "# comment" not in result
|
||||
assert "Before" in result
|
||||
assert "After" in result
|
||||
|
||||
def test_strips_multiple_blocks(self) -> None:
|
||||
content = "# Title\n```python\ncode1\n```\nText\n```yaml\ncode2\n```\nEnd"
|
||||
result = strip_code_blocks(content)
|
||||
assert "code1" not in result
|
||||
assert "code2" not in result
|
||||
assert "Text" in result
|
||||
assert "End" in result
|
||||
|
||||
def test_no_code_blocks(self) -> None:
|
||||
content = "# Title\n\nSome text."
|
||||
result = strip_code_blocks(content)
|
||||
assert result == content
|
||||
|
||||
def test_preserves_line_numbers(self) -> None:
|
||||
content = "Line1\n```\nLine3\n```\nLine5"
|
||||
result = strip_code_blocks(content)
|
||||
lines = result.splitlines()
|
||||
assert len(lines) == 5
|
||||
assert lines[0] == "Line1"
|
||||
assert lines[4] == "Line5"
|
||||
|
||||
|
||||
class TestExtractLinks:
|
||||
def test_extracts_internal_links(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "test.md"
|
||||
f.write_text("[link](other.md)\n[external](https://example.com)\n[anchor](#section)\n")
|
||||
links = extract_links(f)
|
||||
# Should return internal + anchor links (not http or mailto)
|
||||
assert len(links) == 2
|
||||
assert links[0][2] == "other.md"
|
||||
assert links[1][2] == "#section"
|
||||
|
||||
def test_extracts_links_with_anchors(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "test.md"
|
||||
f.write_text("[link](other.md#section)\n")
|
||||
links = extract_links(f)
|
||||
assert len(links) == 1
|
||||
assert links[0][2] == "other.md#section"
|
||||
|
||||
def test_skips_mailto(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "test.md"
|
||||
f.write_text("[email](mailto:test@example.com)\n")
|
||||
links = extract_links(f)
|
||||
assert links == []
|
||||
|
||||
|
||||
class TestCheckRequiredFiles:
|
||||
def test_all_present(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("# README")
|
||||
(tmp_path / "AGENTS.md").write_text("# AGENTS")
|
||||
(tmp_path / "CHANGELOG.md").write_text("# CHANGELOG")
|
||||
issues = check_required_files(tmp_path)
|
||||
assert issues == []
|
||||
|
||||
def test_missing_files(self, tmp_path: Path) -> None:
|
||||
issues = check_required_files(tmp_path)
|
||||
assert len(issues) == 3
|
||||
assert any("README.md" in i for i in issues)
|
||||
assert any("AGENTS.md" in i for i in issues)
|
||||
assert any("CHANGELOG.md" in i for i in issues)
|
||||
|
||||
|
||||
class TestCheckDocsStructure:
|
||||
def test_all_present(self, tmp_path: Path) -> None:
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "index.md").write_text("# Home")
|
||||
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home"}))
|
||||
issues = check_docs_structure(tmp_path, docs)
|
||||
assert issues == []
|
||||
|
||||
def test_missing_docs_dir(self, tmp_path: Path) -> None:
|
||||
issues = check_docs_structure(tmp_path, tmp_path / "docs")
|
||||
assert len(issues) == 1
|
||||
assert "Docs directory not found" in issues[0]
|
||||
|
||||
def test_missing_index(self, tmp_path: Path) -> None:
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
issues = check_docs_structure(tmp_path, docs)
|
||||
assert any("index.md" in i for i in issues)
|
||||
|
||||
def test_invalid_mapping_json(self, tmp_path: Path) -> None:
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "index.md").write_text("# Home")
|
||||
(docs / "mapping.json").write_text("{invalid json")
|
||||
issues = check_docs_structure(tmp_path, docs)
|
||||
assert any("invalid JSON" in i for i in issues)
|
||||
|
||||
def test_empty_mapping(self, tmp_path: Path) -> None:
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "index.md").write_text("# Home")
|
||||
(docs / "mapping.json").write_text("{}")
|
||||
issues = check_docs_structure(tmp_path, docs)
|
||||
assert any("empty" in i for i in issues)
|
||||
|
||||
def test_mapping_not_object(self, tmp_path: Path) -> None:
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "index.md").write_text("# Home")
|
||||
(docs / "mapping.json").write_text("[]")
|
||||
issues = check_docs_structure(tmp_path, docs)
|
||||
assert any("JSON object" in i for i in issues)
|
||||
|
||||
|
||||
class TestCheckInternalLinks:
|
||||
def test_valid_links(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("[link](docs/guide.md)\n")
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "guide.md").write_text("# Guide\n")
|
||||
issues = check_internal_links(tmp_path, docs)
|
||||
assert issues == []
|
||||
|
||||
def test_broken_file_link(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("[link](nonexistent.md)\n")
|
||||
issues = check_internal_links(tmp_path, tmp_path / "docs")
|
||||
assert len(issues) == 1
|
||||
assert "file not found" in issues[0]
|
||||
|
||||
def test_broken_anchor(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("[link](#missing-section)\n")
|
||||
issues = check_internal_links(tmp_path, tmp_path / "docs")
|
||||
assert len(issues) == 1
|
||||
assert "broken anchor" in issues[0]
|
||||
|
||||
def test_broken_anchor_in_target(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("[link](guide.md#missing)\n")
|
||||
(tmp_path / "guide.md").write_text("# Guide\n")
|
||||
issues = check_internal_links(tmp_path, tmp_path / "docs")
|
||||
assert len(issues) == 1
|
||||
assert "broken anchor" in issues[0]
|
||||
|
||||
def test_valid_anchor_in_target(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("[link](guide.md#section)\n")
|
||||
(tmp_path / "guide.md").write_text("# Section\n")
|
||||
issues = check_internal_links(tmp_path, tmp_path / "docs")
|
||||
assert issues == []
|
||||
|
||||
def test_wiki_page_link_skipped(self, tmp_path: Path) -> None:
|
||||
"""Links matching wiki page names in mapping.json should be skipped."""
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "index.md").write_text("[Architecture](Architecture)\n")
|
||||
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home", "tech/architecture.md": "Architecture"}))
|
||||
issues = check_internal_links(tmp_path, docs)
|
||||
assert issues == []
|
||||
|
||||
def test_non_wiki_page_no_extension_skipped(self, tmp_path: Path) -> None:
|
||||
"""Links without file extension and no slash should be skipped (can't verify)."""
|
||||
(tmp_path / "README.md").write_text("[SomePage](SomePage)\n")
|
||||
issues = check_internal_links(tmp_path, tmp_path / "docs")
|
||||
assert issues == []
|
||||
|
||||
def test_broken_anchor_in_target_with_content(self, tmp_path: Path) -> None:
|
||||
"""Broken anchor in an existing target file should be flagged."""
|
||||
(tmp_path / "README.md").write_text("[link](guide.md#missing)\n")
|
||||
(tmp_path / "guide.md").write_text("# Real Title\n\nSome content here.\n")
|
||||
issues = check_internal_links(tmp_path, tmp_path / "docs")
|
||||
assert len(issues) == 1
|
||||
assert "broken anchor" in issues[0]
|
||||
|
||||
def test_valid_anchor_in_target_with_content(self, tmp_path: Path) -> None:
|
||||
"""Valid anchor in an existing target file should pass."""
|
||||
(tmp_path / "README.md").write_text("[link](guide.md#real-title)\n")
|
||||
(tmp_path / "guide.md").write_text("# Real Title\n\nSome content.\n")
|
||||
issues = check_internal_links(tmp_path, tmp_path / "docs")
|
||||
assert issues == []
|
||||
|
||||
def test_invalid_mapping_json_ignored(self, tmp_path: Path) -> None:
|
||||
"""Invalid mapping.json should not crash link checking."""
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "index.md").write_text("[link](guide.md)\n")
|
||||
(docs / "guide.md").write_text("# Guide\n")
|
||||
(docs / "mapping.json").write_text("{invalid json")
|
||||
issues = check_internal_links(tmp_path, docs)
|
||||
# Should still work — just without wiki page mappings
|
||||
assert issues == []
|
||||
|
||||
|
||||
class TestCheckHeadingHierarchy:
|
||||
def test_valid_hierarchy(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("# Title\n## Section\n### Sub\n")
|
||||
issues = check_heading_hierarchy(tmp_path)
|
||||
assert issues == []
|
||||
|
||||
def test_skipped_level(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("# Title\n### Sub\n")
|
||||
issues = check_heading_hierarchy(tmp_path)
|
||||
assert len(issues) == 1
|
||||
assert "hierarchy skip" in issues[0]
|
||||
|
||||
|
||||
class TestCheckTodoFixme:
|
||||
def test_no_todo(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("Just some text.\n")
|
||||
issues = check_todo_fixme(tmp_path)
|
||||
assert issues == []
|
||||
|
||||
def test_found_todo(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("TODO: fix this later\n")
|
||||
issues = check_todo_fixme(tmp_path)
|
||||
assert len(issues) == 1
|
||||
assert "TODO" in issues[0]
|
||||
|
||||
def test_found_fixme(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("FIXME: broken code\n")
|
||||
issues = check_todo_fixme(tmp_path)
|
||||
assert len(issues) == 1
|
||||
assert "FIXME" in issues[0]
|
||||
|
||||
def test_ignores_todo_in_rules(self, tmp_path: Path) -> None:
|
||||
"""References to 'TODO' in rules docs should not be flagged."""
|
||||
(tmp_path / "README.md").write_text("Best practices (no `print()`, no `TODO`/`FIXME`)\n")
|
||||
issues = check_todo_fixme(tmp_path)
|
||||
assert issues == []
|
||||
|
||||
def test_ignores_todo_without_colon(self, tmp_path: Path) -> None:
|
||||
"""'TODO' without a colon should not be flagged."""
|
||||
(tmp_path / "README.md").write_text("The TODO list is empty\n")
|
||||
issues = check_todo_fixme(tmp_path)
|
||||
assert issues == []
|
||||
|
||||
|
||||
class TestCheckTrailingWhitespace:
|
||||
def test_no_trailing(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("No trailing whitespace here\n")
|
||||
issues = check_trailing_whitespace(tmp_path)
|
||||
assert issues == []
|
||||
|
||||
def test_trailing_spaces(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("Trailing spaces \n")
|
||||
issues = check_trailing_whitespace(tmp_path)
|
||||
assert len(issues) == 1
|
||||
assert "trailing whitespace" in issues[0]
|
||||
|
||||
def test_trailing_tabs(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("Trailing tabs\t\n")
|
||||
issues = check_trailing_whitespace(tmp_path)
|
||||
assert len(issues) == 1
|
||||
|
||||
|
||||
class TestCheckStaleDocs:
|
||||
def test_fresh_doc(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("Fresh content\n")
|
||||
issues = check_stale_docs(tmp_path)
|
||||
assert issues == []
|
||||
|
||||
def test_stale_doc(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "README.md"
|
||||
f.write_text("Old content\n")
|
||||
# Set mtime to 200 days ago
|
||||
old_time = (datetime.now() - timedelta(days=200)).timestamp()
|
||||
import os
|
||||
|
||||
os.utime(f, (old_time, old_time))
|
||||
issues = check_stale_docs(tmp_path)
|
||||
assert len(issues) == 1
|
||||
assert "stale" in issues[0]
|
||||
|
||||
|
||||
class TestCheckDuplicateHeadings:
|
||||
def test_no_duplicates(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("# Title\n## Section\n")
|
||||
issues = check_duplicate_headings(tmp_path)
|
||||
assert issues == []
|
||||
|
||||
def test_duplicates(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("# Title\n# Title\n")
|
||||
issues = check_duplicate_headings(tmp_path)
|
||||
assert len(issues) == 1
|
||||
assert "duplicate heading" in issues[0]
|
||||
|
||||
def test_changelog_excluded(self, tmp_path: Path) -> None:
|
||||
"""CHANGELOG.md should be excluded from duplicate heading checks."""
|
||||
(tmp_path / "CHANGELOG.md").write_text("# Features\n# Features\n# Features\n")
|
||||
issues = check_duplicate_headings(tmp_path)
|
||||
assert issues == []
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_passes_clean_repo(self, tmp_path: Path) -> None:
|
||||
"""A clean repo with all files should pass."""
|
||||
(tmp_path / "README.md").write_text("# Title\n\nContent here.\n")
|
||||
(tmp_path / "AGENTS.md").write_text("# AGENTS\n\nContent here.\n")
|
||||
(tmp_path / "CHANGELOG.md").write_text("# Changelog\n\nContent here.\n")
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "index.md").write_text("# Home\n")
|
||||
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home"}))
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--root", str(tmp_path)])
|
||||
assert result.exit_code == 0
|
||||
assert "PASS" in result.output
|
||||
|
||||
def test_fails_on_missing_files(self, tmp_path: Path) -> None:
|
||||
"""Missing required files should fail."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--root", str(tmp_path)])
|
||||
assert result.exit_code == 1
|
||||
assert "FAIL" in result.output
|
||||
|
||||
def test_fix_trailing_whitespace(self, tmp_path: Path) -> None:
|
||||
"""--fix should auto-fix trailing whitespace."""
|
||||
(tmp_path / "README.md").write_text("# Title\n\nContent here. \n")
|
||||
(tmp_path / "AGENTS.md").write_text("# AGENTS\n\nContent here.\n")
|
||||
(tmp_path / "CHANGELOG.md").write_text("# Changelog\n\nContent here.\n")
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "index.md").write_text("# Home\n")
|
||||
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home"}))
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--root", str(tmp_path), "--fix"])
|
||||
assert result.exit_code == 0
|
||||
# Verify whitespace was fixed
|
||||
content = (tmp_path / "README.md").read_text()
|
||||
assert "Content here. \n" not in content
|
||||
assert "Content here.\n" in content
|
||||
|
||||
def test_no_check_links(self, tmp_path: Path) -> None:
|
||||
"""--no-check-links should skip link checking."""
|
||||
(tmp_path / "README.md").write_text("# Title\n[broken](nonexistent.md)\n")
|
||||
(tmp_path / "AGENTS.md").write_text("# AGENTS\n")
|
||||
(tmp_path / "CHANGELOG.md").write_text("# Changelog\n")
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "index.md").write_text("# Home\n")
|
||||
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home"}))
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--root", str(tmp_path), "--no-check-links"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_stale_docs_warning(self, tmp_path: Path) -> None:
|
||||
"""--check-stale should warn but not fail."""
|
||||
(tmp_path / "README.md").write_text("# Title\n")
|
||||
(tmp_path / "AGENTS.md").write_text("# AGENTS\n")
|
||||
(tmp_path / "CHANGELOG.md").write_text("# Changelog\n")
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "index.md").write_text("# Home\n")
|
||||
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home"}))
|
||||
# Make README stale
|
||||
import os
|
||||
|
||||
f = tmp_path / "README.md"
|
||||
old_time = (datetime.now() - timedelta(days=200)).timestamp()
|
||||
os.utime(f, (old_time, old_time))
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--root", str(tmp_path), "--check-stale"])
|
||||
# Stale docs are warnings, not errors
|
||||
assert result.exit_code == 0
|
||||
assert "stale" in result.output
|
||||
@@ -516,6 +516,36 @@ class TestCheckDocumentation:
|
||||
check_documentation(files, result)
|
||||
assert any("Documentation: OK" in s for s in result.summary)
|
||||
|
||||
def test_tofu_changes_without_docs_warns(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "tofu/modules/hetzner-vm/main.tf"}]
|
||||
check_documentation(files, result)
|
||||
assert any("WARNING" in s for s in result.summary)
|
||||
|
||||
def test_workflow_changes_info(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": ".gitea/workflows/ci.yml"}]
|
||||
check_documentation(files, result)
|
||||
assert any("INFO" in s for s in result.summary)
|
||||
|
||||
def test_todo_in_doc_patch_warns(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "docs/guide.md", "patch": "+TODO: fix this later\n+Some content\n"}]
|
||||
check_documentation(files, result)
|
||||
assert any("TODO" in s for s in result.summary)
|
||||
|
||||
def test_todo_in_readme_patch_warns(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "README.md", "patch": "+FIXME: broken\n"}]
|
||||
check_documentation(files, result)
|
||||
assert any("FIXME" in s for s in result.summary)
|
||||
|
||||
def test_no_todo_in_doc_patch_ok(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "docs/guide.md", "patch": "+Some content\n"}]
|
||||
check_documentation(files, result)
|
||||
assert not any("TODO" in s for s in result.summary)
|
||||
|
||||
|
||||
class TestCheckTestCoverage:
|
||||
def test_src_changes_without_tests_warns(self) -> None:
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
"""Tests for devx.tools.rebase, devx.tools.pr_rebase, and detect_pr_number."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.tools.pr_rebase import main as pr_rebase_main
|
||||
from devx.tools.rebase import main as rebase_main
|
||||
|
||||
_FULL_ENV = {
|
||||
"CI_GITEA_TOKEN": "tok",
|
||||
"DEVX_REPO_OWNER": "owner",
|
||||
"DEVX_REPO_NAME": "repo",
|
||||
}
|
||||
|
||||
|
||||
class TestRunGitHelper:
|
||||
"""Tests for the _run_git helper function."""
|
||||
|
||||
@patch("devx.tools.rebase.subprocess.run")
|
||||
def test_run_git_with_check(self, mock_run: MagicMock) -> None:
|
||||
"""_run_git passes check=True by default."""
|
||||
from devx.tools.rebase import _run_git
|
||||
|
||||
mock_run.return_value = MagicMock(stdout="ok\n", returncode=0)
|
||||
result = _run_git(["status"])
|
||||
mock_run.assert_called_once_with(
|
||||
["git", "status"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
assert result.stdout == "ok\n"
|
||||
|
||||
@patch("devx.tools.rebase.subprocess.run")
|
||||
def test_run_git_without_check(self, mock_run: MagicMock) -> None:
|
||||
"""_run_git passes check=False when specified."""
|
||||
from devx.tools.rebase import _run_git
|
||||
|
||||
mock_run.return_value = MagicMock(stdout="", stderr="err", returncode=1)
|
||||
result = _run_git(["rebase", "origin/master"], check=False)
|
||||
mock_run.assert_called_once_with(
|
||||
["git", "rebase", "origin/master"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 1
|
||||
|
||||
|
||||
class TestDetectPrNumber:
|
||||
"""Tests for the detect_pr_number helper in _shared."""
|
||||
|
||||
@patch("devx.tools._shared.subprocess.run")
|
||||
@patch.dict("os.environ", _FULL_ENV, clear=True)
|
||||
@patch("devx.api_clients.GiteaClient")
|
||||
def test_detect_pr_found(self, mock_client_cls: MagicMock, mock_run: MagicMock) -> None:
|
||||
"""detect_pr_number returns PR number when branch has an open PR."""
|
||||
from devx.tools._shared import detect_pr_number
|
||||
|
||||
mock_run.return_value = MagicMock(stdout="feature-branch\n", returncode=0)
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_prs.return_value = [
|
||||
{"number": 42, "head": {"ref": "feature-branch"}},
|
||||
{"number": 99, "head": {"ref": "other-branch"}},
|
||||
]
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
result = detect_pr_number()
|
||||
assert result == 42
|
||||
|
||||
@patch("devx.tools._shared.subprocess.run")
|
||||
@patch.dict("os.environ", _FULL_ENV, clear=True)
|
||||
@patch("devx.api_clients.GiteaClient")
|
||||
def test_detect_pr_not_found(self, mock_client_cls: MagicMock, mock_run: MagicMock) -> None:
|
||||
"""detect_pr_number returns None when no open PR matches branch."""
|
||||
from devx.tools._shared import detect_pr_number
|
||||
|
||||
mock_run.return_value = MagicMock(stdout="no-pr-branch\n", returncode=0)
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_prs.return_value = [
|
||||
{"number": 42, "head": {"ref": "other-branch"}},
|
||||
]
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
result = detect_pr_number()
|
||||
assert result is None
|
||||
|
||||
@patch("devx.tools._shared.subprocess.run")
|
||||
def test_detect_pr_detached_head(self, mock_run: MagicMock) -> None:
|
||||
"""detect_pr_number returns None on detached HEAD."""
|
||||
from devx.tools._shared import detect_pr_number
|
||||
|
||||
mock_run.return_value = MagicMock(stdout="HEAD\n", returncode=0)
|
||||
result = detect_pr_number()
|
||||
assert result is None
|
||||
|
||||
@patch("devx.tools._shared.subprocess.run")
|
||||
def test_detect_pr_git_failure(self, mock_run: MagicMock) -> None:
|
||||
"""detect_pr_number returns None when git command fails."""
|
||||
from devx.tools._shared import detect_pr_number
|
||||
|
||||
mock_run.return_value = MagicMock(stdout="", stderr="error", returncode=1)
|
||||
result = detect_pr_number()
|
||||
assert result is None
|
||||
|
||||
@patch("devx.tools._shared.subprocess.run")
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_detect_pr_no_token(self, mock_run: MagicMock) -> None:
|
||||
"""detect_pr_number returns None when CI_GITEA_TOKEN is not set."""
|
||||
from devx.tools._shared import detect_pr_number
|
||||
|
||||
mock_run.return_value = MagicMock(stdout="feature\n", returncode=0)
|
||||
result = detect_pr_number()
|
||||
assert result is None
|
||||
|
||||
@patch("devx.tools._shared.subprocess.run")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "GITHUB_REPOSITORY": "owner/repo"}, clear=True)
|
||||
@patch("devx.api_clients.GiteaClient")
|
||||
def test_detect_pr_github_repo_fallback(self, mock_client_cls: MagicMock, mock_run: MagicMock) -> None:
|
||||
"""detect_pr_number uses GITHUB_REPOSITORY as fallback for owner/repo."""
|
||||
from devx.tools._shared import detect_pr_number
|
||||
|
||||
mock_run.return_value = MagicMock(stdout="feature\n", returncode=0)
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_prs.return_value = [{"number": 7, "head": {"ref": "feature"}}]
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
result = detect_pr_number()
|
||||
assert result == 7
|
||||
|
||||
@patch("devx.tools._shared.subprocess.run")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "GITHUB_REPOSITORY": "invalid-no-slash"}, clear=True)
|
||||
def test_detect_pr_github_repo_no_slash(self, mock_run: MagicMock) -> None:
|
||||
"""GITHUB_REPOSITORY without slash is ignored, returns None."""
|
||||
from devx.tools._shared import detect_pr_number
|
||||
|
||||
mock_run.return_value = MagicMock(stdout="feature\n", returncode=0)
|
||||
result = detect_pr_number()
|
||||
assert result is None
|
||||
|
||||
@patch("devx.tools._shared.subprocess.run")
|
||||
@patch.dict("os.environ", _FULL_ENV, clear=True)
|
||||
@patch("devx.api_clients.GiteaClient")
|
||||
def test_detect_pr_api_error_returns_none(self, mock_client_cls: MagicMock, mock_run: MagicMock) -> None:
|
||||
"""detect_pr_number returns None when API call fails (best-effort)."""
|
||||
from devx.api_clients import APIError
|
||||
from devx.tools._shared import detect_pr_number
|
||||
|
||||
mock_run.return_value = MagicMock(stdout="feature\n", returncode=0)
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_prs.side_effect = APIError(401, "Unauthorized")
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
result = detect_pr_number()
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestRebaseTool:
|
||||
"""Tests for the local rebase tool (devx.tools.rebase)."""
|
||||
|
||||
@patch("devx.tools.rebase._run_git")
|
||||
def test_rebase_already_up_to_date(self, mock_run_git: MagicMock) -> None:
|
||||
"""When branch is up-to-date, no rebase or push happens."""
|
||||
mock_run_git.side_effect = [
|
||||
MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse
|
||||
MagicMock(stdout="", returncode=0), # fetch
|
||||
MagicMock(stdout="0\n", returncode=0), # rev-list --count
|
||||
]
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(rebase_main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "already up-to-date" in result.output.lower()
|
||||
|
||||
@patch("devx.tools.rebase._run_git")
|
||||
def test_rebase_behind_master_success(self, mock_run_git: MagicMock) -> None:
|
||||
"""When behind master, rebase and force-push."""
|
||||
mock_run_git.side_effect = [
|
||||
MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse
|
||||
MagicMock(stdout="", returncode=0), # fetch
|
||||
MagicMock(stdout="2\n", returncode=0), # rev-list --count (behind by 2)
|
||||
MagicMock(stdout="", stderr="", returncode=0), # rebase
|
||||
MagicMock(stdout="", stderr="", returncode=0), # push
|
||||
]
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(rebase_main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "2 commit(s) behind" in result.output
|
||||
assert "rebase successful" in result.output.lower()
|
||||
assert "pushed" in result.output.lower()
|
||||
|
||||
@patch("devx.tools.rebase._run_git")
|
||||
def test_rebase_no_push_flag(self, mock_run_git: MagicMock) -> None:
|
||||
"""With --no-push, rebase happens but no push."""
|
||||
mock_run_git.side_effect = [
|
||||
MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse
|
||||
MagicMock(stdout="", returncode=0), # fetch
|
||||
MagicMock(stdout="1\n", returncode=0), # rev-list --count
|
||||
MagicMock(stdout="", stderr="", returncode=0), # rebase
|
||||
]
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(rebase_main, ["--no-push"])
|
||||
assert result.exit_code == 0
|
||||
assert "rebase successful" in result.output.lower()
|
||||
# Only 4 git calls (no push)
|
||||
assert mock_run_git.call_count == 4
|
||||
|
||||
@patch("devx.tools.rebase._run_git")
|
||||
def test_rebase_detached_head_fails(self, mock_run_git: MagicMock) -> None:
|
||||
"""Detached HEAD should fail immediately."""
|
||||
mock_run_git.return_value = MagicMock(stdout="HEAD\n", returncode=0)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(rebase_main, [])
|
||||
assert result.exit_code != 0
|
||||
assert "detached" in result.output.lower()
|
||||
|
||||
@patch("devx.tools.rebase._run_git")
|
||||
def test_rebase_branch_detection_failure(self, mock_run_git: MagicMock) -> None:
|
||||
"""Git rev-parse failure should exit with error."""
|
||||
mock_run_git.return_value = MagicMock(stdout="", stderr="fatal: not a repo", returncode=1)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(rebase_main, [])
|
||||
assert result.exit_code != 0
|
||||
assert "could not detect" in result.output.lower()
|
||||
|
||||
@patch("devx.tools.rebase._run_git")
|
||||
def test_rebase_conflict_fails(self, mock_run_git: MagicMock) -> None:
|
||||
"""Rebase conflict should exit with error."""
|
||||
mock_run_git.side_effect = [
|
||||
MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse
|
||||
MagicMock(stdout="", returncode=0), # fetch
|
||||
MagicMock(stdout="1\n", returncode=0), # rev-list --count
|
||||
MagicMock(stdout="", stderr="CONFLICT", returncode=1), # rebase fails
|
||||
]
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(rebase_main, [])
|
||||
assert result.exit_code != 0
|
||||
assert "rebase failed" in result.output.lower()
|
||||
|
||||
@patch("devx.tools.rebase._run_git")
|
||||
def test_rebase_fetch_failure(self, mock_run_git: MagicMock) -> None:
|
||||
"""Fetch failure should exit with error."""
|
||||
mock_run_git.side_effect = [
|
||||
MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse
|
||||
MagicMock(stdout="", stderr="network error", returncode=1), # fetch fails
|
||||
]
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(rebase_main, [])
|
||||
assert result.exit_code != 0
|
||||
assert "fetch failed" in result.output.lower()
|
||||
|
||||
@patch("devx.tools.rebase._run_git")
|
||||
def test_rebase_push_failure(self, mock_run_git: MagicMock) -> None:
|
||||
"""Force-push rejection should exit with error."""
|
||||
mock_run_git.side_effect = [
|
||||
MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse
|
||||
MagicMock(stdout="", returncode=0), # fetch
|
||||
MagicMock(stdout="1\n", returncode=0), # rev-list --count
|
||||
MagicMock(stdout="", stderr="", returncode=0), # rebase
|
||||
MagicMock(stdout="", stderr="rejected", returncode=1), # push fails
|
||||
]
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(rebase_main, [])
|
||||
assert result.exit_code != 0
|
||||
assert "force-push failed" in result.output.lower()
|
||||
|
||||
|
||||
class TestPrRebaseTool:
|
||||
"""Tests for the server-side PR rebase tool (devx.tools.pr_rebase)."""
|
||||
|
||||
@patch.dict("os.environ", _FULL_ENV, clear=True)
|
||||
@patch("devx.tools.pr_rebase.GiteaClient")
|
||||
def test_pr_rebase_success(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Successful API rebase prints confirmation."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(pr_rebase_main, ["--pr", "42"])
|
||||
assert result.exit_code == 0
|
||||
assert "rebased successfully" in result.output.lower()
|
||||
mock_client.update_pr_branch.assert_called_once_with(42, style="rebase")
|
||||
|
||||
@patch.dict("os.environ", _FULL_ENV, clear=True)
|
||||
@patch("devx.tools.pr_rebase.GiteaClient")
|
||||
def test_pr_rebase_api_error(self, mock_client_cls: MagicMock) -> None:
|
||||
"""API error during rebase exits with error."""
|
||||
from devx.api_clients import APIError
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.update_pr_branch.side_effect = APIError(409, "Conflict")
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(pr_rebase_main, ["--pr", "42"])
|
||||
assert result.exit_code != 0
|
||||
assert "rebase failed" in result.output.lower()
|
||||
|
||||
@patch("devx.tools.pr_rebase.load_dotenv")
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_pr_rebase_no_token(self, _mock_load: MagicMock) -> None:
|
||||
"""Missing CI_GITEA_TOKEN should fail."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(pr_rebase_main, ["--pr", "42"])
|
||||
assert result.exit_code != 0
|
||||
assert "CI_GITEA_TOKEN" in result.output
|
||||
|
||||
@patch.dict("os.environ", _FULL_ENV, clear=True)
|
||||
@patch("devx.tools.pr_rebase.detect_pr_number", return_value=None)
|
||||
def test_pr_rebase_no_pr_detected(self, _mock_detect: MagicMock) -> None:
|
||||
"""When PR number can't be auto-detected, fail with instructions."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(pr_rebase_main, [])
|
||||
assert result.exit_code != 0
|
||||
assert "could not detect" in result.output.lower()
|
||||
|
||||
@patch("devx.tools.pr_rebase.load_dotenv")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.tools.pr_rebase.GiteaClient")
|
||||
def test_pr_rebase_no_repo_env(self, _mock_client: MagicMock, _mock_load: MagicMock) -> None:
|
||||
"""Missing repo env vars should fail."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(pr_rebase_main, ["--pr", "42"])
|
||||
assert result.exit_code != 0
|
||||
assert "DEVX_REPO_OWNER" in result.output
|
||||
|
||||
@patch("devx.tools.pr_rebase.load_dotenv")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "GITHUB_REPOSITORY": "owner/repo"}, clear=True)
|
||||
@patch("devx.tools.pr_rebase.GiteaClient")
|
||||
def test_pr_rebase_github_repo_fallback(self, mock_client_cls: MagicMock, _mock_load: MagicMock) -> None:
|
||||
"""GITHUB_REPOSITORY env var is used as fallback for owner/repo."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(pr_rebase_main, ["--pr", "42"])
|
||||
assert result.exit_code == 0
|
||||
mock_client.update_pr_branch.assert_called_once_with(42, style="rebase")
|
||||
@@ -1234,6 +1234,153 @@ class TestMain:
|
||||
assert "already existed" in result.output
|
||||
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("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||
@patch("devx.ci.release.fetch_tags")
|
||||
|
||||
@@ -21,6 +21,7 @@ from devx.ci.sync_wiki import (
|
||||
verify_wiki_integrity,
|
||||
verify_wiki_page,
|
||||
)
|
||||
from devx.exceptions import APIError
|
||||
|
||||
|
||||
class TestEncodeContent:
|
||||
@@ -97,13 +98,11 @@ class TestReadDocContent:
|
||||
|
||||
|
||||
class TestListWikiPages:
|
||||
def test_returns_empty_on_api_error(self) -> None:
|
||||
from devx.exceptions import APIError
|
||||
|
||||
def test_raises_on_api_error(self) -> None:
|
||||
client = MagicMock()
|
||||
client._request.side_effect = APIError(404, "not found")
|
||||
result = list_wiki_pages(client)
|
||||
assert result == {}
|
||||
with pytest.raises(APIError):
|
||||
list_wiki_pages(client)
|
||||
|
||||
def test_returns_page_dict(self) -> None:
|
||||
client = MagicMock()
|
||||
@@ -284,6 +283,43 @@ class TestVerifyWikiIntegrity:
|
||||
failures = verify_wiki_integrity(client, mapping, synced)
|
||||
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:
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
@@ -498,3 +534,41 @@ class TestMain:
|
||||
result = runner.invoke(main, ["--dry-run", "--strict", "--repo", "owner/repo"])
|
||||
assert result.exit_code == 0
|
||||
assert "Integrity check" not in result.output
|
||||
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.ci.sync_wiki.GiteaClient")
|
||||
def test_initial_list_api_error_treated_as_empty(self, mock_client_cls: MagicMock) -> None:
|
||||
"""When the initial page list fails, sync proceeds treating wiki as empty."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
mock_mapping.exists.return_value = True
|
||||
with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
||||
with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"):
|
||||
with patch("devx.ci.sync_wiki.list_wiki_pages", side_effect=APIError(0, "timeout")):
|
||||
with patch("devx.ci.sync_wiki.sync_page", return_value="created"):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--repo", "owner/repo"])
|
||||
assert result.exit_code == 0
|
||||
assert "Created: Home" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.ci.sync_wiki.GiteaClient")
|
||||
def test_verify_skips_when_refetch_fails(self, mock_client_cls: MagicMock) -> None:
|
||||
"""When --verify re-fetch fails after retries, verification is skipped gracefully."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
mock_mapping.exists.return_value = True
|
||||
with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
||||
with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"):
|
||||
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
|
||||
with patch("devx.ci.sync_wiki.sync_page", return_value="updated"):
|
||||
with patch(
|
||||
"devx.ci.sync_wiki._list_wiki_pages_with_retry",
|
||||
side_effect=APIError(0, "timeout"),
|
||||
):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--repo", "owner/repo", "--verify"])
|
||||
assert result.exit_code == 0
|
||||
assert "Skipping content verification" in result.output
|
||||
|
||||
Reference in New Issue
Block a user