Public Access
DEVX-63: feat: extract generic tools into devx, expand devx.mak, remove personal references
Post-merge / detect-type (push) Successful in 37s
Post-merge / validate-commit-msg (push) Successful in 42s
Post-merge / sync-wiki (push) Successful in 52s
Post-merge / vikunja (push) Successful in 49s
Post-merge / release (push) Successful in 59s
Post-merge / badges (push) Successful in 1m0s
Post-merge / configure-repo (push) Successful in 49s
Post-merge / detect-type (push) Successful in 37s
Post-merge / validate-commit-msg (push) Successful in 42s
Post-merge / sync-wiki (push) Successful in 52s
Post-merge / vikunja (push) Successful in 49s
Post-merge / release (push) Successful in 59s
Post-merge / badges (push) Successful in 1m0s
Post-merge / configure-repo (push) Successful in 49s
This commit was merged in pull request #103.
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Pre-merge validation gate for auto-merge preconditions.
|
||||
|
||||
Validates that a PR satisfies auto-merge requirements BEFORE expensive
|
||||
jobs (molecule tests, staging deploy) run. This catches issues early:
|
||||
|
||||
1. Branch name contains a task ID (e.g., ``DEVX-256-fix-foo``).
|
||||
2. PR title follows ``{PREFIX}-N: <title>`` format.
|
||||
3. PR title task ID matches the branch task ID.
|
||||
4. PR title matches the Vikunja task title (requires ``VIKUNJA_TOKEN``).
|
||||
5. Branch is not behind master (would trigger a rebase retry cycle).
|
||||
|
||||
Exit code 0 = ready for auto-merge (preconditions satisfied).
|
||||
Exit code 1 = NOT ready — fix issues before pushing.
|
||||
|
||||
Usage::
|
||||
|
||||
# CI (with VIKUNJA_TOKEN and REPO_TOKEN):
|
||||
python3 -m devx.ci.check_auto_merge_ready \\
|
||||
--branch "$HEAD_REF" \\
|
||||
--pr-title "$PR_TITLE" \\
|
||||
--repo "$REPOSITORY" \\
|
||||
--pr-number "$PR_NUMBER"
|
||||
|
||||
# Local (pre-push hook, no PR yet — validates branch + title format only):
|
||||
python3 -m devx.ci.check_auto_merge_ready --branch "$(git rev-parse --abbrev-ref HEAD)"
|
||||
|
||||
# Local (with PR number, fetches title from Gitea):
|
||||
python3 -m devx.ci.check_auto_merge_ready --branch "$(git rev-parse --abbrev-ref HEAD)" \\
|
||||
--repo owner/repo --pr-number 123
|
||||
|
||||
If ``VIKUNJA_TOKEN`` is not set, the Vikunja title match check is
|
||||
skipped (with a warning) — this allows local pre-push hooks to run
|
||||
without CI secrets. In CI, the token is always set and the check is
|
||||
mandatory.
|
||||
|
||||
If ``REPO_TOKEN`` is not set and ``--pr-number`` is not provided, only
|
||||
branch-name and PR-title-format checks run (local mode).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from devx.api_clients import GiteaClient, VikunjaClient
|
||||
from devx.ci.auto_merge import extract_task_id
|
||||
from devx.config import (
|
||||
GITEA_API_URL,
|
||||
VIKUNJA_API_URL,
|
||||
VIKUNJA_PROJECT_ID,
|
||||
)
|
||||
from devx.i18n import _
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def is_branch_behind_master(branch: str) -> bool:
|
||||
"""Check if the local branch is behind origin/master.
|
||||
|
||||
Fetches origin first (best-effort) then compares commit counts.
|
||||
Returns ``True`` if master has commits not in branch.
|
||||
"""
|
||||
try:
|
||||
subprocess.run( # nosec B603, B607
|
||||
["git", "fetch", "origin", "master", "--quiet"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
result = subprocess.run( # nosec B603, B607
|
||||
["git", "rev-list", "--count", f"origin/master..{branch}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False # Can't determine — don't block
|
||||
result = subprocess.run( # nosec B603, B607
|
||||
["git", "rev-list", "--count", f"{branch}..origin/master"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
behind = int(result.stdout.strip() or "0")
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, ValueError):
|
||||
return False # Don't block on git errors
|
||||
return behind > 0
|
||||
|
||||
|
||||
def get_pr_title_from_gitea(repo: str, pr_number: int) -> str | None:
|
||||
"""Fetch the PR title from the Gitea API.
|
||||
|
||||
Returns ``None`` if ``REPO_TOKEN`` is not set or the PR cannot be fetched.
|
||||
"""
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
if not token or "/" not in repo:
|
||||
return None
|
||||
owner, repo_name = repo.split("/", 1)
|
||||
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
|
||||
try:
|
||||
pr = client.get_pr(pr_number)
|
||||
return str(pr.get("title", ""))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_vikunja_title_optional(task_id: str) -> str | None:
|
||||
"""Fetch the Vikunja task title, returning None if token is not set.
|
||||
|
||||
Unlike :func:`devx.ci.auto_merge.get_vikunja_task_title`, this does NOT
|
||||
raise when ``VIKUNJA_TOKEN`` is missing — it returns ``None`` so the
|
||||
caller can skip the check in local mode.
|
||||
"""
|
||||
token = os.environ.get("VIKUNJA_TOKEN", "")
|
||||
if not token:
|
||||
return None
|
||||
client = VikunjaClient(VIKUNJA_API_URL, token)
|
||||
from devx.config import DEFAULT_PER_PAGE
|
||||
|
||||
page = 1
|
||||
while True:
|
||||
tasks = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE)
|
||||
if not tasks:
|
||||
break
|
||||
matches = [t for t in tasks if t.get("identifier") == task_id]
|
||||
if matches:
|
||||
return str(matches[0].get("title", ""))
|
||||
if len(tasks) < DEFAULT_PER_PAGE:
|
||||
break
|
||||
page += 1
|
||||
return None
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--branch", required=True, help=_("Branch name (e.g., DEVX-256-fix-foo)"))
|
||||
@click.option("--pr-title", default=None, help=_("PR title (auto-fetched if --pr-number given)"))
|
||||
@click.option("--repo", default=None, help=_("Repository in owner/name format"))
|
||||
@click.option("--pr-number", type=int, default=None, help=_("PR number (to fetch title from Gitea)"))
|
||||
@click.option("--skip-vikunja", is_flag=True, help=_("Skip Vikunja title match check"))
|
||||
@click.option("--skip-behind-check", is_flag=True, help=_("Skip branch-behind-master check"))
|
||||
def cli(
|
||||
branch: str,
|
||||
pr_title: str | None,
|
||||
repo: str | None,
|
||||
pr_number: int | None,
|
||||
skip_vikunja: bool,
|
||||
skip_behind_check: bool,
|
||||
) -> None:
|
||||
"""Validate auto-merge preconditions before expensive CI jobs."""
|
||||
import re
|
||||
|
||||
from devx.config import TASK_PREFIX
|
||||
|
||||
pr_title_re = re.compile(rf"^{TASK_PREFIX}-\d+:\s+.+") # noqa: PLW1503
|
||||
|
||||
errors: list[str] = []
|
||||
|
||||
# 1. Branch task ID
|
||||
task_id = extract_task_id(branch)
|
||||
if not task_id:
|
||||
errors.append(
|
||||
_(
|
||||
"No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
|
||||
branch=branch,
|
||||
prefix=TASK_PREFIX,
|
||||
),
|
||||
)
|
||||
# Can't continue — no task ID to validate against
|
||||
for e in errors:
|
||||
click.echo(f"ERROR: {e}", err=True)
|
||||
raise click.ClickException(_("Branch name must contain a task ID."))
|
||||
|
||||
click.echo(f"[pre-merge-check] Task ID: {task_id}")
|
||||
|
||||
# 2. Resolve PR title
|
||||
if pr_title is None and pr_number is not None and repo is not None:
|
||||
pr_title = get_pr_title_from_gitea(repo, pr_number)
|
||||
if pr_title:
|
||||
click.echo(f"[pre-merge-check] PR title (from Gitea): {pr_title}")
|
||||
|
||||
if pr_title is None:
|
||||
# Local mode without PR — only validate branch name
|
||||
if pr_number is not None:
|
||||
raise click.ClickException(_("Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found)."))
|
||||
click.echo("[pre-merge-check] No PR title provided — running branch-name-only check (local mode).")
|
||||
click.echo("[pre-merge-check] Branch name OK. Push to create PR, then CI will validate the title.")
|
||||
return
|
||||
|
||||
# 3. PR title format
|
||||
if not pr_title_re.match(pr_title):
|
||||
errors.append(
|
||||
_(
|
||||
"PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
|
||||
prefix=TASK_PREFIX,
|
||||
title=pr_title,
|
||||
),
|
||||
)
|
||||
|
||||
# 4. PR title task ID matches branch task ID
|
||||
if not pr_title.startswith(f"{task_id}:"):
|
||||
errors.append(
|
||||
_(
|
||||
"PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
|
||||
task_id=task_id,
|
||||
title=pr_title,
|
||||
),
|
||||
)
|
||||
|
||||
# 5. Vikunja task title match (skip if no token or --skip-vikunja)
|
||||
if not skip_vikunja:
|
||||
vikunja_title = get_vikunja_title_optional(task_id)
|
||||
if vikunja_title is None:
|
||||
token_set = bool(os.environ.get("VIKUNJA_TOKEN", ""))
|
||||
if token_set:
|
||||
errors.append(
|
||||
_(
|
||||
"Could not find Vikunja task {task_id} in project {project_id}.",
|
||||
task_id=task_id,
|
||||
project_id=VIKUNJA_PROJECT_ID,
|
||||
),
|
||||
)
|
||||
else:
|
||||
click.echo("[pre-merge-check] WARNING: VIKUNJA_TOKEN not set — skipping Vikunja title match check.")
|
||||
else:
|
||||
expected = f"{task_id}: {vikunja_title}"
|
||||
if pr_title != expected:
|
||||
errors.append(
|
||||
_(
|
||||
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
|
||||
expected=expected,
|
||||
title=pr_title,
|
||||
),
|
||||
)
|
||||
else:
|
||||
click.echo(f"[pre-merge-check] Vikunja title match OK: {expected}")
|
||||
|
||||
# 6. Branch behind master (skip if --skip-behind-check)
|
||||
if not skip_behind_check:
|
||||
if is_branch_behind_master(branch):
|
||||
errors.append(
|
||||
_("Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master")
|
||||
)
|
||||
else:
|
||||
click.echo("[pre-merge-check] Branch is up-to-date with origin/master.")
|
||||
|
||||
if errors:
|
||||
click.echo("", err=True)
|
||||
click.echo("=" * 60, err=True)
|
||||
click.echo("Pre-merge validation FAILED — fix these before pushing:", err=True)
|
||||
click.echo("=" * 60, err=True)
|
||||
for e in errors:
|
||||
click.echo(f" - {e}", err=True)
|
||||
raise click.ClickException(_("Pre-merge validation failed."))
|
||||
|
||||
click.echo("[pre-merge-check] All auto-merge preconditions satisfied.")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
+206
-7
@@ -1,8 +1,12 @@
|
||||
# devx.mak — Shared Makefile fragment for devx-integrated projects.
|
||||
#
|
||||
# This fragment provides common targets for Vikunja task management,
|
||||
# PR creation, and pushing. It is designed to be included from a
|
||||
# project's Makefile.
|
||||
# This fragment provides common targets for:
|
||||
# - Vikunja task management and PR creation
|
||||
# - Workflow validation (actionlint, act_runner)
|
||||
# - Linting (ruff, pyright, bandit, pip-audit)
|
||||
# - CI failure notification
|
||||
# - Environment setup (venv, .env, hooks)
|
||||
# - Test execution and quality checks
|
||||
#
|
||||
# Project config (task prefix, Vikunja project ID, repo owner, repo name)
|
||||
# is read from [tool.devx] in pyproject.toml by devx.config — no
|
||||
@@ -10,7 +14,7 @@
|
||||
#
|
||||
# Usage in your Makefile:
|
||||
#
|
||||
# # Set DEVX_PYTHON if you need a specific interpreter
|
||||
# # Set DEVX_PYTHON to your venv's Python
|
||||
# DEVX_PYTHON := $(BIN)/python
|
||||
#
|
||||
# # Include the devx fragment (silent if devx not installed yet)
|
||||
@@ -22,14 +26,51 @@
|
||||
# If devx is not installed, the -include silently skips and the targets
|
||||
# are simply unavailable (run 'make setup' first).
|
||||
#
|
||||
# Variables:
|
||||
# DEVX_PYTHON — Python executable (default: python3)
|
||||
# DEVX_PR_BASE — PR base branch (default: master)
|
||||
# Variables (set BEFORE including this fragment):
|
||||
# DEVX_PYTHON — Python executable (default: python3)
|
||||
# DEVX_PR_BASE — PR base branch (default: master)
|
||||
# DEVX_VENV — venv directory name (default: .venv)
|
||||
# DEVX_BIN — venv bin directory (default: $(DEVX_VENV)/bin)
|
||||
# DEVX_LINT_PATHS — paths for ruff/bandit (default: src/ tests/)
|
||||
# DEVX_TYPECHECK_PATHS — paths for pyright (default: empty — uses pyright config)
|
||||
# DEVX_COV_PKG — coverage package name (default: src/devx)
|
||||
# DEVX_TEST_PATHS — pytest paths (default: tests/)
|
||||
# DEVX_GITEA_PYPI_HOST — Gitea PyPI host (default: git.oblachno.oblachno.fyi)
|
||||
# DEVX_GITEA_PYPI_ORG — Gitea PyPI org (default: oblachno-oss)
|
||||
# DEVX_ACTIONLINT_CFG — actionlint config file (default: .gitea/actionlint.yaml)
|
||||
# DEVX_WORKFLOW_DIR — workflow directory (default: .gitea/workflows)
|
||||
|
||||
DEVX_PYTHON ?= python3
|
||||
DEVX_PR_BASE ?= master
|
||||
DEVX_VENV ?= .venv
|
||||
DEVX_BIN ?= $(DEVX_VENV)/bin
|
||||
DEVX_LINT_PATHS ?= src/ tests/
|
||||
DEVX_COV_PKG ?= src/devx
|
||||
DEVX_TEST_PATHS ?= tests/
|
||||
DEVX_GITEA_PYPI_HOST ?= git.oblachno.oblachno.fyi
|
||||
DEVX_GITEA_PYPI_ORG ?= oblachno-oss
|
||||
DEVX_ACTIONLINT_CFG ?= .gitea/actionlint.yaml
|
||||
DEVX_WORKFLOW_DIR ?= .gitea/workflows
|
||||
|
||||
# PIP_INSTALL — helper to run pip with Gitea private PyPI registry configured.
|
||||
# Usage: $(DEVX_PIP_INSTALL) install -e '.[ci,lint]'
|
||||
# GITEA_PYPI_USER can be set in .env, as an env var, or as a Make variable.
|
||||
DEVX_PIP_INSTALL := if [ -z "$$REPO_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
|
||||
REPO_TOKEN="$${REPO_TOKEN:-$$GITEA_REGISTRY_TOKEN}"; \
|
||||
_PYPI_USER="$${DEVX_GITEA_PYPI_USER:-$${GITEA_PYPI_USER}}"; \
|
||||
if [ -n "$$REPO_TOKEN" ] && [ -n "$$_PYPI_USER" ]; then export PIP_EXTRA_INDEX_URL="https://$$_PYPI_USER:$$REPO_TOKEN@$(DEVX_GITEA_PYPI_HOST)/api/packages/$(DEVX_GITEA_PYPI_ORG)/pypi/simple/"; fi; \
|
||||
$(DEVX_BIN)/pip
|
||||
|
||||
.PHONY: devx-create-task devx-create-pr devx-push devx-push-with-pr devx-check-config
|
||||
.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
|
||||
.PHONY: devx-lint-ruff devx-lint-format devx-typecheck devx-lint-bandit devx-lint-deps devx-lint
|
||||
.PHONY: devx-clean devx-pre-push
|
||||
.PHONY: devx-check-mutable-globals devx-check-dep-docs devx-check-test-coverage devx-check-docs devx-check-test-speed
|
||||
.PHONY: devx-test-unit devx-pytest-cov
|
||||
|
||||
# ── Vikunja task and PR management ────────────────────────────────────────────
|
||||
|
||||
# Create a Vikunja task (project ID read from [tool.devx] in pyproject.toml)
|
||||
devx-create-task:
|
||||
@@ -50,3 +91,161 @@ devx-check-config:
|
||||
|
||||
# Push and create PR in one step
|
||||
devx-push-with-pr: devx-push devx-create-pr
|
||||
|
||||
# ── Environment setup ─────────────────────────────────────────────────────────
|
||||
|
||||
# Configure Gitea private PyPI registry so pip can find devx and other
|
||||
# private packages. In CI, REPO_TOKEN is set as a secret. Locally, it's in .env.
|
||||
devx-configure-gitea-pypi:
|
||||
@if [ -z "$$REPO_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
|
||||
REPO_TOKEN="$${REPO_TOKEN:-$$GITEA_REGISTRY_TOKEN}"; \
|
||||
if [ -z "$$REPO_TOKEN" ]; then echo "[configure-gitea-pypi] REPO_TOKEN not set — skipping (devx must be on public PyPI)"; exit 0; fi; \
|
||||
echo "[configure-gitea-pypi] Gitea PyPI registry configured (REPO_TOKEN present)."
|
||||
|
||||
# Create .env from .env.example if it doesn't exist
|
||||
devx-env:
|
||||
@if [ ! -f .env ]; then \
|
||||
cp .env.example .env; \
|
||||
echo "Created .env from .env.example — please edit it with your credentials."; \
|
||||
fi
|
||||
|
||||
# Create Python venv with version check
|
||||
devx-venv:
|
||||
@python3 -c "import sys; v=sys.version_info; assert v >= (3, 12), f'Python 3.12+ required, found {v.major}.{v.minor}'; print(f'Python {v.major}.{v.minor}.{v.micro} OK')"
|
||||
$(DEVX_PYTHON) -m venv $(DEVX_VENV)
|
||||
$(DEVX_BIN)/pip install --upgrade pip setuptools wheel
|
||||
|
||||
# Create activate scripts for shell/fish/zsh
|
||||
devx-activate-scripts:
|
||||
@test -f activate.sh || (echo '#!/usr/bin/env bash' > activate.sh && echo 'source "$$(cd "$$(dirname "$${BASH_SOURCE[0]}")" && pwd)/.venv/bin/activate"' >> activate.sh && chmod +x activate.sh)
|
||||
@test -f activate.fish || (echo '#!/usr/bin/env fish' > activate.fish && echo 'set -l script_dir (dirname (status --current-filename))' >> activate.fish && echo 'source "$$script_dir/.venv/bin/activate.fish"' >> activate.fish && chmod +x activate.fish)
|
||||
@test -f activate.zsh || (echo '#!/usr/bin/env zsh' > activate.zsh && echo '0="$${ZERO:-$${0:#$$ZSH_ARGZERO}}"' >> activate.zsh && echo '0="$${$${(M)0:#/*}:-$$PWD/$$0}"' >> activate.zsh && echo 'source "$${0:A:h}/.venv/bin/activate"' >> activate.zsh && chmod +x activate.zsh)
|
||||
|
||||
# Set git hooks path to hooks/
|
||||
devx-install-hooks:
|
||||
@git config core.hooksPath hooks
|
||||
@chmod +x hooks/pre-commit hooks/pre-push 2>/dev/null || true
|
||||
@echo "core.hooksPath set to hooks/ — tracked hooks are now live."
|
||||
|
||||
# ── Tool installation ─────────────────────────────────────────────────────────
|
||||
|
||||
# Install CI/CD tools (actionlint, git-cliff, act_runner, tea) to ~/.local/bin
|
||||
devx-install-tools:
|
||||
@$(DEVX_PYTHON) -m devx.tools.install_tools
|
||||
|
||||
# Install checkmake (Makefile linter)
|
||||
devx-install-checkmake:
|
||||
@$(DEVX_PYTHON) -m devx.tools.install_checkmake
|
||||
|
||||
# Lint Makefiles with checkmake
|
||||
devx-checkmake:
|
||||
@CHECKMAKE_EXE="$$(command -v checkmake 2>/dev/null || echo $(HOME)/.local/bin/checkmake)"; \
|
||||
if ! command -v "$$CHECKMAKE_EXE" >/dev/null 2>&1 && ! [ -x "$$CHECKMAKE_EXE" ]; then \
|
||||
echo "[checkmake] checkmake not found. Run: make devx-install-checkmake"; exit 1; \
|
||||
fi; \
|
||||
"$$CHECKMAKE_EXE" $(CURDIR)/Makefile
|
||||
|
||||
# ── Workflow validation ───────────────────────────────────────────────────────
|
||||
|
||||
# Static lint of Gitea Actions workflow YAML files
|
||||
devx-workflow-lint:
|
||||
@command -v actionlint >/dev/null 2>&1 || { \
|
||||
echo "actionlint not found. Install: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)"; \
|
||||
exit 1; \
|
||||
}
|
||||
actionlint -config-file $(DEVX_ACTIONLINT_CFG) $(DEVX_WORKFLOW_DIR)/*.yml
|
||||
|
||||
# Dry-run all workflows (requires act_runner)
|
||||
devx-workflow-dryrun:
|
||||
@command -v act_runner >/dev/null 2>&1 || { echo "act_runner not found. Install: https://gitea.com/gitea/act_runner/releases"; exit 1; }
|
||||
@echo "Dry-running all workflows (no Docker containers started)..."
|
||||
act_runner exec --dryrun -W $(DEVX_WORKFLOW_DIR)/ 2>&1 | grep -E 'DRYRUN|ERROR|FAIL|Job'
|
||||
|
||||
# Best-effort dry-run (skips if act_runner is not installed)
|
||||
devx-workflow-dryrun-safe:
|
||||
@command -v act_runner >/dev/null 2>&1 && { echo "Dry-running workflows..."; act_runner exec --dryrun -W $(DEVX_WORKFLOW_DIR)/ 2>&1 | grep -E 'DRYRUN|ERROR|FAIL|Job'; } || echo "act_runner not found — skipping workflow dry-run (static lint still passed)"
|
||||
|
||||
# Static lint + dry-run
|
||||
devx-workflow-check: devx-workflow-lint devx-workflow-dryrun
|
||||
@echo "Workflow checks passed (static lint + dry-run)."
|
||||
|
||||
# ── CI failure notification ───────────────────────────────────────────────────
|
||||
|
||||
# Notify on CI failure — creates a Gitea issue via devx.ci.notify_failure.
|
||||
# Usage: make devx-notify-failure WORKFLOW=post-merge/release
|
||||
# Requires: REPO_TOKEN, GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_SHA
|
||||
devx-notify-failure:
|
||||
@. $(DEVX_VENV)/bin/activate 2>/dev/null || true; \
|
||||
export PATH="$(HOME)/.local/bin:$$PATH"; \
|
||||
$(DEVX_PYTHON) -m devx.tools.install_tools --tool tea 2>/dev/null || true; \
|
||||
$(DEVX_PYTHON) -m devx.ci.notify_failure --auto-login \
|
||||
--repo "$${GITHUB_REPOSITORY}" \
|
||||
--run-id "$${GITHUB_RUN_ID}" \
|
||||
--workflow "$(WORKFLOW)" \
|
||||
--commit "$${GITHUB_SHA}"
|
||||
|
||||
# ── Linting ───────────────────────────────────────────────────────────────────
|
||||
|
||||
devx-lint-ruff:
|
||||
@$(DEVX_BIN)/ruff check $(DEVX_LINT_PATHS)
|
||||
|
||||
devx-lint-format:
|
||||
@$(DEVX_BIN)/ruff format --check $(DEVX_LINT_PATHS)
|
||||
|
||||
devx-typecheck:
|
||||
@$(DEVX_BIN)/pyright
|
||||
|
||||
devx-lint-bandit:
|
||||
@$(DEVX_BIN)/bandit -r src/
|
||||
|
||||
devx-lint-deps:
|
||||
@echo "Checking dependencies for known vulnerabilities..."
|
||||
@$(DEVX_BIN)/python -m ensurepip 2>/dev/null || true
|
||||
@PIPAPI_PYTHON_LOCATION=$$(pwd)/$(DEVX_VENV)/bin/python \
|
||||
$(DEVX_BIN)/pip-audit --desc --skip-editable 2>&1 || true
|
||||
|
||||
devx-lint: devx-lint-ruff devx-lint-format devx-typecheck devx-lint-bandit
|
||||
@echo "[devx-lint] Linting checks passed."
|
||||
|
||||
# ── Testing ───────────────────────────────────────────────────────────────────
|
||||
|
||||
devx-test-unit:
|
||||
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -v --no-cov
|
||||
|
||||
devx-pytest-cov:
|
||||
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -v --cov=$(DEVX_COV_PKG) --cov-report=term-missing --cov-fail-under=100
|
||||
|
||||
# ── Quality checks ────────────────────────────────────────────────────────────
|
||||
|
||||
# Scan for module-level mutable globals that cause test isolation bugs
|
||||
devx-check-mutable-globals:
|
||||
@$(DEVX_PYTHON) -m devx.tools.check_mutable_globals
|
||||
|
||||
# Validate that every dependency in pyproject.toml has a documented purpose
|
||||
devx-check-dep-docs:
|
||||
@$(DEVX_PYTHON) -m devx.tools.check_pyproject_deps
|
||||
|
||||
# Check that changed files have corresponding tests
|
||||
devx-check-test-coverage:
|
||||
@$(DEVX_PYTHON) -m devx.tools.check_test_coverage
|
||||
|
||||
# Validate agent and user docs for stale file references
|
||||
devx-check-docs:
|
||||
@$(DEVX_PYTHON) -m devx.tools.check_agent_docs
|
||||
|
||||
# Verify test suite timing
|
||||
devx-check-test-speed:
|
||||
@$(DEVX_PYTHON) -m devx.tools.check_test_speed
|
||||
|
||||
# ── Pre-push validation ───────────────────────────────────────────────────────
|
||||
|
||||
# Run lint + tests before push (projects can override with project-specific targets)
|
||||
devx-pre-push: devx-lint devx-pytest-cov
|
||||
@echo "[devx-pre-push] All checks passed. Proceeding with push."
|
||||
|
||||
# ── Cleanup ───────────────────────────────────────────────────────────────────
|
||||
|
||||
devx-clean:
|
||||
@find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
@find . -type f -name "*.pyc" -delete 2>/dev/null || true
|
||||
@rm -rf .coverage htmlcov/ dist/ build/ *.egg-info/ .molecule/ 2>/dev/null || true
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate agent documentation and user docs for stale file references.
|
||||
|
||||
Scans documentation files (``.devin/``, ``docs/``, ``README.md``) for:
|
||||
- References to files that no longer exist
|
||||
- References to deleted files (configurable blocklist)
|
||||
- References to deprecated patterns (configurable regex patterns)
|
||||
|
||||
Configuration (``[tool.devx.check_agent_docs]`` in pyproject.toml):
|
||||
|
||||
``scan_dirs`` — directories to scan for docs (default: ``[".devin", "docs"]``)
|
||||
``scan_files`` — specific files to scan (default: ``["README.md", "README.rst"]``)
|
||||
``scan_extensions`` — file extensions to scan (default: ``[".md", ".yml", ".yaml"]``)
|
||||
``excluded_paths`` — paths to exclude from scanning (default: ``["docs/retrospectives"]``)
|
||||
``deleted_files`` — list of file paths that should never be referenced
|
||||
``deprecated_patterns`` — list of regex patterns for deprecated references
|
||||
``legitimate_indicators`` — substrings that indicate a legitimate reference to a deprecated pattern
|
||||
``repo_path_prefixes`` — path prefixes that indicate a repo-relative reference
|
||||
(default: ``["ansible/", "scripts/", "tofu/", ".devin/", "src/"]``)
|
||||
``min_path_ref_length`` — minimum length for a path reference to be checked (default: 5)
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.tools.check_agent_docs
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from devx.config import _load_pyproject_devx
|
||||
from devx.i18n import _
|
||||
|
||||
MIN_PATH_REF_LENGTH_DEFAULT = 5
|
||||
|
||||
# Pattern that matches file path references in markdown or code
|
||||
FILE_REF_RE = re.compile(
|
||||
r"(?:`|\")?"
|
||||
r"([\w\-./]+(?:\.[a-zA-Z0-9]+))"
|
||||
r"(?:`|\))?"
|
||||
)
|
||||
|
||||
DEFAULT_SCAN_DIRS = [".devin", "docs"]
|
||||
DEFAULT_SCAN_FILES = ["README.md", "README.rst"]
|
||||
DEFAULT_SCAN_EXTENSIONS = [".md", ".yml", ".yaml"]
|
||||
DEFAULT_EXCLUDED_PATHS = ["docs/retrospectives"]
|
||||
DEFAULT_REPO_PATH_PREFIXES = ["ansible/", "scripts/", "tofu/", ".devin/", "src/"]
|
||||
|
||||
|
||||
def _load_config() -> dict[str, object]:
|
||||
"""Load check_agent_docs configuration from pyproject.toml."""
|
||||
devx_cfg = _load_pyproject_devx()
|
||||
cfg_raw = devx_cfg.get("check_agent_docs", {})
|
||||
if not isinstance(cfg_raw, dict):
|
||||
return {}
|
||||
return cfg_raw # type: ignore[return-value]
|
||||
|
||||
|
||||
def _should_skip(path: Path, excluded_paths: list[str], repo_root: Path) -> bool:
|
||||
"""Check if a path should be excluded from scanning."""
|
||||
try:
|
||||
rel = str(path.relative_to(repo_root))
|
||||
except ValueError:
|
||||
return False
|
||||
return any(excluded in rel for excluded in excluded_paths)
|
||||
|
||||
|
||||
def _is_legitimate_ref(line: str, legitimate_indicators: list[str]) -> bool:
|
||||
"""Check if a line contains a legitimate reference to a deprecated pattern."""
|
||||
line_lower = line.lower()
|
||||
return any(legit.lower() in line_lower for legit in legitimate_indicators)
|
||||
|
||||
|
||||
def _collect_doc_files(
|
||||
repo_root: Path,
|
||||
scan_dirs: list[str],
|
||||
scan_files: list[str],
|
||||
scan_extensions: list[str],
|
||||
excluded_paths: list[str],
|
||||
) -> list[Path]:
|
||||
"""Collect all documentation files to scan."""
|
||||
files: list[Path] = []
|
||||
|
||||
for scan_dir_name in scan_dirs:
|
||||
scan_dir = repo_root / scan_dir_name
|
||||
if not scan_dir.exists():
|
||||
continue
|
||||
for ext in scan_extensions:
|
||||
for path in scan_dir.glob(f"**/*{ext}"):
|
||||
if not _should_skip(path, excluded_paths, repo_root):
|
||||
files.append(path)
|
||||
|
||||
for readme_name in scan_files:
|
||||
path = repo_root / readme_name
|
||||
if path.exists() and not _should_skip(path, excluded_paths, repo_root):
|
||||
files.append(path)
|
||||
|
||||
# Deduplicate while preserving order
|
||||
seen: set[Path] = set()
|
||||
unique: list[Path] = []
|
||||
for f in files:
|
||||
if f not in seen:
|
||||
seen.add(f)
|
||||
unique.append(f)
|
||||
return unique
|
||||
|
||||
|
||||
def _check_file(
|
||||
path: Path,
|
||||
repo_root: Path,
|
||||
deleted_files: set[str],
|
||||
deprecated_patterns: list[re.Pattern[str]],
|
||||
legitimate_indicators: list[str],
|
||||
repo_path_prefixes: list[str],
|
||||
min_path_ref_length: int,
|
||||
) -> list[str]:
|
||||
"""Check a single file for stale references."""
|
||||
issues: list[str] = []
|
||||
rel_path = path.relative_to(repo_root)
|
||||
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return issues
|
||||
|
||||
for lineno, line in enumerate(content.splitlines(), start=1):
|
||||
# Check for deleted file references
|
||||
for deleted in deleted_files:
|
||||
if deleted in line:
|
||||
issues.append(f"{rel_path}:{lineno}: references deleted file '{deleted}'")
|
||||
|
||||
# Check for deprecated pattern references
|
||||
for pattern in deprecated_patterns:
|
||||
if pattern.search(line) and not _is_legitimate_ref(line, legitimate_indicators):
|
||||
issues.append(f"{rel_path}:{lineno}: matches deprecated pattern '{pattern.pattern}'")
|
||||
|
||||
# Check for references to files that don't exist
|
||||
for match in FILE_REF_RE.finditer(line):
|
||||
ref = match.group(1)
|
||||
# Skip URLs, bare words, and short strings
|
||||
if "/" not in ref or len(ref) < min_path_ref_length:
|
||||
continue
|
||||
# Only check references that look like repo paths
|
||||
if not any(ref.startswith(prefix) for prefix in repo_path_prefixes):
|
||||
continue
|
||||
candidate = repo_root / ref
|
||||
if not candidate.exists():
|
||||
issues.append(f"{rel_path}:{lineno}: references non-existent file '{ref}'")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
@click.command()
|
||||
def cli() -> None:
|
||||
"""Validate agent documentation and user docs for stale file references."""
|
||||
repo_root = Path.cwd()
|
||||
cfg = _load_config()
|
||||
|
||||
scan_dirs_raw = cfg.get("scan_dirs")
|
||||
scan_dirs: list[str] = [str(d) for d in scan_dirs_raw] if isinstance(scan_dirs_raw, list) else DEFAULT_SCAN_DIRS
|
||||
scan_files_raw = cfg.get("scan_files")
|
||||
scan_files: list[str] = [str(d) for d in scan_files_raw] if isinstance(scan_files_raw, list) else DEFAULT_SCAN_FILES
|
||||
scan_ext_raw = cfg.get("scan_extensions")
|
||||
scan_extensions: list[str] = (
|
||||
[str(d) for d in scan_ext_raw] if isinstance(scan_ext_raw, list) else DEFAULT_SCAN_EXTENSIONS
|
||||
)
|
||||
excluded_raw = cfg.get("excluded_paths")
|
||||
excluded_paths: list[str] = (
|
||||
[str(d) for d in excluded_raw] if isinstance(excluded_raw, list) else DEFAULT_EXCLUDED_PATHS
|
||||
)
|
||||
prefixes_raw = cfg.get("repo_path_prefixes")
|
||||
repo_path_prefixes: list[str] = (
|
||||
[str(d) for d in prefixes_raw] if isinstance(prefixes_raw, list) else DEFAULT_REPO_PATH_PREFIXES
|
||||
)
|
||||
min_len_raw = cfg.get("min_path_ref_length")
|
||||
min_path_ref_length: int = int(min_len_raw) if isinstance(min_len_raw, int) else MIN_PATH_REF_LENGTH_DEFAULT
|
||||
|
||||
deleted_files: set[str] = set()
|
||||
deleted_raw = cfg.get("deleted_files", [])
|
||||
if isinstance(deleted_raw, list):
|
||||
deleted_files = {str(d) for d in deleted_raw}
|
||||
|
||||
deprecated_patterns: list[re.Pattern[str]] = []
|
||||
deprecated_raw = cfg.get("deprecated_patterns", [])
|
||||
if isinstance(deprecated_raw, list):
|
||||
for pattern_str in deprecated_raw:
|
||||
if isinstance(pattern_str, str):
|
||||
with contextlib.suppress(re.error):
|
||||
deprecated_patterns.append(re.compile(pattern_str))
|
||||
|
||||
legitimate_indicators: list[str] = []
|
||||
legit_raw = cfg.get("legitimate_indicators", [])
|
||||
if isinstance(legit_raw, list):
|
||||
legitimate_indicators = [str(s) for s in legit_raw]
|
||||
|
||||
files = _collect_doc_files(repo_root, scan_dirs, scan_files, scan_extensions, excluded_paths)
|
||||
all_issues: list[str] = []
|
||||
|
||||
for path in sorted(files):
|
||||
issues = _check_file(
|
||||
path,
|
||||
repo_root,
|
||||
deleted_files,
|
||||
deprecated_patterns,
|
||||
legitimate_indicators,
|
||||
repo_path_prefixes,
|
||||
min_path_ref_length,
|
||||
)
|
||||
all_issues.extend(issues)
|
||||
|
||||
if all_issues:
|
||||
click.echo(f"[check_agent_docs] Found {len(all_issues)} issue(s):\n", err=True)
|
||||
for issue in all_issues:
|
||||
click.echo(issue, err=True)
|
||||
click.echo(
|
||||
f"\n[check_agent_docs] FAILED: {len(all_issues)} stale reference(s)",
|
||||
err=True,
|
||||
)
|
||||
raise click.ClickException(_("Found {count} stale documentation reference(s)", count=len(all_issues)))
|
||||
|
||||
click.echo(_("[check_agent_docs] Passed: scanned {count} file(s), no stale references", count=len(files)))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Detect module-level mutable globals that may cause test isolation bugs.
|
||||
|
||||
Scans Python files for patterns like::
|
||||
|
||||
_SEEN: set[Path] = set()
|
||||
_CACHE: dict[Path, Any] = {}
|
||||
PATHS: list[Path] = []
|
||||
|
||||
These are hazardous because one test mutates the container and the next
|
||||
sees stale state. The script reports the file/line and suggests a factory
|
||||
function or fixture replacement.
|
||||
|
||||
Configuration (``[tool.devx.check_mutable_globals]`` in pyproject.toml):
|
||||
|
||||
``scan_dirs`` — list of directories to scan (default: ``["scripts", "tests"]``)
|
||||
``skip_dirs`` — directory names to skip (default: ``__pycache__``, ``.pytest_cache``, ``venv``, ``.venv``)
|
||||
``known_safe`` — list of ``"path:line:var_name"`` entries to ignore
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.tools.check_mutable_globals
|
||||
python3 -m devx.tools.check_mutable_globals --scan-dir src --scan-dir tests
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import contextlib
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from devx.config import _load_pyproject_devx
|
||||
from devx.i18n import _
|
||||
|
||||
MUTABLE_TYPES = {"set", "dict", "list"}
|
||||
PATH_HINTS = ("path", "paths", "seen", "cache", "memo", "registry")
|
||||
DEFAULT_SCAN_DIRS = ["scripts", "tests"]
|
||||
DEFAULT_SKIP_DIRS = {"__pycache__", ".pytest_cache", "venv", ".venv"}
|
||||
|
||||
|
||||
def _load_config() -> tuple[list[str], set[str], set[tuple[str, int, str]]]:
|
||||
"""Load configuration from pyproject.toml [tool.devx.check_mutable_globals]."""
|
||||
devx_cfg = _load_pyproject_devx()
|
||||
cfg_raw = devx_cfg.get("check_mutable_globals", {})
|
||||
if not isinstance(cfg_raw, dict):
|
||||
return DEFAULT_SCAN_DIRS, DEFAULT_SKIP_DIRS, set()
|
||||
cfg: dict[str, object] = cfg_raw # type: ignore[assignment]
|
||||
|
||||
scan_dirs_raw = cfg.get("scan_dirs", DEFAULT_SCAN_DIRS)
|
||||
scan_dirs: list[str] = [str(d) for d in scan_dirs_raw] if isinstance(scan_dirs_raw, list) else DEFAULT_SCAN_DIRS
|
||||
|
||||
skip_dirs_raw = cfg.get("skip_dirs", list(DEFAULT_SKIP_DIRS))
|
||||
skip_dirs: set[str] = {str(d) for d in skip_dirs_raw} if isinstance(skip_dirs_raw, list) else DEFAULT_SKIP_DIRS
|
||||
|
||||
known_safe_raw = cfg.get("known_safe", [])
|
||||
known_safe: set[tuple[str, int, str]] = set()
|
||||
if isinstance(known_safe_raw, list):
|
||||
for entry in known_safe_raw:
|
||||
if isinstance(entry, str) and entry.count(":") >= 2:
|
||||
parts = entry.rsplit(":", 2)
|
||||
with contextlib.suppress(ValueError):
|
||||
known_safe.add((parts[0], int(parts[1]), parts[2]))
|
||||
|
||||
return scan_dirs, skip_dirs, known_safe
|
||||
|
||||
|
||||
def _should_skip(path: Path, skip_dirs: set[str]) -> bool:
|
||||
return any(part in skip_dirs for part in path.parts)
|
||||
|
||||
|
||||
def find_mutable_globals(
|
||||
file_path: Path,
|
||||
repo_root: Path,
|
||||
known_safe: set[tuple[str, int, str]],
|
||||
) -> list[str]:
|
||||
"""Return a list of issue strings for mutable globals in *file_path*."""
|
||||
issues: list[str] = []
|
||||
try:
|
||||
source = file_path.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
except SyntaxError:
|
||||
return issues
|
||||
|
||||
for node in ast.iter_child_nodes(tree):
|
||||
if not isinstance(node, ast.AnnAssign | ast.Assign):
|
||||
continue
|
||||
|
||||
names: list[str] = []
|
||||
if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
||||
names.append(node.target.id)
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name):
|
||||
names.append(target.id)
|
||||
|
||||
for name in names:
|
||||
name_lower = name.lower()
|
||||
value = node.value
|
||||
if value is None:
|
||||
continue
|
||||
|
||||
is_mutable_literal = False
|
||||
if isinstance(value, ast.Call):
|
||||
if isinstance(value.func, ast.Name):
|
||||
if value.func.id in MUTABLE_TYPES:
|
||||
is_mutable_literal = True
|
||||
elif isinstance(value.func, ast.Attribute):
|
||||
# e.g. collections.defaultdict
|
||||
pass
|
||||
elif isinstance(value, (ast.Dict, ast.List, ast.Set)):
|
||||
is_mutable_literal = True
|
||||
|
||||
if not is_mutable_literal:
|
||||
continue
|
||||
|
||||
# Check if the name or type hint suggests Path usage
|
||||
has_path_hint = any(hint in name_lower for hint in PATH_HINTS)
|
||||
has_path_type = False
|
||||
if isinstance(node, ast.AnnAssign) and node.annotation:
|
||||
ann = ast.unparse(node.annotation)
|
||||
has_path_type = "Path" in ann
|
||||
|
||||
if has_path_hint or has_path_type:
|
||||
rel = str(file_path.relative_to(repo_root))
|
||||
if (rel, node.lineno, name) in known_safe:
|
||||
continue
|
||||
value_str = ast.unparse(value) if value is not None else "..."
|
||||
issues.append(
|
||||
f"{rel}:{node.lineno}: mutable global {name!r} "
|
||||
f"({value_str}) — use a factory function or pytest fixture"
|
||||
)
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--scan-dir",
|
||||
multiple=True,
|
||||
help=_("Additional directory to scan (default: scripts, tests). Can be repeated."),
|
||||
)
|
||||
def cli(scan_dir: tuple[str, ...]) -> None:
|
||||
"""Scan for module-level mutable globals that cause test isolation bugs."""
|
||||
repo_root = Path.cwd()
|
||||
config_scan_dirs, skip_dirs, known_safe = _load_config()
|
||||
|
||||
# CLI --scan-dir overrides config if provided
|
||||
scan_dirs = list(scan_dir) if scan_dir else config_scan_dirs
|
||||
|
||||
all_issues: list[str] = []
|
||||
|
||||
for scan_dir_name in scan_dirs:
|
||||
scan_path = repo_root / scan_dir_name
|
||||
if not scan_path.exists():
|
||||
continue
|
||||
for py_file in scan_path.rglob("*.py"):
|
||||
if _should_skip(py_file, skip_dirs):
|
||||
continue
|
||||
all_issues.extend(find_mutable_globals(py_file, repo_root, known_safe))
|
||||
|
||||
if all_issues:
|
||||
click.echo(f"[check-mutable-globals] FAILED: {len(all_issues)} issue(s)", err=True)
|
||||
for issue in all_issues:
|
||||
click.echo(f" {issue}", err=True)
|
||||
raise click.ClickException(
|
||||
_("Found {count} mutable global(s) — use factory functions or pytest fixtures.", count=len(all_issues))
|
||||
)
|
||||
|
||||
click.echo(_("[check-mutable-globals] Passed: no mutable path globals found"))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate that every dependency in pyproject.toml has a documented purpose.
|
||||
|
||||
This script does NOT resolve versions or query PyPI. It only ensures that
|
||||
every dependency listed in ``[project.dependencies]`` or
|
||||
``[project.optional-dependencies]`` has a corresponding comment nearby
|
||||
explaining why it is needed.
|
||||
|
||||
Failure means a dependency lacks documentation.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.tools.check_pyproject_deps
|
||||
python3 -m devx.tools.check_pyproject_deps --file path/to/pyproject.toml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from devx.i18n import _
|
||||
|
||||
|
||||
def check_deps(pyproject_path: Path) -> list[str]:
|
||||
"""Return a list of issue strings for undocumented dependencies.
|
||||
|
||||
An empty list means all dependencies are documented.
|
||||
"""
|
||||
if not pyproject_path.exists():
|
||||
return [str(pyproject_path) + ": file not found"]
|
||||
|
||||
content = pyproject_path.read_text(encoding="utf-8")
|
||||
lines = content.splitlines()
|
||||
|
||||
issues: list[str] = []
|
||||
in_deps_section = False
|
||||
prev_was_comment = False
|
||||
|
||||
for i, raw_line in enumerate(lines, start=1):
|
||||
stripped = raw_line.strip()
|
||||
|
||||
# Detect section headers
|
||||
if stripped in ("[project.dependencies]", "[project.optional-dependencies]"):
|
||||
in_deps_section = True
|
||||
continue
|
||||
if stripped.startswith("[") and in_deps_section:
|
||||
in_deps_section = False
|
||||
continue
|
||||
|
||||
if not in_deps_section:
|
||||
continue
|
||||
|
||||
if stripped == "":
|
||||
continue
|
||||
|
||||
# We're inside a dependency list
|
||||
if stripped.startswith("#"):
|
||||
prev_was_comment = True
|
||||
continue
|
||||
|
||||
if stripped.startswith("-") or stripped.startswith('"'):
|
||||
if not prev_was_comment:
|
||||
issues.append(f"{pyproject_path.name}:{i}: dependency lacks description comment: {stripped}")
|
||||
prev_was_comment = False
|
||||
else:
|
||||
prev_was_comment = False
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--file",
|
||||
"pyproject_file",
|
||||
type=click.Path(path_type=Path),
|
||||
default=Path("pyproject.toml"),
|
||||
help=_("Path to pyproject.toml (default: pyproject.toml in CWD)."),
|
||||
)
|
||||
def cli(pyproject_file: Path) -> None:
|
||||
"""Validate that every dependency in pyproject.toml has a documented purpose."""
|
||||
issues = check_deps(pyproject_file)
|
||||
|
||||
if issues:
|
||||
click.echo(
|
||||
_("FAILED: {count} undocumented dependency/ies", count=len(issues)),
|
||||
err=True,
|
||||
)
|
||||
for issue in issues:
|
||||
click.echo(f" {issue}", err=True)
|
||||
raise click.ClickException(_("Dependencies must have documentation comments."))
|
||||
|
||||
click.echo(_("[check-dep-docs] Passed: all dependencies are documented"))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
@@ -0,0 +1,251 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Pre-commit / CI check: ensure every changed or new file has corresponding tests.
|
||||
|
||||
Configuration (``[tool.devx.check_test_coverage]`` in pyproject.toml):
|
||||
|
||||
``rules`` — list of mapping rules, each with:
|
||||
|
||||
``source_pattern`` — glob pattern for source files (e.g. ``"scripts/*.py"``)
|
||||
``test_paths`` — list of test path templates (e.g. ``["scripts/tests/test_{name}", "tests/unit/test_{name}"]``)
|
||||
``description`` — human-readable description for error messages
|
||||
|
||||
``skip_patterns`` — list of file patterns to skip (e.g. ``["__init__.py", "config.py"]``)
|
||||
``test_file_indicators`` — substrings that identify a file as a test (default: ``["tests/", "/test_", "_test.py"]``)
|
||||
``skip_extensions`` — file extensions to skip (default: .md, .yml, .yaml, .json, .tf, .sh, .conf, .service)
|
||||
|
||||
Built-in defaults cover common Python project layouts (``scripts/*.py``, ``src/**/*.py``).
|
||||
Project-specific rules are merged with defaults (first match wins).
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.tools.check_test_coverage [--staged-only] [--warn-only]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import fnmatch
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from devx.config import _load_pyproject_devx
|
||||
from devx.i18n import _
|
||||
|
||||
DEFAULT_TEST_INDICATORS = ["tests/", "/test_", "_test.py"]
|
||||
DEFAULT_SKIP_EXTENSIONS = (".md", ".yml", ".yaml", ".json", ".tf", ".sh", ".conf", ".service")
|
||||
|
||||
# Built-in rules for common Python project layouts
|
||||
BUILTIN_RULES: list[dict[str, object]] = [
|
||||
{
|
||||
"source_pattern": "scripts/*.py",
|
||||
"test_paths": ["scripts/tests/test_{name}", "tests/unit/test_{name}"],
|
||||
"description": "Missing unit test: scripts/tests/test_{name} or tests/unit/test_{name}",
|
||||
},
|
||||
{
|
||||
"source_pattern": "src/**/*.py",
|
||||
"test_paths": ["tests/unit/test_{name}", "tests/unit/test_{module}_{name}"],
|
||||
"description": "Missing unit test: tests/unit/test_{name}",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _load_rules() -> tuple[list[dict[str, object]], list[str], list[str], tuple[str, ...]]:
|
||||
"""Load test coverage rules from pyproject.toml."""
|
||||
devx_cfg = _load_pyproject_devx()
|
||||
cfg_raw = devx_cfg.get("check_test_coverage", {})
|
||||
if not isinstance(cfg_raw, dict):
|
||||
return BUILTIN_RULES, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS
|
||||
cfg: dict[str, object] = cfg_raw # type: ignore[assignment]
|
||||
|
||||
rules_raw = cfg.get("rules", BUILTIN_RULES)
|
||||
rules: list[dict[str, object]] = [dict(r) for r in rules_raw] if isinstance(rules_raw, list) else BUILTIN_RULES
|
||||
|
||||
skip_raw = cfg.get("skip_patterns", [])
|
||||
skip_patterns: list[str] = [str(s) for s in skip_raw] if isinstance(skip_raw, list) else []
|
||||
|
||||
indicators_raw = cfg.get("test_file_indicators", DEFAULT_TEST_INDICATORS)
|
||||
indicators: list[str] = (
|
||||
[str(s) for s in indicators_raw] if isinstance(indicators_raw, list) else DEFAULT_TEST_INDICATORS
|
||||
)
|
||||
|
||||
skip_ext_raw = cfg.get("skip_extensions", list(DEFAULT_SKIP_EXTENSIONS))
|
||||
if isinstance(skip_ext_raw, list):
|
||||
skip_ext: tuple[str, ...] = tuple(str(s) for s in skip_ext_raw)
|
||||
else:
|
||||
skip_ext = DEFAULT_SKIP_EXTENSIONS
|
||||
|
||||
return rules, skip_patterns, indicators, skip_ext
|
||||
|
||||
|
||||
def _changed_files(staged_only: bool, repo_root: Path) -> list[str]:
|
||||
"""Return list of changed file paths relative to repo root."""
|
||||
if staged_only:
|
||||
cmd = ["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"]
|
||||
else:
|
||||
# Compare against origin/master for CI usage
|
||||
cmd = ["git", "diff", "origin/master...HEAD", "--name-only", "--diff-filter=ACMR"]
|
||||
result = subprocess.run( # nosec B603, B607
|
||||
cmd, capture_output=True, text=True, check=False, cwd=repo_root
|
||||
)
|
||||
if result.returncode != 0:
|
||||
# fallback: just use staged files
|
||||
result = subprocess.run( # nosec B603, B607
|
||||
["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
cwd=repo_root,
|
||||
)
|
||||
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def _is_test_file(filepath: str, indicators: list[str]) -> bool:
|
||||
"""Check if a file is a test file."""
|
||||
return any(indicator in filepath for indicator in indicators)
|
||||
|
||||
|
||||
def _should_skip_file(
|
||||
filepath: str,
|
||||
skip_patterns: list[str],
|
||||
skip_extensions: tuple[str, ...],
|
||||
) -> bool:
|
||||
"""Check if a file should be skipped."""
|
||||
if filepath.startswith("."):
|
||||
return True
|
||||
if filepath.endswith(skip_extensions):
|
||||
return True
|
||||
name = Path(filepath).name
|
||||
return any(fnmatch.fnmatch(name, pattern) or fnmatch.fnmatch(filepath, pattern) for pattern in skip_patterns)
|
||||
|
||||
|
||||
def _resolve_test_path(template: str, source_path: str, repo_root: Path) -> Path:
|
||||
"""Resolve a test path template to an actual path.
|
||||
|
||||
Templates can use:
|
||||
- ``{name}`` — the source file's name (without extension)
|
||||
- ``{module}`` — the source file's parent directory name
|
||||
- ``{package_prefix}`` — underscore-joined subdirectories (for nested modules)
|
||||
"""
|
||||
path = Path(source_path)
|
||||
name = path.stem
|
||||
module = path.parent.name
|
||||
|
||||
# Build package prefix for nested modules (e.g. scripts/utils/secrets.py -> utils)
|
||||
parts = path.parts
|
||||
package_prefix = ""
|
||||
if len(parts) > 2:
|
||||
package_prefix = "_".join(parts[1:-1])
|
||||
|
||||
resolved = template.format(
|
||||
name=name,
|
||||
module=module,
|
||||
package_prefix=package_prefix,
|
||||
)
|
||||
# Normalize hyphens to underscores (Python module naming)
|
||||
resolved = resolved.replace("-", "_")
|
||||
return repo_root / resolved
|
||||
|
||||
|
||||
def _find_missing_tests(
|
||||
files: list[str],
|
||||
repo_root: Path,
|
||||
rules: list[dict[str, object]],
|
||||
skip_patterns: list[str],
|
||||
test_indicators: list[str],
|
||||
skip_extensions: tuple[str, ...],
|
||||
) -> dict[str, str]:
|
||||
"""Map each untested file to the reason it's untested."""
|
||||
missing: dict[str, str] = {}
|
||||
|
||||
for f in files:
|
||||
# Skip test files themselves
|
||||
if _is_test_file(f, test_indicators):
|
||||
continue
|
||||
|
||||
# Skip config, docs, meta files
|
||||
if _should_skip_file(f, skip_patterns, skip_extensions):
|
||||
continue
|
||||
|
||||
for rule in rules:
|
||||
pattern = str(rule.get("source_pattern", ""))
|
||||
if not fnmatch.fnmatch(f, pattern):
|
||||
continue
|
||||
|
||||
test_templates = rule.get("test_paths", [])
|
||||
if not isinstance(test_templates, list):
|
||||
continue
|
||||
|
||||
description_template = str(rule.get("description", "Missing test for {f}"))
|
||||
|
||||
test_paths = [_resolve_test_path(str(t), f, repo_root) for t in test_templates]
|
||||
|
||||
# Check if any test path exists (with .py extension)
|
||||
found = False
|
||||
for tp in test_paths:
|
||||
if tp.with_suffix(".py").exists() or tp.exists():
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
# Format description with file info
|
||||
name = Path(f).stem
|
||||
missing[f] = description_template.format(
|
||||
name=name,
|
||||
f=f,
|
||||
test_name=f"test_{name}".replace("-", "_"),
|
||||
)
|
||||
break
|
||||
|
||||
# If no rule matched, the file is not checked (no test requirement)
|
||||
# This is intentional — only files matching a rule need tests
|
||||
|
||||
return missing
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=_("Check that changed files have corresponding tests"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--staged-only",
|
||||
action="store_true",
|
||||
help=_("Only check staged files (for pre-commit)"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--warn-only",
|
||||
action="store_true",
|
||||
help=_("Print warnings but always exit 0"),
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
repo_root = Path.cwd()
|
||||
rules, skip_patterns, test_indicators, skip_extensions = _load_rules()
|
||||
|
||||
files = _changed_files(args.staged_only, repo_root)
|
||||
if not files:
|
||||
print(_("[check_test_coverage] No changed files to check."))
|
||||
return 0
|
||||
|
||||
missing = _find_missing_tests(files, repo_root, rules, skip_patterns, test_indicators, skip_extensions)
|
||||
if not missing:
|
||||
print(f"[check_test_coverage] All {len(files)} changed file(s) have tests.")
|
||||
return 0
|
||||
|
||||
print("[check_test_coverage] FAILED: missing tests for changed files:\n", file=sys.stderr)
|
||||
for f, reason in missing.items():
|
||||
print(f" {f}", file=sys.stderr)
|
||||
print(f" -> {reason}", file=sys.stderr)
|
||||
|
||||
print(
|
||||
"\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
if args.warn_only:
|
||||
return 0
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
sys.exit(main())
|
||||
@@ -1630,5 +1630,221 @@
|
||||
"pl": "OSTRZEŻENIE: VIKUNJA_TOKEN nie jest ustawiony — pomijanie sprawdzania istnienia zadania. Ustaw w .env, aby włączyć pełną walidację.",
|
||||
"ru": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не установлен — пропуск проверки существования задачи. Установите в .env для полной проверки.",
|
||||
"zh": "警告: VIKUNJA_TOKEN 未设置 — 跳过任务存在性检查。在 .env 中设置以启用完整验证。"
|
||||
},
|
||||
"[check-mutable-globals] Passed: no mutable path globals found": {
|
||||
"bg": "[check-mutable-globals] Passed: no mutable path globals found",
|
||||
"de": "[check-mutable-globals] Passed: no mutable path globals found",
|
||||
"en": "[check-mutable-globals] Passed: no mutable path globals found",
|
||||
"pl": "[check-mutable-globals] Passed: no mutable path globals found",
|
||||
"ru": "[check-mutable-globals] Passed: no mutable path globals found",
|
||||
"zh": "[check-mutable-globals] Passed: no mutable path globals found"
|
||||
},
|
||||
"[check_agent_docs] Passed: scanned {count} file(s), no stale references": {
|
||||
"bg": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
|
||||
"de": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
|
||||
"en": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
|
||||
"pl": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
|
||||
"ru": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
|
||||
"zh": "[check_agent_docs] Passed: scanned {count} file(s), no stale references"
|
||||
},
|
||||
"[check_test_coverage] No changed files to check.": {
|
||||
"bg": "[check_test_coverage] No changed files to check.",
|
||||
"de": "[check_test_coverage] No changed files to check.",
|
||||
"en": "[check_test_coverage] No changed files to check.",
|
||||
"pl": "[check_test_coverage] No changed files to check.",
|
||||
"ru": "[check_test_coverage] No changed files to check.",
|
||||
"zh": "[check_test_coverage] No changed files to check."
|
||||
},
|
||||
"Additional directory to scan (default: scripts, tests). Can be repeated.": {
|
||||
"bg": "Additional directory to scan (default: scripts, tests). Can be repeated.",
|
||||
"de": "Additional directory to scan (default: scripts, tests). Can be repeated.",
|
||||
"en": "Additional directory to scan (default: scripts, tests). Can be repeated.",
|
||||
"pl": "Additional directory to scan (default: scripts, tests). Can be repeated.",
|
||||
"ru": "Additional directory to scan (default: scripts, tests). Can be repeated.",
|
||||
"zh": "Additional directory to scan (default: scripts, tests). Can be repeated."
|
||||
},
|
||||
"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",
|
||||
"en": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
|
||||
"pl": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
|
||||
"ru": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
|
||||
"zh": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master"
|
||||
},
|
||||
"Branch name (e.g., DEVX-256-fix-foo)": {
|
||||
"bg": "Branch name (e.g., DEVX-256-fix-foo)",
|
||||
"de": "Branch name (e.g., DEVX-256-fix-foo)",
|
||||
"en": "Branch name (e.g., DEVX-256-fix-foo)",
|
||||
"pl": "Branch name (e.g., DEVX-256-fix-foo)",
|
||||
"ru": "Branch name (e.g., DEVX-256-fix-foo)",
|
||||
"zh": "Branch name (e.g., DEVX-256-fix-foo)"
|
||||
},
|
||||
"Branch name must contain a task ID.": {
|
||||
"bg": "Branch name must contain a task ID.",
|
||||
"de": "Branch name must contain a task ID.",
|
||||
"en": "Branch name must contain a task ID.",
|
||||
"pl": "Branch name must contain a task ID.",
|
||||
"ru": "Branch name must contain a task ID.",
|
||||
"zh": "Branch name must contain a task ID."
|
||||
},
|
||||
"Check that changed files have corresponding tests": {
|
||||
"bg": "Check that changed files have corresponding tests",
|
||||
"de": "Check that changed files have corresponding tests",
|
||||
"en": "Check that changed files have corresponding tests",
|
||||
"pl": "Check that changed files have corresponding tests",
|
||||
"ru": "Check that changed files have corresponding tests",
|
||||
"zh": "Check that changed files have corresponding tests"
|
||||
},
|
||||
"Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).": {
|
||||
"bg": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).",
|
||||
"de": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).",
|
||||
"en": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).",
|
||||
"pl": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).",
|
||||
"ru": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).",
|
||||
"zh": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found)."
|
||||
},
|
||||
"Dependencies must have documentation comments.": {
|
||||
"bg": "Dependencies must have documentation comments.",
|
||||
"de": "Dependencies must have documentation comments.",
|
||||
"en": "Dependencies must have documentation comments.",
|
||||
"pl": "Dependencies must have documentation comments.",
|
||||
"ru": "Dependencies must have documentation comments.",
|
||||
"zh": "Dependencies must have documentation comments."
|
||||
},
|
||||
"FAILED: {count} undocumented dependency/ies": {
|
||||
"bg": "FAILED: {count} undocumented dependency/ies",
|
||||
"de": "FAILED: {count} undocumented dependency/ies",
|
||||
"en": "FAILED: {count} undocumented dependency/ies",
|
||||
"pl": "FAILED: {count} undocumented dependency/ies",
|
||||
"ru": "FAILED: {count} undocumented dependency/ies",
|
||||
"zh": "FAILED: {count} undocumented dependency/ies"
|
||||
},
|
||||
"Found {count} mutable global(s) — use factory functions or pytest fixtures.": {
|
||||
"bg": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
|
||||
"de": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
|
||||
"en": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
|
||||
"pl": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
|
||||
"ru": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
|
||||
"zh": "Found {count} mutable global(s) — use factory functions or pytest fixtures."
|
||||
},
|
||||
"Found {count} stale documentation reference(s)": {
|
||||
"bg": "Found {count} stale documentation reference(s)",
|
||||
"de": "Found {count} stale documentation reference(s)",
|
||||
"en": "Found {count} stale documentation reference(s)",
|
||||
"pl": "Found {count} stale documentation reference(s)",
|
||||
"ru": "Found {count} stale documentation reference(s)",
|
||||
"zh": "Found {count} stale documentation reference(s)"
|
||||
},
|
||||
"No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.": {
|
||||
"bg": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
|
||||
"de": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
|
||||
"en": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
|
||||
"pl": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
|
||||
"ru": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
|
||||
"zh": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description."
|
||||
},
|
||||
"Only check staged files (for pre-commit)": {
|
||||
"bg": "Only check staged files (for pre-commit)",
|
||||
"de": "Only check staged files (for pre-commit)",
|
||||
"en": "Only check staged files (for pre-commit)",
|
||||
"pl": "Only check staged files (for pre-commit)",
|
||||
"ru": "Only check staged files (for pre-commit)",
|
||||
"zh": "Only check staged files (for pre-commit)"
|
||||
},
|
||||
"PR number (to fetch title from Gitea)": {
|
||||
"bg": "PR number (to fetch title from Gitea)",
|
||||
"de": "PR number (to fetch title from Gitea)",
|
||||
"en": "PR number (to fetch title from Gitea)",
|
||||
"pl": "PR number (to fetch title from Gitea)",
|
||||
"ru": "PR number (to fetch title from Gitea)",
|
||||
"zh": "PR number (to fetch title from Gitea)"
|
||||
},
|
||||
"PR title (auto-fetched if --pr-number given)": {
|
||||
"bg": "PR title (auto-fetched if --pr-number given)",
|
||||
"de": "PR title (auto-fetched if --pr-number given)",
|
||||
"en": "PR title (auto-fetched if --pr-number given)",
|
||||
"pl": "PR title (auto-fetched if --pr-number given)",
|
||||
"ru": "PR title (auto-fetched if --pr-number given)",
|
||||
"zh": "PR title (auto-fetched if --pr-number given)"
|
||||
},
|
||||
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}": {
|
||||
"bg": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
|
||||
"de": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
|
||||
"en": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
|
||||
"pl": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
|
||||
"ru": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
|
||||
"zh": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}"
|
||||
},
|
||||
"PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}": {
|
||||
"bg": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
|
||||
"de": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
|
||||
"en": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
|
||||
"pl": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
|
||||
"ru": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
|
||||
"zh": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}"
|
||||
},
|
||||
"PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}": {
|
||||
"bg": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
|
||||
"de": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
|
||||
"en": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
|
||||
"pl": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
|
||||
"ru": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
|
||||
"zh": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}"
|
||||
},
|
||||
"Path to pyproject.toml (default: pyproject.toml in CWD).": {
|
||||
"bg": "Path to pyproject.toml (default: pyproject.toml in CWD).",
|
||||
"de": "Path to pyproject.toml (default: pyproject.toml in CWD).",
|
||||
"en": "Path to pyproject.toml (default: pyproject.toml in CWD).",
|
||||
"pl": "Path to pyproject.toml (default: pyproject.toml in CWD).",
|
||||
"ru": "Path to pyproject.toml (default: pyproject.toml in CWD).",
|
||||
"zh": "Path to pyproject.toml (default: pyproject.toml in CWD)."
|
||||
},
|
||||
"Pre-merge validation failed.": {
|
||||
"bg": "Pre-merge validation failed.",
|
||||
"de": "Pre-merge validation failed.",
|
||||
"en": "Pre-merge validation failed.",
|
||||
"pl": "Pre-merge validation failed.",
|
||||
"ru": "Pre-merge validation failed.",
|
||||
"zh": "Pre-merge validation failed."
|
||||
},
|
||||
"Print warnings but always exit 0": {
|
||||
"bg": "Print warnings but always exit 0",
|
||||
"de": "Print warnings but always exit 0",
|
||||
"en": "Print warnings but always exit 0",
|
||||
"pl": "Print warnings but always exit 0",
|
||||
"ru": "Print warnings but always exit 0",
|
||||
"zh": "Print warnings but always exit 0"
|
||||
},
|
||||
"Repository in owner/name format": {
|
||||
"bg": "Repository in owner/name format",
|
||||
"de": "Repository in owner/name format",
|
||||
"en": "Repository in owner/name format",
|
||||
"pl": "Repository in owner/name format",
|
||||
"ru": "Repository in owner/name format",
|
||||
"zh": "Repository in owner/name format"
|
||||
},
|
||||
"Skip Vikunja title match check": {
|
||||
"bg": "Skip Vikunja title match check",
|
||||
"de": "Skip Vikunja title match check",
|
||||
"en": "Skip Vikunja title match check",
|
||||
"pl": "Skip Vikunja title match check",
|
||||
"ru": "Skip Vikunja title match check",
|
||||
"zh": "Skip Vikunja title match check"
|
||||
},
|
||||
"Skip branch-behind-master check": {
|
||||
"bg": "Skip branch-behind-master check",
|
||||
"de": "Skip branch-behind-master check",
|
||||
"en": "Skip branch-behind-master check",
|
||||
"pl": "Skip branch-behind-master check",
|
||||
"ru": "Skip branch-behind-master check",
|
||||
"zh": "Skip branch-behind-master check"
|
||||
},
|
||||
"[check-dep-docs] Passed: all dependencies are documented": {
|
||||
"bg": "[check-dep-docs] Passed: all dependencies are documented",
|
||||
"de": "[check-dep-docs] Passed: all dependencies are documented",
|
||||
"en": "[check-dep-docs] Passed: all dependencies are documented",
|
||||
"pl": "[check-dep-docs] Passed: all dependencies are documented",
|
||||
"ru": "[check-dep-docs] Passed: all dependencies are documented",
|
||||
"zh": "[check-dep-docs] Passed: all dependencies are documented"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user