Public Access
feat: extract reusable dev/CI tools from GRM into devx package
Post-merge / detect-type (push) Failing after 9s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / badges (push) Failing after 25s
Post-merge / detect-type (push) Failing after 9s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / badges (push) Failing after 25s
Port core modules (config, exceptions, i18n, api_clients, gitea_cli), 14 CI scripts, 6 dev tools, 5 molecule tools, CLI entry point, workflows, Makefile, tests (784 tests, 100% coverage), and documentation from GRM. The devx package is published to the Gitea PyPI registry and consumed by GRM, infra, and other oblachno-oss projects as a pip dependency. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
commit
60fd11419c
@@ -0,0 +1,3 @@
|
||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,348 @@
|
||||
"""Reusable HTTP API clients for Gitea and Vikunja."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from devx.config import DEFAULT_TIMEOUT, MAX_RETRIES, RETRY_BACKOFF_BASE, RETRY_STATUS_CODES
|
||||
from devx.exceptions import APIError
|
||||
|
||||
logger = logging.getLogger("devx")
|
||||
|
||||
|
||||
def _parse_error(e: requests.HTTPError) -> tuple[int, str]:
|
||||
"""Extract status code and message from an HTTPError response."""
|
||||
response = getattr(e, "response", None)
|
||||
status = response.status_code if response is not None else 0
|
||||
try:
|
||||
body: dict[str, Any] = response.json() if response is not None else {}
|
||||
message: str = body.get("message", str(e))
|
||||
except Exception:
|
||||
message = str(e)
|
||||
return status, message
|
||||
|
||||
|
||||
def _is_retryable(e: Exception) -> bool:
|
||||
"""Check if an exception is a transient error worth retrying."""
|
||||
if isinstance(e, requests.ConnectionError):
|
||||
return True
|
||||
if isinstance(e, requests.HTTPError):
|
||||
status, _ = _parse_error(e)
|
||||
return status in RETRY_STATUS_CODES
|
||||
return isinstance(e, requests.Timeout)
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
"""Low-level Gitea REST API client with connection pooling."""
|
||||
|
||||
def __init__(self, base_url: str, token: str, owner: str, repo: str) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._owner = owner
|
||||
self._repo = repo
|
||||
self._session = requests.Session()
|
||||
self._session.headers.update(
|
||||
{
|
||||
"Authorization": f"token {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
)
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
return f"{self._base_url}/repos/{self._owner}/{self._repo}{path}"
|
||||
|
||||
def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
|
||||
url = self._url(path)
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(MAX_RETRIES):
|
||||
try:
|
||||
response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except requests.HTTPError as e:
|
||||
status, message = _parse_error(e)
|
||||
if _is_retryable(e) and attempt < MAX_RETRIES - 1:
|
||||
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
|
||||
logger.warning(
|
||||
"Transient HTTP %d on %s %s, retrying in %ds (attempt %d/%d)",
|
||||
status,
|
||||
method,
|
||||
path,
|
||||
wait,
|
||||
attempt + 1,
|
||||
MAX_RETRIES,
|
||||
)
|
||||
time.sleep(wait)
|
||||
last_exc = e
|
||||
continue
|
||||
raise APIError(status, message) from e
|
||||
except (requests.ConnectionError, requests.Timeout) as e:
|
||||
if attempt < MAX_RETRIES - 1:
|
||||
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
|
||||
logger.warning(
|
||||
"Connection error on %s %s, retrying in %ds (attempt %d/%d)",
|
||||
method,
|
||||
path,
|
||||
wait,
|
||||
attempt + 1,
|
||||
MAX_RETRIES,
|
||||
)
|
||||
time.sleep(wait)
|
||||
last_exc = e
|
||||
continue
|
||||
raise APIError(0, str(e)) from e
|
||||
# Should not reach here, but just in case
|
||||
if last_exc: # pragma: no cover
|
||||
raise APIError(0, str(last_exc)) from last_exc
|
||||
raise APIError(0, "Max retries exceeded") # pragma: no cover
|
||||
|
||||
# -- repo settings --
|
||||
|
||||
def update_repo_settings(self, settings: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Update repository settings (e.g. auto-delete branch after merge)."""
|
||||
r = self._request("PATCH", "", json=settings)
|
||||
return r.json()
|
||||
|
||||
# -- branch protection --
|
||||
|
||||
def list_branch_protections(self) -> list[dict[str, Any]]:
|
||||
r = self._request("GET", "/branch_protections")
|
||||
return r.json()
|
||||
|
||||
def create_branch_protection(self, config: dict[str, Any]) -> dict[str, Any]:
|
||||
r = self._request("POST", "/branch_protections", json=config)
|
||||
return r.json()
|
||||
|
||||
def update_branch_protection(self, branch: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||
r = self._request("PATCH", f"/branch_protections/{branch}", json=config)
|
||||
return r.json()
|
||||
|
||||
def ensure_branch_protection(self, branch: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Idempotent: create or update branch protection for the given branch."""
|
||||
existing = self.list_branch_protections()
|
||||
for p in existing:
|
||||
if p.get("branch_name") == branch:
|
||||
update_config = {k: v for k, v in config.items() if k != "branch_name"}
|
||||
return self.update_branch_protection(branch, update_config)
|
||||
return self.create_branch_protection(config)
|
||||
|
||||
# -- labels --
|
||||
|
||||
def list_labels(self) -> list[dict[str, Any]]:
|
||||
r = self._request("GET", "/labels")
|
||||
return r.json()
|
||||
|
||||
def create_label(self, name: str, color: str, description: str = "") -> dict[str, Any]:
|
||||
r = self._request(
|
||||
"POST",
|
||||
"/labels",
|
||||
json={"name": name, "color": color, "description": description},
|
||||
)
|
||||
return r.json()
|
||||
|
||||
def ensure_label(self, name: str, color: str, description: str = "") -> dict[str, Any] | None:
|
||||
"""Idempotent: create label if it doesn't already exist."""
|
||||
labels = self.list_labels()
|
||||
for label in labels:
|
||||
if label["name"] == name:
|
||||
return None
|
||||
return self.create_label(name, color, description)
|
||||
|
||||
def create_issue(self, title: str, body: str = "", labels: list[int] | None = None) -> dict[str, Any]:
|
||||
"""Create a new issue in the repository.
|
||||
|
||||
Args:
|
||||
labels: List of label IDs (integers, not names).
|
||||
"""
|
||||
payload: dict[str, Any] = {"title": title, "body": body}
|
||||
if labels:
|
||||
payload["labels"] = labels
|
||||
r = self._request("POST", "/issues", json=payload)
|
||||
return r.json()
|
||||
|
||||
# -- pulls / releases --
|
||||
|
||||
def get_pr_labels(self, pr_number: str | int) -> list[dict[str, Any]]:
|
||||
"""Fetch labels currently attached to a pull request."""
|
||||
r = self._request("GET", f"/issues/{pr_number}/labels")
|
||||
return r.json()
|
||||
|
||||
def merge_pr(self, pr_number: str | int, merge_title: str) -> None:
|
||||
payload = {"Do": "squash", "MergeTitleField": merge_title}
|
||||
self._request("POST", f"/pulls/{pr_number}/merge", json=payload)
|
||||
|
||||
def get_commit_status(self, sha: str) -> list[dict[str, Any]]:
|
||||
"""Fetch all status check contexts reported for a commit.
|
||||
|
||||
Uses the combined status endpoint (/commits/{sha}/status) which
|
||||
returns one entry per context (the latest), deduplicated server-side.
|
||||
The plural endpoint (/commits/{sha}/statuses) returns every historical
|
||||
entry including stale "pending" ones that never got updated.
|
||||
"""
|
||||
r = self._request("GET", f"/commits/{sha}/status")
|
||||
data = r.json()
|
||||
return data.get("statuses", [])
|
||||
|
||||
def get_pr(self, pr_number: str | int) -> dict[str, Any]:
|
||||
"""Fetch pull request details including mergeable state."""
|
||||
r = self._request("GET", f"/pulls/{pr_number}")
|
||||
return r.json()
|
||||
|
||||
def get_pr_files(self, pr_number: str | int) -> list[dict[str, Any]]:
|
||||
"""Fetch the list of files changed in a pull request."""
|
||||
r = self._request("GET", f"/pulls/{pr_number}/files")
|
||||
return r.json()
|
||||
|
||||
def get_pr_commits(self, pr_number: str | int) -> list[dict[str, Any]]:
|
||||
"""Fetch the commits included in a pull request."""
|
||||
r = self._request("GET", f"/pulls/{pr_number}/commits")
|
||||
return r.json()
|
||||
|
||||
def get_pr_reviews(self, pr_number: str | int) -> list[dict[str, Any]]:
|
||||
"""Fetch reviews posted on a pull request."""
|
||||
r = self._request("GET", f"/pulls/{pr_number}/reviews")
|
||||
return r.json()
|
||||
|
||||
def create_review(
|
||||
self,
|
||||
pr_number: str | int,
|
||||
event: str = "COMMENT",
|
||||
body: str = "",
|
||||
comments: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Post a review on a pull request.
|
||||
|
||||
Args:
|
||||
event: ``APPROVED``, ``REQUEST_CHANGES``, or ``COMMENT``.
|
||||
body: Top-level review body text.
|
||||
comments: Line-level comments with ``path``, ``body``,
|
||||
``new_position`` (and optionally ``old_position``).
|
||||
"""
|
||||
# Map common event names to Gitea API values
|
||||
event_map = {"APPROVE": "APPROVED", "REQUEST_CHANGES": "REQUEST_CHANGES", "COMMENT": "COMMENT"}
|
||||
gitea_event = event_map.get(event, event)
|
||||
payload: dict[str, Any] = {"event": gitea_event, "body": body}
|
||||
if comments:
|
||||
payload["comments"] = comments
|
||||
r = self._request("POST", f"/pulls/{pr_number}/reviews", json=payload)
|
||||
return r.json()
|
||||
|
||||
def create_release(
|
||||
self,
|
||||
tag: str,
|
||||
name: str = "",
|
||||
body: str = "",
|
||||
draft: bool = False,
|
||||
prerelease: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
payload = {
|
||||
"tag_name": tag,
|
||||
"name": name or tag,
|
||||
"body": body,
|
||||
"draft": draft,
|
||||
"prerelease": prerelease,
|
||||
}
|
||||
r = self._request("POST", "/releases", json=payload)
|
||||
return r.json()
|
||||
|
||||
def get_release_by_tag(self, tag: str) -> dict[str, Any] | None:
|
||||
"""Fetch a release by its tag name. Returns None if not found."""
|
||||
try:
|
||||
r = self._request("GET", f"/releases/tags/{tag}")
|
||||
return r.json()
|
||||
except APIError:
|
||||
return None
|
||||
|
||||
def create_release_idempotent(
|
||||
self,
|
||||
tag: str,
|
||||
name: str = "",
|
||||
body: str = "",
|
||||
draft: bool = False,
|
||||
prerelease: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a release, or return the existing one if it already exists.
|
||||
|
||||
This is idempotent — safe to call multiple times for the same tag.
|
||||
"""
|
||||
existing = self.get_release_by_tag(tag)
|
||||
if existing:
|
||||
logger.info("Release for tag %s already exists (ID %s), skipping creation.", tag, existing.get("id"))
|
||||
return existing
|
||||
return self.create_release(tag=tag, name=name, body=body, draft=draft, prerelease=prerelease)
|
||||
|
||||
|
||||
class VikunjaClient:
|
||||
"""Low-level Vikunja REST API client with connection pooling."""
|
||||
|
||||
def __init__(self, base_url: str, token: str) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._session = requests.Session()
|
||||
self._session.headers.update({"Authorization": f"Bearer {token}"})
|
||||
|
||||
def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
|
||||
url = f"{self._base_url}{path}"
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(MAX_RETRIES):
|
||||
try:
|
||||
response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except requests.HTTPError as e:
|
||||
status, message = _parse_error(e)
|
||||
if _is_retryable(e) and attempt < MAX_RETRIES - 1:
|
||||
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
|
||||
logger.warning(
|
||||
"Transient HTTP %d on %s %s, retrying in %ds (attempt %d/%d)",
|
||||
status,
|
||||
method,
|
||||
path,
|
||||
wait,
|
||||
attempt + 1,
|
||||
MAX_RETRIES,
|
||||
)
|
||||
time.sleep(wait)
|
||||
last_exc = e
|
||||
continue
|
||||
raise APIError(status, message) from e
|
||||
except (requests.ConnectionError, requests.Timeout) as e:
|
||||
if attempt < MAX_RETRIES - 1:
|
||||
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
|
||||
logger.warning(
|
||||
"Connection error on %s %s, retrying in %ds (attempt %d/%d)",
|
||||
method,
|
||||
path,
|
||||
wait,
|
||||
attempt + 1,
|
||||
MAX_RETRIES,
|
||||
)
|
||||
time.sleep(wait)
|
||||
last_exc = e
|
||||
continue
|
||||
raise APIError(0, str(e)) from e
|
||||
if last_exc: # pragma: no cover
|
||||
raise APIError(0, str(last_exc)) from last_exc
|
||||
raise APIError(0, "Max retries exceeded") # pragma: no cover
|
||||
|
||||
def list_tasks(self, **params: Any) -> list[dict[str, Any]]:
|
||||
r = self._request("GET", "/tasks", params=params)
|
||||
return r.json()
|
||||
|
||||
def get_task(self, task_id: int) -> dict[str, Any]:
|
||||
"""Fetch a single task by its numeric ID."""
|
||||
r = self._request("GET", f"/tasks/{task_id}")
|
||||
return r.json()
|
||||
|
||||
def list_project_tasks(self, project_id: int, **params: Any) -> list[dict[str, Any]]:
|
||||
"""List tasks in a specific project (more efficient than listing all tasks)."""
|
||||
r = self._request("GET", f"/projects/{project_id}/tasks", params=params)
|
||||
return r.json()
|
||||
|
||||
def post_comment(self, task_id: int, comment: str) -> None:
|
||||
self._request("PUT", f"/tasks/{task_id}/comments", json={"comment": comment})
|
||||
|
||||
def update_task(self, task_id: int, **fields: Any) -> None:
|
||||
self._request("POST", f"/tasks/{task_id}", json=fields)
|
||||
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Auto-merge PR when all CI checks pass.
|
||||
|
||||
Runs as the final job in ci.yml. Reads the task ID from ``.taskid`` file
|
||||
(falling back to branch name extraction for backwards compatibility),
|
||||
validates the PR title, and squash-merges with a conventional commit
|
||||
message prefixed by the task ID.
|
||||
|
||||
PR title format: ``DEVX-N: <vikunja task title>``
|
||||
Merge commit format: ``DEVX-N: <conventional commit message>``
|
||||
|
||||
The conventional commit message is extracted from the PR commits.
|
||||
This allows the PR title to be a human-friendly Vikunja task title
|
||||
while the squashed commit follows conventional commits.
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 -m devx.ci.auto_merge <branch> <pr_title> <repo> <pr_number>
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from devx.api_clients import GiteaClient, VikunjaClient
|
||||
from devx.config import (
|
||||
CONVENTIONAL_RE,
|
||||
DEFAULT_PER_PAGE,
|
||||
GITEA_API_URL,
|
||||
TASK_ID_RE,
|
||||
VIKUNJA_API_URL,
|
||||
VIKUNJA_PROJECT_ID,
|
||||
)
|
||||
from devx.exceptions import APIError
|
||||
from devx.i18n import _
|
||||
|
||||
TASKID_FILE = ".taskid"
|
||||
PR_TITLE_RE = re.compile(r"^DEVX-\d+:\s+.+")
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
|
||||
def run_cmd(args: list[str], check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
"""Run a command and return the completed process."""
|
||||
result = subprocess.run(args, capture_output=True, text=True, check=False) # nosec B603
|
||||
if check and result.returncode != 0:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Command failed ({cmd}): {stderr}",
|
||||
cmd=" ".join(args),
|
||||
stderr=result.stderr.strip() or result.stdout.strip(),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def read_taskid(branch: str) -> str:
|
||||
"""Read task ID from .taskid file, falling back to branch name extraction.
|
||||
|
||||
The .taskid file is a simple text file containing just the task ID
|
||||
(e.g., ``DEVX-60``). If the file doesn't exist, extract from the
|
||||
branch name as a backwards-compatibility fallback.
|
||||
"""
|
||||
path = Path(TASKID_FILE)
|
||||
if path.exists():
|
||||
task_id = path.read_text(encoding="utf-8").strip()
|
||||
if task_id:
|
||||
return task_id
|
||||
# Fallback: extract from branch name
|
||||
match = TASK_ID_RE.search(branch)
|
||||
return match.group(0) if match else ""
|
||||
|
||||
|
||||
def extract_task_id(branch: str) -> str:
|
||||
"""Extract DEVX-N task identifier from branch name (legacy fallback)."""
|
||||
match = TASK_ID_RE.search(branch)
|
||||
return match.group(0) if match else ""
|
||||
|
||||
|
||||
def validate_pr_title(pr_title: str, task_id: str) -> None:
|
||||
"""Raise ClickException if PR title does not follow the required format.
|
||||
|
||||
Expected: ``DEVX-N: <vikunja task title>``
|
||||
"""
|
||||
if not PR_TITLE_RE.match(pr_title):
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! PR title must follow format 'DEVX-N: <task title>'.\n"
|
||||
" Expected: {task_id}: <task title>\n"
|
||||
" Got: {pr_title}",
|
||||
task_id=task_id,
|
||||
pr_title=pr_title,
|
||||
)
|
||||
)
|
||||
if not pr_title.startswith(f"{task_id}:"):
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}",
|
||||
task_id=task_id,
|
||||
pr_title=pr_title,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_vikunja_task_title(task_id: str) -> str:
|
||||
"""Fetch the Vikunja task title for the given DEVX-N identifier.
|
||||
|
||||
Returns empty string if VIKUNJA_TOKEN is not set (local dev without token).
|
||||
Raises ClickException if the token is set but the task is not found.
|
||||
"""
|
||||
token = os.environ.get("VIKUNJA_TOKEN", "")
|
||||
if not token:
|
||||
return ""
|
||||
client = VikunjaClient(VIKUNJA_API_URL, token)
|
||||
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
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Could not find Vikunja task {task_id} in project {project_id}. "
|
||||
"Every PR must have a corresponding Vikunja task.",
|
||||
task_id=task_id,
|
||||
project_id=VIKUNJA_PROJECT_ID,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def validate_pr_title_matches_vikunja(pr_title: str, task_id: str) -> None:
|
||||
"""Validate that PR title matches the Vikunja task title.
|
||||
|
||||
Skips validation if VIKUNJA_TOKEN is not set (local dev).
|
||||
Raises ClickException if the task is not found or the title doesn't match.
|
||||
"""
|
||||
vikunja_title = get_vikunja_task_title(task_id)
|
||||
if not vikunja_title:
|
||||
# VIKUNJA_TOKEN not set — skip validation (local dev)
|
||||
click.echo(_("Warning: VIKUNJA_TOKEN not set, skipping title match validation."))
|
||||
return
|
||||
expected = f"{task_id}: {vikunja_title}"
|
||||
if pr_title != expected:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
|
||||
expected=expected,
|
||||
pr_title=pr_title,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def extract_conventional_msg(commits: list[dict[str, Any]]) -> str:
|
||||
"""Extract the conventional commit message from PR commits.
|
||||
|
||||
Iterates commits in reverse order (newest first) to find the first
|
||||
message matching the conventional commit format. Falls back to the
|
||||
newest commit message if none match.
|
||||
"""
|
||||
for commit in reversed(commits):
|
||||
commit_info = commit.get("commit", {})
|
||||
message = str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
|
||||
if CONVENTIONAL_RE.match(message):
|
||||
return message
|
||||
# Fallback: use the newest commit's first line
|
||||
if commits:
|
||||
commit_info = commits[-1].get("commit", {})
|
||||
return str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
|
||||
return ""
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("branch")
|
||||
@click.argument("pr_title")
|
||||
@click.argument("repo")
|
||||
@click.argument("pr_number")
|
||||
def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
||||
|
||||
owner, repo_name = repo.split("/")
|
||||
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
|
||||
|
||||
task_id = read_taskid(branch)
|
||||
if not task_id:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! No task ID found in .taskid file or branch name '{branch}'.",
|
||||
branch=branch,
|
||||
)
|
||||
)
|
||||
click.echo(_("Task ID: {task_id}", task_id=task_id))
|
||||
|
||||
validate_pr_title(pr_title, task_id)
|
||||
validate_pr_title_matches_vikunja(pr_title, task_id)
|
||||
|
||||
# Build merge title: DEVX-N: <conventional commit message>
|
||||
commits = client.get_pr_commits(pr_number)
|
||||
conv_msg = extract_conventional_msg(commits)
|
||||
if not conv_msg:
|
||||
raise click.ClickException(_("Could not extract conventional commit message from PR commits."))
|
||||
merge_title = f"{task_id}: {conv_msg}"
|
||||
|
||||
try:
|
||||
client.merge_pr(pr_number, merge_title)
|
||||
except APIError as e:
|
||||
if e.status == 405 and "behind" in e.message.lower():
|
||||
# Head branch is behind master — pull master and rebase, then retry
|
||||
click.echo(_("Head branch is behind master. Pulling and rebasing..."))
|
||||
try:
|
||||
run_cmd(["git", "fetch", "origin", "master"])
|
||||
run_cmd(["git", "rebase", "origin/master"])
|
||||
run_cmd(["git", "push", "--force-with-lease"])
|
||||
click.echo(_("Rebased and pushed. Retrying merge..."))
|
||||
client.merge_pr(pr_number, merge_title)
|
||||
except (APIError, Exception) as retry_err:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
|
||||
error=str(retry_err),
|
||||
)
|
||||
) from None
|
||||
else:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Merge failed with HTTP {status}: {message}\n"
|
||||
"Please check the PR is ready and you have merge rights.",
|
||||
status=e.status,
|
||||
message=e.message,
|
||||
)
|
||||
) from None
|
||||
|
||||
click.echo(
|
||||
_(
|
||||
"Nice! PR #{pr_number} squash-merged with title: {merge_title}",
|
||||
pr_number=pr_number,
|
||||
merge_title=merge_title,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check translation files for gaps, dead keys, and missing languages.
|
||||
|
||||
Validates translation files against the Python source code that uses them.
|
||||
By default, checks ``src/devx/translations.json`` against keys used in
|
||||
``src/devx/**/*.py``. Additional translation sets can be checked by
|
||||
passing ``--translations`` flags (each pointing to a JSON file; the
|
||||
source directory is inferred as the parent of the translations file).
|
||||
|
||||
Checks performed (all fail with exit code 1 on error):
|
||||
- **Missing keys**: a ``_()`` call in code has no entry in the corresponding
|
||||
translations file.
|
||||
- **Dead keys**: a key in a translations file is not used in any code.
|
||||
- **Missing languages**: a key exists but is missing one of the 5 supported
|
||||
languages (en, bg, de, ru, zh). Reported as a warning, not an error.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.ci.check_translations
|
||||
python3 -m devx.ci.check_translations --translations path/to/translations.json
|
||||
python3 -m devx.ci.check_translations --strict # warnings are errors
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
||||
|
||||
SUPPORTED_LANGS = ("en", "bg", "de", "ru", "zh")
|
||||
|
||||
# Default translation set: devx package itself
|
||||
DEFAULT_TRANS_FILE = REPO_ROOT / "src" / "devx" / "translations.json"
|
||||
DEFAULT_SRC_DIR = REPO_ROOT / "src" / "devx"
|
||||
|
||||
# Functions that wrap _() and receive a translation key as first arg.
|
||||
# Their string-literal arguments should be treated as translation keys.
|
||||
_I18N_WRAPPERS = {"_handle_errors"}
|
||||
|
||||
# Known dynamic keys used via _(variable) that can't be detected by AST.
|
||||
# These are status strings set as variable values and passed to _().
|
||||
DYNAMIC_KEYS = {"completed", "pending", "in_progress", "failed", "active", "inactive", "unknown"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TranslationCheckResult:
|
||||
"""Result of a translation check for one translation set."""
|
||||
|
||||
name: str
|
||||
src_dir: Path
|
||||
trans_file: Path
|
||||
used_keys: set[str] = field(default_factory=set)
|
||||
defined_keys: set[str] = field(default_factory=set)
|
||||
missing_keys: set[str] = field(default_factory=set)
|
||||
dead_keys: set[str] = field(default_factory=set)
|
||||
missing_langs: dict[str, list[str]] = field(default_factory=dict)
|
||||
errors: list[str] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def extract_keys(filepath: Path) -> set[str]:
|
||||
"""Extract translation keys from a Python file using AST.
|
||||
|
||||
Detects:
|
||||
- ``_("key")`` calls with string-literal first argument
|
||||
- ``_handle_errors("key")`` and other wrapper calls (see ``_I18N_WRAPPERS``)
|
||||
"""
|
||||
try:
|
||||
tree = ast.parse(filepath.read_text(encoding="utf-8"), filename=str(filepath))
|
||||
except SyntaxError:
|
||||
return set()
|
||||
keys: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Call):
|
||||
func = node.func
|
||||
if (
|
||||
isinstance(func, ast.Name)
|
||||
and func.id in ("_", *_I18N_WRAPPERS)
|
||||
and node.args
|
||||
and isinstance(node.args[0], ast.Constant)
|
||||
and isinstance(node.args[0].value, str)
|
||||
):
|
||||
keys.add(node.args[0].value)
|
||||
return keys
|
||||
|
||||
|
||||
def collect_keys(src_dir: Path) -> set[str]:
|
||||
"""Collect all translation keys from .py files in a directory tree.
|
||||
|
||||
Also includes known dynamic keys (see ``DYNAMIC_KEYS``) that are used
|
||||
via ``_(variable)`` and can't be detected by AST scanning.
|
||||
"""
|
||||
keys: set[str] = set()
|
||||
for pyfile in src_dir.rglob("*.py"):
|
||||
if pyfile.name == "i18n.py":
|
||||
continue
|
||||
keys |= extract_keys(pyfile)
|
||||
# Add dynamic keys for the default source directory
|
||||
if src_dir == DEFAULT_SRC_DIR:
|
||||
keys |= DYNAMIC_KEYS
|
||||
return keys
|
||||
|
||||
|
||||
def check_translation_set(name: str, src_dir: Path, trans_file: Path) -> TranslationCheckResult:
|
||||
"""Check one translation set for gaps and dead keys."""
|
||||
result = TranslationCheckResult(name=name, src_dir=src_dir, trans_file=trans_file)
|
||||
|
||||
# Collect used keys from source code
|
||||
result.used_keys = collect_keys(src_dir)
|
||||
|
||||
# Load defined keys from translations file
|
||||
if not trans_file.exists():
|
||||
result.errors.append(f"Translations file not found: {trans_file}")
|
||||
return result
|
||||
|
||||
translations = json.loads(trans_file.read_text(encoding="utf-8"))
|
||||
result.defined_keys = set(translations.keys())
|
||||
|
||||
# Check for missing keys (used in code but not in translations)
|
||||
result.missing_keys = result.used_keys - result.defined_keys
|
||||
for key in sorted(result.missing_keys):
|
||||
result.errors.append(f"Missing key in {name}: {key!r}")
|
||||
|
||||
# Check for dead keys (in translations but not used in code)
|
||||
result.dead_keys = result.defined_keys - result.used_keys
|
||||
for key in sorted(result.dead_keys):
|
||||
result.warnings.append(f"Dead key in {name}: {key!r}")
|
||||
|
||||
# Check for missing languages
|
||||
for key, langs in translations.items():
|
||||
missing = [lang for lang in SUPPORTED_LANGS if lang not in langs]
|
||||
if missing:
|
||||
result.missing_langs[key] = missing
|
||||
result.warnings.append(f"Missing languages {missing} for key {key!r} in {name}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def print_result(result: TranslationCheckResult) -> None:
|
||||
"""Print check results in a human-readable format."""
|
||||
click.echo(f"\n=== {result.name} ===")
|
||||
click.echo(f" Source dir: {result.src_dir}")
|
||||
click.echo(f" Translations: {result.trans_file}")
|
||||
click.echo(f" Used keys: {len(result.used_keys)}")
|
||||
click.echo(f" Defined keys: {len(result.defined_keys)}")
|
||||
click.echo(f" Missing keys: {len(result.missing_keys)}")
|
||||
click.echo(f" Dead keys: {len(result.dead_keys)}")
|
||||
click.echo(f" Missing langs: {len(result.missing_langs)} keys")
|
||||
|
||||
for err in result.errors:
|
||||
click.echo(f" ERROR: {err}", err=True)
|
||||
for warn in result.warnings:
|
||||
click.echo(f" WARN: {warn}", err=True)
|
||||
|
||||
if not result.errors and not result.warnings:
|
||||
click.echo(" All good!")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--translations",
|
||||
"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.",
|
||||
)
|
||||
@click.option("--strict", is_flag=True, default=False, help="Treat warnings as errors.")
|
||||
def main(translations: tuple[Path, ...], strict: bool) -> None:
|
||||
"""Check translation files for gaps, dead keys, and missing languages."""
|
||||
if not translations:
|
||||
# Default: check the devx package's own translations
|
||||
results = [
|
||||
check_translation_set("devx", DEFAULT_SRC_DIR, DEFAULT_TRANS_FILE),
|
||||
]
|
||||
else:
|
||||
results = []
|
||||
for trans_file in translations:
|
||||
# Infer source directory as the parent of the translations file
|
||||
src_dir = trans_file.parent
|
||||
name = trans_file.parent.name
|
||||
results.append(check_translation_set(name, src_dir, trans_file))
|
||||
|
||||
has_errors = False
|
||||
has_warnings = False
|
||||
for result in results:
|
||||
print_result(result)
|
||||
if result.errors:
|
||||
has_errors = True
|
||||
if result.warnings:
|
||||
has_warnings = True
|
||||
|
||||
click.echo()
|
||||
if has_errors:
|
||||
click.echo("FAIL: Translation check found errors.", err=True)
|
||||
sys.exit(1)
|
||||
if strict and has_warnings:
|
||||
click.echo("FAIL: Translation check found warnings (--strict mode).", err=True)
|
||||
sys.exit(1)
|
||||
if has_warnings:
|
||||
click.echo("PASS with warnings: Translation check passed (warnings present).")
|
||||
else:
|
||||
click.echo("PASS: All translations are complete and up to date.")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,317 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Classify git changes as user-facing or workflow-only.
|
||||
|
||||
Determines whether changes between two git refs (e.g., last tag and HEAD)
|
||||
affect the tool itself (user-facing) or only the CI/CD infrastructure
|
||||
(workflow-only). This is used by:
|
||||
|
||||
- **release.py** — skips release when only workflow files changed
|
||||
- **CI workflow** — skips molecule tests and release dry-run when only
|
||||
workflow files changed
|
||||
|
||||
Classification strategy (safe-by-default):
|
||||
|
||||
Any file that is NOT in the explicit workflow-only allowlist is treated
|
||||
as user-facing. This ensures new file types default to requiring a
|
||||
release rather than silently skipping it.
|
||||
|
||||
The workflow-only patterns are configurable via the ``patterns``
|
||||
parameter on ``classify_changes()`` and ``has_user_facing_changes()``.
|
||||
The default set (``DEFAULT_WORKFLOW_ONLY_PATTERNS``) covers common
|
||||
infrastructure paths. Each project can pass its own frozenset to
|
||||
accommodate different source layouts.
|
||||
|
||||
Default workflow-only paths (infrastructure → no release needed):
|
||||
- .gitea/workflows/** — Gitea Actions workflows
|
||||
- scripts/** — All scripts (CI/CD, dev tools, setup)
|
||||
- src/devx/__init__.py — Version file (release artifact)
|
||||
- src/devx/api_clients.py — Gitea API client (CI/CD only, not used by CLI)
|
||||
- docs/** — Documentation
|
||||
- tests/** — Test files
|
||||
- hooks/** — Git hooks
|
||||
- AGENTS.md — Agent conventions
|
||||
- README.md — README (lean, links to wiki)
|
||||
- CHANGELOG.md — Changelog (generated)
|
||||
- TROUBLESHOOTING.md — Troubleshooting guide
|
||||
- cliff.toml — git-cliff config
|
||||
- Makefile — Build automation
|
||||
- .pre-commit-config.yaml — Pre-commit config
|
||||
- .ansible-lint — Ansible lint config
|
||||
- .env.example — Environment template
|
||||
- .gitignore — Git ignore rules
|
||||
- .ruff.toml — Ruff config (if separate)
|
||||
- .github/** — GitHub config (if present)
|
||||
|
||||
Everything else is user-facing (tool changes → release needed),
|
||||
including but not limited to:
|
||||
- src/devx/*.py — Python CLI source (except __init__.py)
|
||||
- ansible/** — Ansible role
|
||||
- pyproject.toml — Package metadata
|
||||
- Any new file type not in the allowlist
|
||||
|
||||
Usage:
|
||||
python3 -m devx.ci.classify_changes [--base <ref>] [--head <ref>]
|
||||
python3 -m devx.ci.classify_changes --base v0.3.0 --head HEAD
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
|
||||
import click
|
||||
|
||||
from devx.i18n import _
|
||||
|
||||
# Explicit allowlist of workflow-only path patterns.
|
||||
# Anything NOT matching these is treated as user-facing (safe default).
|
||||
# This is the default set — projects can override via the ``patterns``
|
||||
# parameter on classify_changes() / has_user_facing_changes().
|
||||
DEFAULT_WORKFLOW_ONLY_PATTERNS: frozenset[str] = frozenset(
|
||||
[
|
||||
# CI/CD infrastructure
|
||||
".gitea/",
|
||||
# All scripts are infrastructure (CI/CD, dev tools, setup)
|
||||
# User-facing code lives in src/devx/
|
||||
"scripts/",
|
||||
# Version file — only contains __version__, not user-facing code.
|
||||
# Version bumps are a release artifact, not a feature.
|
||||
"src/devx/__init__.py",
|
||||
# Gitea API client — used only by CI/CD scripts, not by the CLI.
|
||||
"src/devx/api_clients.py",
|
||||
# Documentation
|
||||
"docs/",
|
||||
"AGENTS.md",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"TROUBLESHOOTING.md",
|
||||
# Tests
|
||||
"tests/",
|
||||
# Config / build automation
|
||||
"cliff.toml",
|
||||
"Makefile",
|
||||
".pre-commit-config.yaml",
|
||||
".ansible-lint",
|
||||
".env.example",
|
||||
".gitignore",
|
||||
".ruff.toml",
|
||||
# Hooks
|
||||
"hooks/",
|
||||
# GitHub (if ever added)
|
||||
".github/",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def run_git(args: list[str]) -> str:
|
||||
"""Run a git command and return stdout."""
|
||||
result = subprocess.run( # nosec B603
|
||||
args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise click.ClickException(
|
||||
_("git command failed ({cmd}): {stderr}", cmd=" ".join(args), stderr=result.stderr.strip())
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def get_changed_files(base: str, head: str) -> list[str]:
|
||||
"""Get list of files changed between base and head refs."""
|
||||
output = run_git(["git", "diff", "--name-only", base, head])
|
||||
if not output:
|
||||
return []
|
||||
return output.split("\n")
|
||||
|
||||
|
||||
def is_workflow_only(
|
||||
file_path: str,
|
||||
patterns: frozenset[str] | None = None,
|
||||
) -> bool:
|
||||
"""Check if a file path is workflow-only (infrastructure, not the tool itself).
|
||||
|
||||
Uses an explicit allowlist — anything not in the list is treated as
|
||||
user-facing (safe default that prevents accidental release skips).
|
||||
"""
|
||||
p = patterns if patterns is not None else DEFAULT_WORKFLOW_ONLY_PATTERNS
|
||||
return any(file_path.startswith(pattern) or file_path == pattern for pattern in p)
|
||||
|
||||
|
||||
def is_user_facing(
|
||||
file_path: str,
|
||||
patterns: frozenset[str] | None = None,
|
||||
) -> bool:
|
||||
"""Check if a file path is user-facing (affects the tool).
|
||||
|
||||
Inverse of is_workflow_only — anything not explicitly workflow-only
|
||||
is treated as user-facing.
|
||||
"""
|
||||
return not is_workflow_only(file_path, patterns)
|
||||
|
||||
|
||||
def classify_changes(
|
||||
files: list[str],
|
||||
patterns: frozenset[str] | None = None,
|
||||
) -> dict[str, list[str]]:
|
||||
"""Classify changed files into user-facing and workflow-only.
|
||||
|
||||
Returns a dict with keys "user_facing" and "workflow_only".
|
||||
"""
|
||||
user_facing: list[str] = []
|
||||
workflow_only: list[str] = []
|
||||
for f in files:
|
||||
if is_user_facing(f, patterns):
|
||||
user_facing.append(f)
|
||||
else:
|
||||
workflow_only.append(f)
|
||||
return {"user_facing": user_facing, "workflow_only": workflow_only}
|
||||
|
||||
|
||||
def has_user_facing_changes(
|
||||
base: str,
|
||||
head: str,
|
||||
patterns: frozenset[str] | None = None,
|
||||
) -> bool:
|
||||
"""Check if any user-facing files changed between base and head.
|
||||
|
||||
Imported by ``devx.ci.release`` to decide whether a release
|
||||
is needed. This is a cross-CI import that requires ``PYTHONPATH=.``.
|
||||
"""
|
||||
files = get_changed_files(base, head)
|
||||
return any(is_user_facing(f, patterns) for f in files)
|
||||
|
||||
|
||||
def get_latest_tag() -> str:
|
||||
"""Get the latest git tag, or empty string if none exists."""
|
||||
result = subprocess.run( # nosec B603 B607
|
||||
["git", "describe", "--tags", "--abbrev=0"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return ""
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _write_github_output(key: str, value: str) -> None:
|
||||
"""Append a key=value line to the $GITHUB_OUTPUT file."""
|
||||
import os
|
||||
|
||||
gh_output = os.environ.get("GITHUB_OUTPUT")
|
||||
if not gh_output:
|
||||
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
|
||||
with open(gh_output, "a") as f: # noqa: PTH123
|
||||
f.write(f"{key}={value}\n")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--base", default=None, help="Base ref (default: latest tag).")
|
||||
@click.option("--head", default="HEAD", help="Head ref (default: HEAD).")
|
||||
@click.option("--quiet", is_flag=True, default=False, help="Only output true/false.")
|
||||
@click.option(
|
||||
"--check",
|
||||
type=click.Choice(["all", "ansible", "user-facing"]),
|
||||
default="all",
|
||||
help="Check specific category: all (default), ansible, or user-facing.",
|
||||
)
|
||||
@click.option(
|
||||
"--github-output",
|
||||
"github_output",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Write results to $GITHUB_OUTPUT file (for CI workflow steps).",
|
||||
)
|
||||
def main(base: str | None, head: str, quiet: bool, check: str, github_output: bool) -> None:
|
||||
if base is None:
|
||||
base = get_latest_tag()
|
||||
if not base:
|
||||
if github_output:
|
||||
_write_github_output("ansible-changed", "true")
|
||||
_write_github_output("user-facing-changed", "true")
|
||||
click.echo("No tags found — treating all changes as user-facing.")
|
||||
return
|
||||
if quiet:
|
||||
click.echo("true")
|
||||
else:
|
||||
click.echo(_("No tags found — treating all changes as user-facing."))
|
||||
return
|
||||
|
||||
files = get_changed_files(base, head)
|
||||
if not files:
|
||||
if github_output:
|
||||
_write_github_output("ansible-changed", "false")
|
||||
_write_github_output("user-facing-changed", "false")
|
||||
click.echo(f"No changes between {base} and {head}.")
|
||||
return
|
||||
if quiet:
|
||||
click.echo("false")
|
||||
else:
|
||||
click.echo(_("No changes between {base} and {head}.", base=base, head=head))
|
||||
return
|
||||
|
||||
if github_output:
|
||||
ansible_files = [f for f in files if f.startswith("ansible/") or f == ".ansible-lint"]
|
||||
user_files = [f for f in files if is_user_facing(f)]
|
||||
_write_github_output("ansible-changed", "true" if ansible_files else "false")
|
||||
_write_github_output("user-facing-changed", "true" if user_files else "false")
|
||||
click.echo(f"Ansible files changed: {bool(ansible_files)}")
|
||||
click.echo(f"User-facing files changed: {bool(user_files)}")
|
||||
return
|
||||
|
||||
if check == "ansible":
|
||||
# Check only for Ansible-related file changes
|
||||
ansible_files = [f for f in files if f.startswith("ansible/") or f == ".ansible-lint"]
|
||||
has_ansible = bool(ansible_files)
|
||||
if quiet:
|
||||
click.echo("true" if has_ansible else "false")
|
||||
return
|
||||
click.echo(_("\nAnsible files changed ({count}):", count=len(ansible_files)))
|
||||
for f in ansible_files:
|
||||
click.echo(f" {f}")
|
||||
click.echo(_("\nResult: {status}", status="Ansible changes detected" if has_ansible else "No Ansible changes"))
|
||||
return
|
||||
|
||||
if check == "user-facing":
|
||||
# Check only for user-facing file changes (inverse of workflow-only)
|
||||
user_files = [f for f in files if is_user_facing(f)]
|
||||
has_user = bool(user_files)
|
||||
if quiet:
|
||||
click.echo("true" if has_user else "false")
|
||||
return
|
||||
click.echo(_("\nUser-facing files changed ({count}):", count=len(user_files)))
|
||||
for f in user_files:
|
||||
click.echo(f" {f}")
|
||||
click.echo(
|
||||
_("\nResult: {status}", status="User-facing changes detected" if has_user else "No user-facing changes")
|
||||
)
|
||||
return
|
||||
|
||||
result = classify_changes(files)
|
||||
has_user = bool(result["user_facing"])
|
||||
|
||||
if quiet:
|
||||
click.echo("true" if has_user else "false")
|
||||
return
|
||||
|
||||
click.echo(_("Comparing {base}..{head} ({count} files changed)", base=base, head=head, count=len(files)))
|
||||
click.echo(_("\nUser-facing changes ({count}):", count=len(result["user_facing"])))
|
||||
for f in result["user_facing"]:
|
||||
click.echo(f" {f}")
|
||||
click.echo(_("\nWorkflow-only changes ({count}):", count=len(result["workflow_only"])))
|
||||
for f in result["workflow_only"]:
|
||||
click.echo(f" {f}")
|
||||
if has_user:
|
||||
status = "USER-FACING changes detected — release needed"
|
||||
else:
|
||||
status = "Workflow-only changes — no release needed"
|
||||
click.echo(_("\nResult: {status}", status=status))
|
||||
|
||||
if not has_user:
|
||||
sys.exit(2) # Exit code 2 = workflow-only (used by CI to skip release)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Detect whether the latest git commit is a release commit.
|
||||
|
||||
Release commits have the format ``release: vX.Y.Z [skip ci]``.
|
||||
This script writes ``is-release=true`` or ``is-release=false`` to
|
||||
``$GITHUB_OUTPUT`` for use in CI workflow conditionals.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.ci.detect_release_commit
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
|
||||
RELEASE_RE = re.compile(r"^release: v\d+\.\d+\.\d+")
|
||||
|
||||
|
||||
def get_commit_message() -> str:
|
||||
"""Get the subject of the latest git commit."""
|
||||
result = subprocess.run( # nosec B603 B607
|
||||
["git", "log", "-1", "--pretty=%s"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise click.ClickException(f"git log failed: {result.stderr.strip()}")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def is_release_commit(message: str) -> bool:
|
||||
"""Check if a commit message matches the release commit format."""
|
||||
return bool(RELEASE_RE.match(message))
|
||||
|
||||
|
||||
def write_github_output(key: str, value: str) -> None:
|
||||
"""Append a key=value line to the $GITHUB_OUTPUT file."""
|
||||
gh_output = os.environ.get("GITHUB_OUTPUT")
|
||||
if not gh_output:
|
||||
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
|
||||
with open(gh_output, "a") as f: # noqa: PTH123
|
||||
f.write(f"{key}={value}\n")
|
||||
|
||||
|
||||
@click.command()
|
||||
def main() -> None:
|
||||
"""Detect if the latest commit is a release commit and set GITHUB_OUTPUT."""
|
||||
msg = get_commit_message()
|
||||
click.echo(f"Commit message: {msg}")
|
||||
is_release = is_release_commit(msg)
|
||||
write_github_output("is-release", "true" if is_release else "false")
|
||||
if is_release:
|
||||
click.echo("Release commit — skipping all post-merge jobs.")
|
||||
else:
|
||||
click.echo("Regular merge commit — running all post-merge jobs.")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Discover available Gitea Actions runners for dynamic job distribution.
|
||||
|
||||
Queries the Gitea API for registered runners at three levels:
|
||||
1. Repository level: GET /repos/{owner}/{repo}/actions/runners
|
||||
2. Organization level: GET /orgs/{org}/actions/runners
|
||||
3. Instance (admin) level: GET /admin/actions/runners
|
||||
|
||||
Falls back to the ``MOLECULE_RUNNERS`` repo variable or environment
|
||||
variable, then to ``DEFAULT_MAX_RUNNERS`` (3).
|
||||
|
||||
Outputs:
|
||||
- ``--count``: prints the number of available runners
|
||||
- ``--indices``: prints a JSON array [0, 1, ..., N-1] for use as a
|
||||
dynamic matrix in Gitea Actions
|
||||
- (default): prints both as ``count=N`` and ``indices=[0,1,...]``
|
||||
|
||||
Usage:
|
||||
python3 -m devx.ci.discover_runners --owner oblachno-oss --repo devx
|
||||
python3 -m devx.ci.discover_runners --indices
|
||||
python3 -m devx.ci.discover_runners --count
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import click
|
||||
import requests
|
||||
|
||||
from devx.config import GITEA_API_URL
|
||||
|
||||
DEFAULT_MAX_RUNNERS = 3
|
||||
|
||||
|
||||
def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
|
||||
"""Query the Gitea API for registered runners at all levels.
|
||||
|
||||
Returns the total count of active runners. If the API call fails
|
||||
(e.g., no admin access for instance-level runners), falls back to
|
||||
what we can see.
|
||||
"""
|
||||
headers = {"Authorization": f"token {token}"}
|
||||
total = 0
|
||||
|
||||
# 1. Repository-level runners
|
||||
try:
|
||||
r = requests.get(
|
||||
f"{api_url}/repos/{owner}/{repo}/actions/runners",
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
total += data.get("total_count", 0)
|
||||
except (requests.RequestException, ValueError):
|
||||
pass
|
||||
|
||||
# 2. Organization-level runners
|
||||
try:
|
||||
r = requests.get(
|
||||
f"{api_url}/orgs/{owner}/actions/runners",
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
total += data.get("total_count", 0)
|
||||
except (requests.RequestException, ValueError):
|
||||
pass
|
||||
|
||||
# 3. Instance-level runners (requires admin scope)
|
||||
try:
|
||||
r = requests.get(
|
||||
f"{api_url}/admin/actions/runners",
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
total += data.get("total_count", 0)
|
||||
except (requests.RequestException, ValueError):
|
||||
pass
|
||||
|
||||
return total
|
||||
|
||||
|
||||
def get_runner_count(api_url: str, token: str, owner: str, repo: str) -> int:
|
||||
"""Determine the number of available runners.
|
||||
|
||||
Tries the Gitea API first, then falls back to env vars, then default.
|
||||
"""
|
||||
# Try API query if we have a token
|
||||
if token:
|
||||
api_count = query_runners(api_url, token, owner, repo)
|
||||
if api_count > 0:
|
||||
return api_count
|
||||
|
||||
# Fall back to MOLECULE_RUNNERS env var (set by CI from repo variable)
|
||||
env_count = os.environ.get("MOLECULE_RUNNERS")
|
||||
if env_count:
|
||||
try:
|
||||
count = int(env_count)
|
||||
if count > 0:
|
||||
return count
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Fall back to default
|
||||
return DEFAULT_MAX_RUNNERS
|
||||
|
||||
|
||||
def generate_indices(count: int) -> list[str]:
|
||||
"""Generate a list of runner indices ["1", "2", ..., "N"].
|
||||
|
||||
Uses 1-based string indices because Gitea Actions renders
|
||||
integer 0 and string "0" as empty in ${{ matrix.runner-index }}
|
||||
expressions, causing --runner-index to be passed without a value.
|
||||
The distribute_molecule.py script converts these back to 0-based
|
||||
internally.
|
||||
"""
|
||||
return [str(i + 1) for i in range(count)]
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--owner", default=None, help="Repository owner (for API query).")
|
||||
@click.option("--repo", default=None, help="Repository name (for API query).")
|
||||
@click.option("--count", "output_count", is_flag=True, help="Output only the count.")
|
||||
@click.option("--indices", "output_indices", is_flag=True, help="Output only the JSON indices array.")
|
||||
@click.option(
|
||||
"--github-output",
|
||||
"github_output",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Write results to $GITHUB_OUTPUT file (for CI workflow steps).",
|
||||
)
|
||||
def main(
|
||||
owner: str | None,
|
||||
repo: str | None,
|
||||
output_count: bool,
|
||||
output_indices: bool,
|
||||
github_output: bool,
|
||||
) -> None:
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
|
||||
if owner is None:
|
||||
owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss")
|
||||
if repo is None:
|
||||
repo = os.environ.get("DEVX_REPO_NAME", "devx")
|
||||
|
||||
count = get_runner_count(GITEA_API_URL, token, owner, repo)
|
||||
indices = generate_indices(count)
|
||||
|
||||
if github_output:
|
||||
gh_output = os.environ.get("GITHUB_OUTPUT")
|
||||
if not gh_output:
|
||||
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
|
||||
with open(gh_output, "a") as f: # noqa: PTH123
|
||||
f.write(f"runner-count={count}\n")
|
||||
f.write(f"runner-indices={json.dumps(indices)}\n")
|
||||
click.echo(f"Runner count: {count}")
|
||||
click.echo(f"Runner indices: {indices}")
|
||||
return
|
||||
|
||||
if output_count:
|
||||
click.echo(str(count))
|
||||
return
|
||||
|
||||
if output_indices:
|
||||
click.echo(json.dumps(indices))
|
||||
return
|
||||
|
||||
# Default: output both as key=value pairs for CI consumption
|
||||
click.echo(f"count={count}")
|
||||
click.echo(f"indices={json.dumps(indices)}")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check documentation coverage for CLI commands and major modules.
|
||||
|
||||
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%.
|
||||
|
||||
Usage:
|
||||
python3 -m devx.ci.doc_coverage [--docs-dir docs/] [--fail-on-missing]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from devx.i18n import _
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
||||
DOCS_DIR = REPO_ROOT / "docs"
|
||||
CLI_FILE = REPO_ROOT / "src" / "devx" / "cli.py"
|
||||
|
||||
# Major modules that should be documented in tech/architecture.md
|
||||
REQUIRED_MODULES = [
|
||||
"cli.py",
|
||||
"i18n.py",
|
||||
"exceptions.py",
|
||||
"api_clients.py",
|
||||
"config.py",
|
||||
"gitea_cli.py",
|
||||
]
|
||||
|
||||
# CI scripts that should be documented in tech/ci-cd-workflow.md
|
||||
REQUIRED_SCRIPTS = [
|
||||
"auto_merge.py",
|
||||
"release.py",
|
||||
"publish.py",
|
||||
"pr_review.py",
|
||||
"notify_failure.py",
|
||||
"post_merge.py",
|
||||
"classify_changes.py",
|
||||
"discover_runners.py",
|
||||
"detect_release_commit.py",
|
||||
"push_badges.py",
|
||||
"distribute_molecule.py",
|
||||
"molecule_ci_guard.py",
|
||||
"validate_commit_msg.py",
|
||||
]
|
||||
|
||||
|
||||
def extract_cli_commands() -> list[str]:
|
||||
"""Extract command names from the CLI source file."""
|
||||
if not CLI_FILE.exists():
|
||||
return []
|
||||
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
|
||||
for match in re.finditer(r"@\w+\.command\b", content):
|
||||
# Check for explicit name="..." in the decorator arguments
|
||||
decorator_end = content.find(")", match.start())
|
||||
decorator_text = content[match.start() : decorator_end + 1]
|
||||
name_match = re.search(r'["\']([^"\']+)["\']', decorator_text)
|
||||
if name_match:
|
||||
commands.append(name_match.group(1))
|
||||
continue
|
||||
# Find the next def statement after this decorator
|
||||
after = content[decorator_end:]
|
||||
def_match = re.search(r"def\s+(\w+)\s*\(", after)
|
||||
if def_match:
|
||||
commands.append(def_match.group(1))
|
||||
return commands
|
||||
|
||||
|
||||
def check_command_documented(command: str, docs_content: str) -> bool:
|
||||
"""Check if a CLI command is documented in the docs content."""
|
||||
# Look for the command name as a heading or in code blocks
|
||||
# Also matches subgroup prefixes like "devx ci release" or "devx tools setup"
|
||||
patterns = [
|
||||
rf"##.*\b{re.escape(command)}\b",
|
||||
rf"`devx\s+(?:\w+\s+)?{re.escape(command)}\b",
|
||||
rf"\bdevx\s+(?:\w+\s+)?{re.escape(command)}\b",
|
||||
rf"###.*\b{re.escape(command)}\b",
|
||||
]
|
||||
return any(re.search(p, docs_content, re.IGNORECASE) for p in patterns)
|
||||
|
||||
|
||||
def check_module_documented(module: str, docs_content: str) -> bool:
|
||||
"""Check if a module is mentioned in the docs content."""
|
||||
return module in docs_content
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--docs-dir", default=str(DOCS_DIR), help="Path to the docs directory.")
|
||||
@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)
|
||||
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"
|
||||
|
||||
missing: list[str] = []
|
||||
total = 0
|
||||
|
||||
# Check CLI commands
|
||||
click.echo(_("Checking CLI command documentation..."))
|
||||
commands = extract_cli_commands()
|
||||
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))
|
||||
else:
|
||||
click.echo(_(" MISSING: devx {cmd}", cmd=cmd))
|
||||
missing.append(f"CLI command: devx {cmd}")
|
||||
|
||||
# Check modules in architecture.md
|
||||
click.echo(_("\nChecking module documentation in architecture.md..."))
|
||||
total += len(REQUIRED_MODULES)
|
||||
arch_docs = architecture_file.read_text() if architecture_file.exists() else ""
|
||||
for module in REQUIRED_MODULES:
|
||||
if check_module_documented(module, arch_docs):
|
||||
click.echo(_(" OK: {module}", module=module))
|
||||
else:
|
||||
click.echo(_(" MISSING: {module}", module=module))
|
||||
missing.append(f"Module: {module}")
|
||||
|
||||
# Check CI scripts in ci-cd-workflow.md
|
||||
click.echo(_("\nChecking CI script documentation in ci-cd-workflow.md..."))
|
||||
total += len(REQUIRED_SCRIPTS)
|
||||
ci_docs = ci_cd_file.read_text() if ci_cd_file.exists() else ""
|
||||
for script in REQUIRED_SCRIPTS:
|
||||
if check_module_documented(script, ci_docs):
|
||||
click.echo(_(" OK: {script}", script=script))
|
||||
else:
|
||||
click.echo(_(" MISSING: {script}", script=script))
|
||||
missing.append(f"CI script: {script}")
|
||||
|
||||
# Report
|
||||
covered = total - len(missing)
|
||||
percentage = (covered / total * 100) if total > 0 else 100.0
|
||||
click.echo(
|
||||
_(
|
||||
"\nDoc coverage: {covered}/{total} ({pct}%)",
|
||||
covered=covered,
|
||||
total=total,
|
||||
pct=f"{percentage:.0f}",
|
||||
)
|
||||
)
|
||||
|
||||
if missing:
|
||||
click.echo(_("\nMissing documentation:"))
|
||||
for item in missing:
|
||||
click.echo(f" - {item}")
|
||||
|
||||
if missing and fail_on_missing:
|
||||
click.echo(_("\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce."))
|
||||
sys.exit(1)
|
||||
|
||||
if not missing:
|
||||
click.echo(_("\nAll documentation coverage checks passed!"))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create a Gitea issue when a CI workflow fails.
|
||||
|
||||
Used by the release and publish workflows to alert on failures that would
|
||||
otherwise go unnoticed in the Actions tab. Uses the ``tea`` Gitea CLI
|
||||
for issue creation — tea must be installed and configured.
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 -m devx.ci.notify_failure \
|
||||
--repo <owner/repo> \
|
||||
--run-id <run_id> \
|
||||
--workflow <workflow_name> \
|
||||
--commit <commit_sha>
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from devx.config import GITEA_API_URL
|
||||
from devx.gitea_cli import TeaCLI, TeaCLIError
|
||||
from devx.i18n import _
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
|
||||
def _create_issue_via_tea(repo: str, title: str, body: str) -> int:
|
||||
"""Create issue via tea CLI. Returns issue index.
|
||||
|
||||
Raises TeaCLIError if tea is not installed or the command fails.
|
||||
"""
|
||||
tea = TeaCLI(repo=repo)
|
||||
|
||||
# Check if "bug" label exists
|
||||
labels: list[str] = []
|
||||
with contextlib.suppress(TeaCLIError):
|
||||
existing_labels = tea.list_labels(repo)
|
||||
if any(label.get("name") == "bug" for label in existing_labels):
|
||||
labels = ["bug"]
|
||||
|
||||
issue = tea.create_issue(repo, title=title, body=body, labels=labels if labels else None)
|
||||
if labels:
|
||||
with contextlib.suppress(TeaCLIError):
|
||||
tea.add_label(repo, issue["index"], labels)
|
||||
return int(issue.get("index", 0))
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--repo", required=True, help="Repository in owner/name format.")
|
||||
@click.option("--run-id", required=True, help="CI run ID.")
|
||||
@click.option("--workflow", required=True, help="Workflow name.")
|
||||
@click.option("--commit", required=True, help="Commit SHA.")
|
||||
def main(repo: str, run_id: str, workflow: str, commit: str) -> None:
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
||||
|
||||
title = f"[CI] {workflow} workflow failed (run #{run_id})"
|
||||
body = (
|
||||
f"The **{workflow}** workflow failed.\n\n"
|
||||
f"- **Run ID**: #{run_id}\n"
|
||||
f"- **Commit**: `{commit[:8]}`\n"
|
||||
f"- **Check the logs**: {GITEA_API_URL.replace('/api/v1', '')}/"
|
||||
f"{repo}/actions/runs/{run_id}\n\n"
|
||||
f"Please investigate and fix the issue."
|
||||
)
|
||||
|
||||
try:
|
||||
issue_id = _create_issue_via_tea(repo, title, body)
|
||||
except TeaCLIError as e:
|
||||
raise click.ClickException(_("Failed to create issue via tea: {error}", error=str(e))) from None
|
||||
|
||||
click.echo(
|
||||
_(
|
||||
"Created issue #{issue_id}: {title}",
|
||||
issue_id=issue_id or "?",
|
||||
title=title,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Update Vikunja task after a merge to master.
|
||||
|
||||
Usage:
|
||||
VIKUNJA_TOKEN=<token> python3 -m devx.ci.post_merge <commit_msg> [--commit-sha <sha>]
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from devx.api_clients import VikunjaClient
|
||||
from devx.config import DEFAULT_PER_PAGE, TASK_ID_RE, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
|
||||
from devx.exceptions import APIError
|
||||
from devx.i18n import _
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
|
||||
def _get_git_commit_message() -> str:
|
||||
"""Get the full commit message of the latest commit."""
|
||||
result = subprocess.run( # nosec B603 B607
|
||||
["git", "log", "-1", "--pretty=%B"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise click.ClickException(f"git log failed: {result.stderr.strip()}")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _get_git_commit_sha() -> str:
|
||||
"""Get the SHA of the latest commit."""
|
||||
result = subprocess.run( # nosec B603 B607
|
||||
["git", "rev-parse", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise click.ClickException(f"git rev-parse failed: {result.stderr.strip()}")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def extract_task_id(commit_msg: str) -> str:
|
||||
"""Extract DEVX-N task identifier from the first line of commit message."""
|
||||
first_line = commit_msg.split("\n")[0]
|
||||
match = TASK_ID_RE.search(first_line)
|
||||
return match.group(0) if match else ""
|
||||
|
||||
|
||||
def extract_conventional_msg(commit_msg: str) -> str:
|
||||
"""Strip the DEVX-N prefix from the commit subject.
|
||||
|
||||
Handles both formats:
|
||||
- ``DEVX-N: <message>`` (legacy, colon-separated)
|
||||
- ``DEVX-N <message>`` (current, space-separated)
|
||||
"""
|
||||
first_line = commit_msg.split("\n")[0]
|
||||
return re.sub(r"^DEVX-\d+[:\s]\s*", "", first_line)
|
||||
|
||||
|
||||
def resolve_task_id(client: VikunjaClient, task_id: str) -> int:
|
||||
"""Resolve DEVX-N identifier to Vikunja numeric task ID.
|
||||
|
||||
Paginates through the project's tasks to handle projects with more
|
||||
than 50 tasks. Raises ClickException if the task is not found.
|
||||
"""
|
||||
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 int(matches[0]["id"])
|
||||
if len(tasks) < DEFAULT_PER_PAGE:
|
||||
break
|
||||
page += 1
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Could not find Vikunja task {task_id} in project {project_id}. "
|
||||
"Every PR must have a corresponding Vikunja task.",
|
||||
task_id=task_id,
|
||||
project_id=VIKUNJA_PROJECT_ID,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def build_comment(task_id: str, conv_msg: str, commit_sha: str) -> str:
|
||||
"""Build HTML comment body for Vikunja."""
|
||||
return f"<p><strong>{task_id}</strong>: {conv_msg}</p><p>Commit: <code>{commit_sha}</code></p>"
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("commit_msg", required=False)
|
||||
@click.option("--commit-sha", default="", help="Commit SHA")
|
||||
@click.option("--from-git", is_flag=True, default=False, help="Read commit message and SHA from git.")
|
||||
@click.option(
|
||||
"--git-sha",
|
||||
default="",
|
||||
help="Read commit message from a specific git SHA (avoids race condition with parallel jobs).",
|
||||
)
|
||||
def main(commit_msg: str | None, commit_sha: str, from_git: bool, git_sha: str) -> None:
|
||||
if git_sha:
|
||||
# Read commit message from a specific SHA — this avoids the race
|
||||
# condition where a parallel job (e.g., release) pushes a new commit
|
||||
# to master before this job reads HEAD.
|
||||
result = subprocess.run( # nosec B603 B607
|
||||
["git", "log", "-1", "--pretty=%B", git_sha],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise click.ClickException(f"git log failed for SHA {git_sha}: {result.stderr.strip()}")
|
||||
commit_msg = result.stdout.strip()
|
||||
if not commit_sha:
|
||||
commit_sha = git_sha
|
||||
elif from_git:
|
||||
commit_msg = _get_git_commit_message()
|
||||
if not commit_sha:
|
||||
commit_sha = _get_git_commit_sha()
|
||||
if not commit_msg:
|
||||
raise click.ClickException("commit_msg argument is required (or use --from-git or --git-sha)")
|
||||
token = os.environ.get("VIKUNJA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: VIKUNJA_TOKEN is not set."))
|
||||
|
||||
task_id = extract_task_id(commit_msg)
|
||||
if not task_id:
|
||||
first_line = commit_msg.split("\n")[0]
|
||||
# Skip gracefully for infrastructure commits that don't follow
|
||||
# the DEVX-N convention: release commits, reverts, bot commits, etc.
|
||||
infra_patterns = [
|
||||
r"^release: v\d+\.\d+\.\d+", # release commits
|
||||
r"^revert: ", # git revert commits
|
||||
r"^Merge ", # merge commits
|
||||
r"^\[skip ci\]", # skip-ci commits
|
||||
]
|
||||
for pattern in infra_patterns:
|
||||
if re.match(pattern, first_line):
|
||||
click.echo(
|
||||
_(
|
||||
"Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}",
|
||||
msg=first_line,
|
||||
)
|
||||
)
|
||||
return
|
||||
# Non-infrastructure commits without DEVX-N prefix — warn but don't fail
|
||||
click.echo(
|
||||
_(
|
||||
"Warning: No task ID (DEVX-N) found in commit message: {msg}. Skipping Vikunja update.",
|
||||
msg=first_line,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
client = VikunjaClient(VIKUNJA_API_URL, token)
|
||||
vikunja_task_id = resolve_task_id(client, task_id)
|
||||
conv_msg = extract_conventional_msg(commit_msg)
|
||||
sha = commit_sha or "unknown"
|
||||
html = build_comment(task_id, conv_msg, sha)
|
||||
|
||||
try:
|
||||
client.post_comment(vikunja_task_id, html)
|
||||
client.update_task(vikunja_task_id, done=True)
|
||||
except APIError as e:
|
||||
# Vikunja is a project management tool — if it's down, the merge
|
||||
# still succeeded. Warn but don't fail the post-merge workflow.
|
||||
click.echo(
|
||||
_(
|
||||
"Warning: Vikunja API error (HTTP {status}): {message}. "
|
||||
"Task {task_id} was NOT updated. The merge succeeded — "
|
||||
"please update the Vikunja task manually.",
|
||||
status=e.status,
|
||||
message=e.message,
|
||||
task_id=task_id,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
click.echo(
|
||||
_(
|
||||
"Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.",
|
||||
task_id=task_id,
|
||||
vikunja_id=vikunja_task_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,571 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Automated PR review: check architecture compliance, best practices, and quality.
|
||||
|
||||
Fetches the PR diff via the Gitea API, runs a series of automated checks,
|
||||
and posts a structured review using GiteaClient.create_review.
|
||||
|
||||
Checks performed:
|
||||
1. Architecture compliance — no business logic in CLI, no direct subprocess
|
||||
calls outside executor, no hardcoded config that should be in config.py
|
||||
2. Best practices — no bare except, no print() (use click.echo), no TODO/FIXME
|
||||
left in merged code, no functions > 50 lines
|
||||
3. Security — no secrets in code, no shell=True, no eval/exec
|
||||
4. i18n — no raw English strings in click.echo() without _() wrapper
|
||||
5. Resource management — no open() without with statement, no subprocess without cleanup
|
||||
6. Documentation — new CLI commands documented, new modules in architecture.md
|
||||
7. Test coverage — 100% enforced by pytest-cov (checked in quality job)
|
||||
8. Commit conventions — conventional commit format on branch commits
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 -m devx.ci.pr_review <pr_number> <owner/repo>
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from devx.api_clients import GiteaClient
|
||||
from devx.config import GITEA_API_URL
|
||||
from devx.exceptions import APIError
|
||||
from devx.i18n import _
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
# Files that are exempt from certain checks
|
||||
WORKFLOW_ONLY_SUFFIXES = (".yml", ".yaml", ".md", ".json", ".toml", ".cfg", ".ini", ".txt")
|
||||
PYTHON_SUFFIX = ".py"
|
||||
|
||||
# Architecture rules
|
||||
CLI_FILE = "src/devx/cli.py"
|
||||
EXECUTOR_FILE = "src/devx/executor.py"
|
||||
CONFIG_FILE = "src/devx/config.py"
|
||||
|
||||
# Patterns that indicate business logic in CLI (should be in runner_manager.py)
|
||||
BUSINESS_LOGIC_IN_CLI = [
|
||||
(r"subprocess\.(run|call|Popen|check_output|check_call)", "subprocess call in CLI — delegate to executor.py"),
|
||||
(r"\bos\.system\b", "os.system call in CLI — delegate to executor.py"),
|
||||
(r"\bansible-playbook\b", "ansible-playbook reference in CLI — delegate to executor.py"),
|
||||
]
|
||||
|
||||
# Patterns that indicate bad practices
|
||||
BAD_PRACTICES = [
|
||||
(r"\bprint\s*\(", "print() found — use click.echo() for user output"),
|
||||
(r"\beval\s*\(", "eval() found — security risk, avoid dynamic code execution"),
|
||||
(r"\bexec\s*\(", "exec() found — security risk, avoid dynamic code execution"),
|
||||
(r"shell\s*=\s*True", "shell=True found — security risk, use shell=False with list args"),
|
||||
(r"except\s*:", "bare except found — catch specific exceptions"),
|
||||
(r"except\s+Exception\s*:", "broad Exception catch — catch specific exceptions"),
|
||||
(r"#\s*(TODO|FIXME|HACK|XXX)", "TODO/FIXME found — resolve before merging"),
|
||||
]
|
||||
|
||||
# Patterns for hardcoded config values that should be in config.py
|
||||
HARDCODED_CONFIG = [
|
||||
(r"https?://[a-z]+\.[a-z]+\.[a-z]+", "hardcoded URL — move to config.py with env var override"),
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReviewResult:
|
||||
"""Result of automated review checks."""
|
||||
|
||||
issues: list[dict[str, Any]] = field(default_factory=list)
|
||||
summary: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def has_issues(self) -> bool:
|
||||
return bool(self.issues)
|
||||
|
||||
def add_issue(self, file_path: str, line: int, message: str, severity: str = "warning") -> None:
|
||||
self.issues.append(
|
||||
{
|
||||
"path": file_path,
|
||||
"body": f"[{severity}] {message}",
|
||||
"new_position": line,
|
||||
}
|
||||
)
|
||||
|
||||
def add_summary(self, text: str) -> None:
|
||||
self.summary.append(text)
|
||||
|
||||
|
||||
def is_python_file(path: str) -> bool:
|
||||
"""Check if a file is a Python source file."""
|
||||
return path.endswith(PYTHON_SUFFIX) and not path.startswith("tests/")
|
||||
|
||||
|
||||
def is_workflow_only(path: str) -> bool:
|
||||
"""Check if a file is workflow/config/docs only (not Python source)."""
|
||||
return path.endswith(WORKFLOW_ONLY_SUFFIXES) or path.startswith((".gitea/", "docs/", "ansible/"))
|
||||
|
||||
|
||||
def check_architecture_compliance(files: list[dict[str, Any]], result: ReviewResult) -> None:
|
||||
"""Check that changes follow the documented architecture."""
|
||||
for f in files:
|
||||
path = f.get("filename", "")
|
||||
if not is_python_file(path):
|
||||
continue
|
||||
|
||||
patch = f.get("patch", "")
|
||||
if not patch:
|
||||
continue
|
||||
|
||||
lines = patch.split("\n")
|
||||
current_line = 0
|
||||
for line in lines:
|
||||
if line.startswith("@@"):
|
||||
match = re.search(r"\+(\d+)", line)
|
||||
if match:
|
||||
current_line = int(match.group(1)) - 1
|
||||
continue
|
||||
if line.startswith("+") and not line.startswith("+++"):
|
||||
current_line += 1
|
||||
content = line[1:]
|
||||
|
||||
# Check for business logic in CLI
|
||||
if path == CLI_FILE:
|
||||
for pattern, msg in BUSINESS_LOGIC_IN_CLI:
|
||||
if re.search(pattern, content):
|
||||
result.add_issue(path, current_line, msg, "error")
|
||||
|
||||
if not result.issues:
|
||||
result.add_summary("- Architecture compliance: OK")
|
||||
|
||||
|
||||
def check_best_practices(files: list[dict[str, Any]], result: ReviewResult) -> None:
|
||||
"""Check for common code quality issues."""
|
||||
for f in files:
|
||||
path = f.get("filename", "")
|
||||
if not is_python_file(path):
|
||||
continue
|
||||
|
||||
patch = f.get("patch", "")
|
||||
if not patch:
|
||||
continue
|
||||
|
||||
lines = patch.split("\n")
|
||||
current_line = 0
|
||||
for line in lines:
|
||||
if line.startswith("@@"):
|
||||
match = re.search(r"\+(\d+)", line)
|
||||
if match:
|
||||
current_line = int(match.group(1)) - 1
|
||||
continue
|
||||
if line.startswith("+") and not line.startswith("+++"):
|
||||
current_line += 1
|
||||
content = line[1:]
|
||||
|
||||
for pattern, msg in BAD_PRACTICES:
|
||||
if re.search(pattern, content):
|
||||
result.add_issue(path, current_line, msg, "warning")
|
||||
|
||||
if not any(i["body"].startswith("[warning]") for i in result.issues):
|
||||
result.add_summary("- Best practices: OK")
|
||||
|
||||
|
||||
def check_security(files: list[dict[str, Any]], result: ReviewResult) -> None:
|
||||
"""Check for security issues in changed files."""
|
||||
for f in files:
|
||||
path = f.get("filename", "")
|
||||
if not is_python_file(path):
|
||||
continue
|
||||
|
||||
patch = f.get("patch", "")
|
||||
if not patch:
|
||||
continue
|
||||
|
||||
lines = patch.split("\n")
|
||||
current_line = 0
|
||||
for line in lines:
|
||||
if line.startswith("@@"):
|
||||
match = re.search(r"\+(\d+)", line)
|
||||
if match:
|
||||
current_line = int(match.group(1)) - 1
|
||||
continue
|
||||
if line.startswith("+") and not line.startswith("+++"):
|
||||
current_line += 1
|
||||
content = line[1:]
|
||||
|
||||
# Check for hardcoded secrets
|
||||
secret_re = r'(token|password|secret|key)\s*=\s*["\'][^"\']{8,}["\']' # nosec B105
|
||||
is_secret = re.search(secret_re, content, re.IGNORECASE)
|
||||
is_comment = content.strip().startswith("#")
|
||||
is_example = "your-" in content or "example" in content
|
||||
if is_secret and not is_comment and not is_example:
|
||||
result.add_issue(
|
||||
path,
|
||||
current_line,
|
||||
"potential hardcoded secret — use environment variable",
|
||||
"error",
|
||||
)
|
||||
|
||||
if not any(i["body"].startswith("[error]") and "secret" in i["body"] for i in result.issues):
|
||||
result.add_summary("- Security: OK")
|
||||
|
||||
|
||||
def check_i18n(files: list[dict[str, Any]], result: ReviewResult) -> None:
|
||||
"""Check that user-facing strings are wrapped in _().
|
||||
|
||||
Detects ``click.echo()`` calls with raw string literals that are not
|
||||
wrapped in ``_()``. Only checks ``src/`` files, not tests or scripts.
|
||||
"""
|
||||
# Pattern: click.echo("...") or click.echo(f"...") without _() wrapper
|
||||
raw_echo_re = re.compile(r'click\.echo\s*\(\s*["\']([^"\']+)["\']')
|
||||
raw_fstring_re = re.compile(r'click\.echo\s*\(\s*f["\']')
|
||||
# Also check click.ClickException and raise with string
|
||||
raw_exception_re = re.compile(r'click\.ClickException\s*\(\s*["\']([^"\']+)["\']')
|
||||
|
||||
for f in files:
|
||||
path = f.get("filename", "")
|
||||
if not is_python_file(path) or not path.startswith("src/"):
|
||||
continue
|
||||
|
||||
patch = f.get("patch", "")
|
||||
if not patch:
|
||||
continue
|
||||
|
||||
lines = patch.split("\n")
|
||||
current_line = 0
|
||||
for line in lines:
|
||||
if line.startswith("@@"):
|
||||
match = re.search(r"\+(\d+)", line)
|
||||
if match:
|
||||
current_line = int(match.group(1)) - 1
|
||||
continue
|
||||
if line.startswith("+") and not line.startswith("+++"):
|
||||
current_line += 1
|
||||
content = line[1:]
|
||||
|
||||
# Skip comments and docstrings
|
||||
stripped = content.strip()
|
||||
if stripped.startswith("#") or stripped.startswith('"""') or stripped.startswith("'''"):
|
||||
continue
|
||||
|
||||
# Check for raw strings in click.echo without _()
|
||||
for regex, msg in [
|
||||
(raw_echo_re, "click.echo() with raw string — wrap in _() for i18n"),
|
||||
(raw_fstring_re, "click.echo() with f-string — wrap in _() for i18n"),
|
||||
(raw_exception_re, "ClickException with raw string — wrap in _() for i18n"),
|
||||
]:
|
||||
if regex.search(content):
|
||||
result.add_issue(path, current_line, msg, "warning")
|
||||
|
||||
if not any("i18n" in i["body"] for i in result.issues):
|
||||
result.add_summary("- i18n: OK")
|
||||
|
||||
|
||||
def check_resource_management(files: list[dict[str, Any]], result: ReviewResult) -> None:
|
||||
"""Check for resource leaks: open() without with, subprocess without cleanup.
|
||||
|
||||
Detects:
|
||||
- ``open()`` calls not in a ``with`` statement
|
||||
- ``subprocess.Popen()`` without ``.wait()`` or ``.communicate()``
|
||||
"""
|
||||
# Pattern: open("...") not preceded by "with" on the same line
|
||||
open_re = re.compile(r"(?<!with\s)\bopen\s*\(")
|
||||
popen_re = re.compile(r"subprocess\.Popen\s*\(")
|
||||
|
||||
for f in files:
|
||||
path = f.get("filename", "")
|
||||
if not is_python_file(path):
|
||||
continue
|
||||
|
||||
patch = f.get("patch", "")
|
||||
if not patch:
|
||||
continue
|
||||
|
||||
lines = patch.split("\n")
|
||||
current_line = 0
|
||||
for line in lines:
|
||||
if line.startswith("@@"):
|
||||
match = re.search(r"\+(\d+)", line)
|
||||
if match:
|
||||
current_line = int(match.group(1)) - 1
|
||||
continue
|
||||
if line.startswith("+") and not line.startswith("+++"):
|
||||
current_line += 1
|
||||
content = line[1:]
|
||||
|
||||
# Skip comments
|
||||
if content.strip().startswith("#"):
|
||||
continue
|
||||
|
||||
# Check for open() without with
|
||||
if open_re.search(content) and "with " not in content:
|
||||
result.add_issue(
|
||||
path, current_line, "open() without with statement — potential resource leak", "warning"
|
||||
)
|
||||
|
||||
# Check for Popen without communicate/wait on same line
|
||||
if popen_re.search(content) and ".communicate" not in content and ".wait" not in content:
|
||||
result.add_issue(
|
||||
path,
|
||||
current_line,
|
||||
"subprocess.Popen() without immediate .communicate() or .wait() — ensure cleanup",
|
||||
"warning",
|
||||
)
|
||||
|
||||
if not any("resource" in i["body"].lower() for i in result.issues):
|
||||
result.add_summary("- Resource management: OK")
|
||||
|
||||
|
||||
def check_function_length(files: list[dict[str, Any]], result: ReviewResult) -> None:
|
||||
"""Check that no new function is excessively long (> 50 lines)."""
|
||||
for f in files:
|
||||
path = f.get("filename", "")
|
||||
if not is_python_file(path):
|
||||
continue
|
||||
|
||||
patch = f.get("patch", "")
|
||||
if not patch:
|
||||
continue
|
||||
|
||||
# Count consecutive added lines within a function
|
||||
lines = patch.split("\n")
|
||||
current_line = 0
|
||||
func_start = 0
|
||||
func_name = ""
|
||||
added_in_func = 0
|
||||
|
||||
for line in lines:
|
||||
if line.startswith("@@"):
|
||||
if func_name and added_in_func > 50:
|
||||
result.add_issue(
|
||||
path,
|
||||
func_start,
|
||||
f"function '{func_name}' adds {added_in_func} lines — consider splitting (> 50 lines)",
|
||||
"warning",
|
||||
)
|
||||
match = re.search(r"\+(\d+)", line)
|
||||
if match:
|
||||
current_line = int(match.group(1)) - 1
|
||||
func_name = ""
|
||||
added_in_func = 0
|
||||
continue
|
||||
if line.startswith("+") and not line.startswith("+++"):
|
||||
current_line += 1
|
||||
content = line[1:]
|
||||
func_match = re.match(r"\s*def\s+(\w+)\s*\(", content)
|
||||
if func_match:
|
||||
if func_name and added_in_func > 50:
|
||||
result.add_issue(
|
||||
path,
|
||||
func_start,
|
||||
f"function '{func_name}' adds {added_in_func} lines — consider splitting (> 50 lines)",
|
||||
"warning",
|
||||
)
|
||||
func_name = func_match.group(1)
|
||||
func_start = current_line
|
||||
added_in_func = 0
|
||||
else:
|
||||
added_in_func += 1
|
||||
elif line.startswith(" ") or line.startswith("-"):
|
||||
pass # context or removed line
|
||||
|
||||
# Check last function
|
||||
if func_name and added_in_func > 50:
|
||||
result.add_issue(
|
||||
path,
|
||||
func_start,
|
||||
f"function '{func_name}' adds {added_in_func} lines — consider splitting (> 50 lines)",
|
||||
"warning",
|
||||
)
|
||||
|
||||
|
||||
def check_documentation(files: list[dict[str, Any]], result: ReviewResult) -> None:
|
||||
"""Check that documentation is updated for relevant changes."""
|
||||
has_src_changes = any(
|
||||
is_python_file(f.get("filename", "")) and f.get("filename", "").startswith("src/") for f in files
|
||||
)
|
||||
has_doc_changes = any(
|
||||
f.get("filename", "").startswith("docs/") or f.get("filename", "") in ("README.md", "AGENTS.md", "CHANGELOG.md")
|
||||
for f in files
|
||||
)
|
||||
has_ansible_changes = any(f.get("filename", "").startswith("ansible/") for f in files)
|
||||
|
||||
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")
|
||||
else:
|
||||
result.add_summary("- Documentation: OK")
|
||||
|
||||
|
||||
def check_test_coverage(files: list[dict[str, Any]], result: ReviewResult) -> None:
|
||||
"""Check that tests are updated for source changes."""
|
||||
has_src_changes = any(
|
||||
is_python_file(f.get("filename", "")) and f.get("filename", "").startswith("src/") for f in files
|
||||
)
|
||||
has_test_changes = any(f.get("filename", "").startswith("tests/") for f in files)
|
||||
|
||||
if has_src_changes and not has_test_changes:
|
||||
result.add_summary("- Tests: WARNING — source files changed but no test files updated")
|
||||
else:
|
||||
result.add_summary("- Tests: OK")
|
||||
|
||||
|
||||
def check_commit_conventions(client: GiteaClient, pr_number: str, result: ReviewResult) -> None:
|
||||
"""Check that PR commits follow conventional commit format.
|
||||
|
||||
Verifies that at least one commit on the PR branch matches the
|
||||
conventional commit pattern (type: description). Merge commits
|
||||
and revert commits are exempt.
|
||||
"""
|
||||
try:
|
||||
commits = client.get_pr_commits(pr_number)
|
||||
except APIError as e:
|
||||
result.add_summary(f"- Commit conventions: ERROR — could not fetch commits: {e.message}")
|
||||
return
|
||||
|
||||
if not commits:
|
||||
result.add_summary("- Commit conventions: OK (no commits to check)")
|
||||
return
|
||||
|
||||
from devx.config import CONVENTIONAL_RE
|
||||
|
||||
has_conventional = False
|
||||
non_conventional: list[str] = []
|
||||
|
||||
for commit in commits:
|
||||
commit_info = commit.get("commit", {})
|
||||
message = str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
|
||||
# Skip merge commits and revert commits
|
||||
if message.startswith(("Merge", "Revert")):
|
||||
continue
|
||||
if CONVENTIONAL_RE.match(message):
|
||||
has_conventional = True
|
||||
else:
|
||||
non_conventional.append(message[:60])
|
||||
|
||||
if has_conventional:
|
||||
result.add_summary("- Commit conventions: OK")
|
||||
elif non_conventional:
|
||||
result.add_summary(
|
||||
f"- Commit conventions: WARNING — no conventional commit found. "
|
||||
f"Non-conventional commits: {', '.join(non_conventional[:3])}"
|
||||
)
|
||||
else:
|
||||
result.add_summary("- Commit conventions: OK (all commits are merges/reverts)")
|
||||
|
||||
|
||||
def run_review(client: GiteaClient, pr_number: str) -> ReviewResult:
|
||||
"""Run all review checks and return the result."""
|
||||
result = ReviewResult()
|
||||
|
||||
try:
|
||||
files = client.get_pr_files(pr_number)
|
||||
except APIError as e:
|
||||
result.add_summary(f"- ERROR: Could not fetch PR files: {e.message}")
|
||||
return result
|
||||
|
||||
if not files:
|
||||
result.add_summary("- No files changed in this PR")
|
||||
return result
|
||||
|
||||
# Run all checks
|
||||
check_architecture_compliance(files, result)
|
||||
check_best_practices(files, result)
|
||||
check_security(files, result)
|
||||
check_i18n(files, result)
|
||||
check_resource_management(files, result)
|
||||
check_function_length(files, result)
|
||||
check_documentation(files, result)
|
||||
check_test_coverage(files, result)
|
||||
check_commit_conventions(client, pr_number, result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def build_review_body(result: ReviewResult) -> str:
|
||||
"""Build the review body text from the review result."""
|
||||
lines = ["## Automated PR Review", ""]
|
||||
|
||||
for item in result.summary:
|
||||
lines.append(item)
|
||||
|
||||
if result.issues:
|
||||
lines.append("")
|
||||
lines.append(f"**{len(result.issues)} issue(s) found:**")
|
||||
lines.append("")
|
||||
for issue in result.issues:
|
||||
lines.append(f"- `{issue['path']}:{issue['new_position']}` — {issue['body']}")
|
||||
else:
|
||||
lines.append("")
|
||||
lines.append("No issues found by automated checks.")
|
||||
|
||||
lines.append("")
|
||||
lines.append("---")
|
||||
lines.append("**Auto-merge:** If all CI checks pass, this PR will be merged automatically.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def post_review(client: GiteaClient, pr_number: str, result: ReviewResult) -> dict[str, Any]:
|
||||
"""Post the review to the PR.
|
||||
|
||||
Uses REQUEST_CHANGES when issues are found, COMMENT otherwise.
|
||||
Never uses APPROVE — the bot shares the PR author's token, so
|
||||
Gitea rejects self-approval. The actual APPROVE must come from
|
||||
the manual review step.
|
||||
"""
|
||||
body = build_review_body(result)
|
||||
event = "REQUEST_CHANGES" if result.has_issues else "COMMENT"
|
||||
comments = result.issues if result.has_issues else []
|
||||
|
||||
return client.create_review(pr_number, event=event, body=body, comments=comments)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("pr_number")
|
||||
@click.argument("repo")
|
||||
@click.option("--dry-run", is_flag=True, default=False, help="Print review without posting.")
|
||||
def main(pr_number: str, repo: str, dry_run: bool) -> None:
|
||||
"""Run automated PR review and post results to Gitea."""
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
||||
|
||||
owner, repo_name = repo.split("/")
|
||||
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
|
||||
|
||||
result = run_review(client, pr_number)
|
||||
|
||||
body = build_review_body(result)
|
||||
event = "REQUEST_CHANGES" if result.has_issues else "COMMENT"
|
||||
|
||||
click.echo(f"Review event: {event}")
|
||||
click.echo(f"Issues found: {len(result.issues)}")
|
||||
click.echo("")
|
||||
click.echo(body)
|
||||
|
||||
if dry_run:
|
||||
click.echo("\n[dry-run] Review not posted.")
|
||||
return
|
||||
|
||||
try:
|
||||
review = post_review(client, pr_number, result)
|
||||
except APIError as e:
|
||||
if "approve" in e.message.lower() or "422" in str(e.status):
|
||||
click.echo(_("Note: Self-approval not allowed. Posting COMMENT instead."))
|
||||
review = client.create_review(pr_number, event="COMMENT", body=body)
|
||||
else:
|
||||
raise
|
||||
review_id = review.get("id", "?")
|
||||
click.echo(
|
||||
_(
|
||||
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
|
||||
review_id=review_id,
|
||||
pr_number=pr_number,
|
||||
event=event,
|
||||
num_comments=len(result.issues),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build package, optionally publish to PyPI or Gitea PyPI, and create Gitea release.
|
||||
|
||||
Uses git-cliff to generate the release notes from conventional commits.
|
||||
Uses the ``tea`` Gitea CLI for release creation.
|
||||
|
||||
Publishing destinations (checked in order):
|
||||
1. **Gitea PyPI registry** — if ``--registry-url`` is given (or
|
||||
``DEVX_PYPI_REGISTRY_URL`` env var is set, or ``GITEA_API_URL``
|
||||
is converted to a packages URL). Uses ``twine upload
|
||||
--repository-url <url> -u <token> -p <token>`` with the
|
||||
``REPO_TOKEN`` as both username and password.
|
||||
2. **Standard PyPI** — if ``PYPI_TOKEN`` is set. Uses the standard
|
||||
``twine upload -u __token__ -p <token>`` flow.
|
||||
3. **Skip** — if neither is configured, only the Gitea release is created.
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> [PYPI_TOKEN=<token>] python3 -m devx.ci.publish <tag> <repo>
|
||||
REPO_TOKEN=<token> python3 -m devx.ci.publish <tag> <repo> --registry-url https://git.example.com/api/packages/owner/pypi
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from devx.config import GITEA_API_URL
|
||||
from devx.gitea_cli import TeaCLI, TeaCLIError
|
||||
from devx.i18n import _
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
CLIFF_CONFIG = "cliff.toml"
|
||||
|
||||
|
||||
def generate_release_notes(tag: str) -> str:
|
||||
"""Generate release notes for the given tag using git-cliff.
|
||||
|
||||
Falls back to a generic message if git-cliff is not available.
|
||||
"""
|
||||
cliff_bin = shutil.which("git-cliff")
|
||||
if not cliff_bin:
|
||||
return f"Release {tag}\n\nSee CHANGELOG.md for details."
|
||||
try:
|
||||
result = subprocess.run( # nosec B603
|
||||
[cliff_bin, "--config", CLIFF_CONFIG, "--latest", "--strip", "header"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return f"Release {tag}\n\nSee CHANGELOG.md for details."
|
||||
|
||||
|
||||
def build_package() -> None:
|
||||
"""Build the Python package using python -m build."""
|
||||
result = subprocess.run( # nosec B603
|
||||
[sys.executable, "-m", "build"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! Package build failed:\n{stderr}",
|
||||
stderr=result.stderr.strip(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def publish_to_pypi(token: str) -> None:
|
||||
"""Publish built packages to PyPI using twine."""
|
||||
result = subprocess.run( # nosec B603
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"twine",
|
||||
"upload",
|
||||
"dist/*",
|
||||
"-u",
|
||||
"__token__",
|
||||
"-p",
|
||||
token,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! PyPI publish failed:\n{stderr}",
|
||||
stderr=result.stderr.strip(),
|
||||
)
|
||||
)
|
||||
click.echo(_("Published to PyPI."))
|
||||
|
||||
|
||||
def publish_to_gitea_registry(registry_url: str, token: str) -> None:
|
||||
"""Publish built packages to a Gitea PyPI registry using twine.
|
||||
|
||||
Uses the token as both username and password, which is the standard
|
||||
Gitea package authentication method.
|
||||
"""
|
||||
result = subprocess.run( # nosec B603
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"twine",
|
||||
"upload",
|
||||
"dist/*",
|
||||
"--repository-url",
|
||||
registry_url,
|
||||
"-u",
|
||||
token,
|
||||
"-p",
|
||||
token,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! Gitea PyPI registry publish failed:\n{stderr}",
|
||||
stderr=result.stderr.strip(),
|
||||
)
|
||||
)
|
||||
click.echo(_("Published to Gitea PyPI registry."))
|
||||
|
||||
|
||||
def _default_gitea_registry_url() -> str:
|
||||
"""Derive a Gitea PyPI registry URL from GITEA_API_URL.
|
||||
|
||||
Converts e.g. ``https://git.example.com/api/v1`` to
|
||||
``https://git.example.com/api/packages/<owner>/pypi``.
|
||||
The owner is read from ``DEVX_REPO_OWNER``.
|
||||
"""
|
||||
base = GITEA_API_URL.rstrip("/")
|
||||
# Strip /api/v1 or /api suffix to get the base URL
|
||||
if base.endswith("/api/v1"):
|
||||
base = base[: -len("/api/v1")]
|
||||
elif base.endswith("/api"):
|
||||
base = base[: -len("/api")]
|
||||
owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss")
|
||||
return f"{base}/api/packages/{owner}/pypi"
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("tag")
|
||||
@click.argument("repo")
|
||||
@click.option(
|
||||
"--registry-url",
|
||||
default=None,
|
||||
help="Gitea PyPI registry URL. Defaults to DEVX_PYPI_REGISTRY_URL env var "
|
||||
"or a URL derived from GITEA_API_URL. When set, publishes to Gitea PyPI "
|
||||
"instead of standard PyPI (unless PYPI_TOKEN is also set).",
|
||||
)
|
||||
def main(tag: str, repo: str, registry_url: str | None) -> None:
|
||||
gitea_token = os.environ.get("REPO_TOKEN", "")
|
||||
if not gitea_token:
|
||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
||||
|
||||
pypi_token = os.environ.get("PYPI_TOKEN", "")
|
||||
|
||||
# Resolve registry URL: CLI flag > env var > derived from GITEA_API_URL
|
||||
if registry_url is None:
|
||||
registry_url = os.environ.get("DEVX_PYPI_REGISTRY_URL", "")
|
||||
if not registry_url:
|
||||
registry_url = _default_gitea_registry_url()
|
||||
|
||||
build_package()
|
||||
|
||||
if pypi_token:
|
||||
# Standard PyPI flow takes precedence when PYPI_TOKEN is set
|
||||
publish_to_pypi(pypi_token)
|
||||
elif registry_url:
|
||||
# Gitea PyPI registry flow
|
||||
publish_to_gitea_registry(registry_url, gitea_token)
|
||||
else:
|
||||
click.echo(
|
||||
_(
|
||||
"PYPI_TOKEN not set and no registry URL configured — "
|
||||
"skipping PyPI publish. No worries, we'll just create the Gitea release."
|
||||
)
|
||||
)
|
||||
|
||||
tea = TeaCLI(repo=repo)
|
||||
release_body = generate_release_notes(tag)
|
||||
|
||||
try:
|
||||
tea.create_release(repo, tag=tag, title=tag, body=release_body)
|
||||
except TeaCLIError as e:
|
||||
raise click.ClickException(_("Release creation failed: {error}", error=str(e))) from None
|
||||
|
||||
click.echo(
|
||||
_(
|
||||
"Nice! Gitea release {tag} created.",
|
||||
tag=tag,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate badge SVG files and push them to the ``badges`` branch.
|
||||
|
||||
Also updates README.md and docs/index.md on master with cache-busting
|
||||
``raw/commit/<sha>/badge.svg`` URLs so that browsers always fetch the
|
||||
latest badge version (Gitea caches ``raw/branch/`` URLs for 6 hours).
|
||||
|
||||
The script fetches the latest master before generating badges so that
|
||||
the version badge always reflects the current state of the repository
|
||||
(even if a release commit was pushed moments before by the parallel
|
||||
release job).
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.ci.push_badges
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
||||
|
||||
# Badge filenames that get pushed to the badges branch
|
||||
BADGE_FILES = ["coverage.svg", "tests.svg", "docs.svg", "quality.svg", "version.svg", "python.svg"]
|
||||
|
||||
# Files that contain badge URLs and need to be updated
|
||||
FILES_WITH_BADGE_URLS = ["README.md", "docs/index.md"]
|
||||
|
||||
# Pattern to match raw/branch/badges/<name>.svg URLs
|
||||
_BADGE_URL_RE = re.compile(r"(https://[^/]+/[^/]+/[^/]+/raw/)(?:branch/badges|commit/[0-9a-f]{40})/([a-z_]+\.svg)")
|
||||
|
||||
|
||||
def _run(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
|
||||
"""Run a command and return the result."""
|
||||
return subprocess.run(cmd, check=True, text=True, **kwargs) # nosec B603
|
||||
|
||||
|
||||
def _run_capture(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
|
||||
"""Run a command and capture stdout."""
|
||||
return subprocess.run(cmd, check=True, text=True, capture_output=True, **kwargs) # nosec B603
|
||||
|
||||
|
||||
def fetch_latest_master(branch: str = "master") -> None:
|
||||
"""Fetch and hard-reset to the latest remote branch.
|
||||
|
||||
Ensures the working tree reflects the absolute latest state of the
|
||||
remote, which is critical when the release job may have just pushed
|
||||
a new version commit.
|
||||
"""
|
||||
_run(["git", "fetch", "origin", branch]) # nosec B607
|
||||
_run(["git", "reset", "--hard", f"origin/{branch}"]) # nosec B607
|
||||
click.echo(f"Synced to latest origin/{branch}")
|
||||
|
||||
|
||||
def generate_badges(output_dir: str) -> None:
|
||||
"""Generate badge SVG files using generate_badges.py."""
|
||||
_run([sys.executable, "scripts/generate_badges.py", "--output-dir", output_dir])
|
||||
badges = list(Path(output_dir).glob("*.svg"))
|
||||
if not badges:
|
||||
raise click.ClickException("No badge SVG files generated")
|
||||
click.echo(f"Generated {len(badges)} badge files")
|
||||
|
||||
|
||||
def push_to_badges_branch(badges_dir: str) -> str:
|
||||
"""Push generated badges to the orphan ``badges`` branch.
|
||||
|
||||
Returns the commit SHA of the pushed badges branch.
|
||||
"""
|
||||
_run(["git", "config", "user.name", "gitea-actions-bot"]) # nosec B607
|
||||
_run(["git", "config", "user.email", "actions@oblachno.fyi"]) # nosec B607
|
||||
_run(["git", "checkout", "--orphan", "badges"]) # nosec B607
|
||||
_run(["git", "rm", "-rf", "."]) # nosec B607
|
||||
|
||||
# Copy badge files to root
|
||||
import shutil
|
||||
|
||||
for svg in Path(badges_dir).glob("*.svg"):
|
||||
shutil.copy2(svg, Path.cwd() / svg.name)
|
||||
|
||||
_run(["git", "add", "./*.svg"]) # nosec B607
|
||||
_run(["git", "commit", "--no-verify", "-m", "Update badges [skip ci]"]) # nosec B607
|
||||
_run(["git", "push", "origin", "badges", "--force"]) # nosec B607
|
||||
click.echo("Badges pushed to badges branch")
|
||||
|
||||
# Get the commit SHA of the badges branch
|
||||
result = _run_capture(["git", "rev-parse", "HEAD"]) # nosec B607
|
||||
sha = result.stdout.strip()
|
||||
click.echo(f"Badges commit SHA: {sha}")
|
||||
return sha
|
||||
|
||||
|
||||
def update_badge_urls(content: str, badges_sha: str) -> str:
|
||||
"""Replace raw/branch/badges/<name>.svg URLs with raw/commit/<sha>/<name>.svg.
|
||||
|
||||
This bypasses Gitea's 6-hour cache on raw/branch/ URLs by using a
|
||||
URL that changes each time the badges branch is updated.
|
||||
"""
|
||||
return _BADGE_URL_RE.sub(
|
||||
lambda m: f"{m.group(1)}commit/{badges_sha}/{m.group(2)}",
|
||||
content,
|
||||
)
|
||||
|
||||
|
||||
def update_readme_with_badge_sha(badges_sha: str, repo_root: Path | None = None) -> None:
|
||||
"""Update README.md and docs/index.md with cache-busting badge URLs.
|
||||
|
||||
Switches back to master, replaces ``raw/branch/badges/`` URLs with
|
||||
``raw/commit/<sha>/`` URLs, commits and pushes.
|
||||
"""
|
||||
root = repo_root or REPO_ROOT
|
||||
|
||||
# Switch back to master
|
||||
_run(["git", "checkout", "master"]) # nosec B607
|
||||
_run(["git", "fetch", "origin", "master"]) # nosec B607
|
||||
_run(["git", "reset", "--hard", "origin/master"]) # nosec B607
|
||||
|
||||
updated_any = False
|
||||
for filename in FILES_WITH_BADGE_URLS:
|
||||
filepath = root / filename
|
||||
if not filepath.exists():
|
||||
continue
|
||||
content = filepath.read_text()
|
||||
new_content = update_badge_urls(content, badges_sha)
|
||||
if new_content != content:
|
||||
filepath.write_text(new_content)
|
||||
click.echo(f"Updated badge URLs in {filename}")
|
||||
updated_any = True
|
||||
|
||||
if not updated_any:
|
||||
click.echo("No badge URLs found to update — README already up to date")
|
||||
return
|
||||
|
||||
_run(["git", "add", "README.md", "docs/index.md"]) # nosec B607
|
||||
_run(
|
||||
[
|
||||
"git",
|
||||
"commit",
|
||||
"--no-verify",
|
||||
"-m",
|
||||
f"chore: update badge URLs to commit {badges_sha[:8]} [skip ci]",
|
||||
]
|
||||
) # nosec B607
|
||||
_run(["git", "push", "origin", "master"]) # nosec B607
|
||||
click.echo(f"Pushed README update with badge SHA {badges_sha[:8]}")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--output-dir", default=".badges/", help="Temporary directory for badge files.")
|
||||
@click.option("--branch", default="master", help="Branch to sync before generating badges.")
|
||||
@click.option(
|
||||
"--no-readme-update",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Skip updating README with cache-busting URLs (for local testing).",
|
||||
)
|
||||
def main(output_dir: str, branch: str, no_readme_update: bool) -> None:
|
||||
"""Generate badges and push them to the badges branch."""
|
||||
fetch_latest_master(branch)
|
||||
generate_badges(output_dir)
|
||||
badges_sha = push_to_badges_branch(output_dir)
|
||||
if not no_readme_update:
|
||||
update_readme_with_badge_sha(badges_sha)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,370 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Automated release: calculate next version, update files, tag, and push.
|
||||
|
||||
Uses git-cliff to determine the next semver version from conventional commits
|
||||
since the last tag. Updates ``__version__`` in ``__init__.py`` (the single
|
||||
source of truth, read by setuptools via ``dynamic = ["version"]``) and
|
||||
``CHANGELOG.md``, commits them with a ``release:`` prefix, tags the commit
|
||||
with the changelog as the tag message, and pushes both to trigger the publish
|
||||
workflow.
|
||||
|
||||
**Test enforcement**: Before committing or tagging, the script runs
|
||||
``make lint-ruff`` and ``make pytest-cov`` to verify the release is healthy.
|
||||
If either fails, the release is aborted — no commit, no tag. This ensures
|
||||
we never release a version that fails tests. Use ``--skip-tests`` only for
|
||||
emergency releases (not recommended).
|
||||
|
||||
The ``release:`` prefix (instead of ``chore(release):``) keeps the history
|
||||
clean while still being descriptive. Loops are prevented by the
|
||||
``has_unreleased_changes`` check — after a release commit is tagged, the next
|
||||
run finds no unreleased changes and exits.
|
||||
|
||||
This script is idempotent: if there are no new conventional commits since the
|
||||
last tag, it exits with a message and does nothing. If the tag already exists
|
||||
(e.g., from a partial previous run), it skips tag creation and only pushes.
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 -m devx.ci.release [--dry-run] [--skip-tests]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from devx.ci.classify_changes import has_user_facing_changes # cross-CI import, needs PYTHONPATH=.
|
||||
from devx.i18n import _
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
INIT_FILE = os.getenv("DEVX_VERSION_FILE", "src/devx/__init__.py")
|
||||
CHANGELOG_FILE = "CHANGELOG.md"
|
||||
CLIFF_CONFIG = "cliff.toml"
|
||||
|
||||
|
||||
def run_cmd(args: list[str], check: bool = True, capture: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
"""Run a command and return the completed process."""
|
||||
result = subprocess.run( # nosec B603
|
||||
args,
|
||||
capture_output=capture,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Command failed ({cmd}): {stderr}",
|
||||
cmd=" ".join(args),
|
||||
stderr=result.stderr.strip() if result.stderr else result.stdout.strip(),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def get_latest_tag() -> str:
|
||||
"""Get the latest git tag, or empty string if none exists."""
|
||||
result = run_cmd(["git", "describe", "--tags", "--abbrev=0"], check=False)
|
||||
if result.returncode != 0:
|
||||
return ""
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def tag_exists(tag: str) -> bool:
|
||||
"""Check if a git tag already exists."""
|
||||
result = run_cmd(["git", "tag", "-l", tag], check=False)
|
||||
return bool(result.stdout.strip())
|
||||
|
||||
|
||||
def get_bumped_version() -> str:
|
||||
"""Use git-cliff to calculate the next version from conventional commits."""
|
||||
result = run_cmd(["git-cliff", "--bumped-version", "--config", CLIFF_CONFIG])
|
||||
version = result.stdout.strip()
|
||||
if not version:
|
||||
raise click.ClickException(_("git-cliff returned empty version."))
|
||||
# git-cliff may return with or without 'v' prefix
|
||||
return version.lstrip("v")
|
||||
|
||||
|
||||
def get_changelog(new_version: str) -> str:
|
||||
"""Generate changelog content for the new version using git-cliff."""
|
||||
result = run_cmd(
|
||||
[
|
||||
"git-cliff",
|
||||
"--config",
|
||||
CLIFF_CONFIG,
|
||||
"--tag",
|
||||
f"v{new_version}",
|
||||
"--unreleased",
|
||||
"--bump",
|
||||
]
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def has_unreleased_changes(bumped_version: str | None = None) -> bool:
|
||||
"""Check if there are unreleased conventional commits since the last tag.
|
||||
|
||||
Uses ``git log`` to check for commits between the last tag and HEAD.
|
||||
This is more reliable than comparing version strings — if git-cliff
|
||||
bumps to the same version (e.g., two fix commits between tags), the
|
||||
version comparison would incorrectly report "no unreleased changes"
|
||||
even though there are commits that haven't been released yet.
|
||||
"""
|
||||
latest = get_latest_tag()
|
||||
if not latest:
|
||||
return True
|
||||
# Check for any commits since the last tag
|
||||
result = run_cmd(
|
||||
["git", "log", f"{latest}..HEAD", "--oneline"],
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
return bool(result.stdout.strip())
|
||||
|
||||
|
||||
def update_init_version(new_version: str) -> None:
|
||||
"""Update __version__ in __init__.py."""
|
||||
with open(INIT_FILE) as f:
|
||||
content = f.read()
|
||||
if not re.search(r'^__version__\s*=\s*"[^"]*"', content, flags=re.MULTILINE):
|
||||
raise click.ClickException(_("Could not find __version__ in {file}", file=INIT_FILE))
|
||||
updated = re.sub(
|
||||
r'^__version__\s*=\s*"[^"]*"',
|
||||
f'__version__ = "{new_version}"',
|
||||
content,
|
||||
count=1,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
with open(INIT_FILE, "w") as f:
|
||||
f.write(updated)
|
||||
|
||||
|
||||
def update_changelog(changelog: str) -> None:
|
||||
"""Prepend the new changelog section to CHANGELOG.md.
|
||||
|
||||
The changelog from git-cliff may include a header (e.g., "# Changelog").
|
||||
This function strips everything before the first ``## [`` version section
|
||||
before inserting, to avoid duplicating the header.
|
||||
"""
|
||||
# Strip git-cliff header — keep only from the first version section
|
||||
section_match = re.search(r"^## \[", changelog, flags=re.MULTILINE)
|
||||
if section_match:
|
||||
changelog = changelog[section_match.start() :]
|
||||
|
||||
try:
|
||||
with open(CHANGELOG_FILE) as f:
|
||||
existing = f.read()
|
||||
except FileNotFoundError:
|
||||
with open(CHANGELOG_FILE, "w") as f:
|
||||
f.write(changelog + "\n")
|
||||
return
|
||||
|
||||
# Find the first version section header (## [...] or ## [unreleased])
|
||||
match = re.search(r"^## \[", existing, flags=re.MULTILINE)
|
||||
if match:
|
||||
# Insert before the first version section
|
||||
pos = match.start()
|
||||
updated = existing[:pos] + changelog + "\n\n" + existing[pos:]
|
||||
else:
|
||||
# No version sections found — append
|
||||
updated = existing.rstrip() + "\n\n" + changelog + "\n"
|
||||
with open(CHANGELOG_FILE, "w") as f:
|
||||
f.write(updated)
|
||||
|
||||
|
||||
def commit_release_changes(new_version: str) -> bool:
|
||||
"""Stage version file and changelog, then create a release commit.
|
||||
|
||||
Uses ``release:`` prefix (not ``chore(release):``) for clarity.
|
||||
The commit is created with ``--no-verify`` to bypass the commit-msg hook
|
||||
(which requires ``DEVX-N:`` prefix for master commits) since release
|
||||
commits are a special case generated by the release script.
|
||||
Returns True if a commit was created, False if there were no staged changes.
|
||||
"""
|
||||
run_cmd(["git", "add", INIT_FILE, CHANGELOG_FILE])
|
||||
status = run_cmd(["git", "diff", "--cached", "--quiet"], check=False)
|
||||
if status.returncode == 0:
|
||||
click.echo(_("No staged changes — version and changelog already up to date."))
|
||||
return False
|
||||
run_cmd(["git", "commit", "--no-verify", "-m", f"release: v{new_version} [skip ci]"])
|
||||
return True
|
||||
|
||||
|
||||
def run_tests() -> None:
|
||||
"""Run lint and tests to verify the release is healthy.
|
||||
|
||||
This is called *after* version files are updated but *before* the tag is
|
||||
created, ensuring we never tag a release that fails tests.
|
||||
"""
|
||||
click.echo(_("Running lint checks..."))
|
||||
lint = run_cmd(["make", "lint-ruff"], check=False)
|
||||
if lint.returncode != 0:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
|
||||
stderr=lint.stderr.strip() if lint.stderr else lint.stdout.strip(),
|
||||
)
|
||||
)
|
||||
click.echo(_("Lint passed."))
|
||||
|
||||
click.echo(_("Running tests..."))
|
||||
tests = run_cmd(["make", "pytest-cov"], check=False)
|
||||
if tests.returncode != 0:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Tests failed — refusing to release. Fix test failures first.\n{stderr}",
|
||||
stderr=tests.stderr.strip() if tests.stderr else tests.stdout.strip(),
|
||||
)
|
||||
)
|
||||
click.echo(_("Tests passed."))
|
||||
|
||||
|
||||
def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool:
|
||||
"""Create an annotated tag with the changelog as message and push it.
|
||||
|
||||
Returns True if the tag was created/pushed, False if it already existed.
|
||||
"""
|
||||
tag = f"v{new_version}"
|
||||
if tag_exists(tag):
|
||||
click.echo(_("Tag {tag} already exists, skipping creation.", tag=tag))
|
||||
if not dry_run:
|
||||
# Ensure the existing tag is pushed
|
||||
run_cmd(["git", "push", "origin", tag], check=False)
|
||||
return False
|
||||
tag_msg = f"Release v{new_version}\n\n{changelog}"
|
||||
if dry_run:
|
||||
click.echo(_("[dry-run] Would create tag: {tag}", tag=tag))
|
||||
return True
|
||||
run_cmd(["git", "tag", "-a", tag, "-m", tag_msg])
|
||||
run_cmd(["git", "push", "origin", tag])
|
||||
return True
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--dry-run", is_flag=True, default=False, help="Show what would happen without making changes.")
|
||||
@click.option(
|
||||
"--skip-tests",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Skip lint and test verification (NOT recommended — only for emergency releases).",
|
||||
)
|
||||
def main(dry_run: bool, skip_tests: bool) -> None:
|
||||
# Ensure we're on master (skip this check in dry-run mode for PR validation)
|
||||
branch = run_cmd(["git", "rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip()
|
||||
if branch != "master" and not dry_run:
|
||||
raise click.ClickException(_("Release must be run on master, currently on '{branch}'.", branch=branch))
|
||||
if branch != "master" and dry_run:
|
||||
click.echo(
|
||||
_(
|
||||
"Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
|
||||
branch=branch,
|
||||
)
|
||||
)
|
||||
|
||||
# Release lock: if HEAD is already a release commit, another release
|
||||
# run is in progress (or already completed). Skip to prevent duplicate tags.
|
||||
head_msg = run_cmd(["git", "log", "-1", "--pretty=%s"]).stdout.strip()
|
||||
if re.match(r"^release: v\d+\.\d+\.\d+", head_msg):
|
||||
click.echo(
|
||||
_(
|
||||
"HEAD is already a release commit ('{msg}'). Another release may have just completed. Skipping.",
|
||||
msg=head_msg,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
# Check if any user-facing files changed since the last tag.
|
||||
# If only workflow/infra files changed, skip the release entirely.
|
||||
latest_tag = get_latest_tag()
|
||||
if latest_tag and not has_user_facing_changes(latest_tag, "HEAD"):
|
||||
click.echo(
|
||||
_(
|
||||
"No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
|
||||
tag=latest_tag,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
# Calculate next version (single git-cliff call — Gap 7 fix)
|
||||
new_version = get_bumped_version()
|
||||
|
||||
# Check for unreleased changes (reuses the version we just calculated)
|
||||
if not has_unreleased_changes(bumped_version=new_version):
|
||||
click.echo(_("No unreleased changes found. Nothing to release."))
|
||||
return
|
||||
|
||||
current_tag = get_latest_tag()
|
||||
click.echo(
|
||||
_(
|
||||
"Bumping version: {current} -> v{new_version}",
|
||||
current=current_tag or "(none)",
|
||||
new_version=new_version,
|
||||
)
|
||||
)
|
||||
|
||||
# Generate changelog
|
||||
changelog = get_changelog(new_version)
|
||||
if not changelog:
|
||||
click.echo(_("Warning: git-cliff generated empty changelog."))
|
||||
|
||||
if dry_run:
|
||||
click.echo(_("\n[dry-run] Changelog:\n{changelog}", changelog=changelog))
|
||||
click.echo(_("[dry-run] Would update {init}", init=INIT_FILE))
|
||||
click.echo(_("[dry-run] Would update {changelog_file}", changelog_file=CHANGELOG_FILE))
|
||||
click.echo(_("[dry-run] Would commit: release: v{version}", version=new_version))
|
||||
click.echo(_("[dry-run] Would push commit to master"))
|
||||
click.echo(_("[dry-run] Would create tag: v{version}", version=new_version))
|
||||
return
|
||||
|
||||
# Update version file
|
||||
update_init_version(new_version)
|
||||
click.echo(_("Updated version in {init}", init=INIT_FILE))
|
||||
|
||||
# Update CHANGELOG.md (Gap 3 fix)
|
||||
update_changelog(changelog)
|
||||
click.echo(_("Updated {changelog_file}", changelog_file=CHANGELOG_FILE))
|
||||
|
||||
# Verify tests pass BEFORE committing or tagging.
|
||||
# This ensures we never release a version that fails tests.
|
||||
if skip_tests:
|
||||
click.echo(_("WARNING: --skip-tests passed — skipping test verification."))
|
||||
else:
|
||||
run_tests()
|
||||
|
||||
# Commit version + changelog (Gap 11: use 'release:' prefix, not 'chore(release):')
|
||||
committed = commit_release_changes(new_version)
|
||||
if committed:
|
||||
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)
|
||||
run_cmd(["git", "push", "origin", "master"])
|
||||
click.echo(_("Pushed release commit to master."))
|
||||
else:
|
||||
click.echo(_("Skipping commit push — no staged changes."))
|
||||
|
||||
# Create and push tag (Gap 4: handles existing tag)
|
||||
created = create_and_push_tag(new_version, changelog, dry_run)
|
||||
if created:
|
||||
click.echo(
|
||||
_(
|
||||
"Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
|
||||
version=new_version,
|
||||
)
|
||||
)
|
||||
else:
|
||||
click.echo(
|
||||
_(
|
||||
"Tag v{version} already existed. Publish workflow should already have been triggered.",
|
||||
version=new_version,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,311 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sync documentation from /docs/ to the Gitea wiki via API.
|
||||
|
||||
Reads markdown files from the ``docs/`` directory, uses ``mapping.json`` to
|
||||
map file paths to wiki page titles, and creates/updates wiki pages via the
|
||||
Gitea API. Pages that exist in the wiki but not in the mapping are left
|
||||
untouched (not deleted).
|
||||
|
||||
Gitea 1.26 wiki API endpoints (all use content_base64, NOT content):
|
||||
- Create: POST /repos/{owner}/{repo}/wiki/new {title, content_base64, message}
|
||||
- Update: PATCH /repos/{owner}/{repo}/wiki/page/{sub_url} {title, content_base64, message}
|
||||
- List: GET /repos/{owner}/{repo}/wiki/pages → [{title, sub_url, ...}]
|
||||
- Fetch: GET /repos/{owner}/{repo}/wiki/page/{sub_url} → {title, content_base64, ...}
|
||||
- Delete: DELETE /repos/{owner}/{repo}/wiki/page/{sub_url}
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 -m devx.ci.sync_wiki [--dry-run] [--repo owner/repo]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from devx.api_clients import GiteaClient
|
||||
from devx.config import GITEA_API_URL
|
||||
from devx.exceptions import APIError
|
||||
from devx.i18n import _
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
DOCS_DIR = Path(__file__).resolve().parent.parent.parent.parent / "docs"
|
||||
MAPPING_FILE = DOCS_DIR / "mapping.json"
|
||||
|
||||
|
||||
def load_mapping() -> dict[str, str]:
|
||||
"""Load the file-to-wiki-page mapping from mapping.json."""
|
||||
with open(MAPPING_FILE) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def read_doc_content(file_path: str) -> str:
|
||||
"""Read markdown content from a docs file."""
|
||||
full_path = DOCS_DIR / file_path
|
||||
with open(full_path) as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def encode_content(content: str) -> str:
|
||||
"""Encode content as base64 for the Gitea wiki API.
|
||||
|
||||
The Gitea wiki API requires content_base64, not plain content.
|
||||
Sending plain content silently fails (pages are created/updated
|
||||
but with empty content).
|
||||
"""
|
||||
return base64.b64encode(content.encode("utf-8")).decode("ascii")
|
||||
|
||||
|
||||
def decode_content(content_b64: str) -> str:
|
||||
"""Decode base64 content from the Gitea wiki API."""
|
||||
if not content_b64:
|
||||
return ""
|
||||
return base64.b64decode(content_b64).decode("utf-8")
|
||||
|
||||
|
||||
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 {}
|
||||
return {page.get("title", ""): page.get("sub_url", page.get("title", "")) for page in pages}
|
||||
|
||||
|
||||
def fetch_page_content(client: GiteaClient, sub_url: str) -> str:
|
||||
"""Fetch a wiki page's content by sub_url, decoded from base64."""
|
||||
try:
|
||||
page = client._request("GET", f"/wiki/page/{sub_url}").json()
|
||||
return decode_content(page.get("content_base64", ""))
|
||||
except APIError:
|
||||
return ""
|
||||
|
||||
|
||||
def sync_page(
|
||||
client: GiteaClient,
|
||||
page_title: str,
|
||||
content: str,
|
||||
existing_pages: dict[str, str],
|
||||
dry_run: bool,
|
||||
) -> str:
|
||||
"""Create or update a single wiki page.
|
||||
|
||||
Returns "created", "updated", or "skipped" (if dry-run).
|
||||
"""
|
||||
if dry_run:
|
||||
click.echo(_("[dry-run] Would sync page: {title} ({chars} chars)", title=page_title, chars=len(content)))
|
||||
return "skipped"
|
||||
|
||||
content_b64 = encode_content(content)
|
||||
|
||||
if page_title in existing_pages:
|
||||
# Update existing page via PATCH
|
||||
sub_url = existing_pages[page_title]
|
||||
client._request(
|
||||
"PATCH",
|
||||
f"/wiki/page/{sub_url}",
|
||||
json={
|
||||
"title": page_title,
|
||||
"content_base64": content_b64,
|
||||
"message": f"Sync from docs/ — update {page_title}",
|
||||
},
|
||||
)
|
||||
return "updated"
|
||||
|
||||
# Create new page via POST /wiki/new
|
||||
client._request(
|
||||
"POST",
|
||||
"/wiki/new",
|
||||
json={
|
||||
"title": page_title,
|
||||
"content_base64": content_b64,
|
||||
"message": f"Sync from docs/ — create {page_title}",
|
||||
},
|
||||
)
|
||||
return "created"
|
||||
|
||||
|
||||
def verify_wiki_page(
|
||||
client: GiteaClient, page_title: str, expected_content: str, existing_pages: dict[str, str]
|
||||
) -> bool:
|
||||
"""Verify that a wiki page has non-empty content matching the docs.
|
||||
|
||||
Returns True if the page content matches, False otherwise.
|
||||
"""
|
||||
if page_title not in existing_pages:
|
||||
return False
|
||||
sub_url = existing_pages[page_title]
|
||||
actual = fetch_page_content(client, sub_url)
|
||||
return actual.strip() == expected_content.strip()
|
||||
|
||||
|
||||
def verify_wiki_integrity(
|
||||
client: GiteaClient,
|
||||
mapping: dict[str, str],
|
||||
synced: dict[str, str],
|
||||
) -> list[str]:
|
||||
"""Comprehensive wiki verification.
|
||||
|
||||
Checks:
|
||||
1. Every mapped page exists in the wiki
|
||||
2. Every mapped page has non-empty content
|
||||
3. Every mapped page's content matches the docs
|
||||
4. No stale pages exist in the wiki (pages not in mapping)
|
||||
5. Page count matches
|
||||
|
||||
Returns a list of failure messages (empty if all checks pass).
|
||||
"""
|
||||
failures: list[str] = []
|
||||
existing_pages = list_wiki_pages(client)
|
||||
expected_titles = set(mapping.values())
|
||||
|
||||
# Check 1: Page count
|
||||
if len(existing_pages) != len(expected_titles):
|
||||
failures.append(f"Page count mismatch: wiki has {len(existing_pages)}, mapping has {len(expected_titles)}")
|
||||
|
||||
# Check 2: Missing pages (in mapping but not in wiki)
|
||||
missing = expected_titles - set(existing_pages.keys())
|
||||
for title in sorted(missing):
|
||||
failures.append(f"Missing page: {title}")
|
||||
|
||||
# Check 3: Stale pages (in wiki but not in mapping)
|
||||
stale = set(existing_pages.keys()) - expected_titles
|
||||
for title in sorted(stale):
|
||||
failures.append(f"Stale page (not in mapping): {title}")
|
||||
|
||||
# Check 4: Content verification
|
||||
for page_title, expected_content in sorted(synced.items()):
|
||||
ok = verify_wiki_page(client, page_title, expected_content, existing_pages)
|
||||
if not ok:
|
||||
sub_url = existing_pages.get(page_title, "?")
|
||||
actual = fetch_page_content(client, sub_url)
|
||||
if not actual.strip():
|
||||
failures.append(f"Empty content: {page_title}")
|
||||
else:
|
||||
failures.append(f"Content mismatch: {page_title}")
|
||||
|
||||
return failures
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--dry-run", is_flag=True, default=False, help="Show what would happen without making changes.")
|
||||
@click.option("--repo", default=None, help="Repository in owner/name format (auto-detected if omitted).")
|
||||
@click.option(
|
||||
"--verify",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="After syncing, verify each page has non-empty content. Exit 1 if any page is empty or mismatched.",
|
||||
)
|
||||
@click.option(
|
||||
"--strict",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Full integrity check: verify page count, missing pages, stale pages, and content. Implies --verify.",
|
||||
)
|
||||
def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
||||
|
||||
if repo is None:
|
||||
owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss")
|
||||
repo_name = os.environ.get("DEVX_REPO_NAME", "devx")
|
||||
else:
|
||||
owner, repo_name = repo.split("/")
|
||||
|
||||
if not MAPPING_FILE.exists():
|
||||
raise click.ClickException(_("ERROR: mapping.json not found at {path}", path=MAPPING_FILE))
|
||||
|
||||
mapping = load_mapping()
|
||||
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
|
||||
|
||||
click.echo(_("Syncing {count} documentation pages to wiki...", count=len(mapping)))
|
||||
|
||||
existing_pages = list_wiki_pages(client)
|
||||
if existing_pages:
|
||||
click.echo(_("Found {count} existing wiki pages.", count=len(existing_pages)))
|
||||
|
||||
created = 0
|
||||
updated = 0
|
||||
skipped = 0
|
||||
synced: dict[str, str] = {} # title -> content, for verification
|
||||
|
||||
for file_path, page_title in sorted(mapping.items()):
|
||||
try:
|
||||
content = read_doc_content(file_path)
|
||||
except FileNotFoundError:
|
||||
click.echo(_("WARNING: File {file} not found — skipping.", file=file_path))
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
if not content.strip():
|
||||
click.echo(_("WARNING: File {file} is empty — skipping.", file=file_path))
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
result = sync_page(client, page_title, content, existing_pages, dry_run)
|
||||
if result == "created":
|
||||
created += 1
|
||||
click.echo(_(" Created: {title}", title=page_title))
|
||||
elif result == "updated":
|
||||
updated += 1
|
||||
click.echo(_(" Updated: {title}", title=page_title))
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
synced[page_title] = content
|
||||
|
||||
click.echo(
|
||||
_(
|
||||
"\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
|
||||
created=created,
|
||||
updated=updated,
|
||||
skipped=skipped,
|
||||
)
|
||||
)
|
||||
|
||||
# --strict implies --verify
|
||||
do_verify = verify or strict
|
||||
|
||||
if do_verify and not dry_run:
|
||||
if strict:
|
||||
click.echo(_("\nRunning full wiki integrity check..."))
|
||||
failures = verify_wiki_integrity(client, mapping, synced)
|
||||
if failures:
|
||||
click.echo(_("\nIntegrity check FAILED ({count} issues):", count=len(failures)))
|
||||
for f in failures:
|
||||
click.echo(f" - {f}")
|
||||
raise click.ClickException(_("Wiki integrity check failed — {count} issue(s)", count=len(failures)))
|
||||
click.echo(_("\nIntegrity check passed — all {count} pages verified.", count=len(synced)))
|
||||
else:
|
||||
click.echo(_("\nVerifying wiki pages have content..."))
|
||||
# Re-fetch the page list to get updated sub_urls
|
||||
existing_pages = list_wiki_pages(client)
|
||||
failures = 0
|
||||
for page_title, expected_content in sorted(synced.items()):
|
||||
ok = verify_wiki_page(client, page_title, expected_content, existing_pages)
|
||||
if ok:
|
||||
click.echo(_(" OK: {title} ({chars} chars)", title=page_title, chars=len(expected_content)))
|
||||
else:
|
||||
click.echo(_(" FAIL: {title} — content mismatch or empty!", title=page_title))
|
||||
failures += 1
|
||||
if failures > 0:
|
||||
click.echo(
|
||||
_(
|
||||
"\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
|
||||
failures=failures,
|
||||
)
|
||||
)
|
||||
raise click.ClickException(
|
||||
_("Wiki verification failed — {failures} page(s) empty or mismatched", failures=failures)
|
||||
)
|
||||
click.echo(_("\nVerification passed — all wiki pages have correct content."))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate commit messages for devx.
|
||||
|
||||
Rules:
|
||||
- On feature branches: conventional commits ONLY, must NOT include DEVX-N prefix.
|
||||
- On master branch: must follow '<task-id>: <conventional commit>' pattern,
|
||||
e.g. 'DEVX-24: fix: resolve timeout'.
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
|
||||
from devx.config import CONVENTIONAL_RE
|
||||
from devx.i18n import _
|
||||
|
||||
MASTER_TASK_ID_RE = re.compile(r"^DEVX-\d+:")
|
||||
|
||||
|
||||
def first_line(text: str) -> str:
|
||||
return text.split("\n")[0]
|
||||
|
||||
|
||||
def get_branch() -> str:
|
||||
try:
|
||||
result = subprocess.run( # nosec
|
||||
["git", "symbolic-ref", "--short", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
except subprocess.CalledProcessError:
|
||||
return ""
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("commit_msg_file")
|
||||
@click.option("--branch", default=None, help="Override branch detection (for CI use).")
|
||||
def main(commit_msg_file: str, branch: str | None) -> None:
|
||||
with open(commit_msg_file) as f:
|
||||
msg = f.read().strip()
|
||||
|
||||
if branch is None:
|
||||
branch = get_branch()
|
||||
subject = first_line(msg)
|
||||
|
||||
if branch == "master":
|
||||
if not MASTER_TASK_ID_RE.match(subject):
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! Master branch commits must start with a task ID.\n"
|
||||
" Expected: DEVX-N: <conventional commit message>\n"
|
||||
" Got: {subject}",
|
||||
subject=subject,
|
||||
)
|
||||
)
|
||||
remainder = MASTER_TASK_ID_RE.sub("", subject).strip()
|
||||
if not CONVENTIONAL_RE.match(remainder):
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! Master branch commit must follow conventional format after task ID.\n"
|
||||
" Expected: DEVX-N: <type>: <description>\n"
|
||||
" Got: {subject}",
|
||||
subject=subject,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
if MASTER_TASK_ID_RE.match(subject):
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! Do not include task ID (DEVX-N) in feature branch commits.\n"
|
||||
" The task ID will be added automatically on merge via CI."
|
||||
)
|
||||
)
|
||||
|
||||
if not CONVENTIONAL_RE.match(subject):
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! Commit message must follow conventional commit format.\n"
|
||||
" Expected: <type>: <description>\n"
|
||||
" Got: {subject}\n"
|
||||
" Allowed types: feat, fix, chore, docs, style, refactor,\n"
|
||||
" perf, test, ci, build, revert, BREAKING CHANGE",
|
||||
subject=subject,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
"""CLI entry point for devx — routes to subcommands.
|
||||
|
||||
Usage::
|
||||
|
||||
devx ci <command> [args] # CI/CD automation scripts
|
||||
devx tools <command> [args] # Development tools
|
||||
devx molecule <command> [args] # Molecule testing tools (optional)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import click
|
||||
|
||||
from devx.i18n import _
|
||||
|
||||
|
||||
def _run_module(module_path: str, args: list[str]) -> None:
|
||||
"""Run a devx module's main() with the given args."""
|
||||
import importlib
|
||||
|
||||
module = importlib.import_module(module_path)
|
||||
# Set sys.argv so click sees the right arguments
|
||||
old_argv = sys.argv
|
||||
sys.argv = [module_path] + args
|
||||
try:
|
||||
main_fn = getattr(module, "main", None)
|
||||
if main_fn is None:
|
||||
raise click.ClickException(_("Module {mod} has no main() function", mod=module_path))
|
||||
if callable(main_fn):
|
||||
main_fn(args=args, standalone_mode=False) # type: ignore[arg-type]
|
||||
else:
|
||||
# It's a click command object
|
||||
main_fn.main(args=args, prog_name=module_path, standalone_mode=False) # type: ignore[attr-defined]
|
||||
except click.exceptions.Abort:
|
||||
sys.exit(1)
|
||||
except click.ClickException as e:
|
||||
e.show()
|
||||
sys.exit(e.exit_code)
|
||||
finally:
|
||||
sys.argv = old_argv
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.version_option()
|
||||
def cli() -> None:
|
||||
"""devx — reusable development and CI/CD tools."""
|
||||
|
||||
|
||||
@cli.group()
|
||||
def ci() -> None:
|
||||
"""CI/CD automation commands."""
|
||||
|
||||
|
||||
@ci.command("auto-merge")
|
||||
@click.argument("args", nargs=-1)
|
||||
def ci_auto_merge(args: tuple[str, ...]) -> None:
|
||||
"""Auto-merge PR when all CI checks pass."""
|
||||
_run_module("devx.ci.auto_merge", list(args))
|
||||
|
||||
|
||||
@ci.command("check-translations")
|
||||
@click.argument("args", nargs=-1)
|
||||
def ci_check_translations(args: tuple[str, ...]) -> None:
|
||||
"""Check translation files for gaps and dead keys."""
|
||||
_run_module("devx.ci.check_translations", list(args))
|
||||
|
||||
|
||||
@ci.command("classify-changes")
|
||||
@click.argument("args", nargs=-1)
|
||||
def ci_classify_changes(args: tuple[str, ...]) -> None:
|
||||
"""Classify git changes as user-facing or workflow-only."""
|
||||
_run_module("devx.ci.classify_changes", list(args))
|
||||
|
||||
|
||||
@ci.command("detect-release-commit")
|
||||
@click.argument("args", nargs=-1)
|
||||
def ci_detect_release_commit(args: tuple[str, ...]) -> None:
|
||||
"""Detect whether the latest git commit is a release commit."""
|
||||
_run_module("devx.ci.detect_release_commit", list(args))
|
||||
|
||||
|
||||
@ci.command("discover-runners")
|
||||
@click.argument("args", nargs=-1)
|
||||
def ci_discover_runners(args: tuple[str, ...]) -> None:
|
||||
"""Discover available Gitea Actions runners."""
|
||||
_run_module("devx.ci.discover_runners", list(args))
|
||||
|
||||
|
||||
@ci.command("doc-coverage")
|
||||
@click.argument("args", nargs=-1)
|
||||
def ci_doc_coverage(args: tuple[str, ...]) -> None:
|
||||
"""Check documentation coverage for CLI commands and modules."""
|
||||
_run_module("devx.ci.doc_coverage", list(args))
|
||||
|
||||
|
||||
@ci.command("notify-failure")
|
||||
@click.argument("args", nargs=-1)
|
||||
def ci_notify_failure(args: tuple[str, ...]) -> None:
|
||||
"""Create a Gitea issue when a CI workflow fails."""
|
||||
_run_module("devx.ci.notify_failure", list(args))
|
||||
|
||||
|
||||
@ci.command("post-merge")
|
||||
@click.argument("args", nargs=-1)
|
||||
def ci_post_merge(args: tuple[str, ...]) -> None:
|
||||
"""Update Vikunja task after a merge to master."""
|
||||
_run_module("devx.ci.post_merge", list(args))
|
||||
|
||||
|
||||
@ci.command("pr-review")
|
||||
@click.argument("args", nargs=-1)
|
||||
def ci_pr_review(args: tuple[str, ...]) -> None:
|
||||
"""Run automated PR review."""
|
||||
_run_module("devx.ci.pr_review", list(args))
|
||||
|
||||
|
||||
@ci.command("publish")
|
||||
@click.argument("args", nargs=-1)
|
||||
def ci_publish(args: tuple[str, ...]) -> None:
|
||||
"""Build package, publish to registry, and create Gitea release."""
|
||||
_run_module("devx.ci.publish", list(args))
|
||||
|
||||
|
||||
@ci.command("push-badges")
|
||||
@click.argument("args", nargs=-1)
|
||||
def ci_push_badges(args: tuple[str, ...]) -> None:
|
||||
"""Generate badge SVG files and push to the badges branch."""
|
||||
_run_module("devx.ci.push_badges", list(args))
|
||||
|
||||
|
||||
@ci.command("release")
|
||||
@click.argument("args", nargs=-1)
|
||||
def ci_release(args: tuple[str, ...]) -> None:
|
||||
"""Automated release: calculate next version, update files, tag, push."""
|
||||
_run_module("devx.ci.release", list(args))
|
||||
|
||||
|
||||
@ci.command("sync-wiki")
|
||||
@click.argument("args", nargs=-1)
|
||||
def ci_sync_wiki(args: tuple[str, ...]) -> None:
|
||||
"""Sync documentation from docs/ to the Gitea wiki."""
|
||||
_run_module("devx.ci.sync_wiki", list(args))
|
||||
|
||||
|
||||
@ci.command("validate-commit-msg")
|
||||
@click.argument("args", nargs=-1)
|
||||
def ci_validate_commit_msg(args: tuple[str, ...]) -> None:
|
||||
"""Validate commit messages for conventional commit format."""
|
||||
_run_module("devx.ci.validate_commit_msg", list(args))
|
||||
|
||||
|
||||
@cli.group()
|
||||
def tools() -> None:
|
||||
"""Development tool commands."""
|
||||
|
||||
|
||||
@tools.command("check-test-speed")
|
||||
@click.argument("args", nargs=-1)
|
||||
def tools_check_test_speed(args: tuple[str, ...]) -> None:
|
||||
"""Run unit tests and enforce a maximum execution-time budget."""
|
||||
_run_module("devx.tools.check_test_speed", list(args))
|
||||
|
||||
|
||||
@tools.command("configure-repo")
|
||||
@click.argument("args", nargs=-1)
|
||||
def tools_configure_repo(args: tuple[str, ...]) -> None:
|
||||
"""Configure repository: branch protection + labels via Gitea API."""
|
||||
_run_module("devx.tools.configure_repo", list(args))
|
||||
|
||||
|
||||
@tools.command("generate-badges")
|
||||
@click.argument("args", nargs=-1)
|
||||
def tools_generate_badges(args: tuple[str, ...]) -> None:
|
||||
"""Generate self-contained SVG badge files from project metrics."""
|
||||
_run_module("devx.tools.generate_badges", list(args))
|
||||
|
||||
|
||||
@tools.command("install-checkmake")
|
||||
@click.argument("args", nargs=-1)
|
||||
def tools_install_checkmake(args: tuple[str, ...]) -> None:
|
||||
"""Install checkmake if not already present."""
|
||||
_run_module("devx.tools.install_checkmake", list(args))
|
||||
|
||||
|
||||
@tools.command("install-tools")
|
||||
@click.argument("args", nargs=-1)
|
||||
def tools_install_tools(args: tuple[str, ...]) -> None:
|
||||
"""Install CI/CD development tools (actionlint, git-cliff, tea, etc.)."""
|
||||
_run_module("devx.tools.install_tools", list(args))
|
||||
|
||||
|
||||
@tools.command("setup")
|
||||
@click.argument("args", nargs=-1)
|
||||
def tools_setup(args: tuple[str, ...]) -> None:
|
||||
"""Project setup: install Python deps and pre-commit hooks."""
|
||||
_run_module("devx.tools.setup", list(args))
|
||||
|
||||
|
||||
@cli.group()
|
||||
def molecule() -> None:
|
||||
"""Molecule testing commands (requires devx[molecule])."""
|
||||
|
||||
|
||||
@molecule.command("distribute")
|
||||
@click.argument("args", nargs=-1)
|
||||
def molecule_distribute(args: tuple[str, ...]) -> None:
|
||||
"""Distribute molecule test pairs across parallel runners."""
|
||||
_run_module("devx.molecule.distribute_molecule", list(args))
|
||||
|
||||
|
||||
@molecule.command("discover-runners")
|
||||
@click.argument("args", nargs=-1)
|
||||
def molecule_discover_runners(args: tuple[str, ...]) -> None:
|
||||
"""Discover available Gitea Actions runners for molecule tests."""
|
||||
_run_module("devx.molecule.discover_runners", list(args))
|
||||
|
||||
|
||||
@molecule.command("guard")
|
||||
@click.argument("args", nargs=-1)
|
||||
def molecule_guard(args: tuple[str, ...]) -> None:
|
||||
"""Run molecule tests sequentially with CI failure polling."""
|
||||
_run_module("devx.molecule.molecule_ci_guard", list(args))
|
||||
|
||||
|
||||
@molecule.command("all")
|
||||
@click.argument("args", nargs=-1)
|
||||
def molecule_all(args: tuple[str, ...]) -> None:
|
||||
"""Run all molecule scenarios on all supported OS platforms."""
|
||||
_run_module("devx.molecule.molecule_all", list(args))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli()
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Shared configuration constants for devx scripts and API clients.
|
||||
|
||||
All defaults can be overridden via environment variables with the ``DEVX_``
|
||||
prefix. Projects consuming devx can set these in their ``.env`` files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
# API endpoints — override via env vars for different Gitea/Vikunja instances
|
||||
GITEA_API_URL = os.getenv("DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
|
||||
VIKUNJA_API_URL = os.getenv("DEVX_VIKUNJA_API_URL", "https://work.oblachno.oblachno.fyi/api/v1")
|
||||
|
||||
# Organization defaults
|
||||
REPO_OWNER = os.getenv("DEVX_REPO_OWNER", "oblachno-oss")
|
||||
|
||||
# Task prefix for Vikunja task IDs — each project sets its own (GRM, DEVX, INFRA, etc.)
|
||||
TASK_PREFIX = os.getenv("DEVX_TASK_PREFIX", "DEVX")
|
||||
TASK_ID_RE = re.compile(rf"{TASK_PREFIX}-\d+")
|
||||
|
||||
# Vikunja project ID — each project uses a different Vikunja project
|
||||
VIKUNJA_PROJECT_ID = int(os.getenv("DEVX_VIKUNJA_PROJECT_ID", "6"))
|
||||
|
||||
# HTTP client defaults
|
||||
DEFAULT_TIMEOUT = 30
|
||||
DEFAULT_PER_PAGE = 50
|
||||
|
||||
# Retry configuration for transient errors (429, 5xx, connection errors)
|
||||
MAX_RETRIES = 3
|
||||
RETRY_BACKOFF_BASE = 2 # seconds: 2, 4, 8
|
||||
RETRY_STATUS_CODES = {429, 500, 502, 503, 504}
|
||||
|
||||
# Conventional commit regex — used by validate_commit_msg.py
|
||||
CONVENTIONAL_RE = re.compile(r"^(feat|fix|chore|docs|style|refactor|perf|test|ci|build|revert)(\(.+\))?: .+")
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Custom exceptions for devx tools and API clients."""
|
||||
|
||||
|
||||
class DevxError(Exception):
|
||||
"""Base exception for all devx errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class APIError(DevxError):
|
||||
"""Raised when a REST API call returns an HTTP error."""
|
||||
|
||||
def __init__(self, status: int, message: str) -> None:
|
||||
self.status = status
|
||||
self.message = message
|
||||
super().__init__(f"HTTP {status}: {message}")
|
||||
@@ -0,0 +1,323 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Thin Python wrapper around the ``tea`` Gitea CLI for CI/CD scripts.
|
||||
|
||||
This module provides a programmatic interface to the ``tea`` CLI tool,
|
||||
parsing JSON output for structured data. It is used by CI scripts to
|
||||
avoid hand-rolling HTTP requests and to leverage the official Gitea CLI
|
||||
for reliability.
|
||||
|
||||
The wrapper requires ``tea`` to be installed and configured (run
|
||||
``make setup`` which calls ``scripts/install_tools.py`` and
|
||||
``scripts/setup.py``).
|
||||
|
||||
Operations supported via tea:
|
||||
- Creating pull requests
|
||||
- Creating issues
|
||||
- Adding labels to issues/PRs
|
||||
- Creating labels
|
||||
- Merging pull requests
|
||||
- Creating releases
|
||||
- Posting reviews on PRs
|
||||
- Listing branches
|
||||
|
||||
Operations NOT supported via tea (still use GiteaClient):
|
||||
- Wiki page management
|
||||
- Commit status checks
|
||||
- Runner discovery
|
||||
- PR file/commit listing (tea has limited support)
|
||||
- Branch protection with detailed config (tea only has basic protect/unprotect)
|
||||
|
||||
Usage::
|
||||
|
||||
from devx.gitea_cli import TeaCLI
|
||||
|
||||
tea = TeaCLI()
|
||||
tea.create_issue("owner/repo", title="Bug", body="Description", labels=["bug"])
|
||||
tea.add_label("owner/repo", 42, ["ready-to-merge"])
|
||||
tea.create_release("owner/repo", tag="v1.0.0", title="Release 1.0.0", body="Notes")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
from typing import Any
|
||||
|
||||
|
||||
class TeaCLIError(Exception):
|
||||
"""Raised when a tea CLI command fails."""
|
||||
|
||||
|
||||
class TeaCLI:
|
||||
"""Wrapper around the ``tea`` Gitea CLI tool.
|
||||
|
||||
All methods parse JSON output from tea for structured access.
|
||||
Commands are run with ``--output json`` where structured data is expected.
|
||||
"""
|
||||
|
||||
def __init__(self, tea_bin: str | None = None, repo: str | None = None) -> None:
|
||||
"""Initialize the tea CLI wrapper.
|
||||
|
||||
Args:
|
||||
tea_bin: Path to the tea binary. If None, auto-detect via shutil.which.
|
||||
repo: Default repo in ``owner/name`` format for commands that need it.
|
||||
"""
|
||||
self._tea = tea_bin or shutil.which("tea") or "tea"
|
||||
self._repo = repo
|
||||
|
||||
def _run(self, args: list[str], json_output: bool = True) -> str:
|
||||
"""Run a tea command and return stdout.
|
||||
|
||||
Args:
|
||||
args: Command arguments (without the leading ``tea``).
|
||||
json_output: If True, append ``--output json`` to the command.
|
||||
|
||||
Returns:
|
||||
stdout as a string.
|
||||
|
||||
Raises:
|
||||
TeaCLIError: If the command fails.
|
||||
"""
|
||||
cmd = [self._tea, *args]
|
||||
if json_output:
|
||||
cmd.extend(["--output", "json"])
|
||||
result = subprocess.run( # nosec B603
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise TeaCLIError(
|
||||
f"tea command failed (rc={result.returncode}): {' '.join(args)}\nstderr: {result.stderr.strip()}"
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
def _run_raw(self, args: list[str]) -> str:
|
||||
"""Run a tea command without JSON output and return stdout."""
|
||||
return self._run(args, json_output=False)
|
||||
|
||||
def _repo_arg(self, repo: str | None = None) -> list[str]:
|
||||
"""Build the --repo argument list."""
|
||||
target = repo or self._repo
|
||||
if target:
|
||||
return ["--repo", target]
|
||||
return []
|
||||
|
||||
# -- Issues --
|
||||
|
||||
def create_issue(
|
||||
self,
|
||||
repo: str,
|
||||
title: str,
|
||||
body: str = "",
|
||||
labels: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create an issue and return the issue dict.
|
||||
|
||||
Args:
|
||||
repo: Repository in ``owner/name`` format.
|
||||
title: Issue title.
|
||||
body: Issue body (markdown).
|
||||
labels: List of label names to attach.
|
||||
|
||||
Returns:
|
||||
The created issue as a dict (parsed from tea JSON output).
|
||||
"""
|
||||
args = ["issues", "create", "--title", title, "--body", body, *self._repo_arg(repo)]
|
||||
output = self._run(args, json_output=False)
|
||||
# tea issues create doesn't output JSON; extract issue number from output
|
||||
# Format: "Created issue #42: <title>"
|
||||
issue_index = _extract_issue_number(output)
|
||||
return {"title": title, "body": body, "index": issue_index, "url": output.strip()}
|
||||
|
||||
# -- Labels --
|
||||
|
||||
def list_labels(self, repo: str) -> list[dict[str, Any]]:
|
||||
"""List all labels for a repository."""
|
||||
output = self._run(["labels", "list", *self._repo_arg(repo)])
|
||||
if not output:
|
||||
return []
|
||||
return json.loads(output)
|
||||
|
||||
def create_label(
|
||||
self,
|
||||
repo: str,
|
||||
name: str,
|
||||
color: str = "",
|
||||
description: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Create a label. Returns the label dict.
|
||||
|
||||
Args:
|
||||
repo: Repository in ``owner/name`` format.
|
||||
name: Label name.
|
||||
color: Hex color (without #), e.g. ``2ecc71``.
|
||||
description: Label description.
|
||||
"""
|
||||
args = ["labels", "create", name, *self._repo_arg(repo)]
|
||||
if color:
|
||||
args.extend(["--color", f"#{color}"])
|
||||
if description:
|
||||
args.extend(["--description", description])
|
||||
output = self._run(args, json_output=False)
|
||||
return {"name": name, "color": color, "description": description, "output": output}
|
||||
|
||||
def add_label(self, repo: str, issue_index: int, labels: list[str]) -> None:
|
||||
"""Add labels to an issue or PR.
|
||||
|
||||
Args:
|
||||
repo: Repository in ``owner/name`` format.
|
||||
issue_index: Issue or PR number.
|
||||
labels: List of label names to add.
|
||||
"""
|
||||
for _label in labels:
|
||||
self._run_raw(["issues", "edit", "--add-labels", ",".join(labels), str(issue_index), *self._repo_arg(repo)])
|
||||
return # tea edit handles all labels at once
|
||||
# No labels to add — nothing to do
|
||||
|
||||
# -- Pull Requests --
|
||||
|
||||
def create_pr(
|
||||
self,
|
||||
repo: str,
|
||||
title: str,
|
||||
head: str,
|
||||
base: str,
|
||||
body: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Create a pull request and return the PR dict.
|
||||
|
||||
Args:
|
||||
repo: Repository in ``owner/name`` format.
|
||||
title: PR title.
|
||||
head: Head branch name.
|
||||
base: Base branch name.
|
||||
body: PR description (markdown).
|
||||
"""
|
||||
args = [
|
||||
"pulls",
|
||||
"create",
|
||||
"--title",
|
||||
title,
|
||||
"--base",
|
||||
base,
|
||||
"--head",
|
||||
head,
|
||||
*self._repo_arg(repo),
|
||||
]
|
||||
if body:
|
||||
args.extend(["--body", body])
|
||||
output = self._run(args, json_output=False)
|
||||
pr_index = _extract_pr_number(output)
|
||||
return {"title": title, "index": pr_index, "url": output.strip()}
|
||||
|
||||
def merge_pr(self, repo: str, pr_index: int, style: str = "squash") -> None:
|
||||
"""Merge a pull request.
|
||||
|
||||
Args:
|
||||
repo: Repository in ``owner/name`` format.
|
||||
pr_index: PR number.
|
||||
style: Merge style: ``squash``, ``merge``, ``rebase``, ``rebase-edit``.
|
||||
"""
|
||||
self._run_raw(["pulls", "merge", "--style", style, str(pr_index), *self._repo_arg(repo)])
|
||||
|
||||
def review_pr(
|
||||
self,
|
||||
repo: str,
|
||||
pr_index: int,
|
||||
event: str = "COMMENT",
|
||||
body: str = "",
|
||||
) -> None:
|
||||
"""Post a review on a pull request.
|
||||
|
||||
Args:
|
||||
repo: Repository in ``owner/name`` format.
|
||||
pr_index: PR number.
|
||||
event: Review event: ``APPROVE``, ``REQUEST_CHANGES``, ``COMMENT``.
|
||||
body: Review body text.
|
||||
"""
|
||||
args = ["pulls", "review", str(pr_index), *self._repo_arg(repo)]
|
||||
if event == "APPROVE":
|
||||
args.append("--approve")
|
||||
elif event == "REQUEST_CHANGES":
|
||||
args.extend(["--reject"])
|
||||
if body:
|
||||
args.extend(["--comment", body])
|
||||
self._run_raw(args)
|
||||
|
||||
# -- Releases --
|
||||
|
||||
def create_release(
|
||||
self,
|
||||
repo: str,
|
||||
tag: str,
|
||||
title: str = "",
|
||||
body: str = "",
|
||||
target: str = "",
|
||||
draft: bool = False,
|
||||
prerelease: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a release and return the release dict.
|
||||
|
||||
Args:
|
||||
repo: Repository in ``owner/name`` format.
|
||||
tag: Tag name (e.g. ``v1.0.0``).
|
||||
title: Release title.
|
||||
body: Release notes (markdown).
|
||||
target: Target branch/commit for the tag.
|
||||
draft: If True, create as draft.
|
||||
prerelease: If True, mark as prerelease.
|
||||
"""
|
||||
args = ["releases", "create", tag, *self._repo_arg(repo)]
|
||||
if title:
|
||||
args.extend(["--title", title])
|
||||
if body:
|
||||
args.extend(["--note", body])
|
||||
if target:
|
||||
args.extend(["--target", target])
|
||||
if draft:
|
||||
args.append("--draft")
|
||||
if prerelease:
|
||||
args.append("--prerelease")
|
||||
output = self._run(args, json_output=False)
|
||||
return {"tag": tag, "title": title, "url": output.strip()}
|
||||
|
||||
def list_releases(self, repo: str) -> list[dict[str, Any]]:
|
||||
"""List all releases for a repository."""
|
||||
output = self._run(["releases", "list", *self._repo_arg(repo)])
|
||||
if not output:
|
||||
return []
|
||||
return json.loads(output)
|
||||
|
||||
# -- Branches --
|
||||
|
||||
def list_branches(self, repo: str) -> list[dict[str, Any]]:
|
||||
"""List all branches for a repository."""
|
||||
output = self._run(["branches", "list", *self._repo_arg(repo)])
|
||||
if not output:
|
||||
return []
|
||||
return json.loads(output)
|
||||
|
||||
# -- Utility --
|
||||
|
||||
def whoami(self) -> str:
|
||||
"""Return the current authenticated user."""
|
||||
return self._run_raw(["whoami"])
|
||||
|
||||
|
||||
def _extract_issue_number(output: str) -> int:
|
||||
"""Extract the issue number from tea output like 'Created issue #42: ...'."""
|
||||
for part in output.split():
|
||||
if part.startswith("#"):
|
||||
try:
|
||||
return int(part[1:].rstrip(":"))
|
||||
except ValueError:
|
||||
continue
|
||||
return 0
|
||||
|
||||
|
||||
def _extract_pr_number(output: str) -> int:
|
||||
"""Extract the PR number from tea output like 'Created PR #42: ...'."""
|
||||
return _extract_issue_number(output)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Simple i18n for devx scripts and tools.
|
||||
|
||||
Set DEVX_LANG environment variable to override the default English.
|
||||
Supported: en, bg, de, ru, zh.
|
||||
|
||||
Projects can extend translations by setting DEVX_TRANSLATIONS_PATH to a
|
||||
JSON file with additional keys. Keys from the project's file are merged
|
||||
on top of devx's built-in translations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Load built-in translations
|
||||
_BUILTIN_TRANSLATIONS: dict[str, dict[str, str]] = json.loads(
|
||||
(Path(__file__).parent / "translations.json").read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
|
||||
def _load_project_translations() -> dict[str, dict[str, str]]:
|
||||
"""Load project-specific translations from DEVX_TRANSLATIONS_PATH if set."""
|
||||
path = os.getenv("DEVX_TRANSLATIONS_PATH")
|
||||
if not path:
|
||||
return {}
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
|
||||
|
||||
# Merge project translations on top of built-in
|
||||
TRANSLATIONS: dict[str, dict[str, str]] = {**_BUILTIN_TRANSLATIONS, **_load_project_translations()}
|
||||
|
||||
|
||||
def _(key: str, **kwargs: object) -> str:
|
||||
"""Return a translated string for the given key.
|
||||
|
||||
Translation is opt-in via the ``DEVX_LANG`` environment variable.
|
||||
If unset, English is always returned regardless of system locale.
|
||||
"""
|
||||
lang = os.getenv("DEVX_LANG", "en")
|
||||
if lang not in ("en", "bg", "de", "ru", "zh"):
|
||||
lang = "en"
|
||||
template = TRANSLATIONS.get(key, {}).get(lang, key)
|
||||
return template.format(**kwargs)
|
||||
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Discover available Gitea Actions runners for dynamic job distribution.
|
||||
|
||||
Queries the Gitea API for registered runners at three levels:
|
||||
1. Repository level: GET /repos/{owner}/{repo}/actions/runners
|
||||
2. Organization level: GET /orgs/{org}/actions/runners
|
||||
3. Instance (admin) level: GET /admin/actions/runners
|
||||
|
||||
Falls back to the ``MOLECULE_RUNNERS`` repo variable or environment
|
||||
variable, then to ``DEFAULT_MAX_RUNNERS`` (3).
|
||||
|
||||
Outputs:
|
||||
- ``--count``: prints the number of available runners
|
||||
- ``--indices``: prints a JSON array [0, 1, ..., N-1] for use as a
|
||||
dynamic matrix in Gitea Actions
|
||||
- (default): prints both as ``count=N`` and ``indices=[0,1,...]``
|
||||
|
||||
Usage:
|
||||
python3 -m devx.molecule.discover_runners --owner oblachno-oss --repo grm
|
||||
python3 -m devx.molecule.discover_runners --indices
|
||||
python3 -m devx.molecule.discover_runners --count
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import click
|
||||
import requests
|
||||
|
||||
from devx.config import GITEA_API_URL
|
||||
|
||||
DEFAULT_MAX_RUNNERS = 3
|
||||
|
||||
|
||||
def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
|
||||
"""Query the Gitea API for registered runners at all levels.
|
||||
|
||||
Returns the total count of active runners. If the API call fails
|
||||
(e.g., no admin access for instance-level runners), falls back to
|
||||
what we can see.
|
||||
"""
|
||||
headers = {"Authorization": f"token {token}"}
|
||||
total = 0
|
||||
|
||||
# 1. Repository-level runners
|
||||
try:
|
||||
r = requests.get(
|
||||
f"{api_url}/repos/{owner}/{repo}/actions/runners",
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
total += data.get("total_count", 0)
|
||||
except (requests.RequestException, ValueError):
|
||||
pass
|
||||
|
||||
# 2. Organization-level runners
|
||||
try:
|
||||
r = requests.get(
|
||||
f"{api_url}/orgs/{owner}/actions/runners",
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
total += data.get("total_count", 0)
|
||||
except (requests.RequestException, ValueError):
|
||||
pass
|
||||
|
||||
# 3. Instance-level runners (requires admin scope)
|
||||
try:
|
||||
r = requests.get(
|
||||
f"{api_url}/admin/actions/runners",
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
total += data.get("total_count", 0)
|
||||
except (requests.RequestException, ValueError):
|
||||
pass
|
||||
|
||||
return total
|
||||
|
||||
|
||||
def get_runner_count(api_url: str, token: str, owner: str, repo: str) -> int:
|
||||
"""Determine the number of available runners.
|
||||
|
||||
Tries the Gitea API first, then falls back to env vars, then default.
|
||||
"""
|
||||
# Try API query if we have a token
|
||||
if token:
|
||||
api_count = query_runners(api_url, token, owner, repo)
|
||||
if api_count > 0:
|
||||
return api_count
|
||||
|
||||
# Fall back to MOLECULE_RUNNERS env var (set by CI from repo variable)
|
||||
env_count = os.environ.get("MOLECULE_RUNNERS")
|
||||
if env_count:
|
||||
try:
|
||||
count = int(env_count)
|
||||
if count > 0:
|
||||
return count
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Fall back to default
|
||||
return DEFAULT_MAX_RUNNERS
|
||||
|
||||
|
||||
def generate_indices(count: int) -> list[str]:
|
||||
"""Generate a list of runner indices ["1", "2", ..., "N"].
|
||||
|
||||
Uses 1-based string indices because Gitea Actions renders
|
||||
integer 0 and string "0" as empty in ${{ matrix.runner-index }}
|
||||
expressions, causing --runner-index to be passed without a value.
|
||||
The distribute_molecule.py script converts these back to 0-based
|
||||
internally.
|
||||
"""
|
||||
return [str(i + 1) for i in range(count)]
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--owner", default=None, help="Repository owner (for API query).")
|
||||
@click.option("--repo", default=None, help="Repository name (for API query).")
|
||||
@click.option("--count", "output_count", is_flag=True, help="Output only the count.")
|
||||
@click.option("--indices", "output_indices", is_flag=True, help="Output only the JSON indices array.")
|
||||
@click.option(
|
||||
"--github-output",
|
||||
"github_output",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Write results to $GITHUB_OUTPUT file (for CI workflow steps).",
|
||||
)
|
||||
def main(
|
||||
owner: str | None,
|
||||
repo: str | None,
|
||||
output_count: bool,
|
||||
output_indices: bool,
|
||||
github_output: bool,
|
||||
) -> None:
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
|
||||
if owner is None:
|
||||
owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss")
|
||||
if repo is None:
|
||||
repo = os.environ.get("DEVX_REPO_NAME", "devx")
|
||||
|
||||
count = get_runner_count(GITEA_API_URL, token, owner, repo)
|
||||
indices = generate_indices(count)
|
||||
|
||||
if github_output:
|
||||
gh_output = os.environ.get("GITHUB_OUTPUT")
|
||||
if not gh_output:
|
||||
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
|
||||
with open(gh_output, "a") as f: # noqa: PTH123
|
||||
f.write(f"runner-count={count}\n")
|
||||
f.write(f"runner-indices={json.dumps(indices)}\n")
|
||||
click.echo(f"Runner count: {count}")
|
||||
click.echo(f"Runner indices: {indices}")
|
||||
return
|
||||
|
||||
if output_count:
|
||||
click.echo(str(count))
|
||||
return
|
||||
|
||||
if output_indices:
|
||||
click.echo(json.dumps(indices))
|
||||
return
|
||||
|
||||
# Default: output both as key=value pairs for CI consumption
|
||||
click.echo(f"count={count}")
|
||||
click.echo(f"indices={json.dumps(indices)}")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Distribute molecule (scenario, platform) pairs across N parallel runners.
|
||||
|
||||
Discovers all molecule scenarios under ansible/roles/*/molecule/ and
|
||||
crosses them with the supported OS platform matrix, then splits the
|
||||
resulting test pairs evenly across the requested number of runners.
|
||||
|
||||
Each pair is printed as ``scenario|platform_name|platform_image|platform_command``
|
||||
so the CI workflow can set the appropriate environment variables.
|
||||
|
||||
Usage:
|
||||
python3 -m devx.molecule.distribute_molecule --runner-index 1 --max-runners 3
|
||||
# prints: default|ubuntu-2204|ubuntu:22.04| lifecycle|ubuntu-2204|ubuntu:22.04| ...
|
||||
python3 -m devx.molecule.distribute_molecule --list
|
||||
# prints all scenarios, one per line
|
||||
python3 -m devx.molecule.distribute_molecule --list-platforms
|
||||
# prints all platforms, one per line
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from devx.i18n import _
|
||||
from devx.molecule.platforms import PLATFORMS
|
||||
|
||||
DEFAULT_MAX_RUNNERS = 3
|
||||
MOLECULE_ROOT = Path("ansible/roles/gitea-runner/molecule")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestPair:
|
||||
"""A (scenario, platform) combination to test."""
|
||||
|
||||
scenario: str
|
||||
platform: dict[str, str]
|
||||
|
||||
def encode(self) -> str:
|
||||
"""Serialize to a pipe-delimited string for CI consumption."""
|
||||
return f"{self.scenario}|{self.platform['name']}|{self.platform['image']}|{self.platform['command']}"
|
||||
|
||||
@staticmethod
|
||||
def decode(encoded: str) -> TestPair:
|
||||
"""Deserialize from a pipe-delimited string."""
|
||||
parts = encoded.split("|")
|
||||
return TestPair(
|
||||
scenario=parts[0],
|
||||
platform={"name": parts[1], "image": parts[2], "command": parts[3]},
|
||||
)
|
||||
|
||||
|
||||
def discover_scenarios(root: Path | None = None) -> list[str]:
|
||||
"""Return sorted list of molecule scenario directory names."""
|
||||
if root is None:
|
||||
root = MOLECULE_ROOT
|
||||
if not root.is_dir():
|
||||
raise click.ClickException(_("Molecule directory not found: {path}", path=str(root)))
|
||||
scenarios = [d.name for d in root.iterdir() if d.is_dir() and not d.name.startswith("_") and d.name != "common"]
|
||||
return sorted(scenarios)
|
||||
|
||||
|
||||
def build_pairs(scenarios: list[str], platforms: list[dict[str, str]] | None = None) -> list[TestPair]:
|
||||
"""Build the full cross-product of scenarios and platforms."""
|
||||
if platforms is None:
|
||||
platforms = PLATFORMS
|
||||
return [TestPair(s, p) for s in scenarios for p in platforms]
|
||||
|
||||
|
||||
def distribute(pairs: list[TestPair], max_runners: int) -> list[list[TestPair]]:
|
||||
"""Split *pairs* into *max_runners* balanced groups (round-robin)."""
|
||||
groups: list[list[TestPair]] = [[] for _ in range(max_runners)]
|
||||
for i, pair in enumerate(pairs):
|
||||
groups[i % max_runners].append(pair)
|
||||
return groups
|
||||
|
||||
|
||||
def pairs_for_runner(pairs: list[TestPair], runner_index: int, max_runners: int) -> list[TestPair]:
|
||||
"""Return the subset of pairs assigned to *runner_index*."""
|
||||
groups = distribute(pairs, max_runners)
|
||||
if runner_index < 0 or runner_index >= len(groups):
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Runner index {index} out of range (0..{max})",
|
||||
index=runner_index,
|
||||
max=max_runners - 1,
|
||||
)
|
||||
)
|
||||
return groups[runner_index]
|
||||
|
||||
|
||||
def _write_github_env(key: str, value: str) -> None:
|
||||
"""Append a key=value line to the $GITHUB_ENV file."""
|
||||
import os
|
||||
|
||||
gh_env = os.environ.get("GITHUB_ENV")
|
||||
if not gh_env:
|
||||
raise click.ClickException("GITHUB_ENV environment variable is not set")
|
||||
with open(gh_env, "a") as f: # noqa: PTH123
|
||||
f.write(f"{key}={value}\n")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--runner-index",
|
||||
type=int,
|
||||
default=None,
|
||||
help="One-based runner index (Gitea Actions renders 0 as empty). "
|
||||
"Converted to zero-based internally. If omitted, prints all groups.",
|
||||
)
|
||||
@click.option(
|
||||
"--max-runners",
|
||||
type=int,
|
||||
default=DEFAULT_MAX_RUNNERS,
|
||||
show_default=True,
|
||||
help="Total number of parallel runners.",
|
||||
)
|
||||
@click.option(
|
||||
"--list",
|
||||
"list_all",
|
||||
is_flag=True,
|
||||
help="List all discovered scenarios, one per line.",
|
||||
)
|
||||
@click.option(
|
||||
"--list-platforms",
|
||||
"list_platforms",
|
||||
is_flag=True,
|
||||
help="List all supported platforms, one per line.",
|
||||
)
|
||||
@click.option(
|
||||
"--github-env",
|
||||
"github_env",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Write TEST_PAIRS and SKIP to $GITHUB_ENV (for CI workflow steps).",
|
||||
)
|
||||
@click.option(
|
||||
"--skip-if-excess",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="With --github-env: write SKIP=true when runner-index exceeds max-runners.",
|
||||
)
|
||||
def cli(
|
||||
runner_index: int | None,
|
||||
max_runners: int,
|
||||
list_all: bool,
|
||||
list_platforms: bool,
|
||||
github_env: bool,
|
||||
skip_if_excess: bool,
|
||||
) -> None:
|
||||
scenarios = discover_scenarios()
|
||||
if list_all:
|
||||
for s in scenarios:
|
||||
click.echo(s)
|
||||
return
|
||||
if list_platforms:
|
||||
for p in PLATFORMS:
|
||||
click.echo(f"{p['name']}|{p['image']}|{p['command']}")
|
||||
return
|
||||
pairs = build_pairs(scenarios)
|
||||
if runner_index is None:
|
||||
groups = distribute(pairs, max_runners)
|
||||
for i, group in enumerate(groups):
|
||||
labels = " ".join(p.encode() for p in group) if group else "(none)"
|
||||
click.echo(f"Runner {i}: {labels}")
|
||||
return
|
||||
|
||||
# Skip if runner index exceeds available runners (CI static matrix has 3 slots)
|
||||
if skip_if_excess and github_env and runner_index > max_runners:
|
||||
click.echo(f"Skipping — runner index {runner_index} > max runners {max_runners}")
|
||||
_write_github_env("TEST_PAIRS", "")
|
||||
_write_github_env("SKIP", "true")
|
||||
return
|
||||
|
||||
# Convert 1-based CLI index to 0-based internal index
|
||||
zero_based = runner_index - 1
|
||||
assigned = pairs_for_runner(pairs, zero_based, max_runners)
|
||||
encoded = " ".join(p.encode() for p in assigned)
|
||||
|
||||
if github_env:
|
||||
_write_github_env("TEST_PAIRS", encoded)
|
||||
_write_github_env("SKIP", "false")
|
||||
click.echo(f"Assigned pairs: {encoded}")
|
||||
return
|
||||
|
||||
click.echo(encoded)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli()
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run all molecule scenarios on all supported OS platforms.
|
||||
|
||||
Replaces the previous ``scripts/molecule_all.sh`` with a tested Python equivalent.
|
||||
Sequential execution — CI uses the parallel matrix instead.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.molecule.molecule_all
|
||||
python3 -m devx.molecule.molecule_all --bin .venv/bin
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from devx.molecule.platforms import PLATFORMS
|
||||
|
||||
ROLE_DIR = Path("ansible/roles/gitea-runner")
|
||||
SCENARIOS = ["default", "multi-instance", "lifecycle", "template-content", "deregister", "update"]
|
||||
|
||||
|
||||
def _run_molecule(molecule_bin: str, scenario: str, role_dir: Path, env: dict[str, str]) -> int:
|
||||
"""Run a single molecule scenario. Returns the exit code."""
|
||||
cmd = [molecule_bin, "test"]
|
||||
if scenario != "default":
|
||||
cmd.extend(["-s", scenario])
|
||||
|
||||
click.echo(f"--- Scenario: {scenario} ---")
|
||||
result = subprocess.run( # nosec B603
|
||||
cmd,
|
||||
cwd=str(role_dir),
|
||||
env=env,
|
||||
)
|
||||
return result.returncode
|
||||
|
||||
|
||||
def _run_platform(
|
||||
molecule_bin: str,
|
||||
platform: dict[str, str],
|
||||
role_dir: Path,
|
||||
scenarios: list[str],
|
||||
base_env: dict[str, str],
|
||||
) -> int:
|
||||
"""Run all scenarios for a single platform. Returns the first non-zero exit code."""
|
||||
env = dict(base_env)
|
||||
env["MOLECULE_PLATFORM_NAME"] = platform["name"]
|
||||
env["MOLECULE_PLATFORM_IMAGE"] = platform["image"]
|
||||
if platform.get("command"):
|
||||
env["MOLECULE_PLATFORM_COMMAND"] = platform["command"]
|
||||
else:
|
||||
env.pop("MOLECULE_PLATFORM_COMMAND", None)
|
||||
|
||||
click.echo(f"=== Platform: {platform['name']} ===")
|
||||
for scenario in scenarios:
|
||||
rc = _run_molecule(molecule_bin, scenario, role_dir, env)
|
||||
if rc != 0:
|
||||
return rc
|
||||
return 0
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--bin", "bin_dir", default=".venv/bin", help="Path to the virtualenv bin directory.")
|
||||
def main(bin_dir: str) -> None:
|
||||
"""Run all molecule scenarios on all supported OS platforms sequentially."""
|
||||
molecule_bin = str(Path(bin_dir) / "molecule")
|
||||
if not Path(molecule_bin).exists():
|
||||
raise click.ClickException(f"molecule not found at {molecule_bin}. Run 'make setup' first.")
|
||||
|
||||
if not ROLE_DIR.exists():
|
||||
raise click.ClickException(f"Role directory not found: {ROLE_DIR}")
|
||||
|
||||
base_env = dict(os.environ)
|
||||
base_env["ANSIBLE_ALLOW_BROKEN_CONDITIONALS"] = "true"
|
||||
base_env["ANSIBLE_INJECT_INVOCATION"] = "1"
|
||||
|
||||
for platform in PLATFORMS:
|
||||
rc = _run_platform(molecule_bin, platform, ROLE_DIR, SCENARIOS, base_env)
|
||||
if rc != 0:
|
||||
click.echo(f"FAILED on platform {platform['name']}", err=True)
|
||||
sys.exit(rc)
|
||||
|
||||
click.echo("All molecule scenarios passed on all platforms.")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main() # pragma: no cover
|
||||
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run molecule tests sequentially while polling Gitea for other runner failures.
|
||||
|
||||
Each pair is encoded as ``scenario|platform_name|platform_image|platform_command``.
|
||||
Pairs are executed one at a time (molecule scenarios share temp directories and
|
||||
Docker networks, so parallel execution within a single runner is unsafe).
|
||||
|
||||
A background thread polls the Gitea API. If any other molecule matrix runner
|
||||
reports failure, the current molecule subprocess is killed and this runner
|
||||
exits early with code 1.
|
||||
|
||||
Usage:
|
||||
python3 -m devx.molecule.molecule_ci_guard <pair1> <pair2> ...
|
||||
|
||||
Environment variables:
|
||||
GITEA_URL Base URL of the Gitea instance.
|
||||
REPO_TOKEN API token with repo access.
|
||||
RUN_ID Workflow run ID (GITHUB_RUN_ID).
|
||||
JOB_NAME Base job name (GITHUB_JOB), e.g. "molecule-tests".
|
||||
MATRIX_INDEX Current matrix index (runner-index).
|
||||
GITEA_REPOSITORY Repository in "owner/repo" format.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import signal
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import requests
|
||||
|
||||
from devx.i18n import _
|
||||
|
||||
POLL_INTERVAL = 10
|
||||
|
||||
|
||||
def get_running_jobs(gitea_url: str, owner: str, repo: str, token: str, run_id: int) -> list[dict]:
|
||||
"""Return jobs for the given workflow run."""
|
||||
url = f"{gitea_url}/api/v1/repos/{owner}/{repo}/actions/runs/{run_id}/jobs"
|
||||
headers = {"Authorization": f"token {token}"}
|
||||
response = requests.get(url, headers=headers, timeout=30)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data.get("jobs", [])
|
||||
|
||||
|
||||
def any_other_runner_failed(jobs: list[dict], current_job_name: str, current_index: int) -> bool:
|
||||
"""Return True if any other molecule matrix job has failed."""
|
||||
for job in jobs:
|
||||
name = job.get("name", "")
|
||||
if not name.startswith(current_job_name):
|
||||
continue
|
||||
if name == f"{current_job_name} ({current_index})" or name == current_job_name:
|
||||
continue
|
||||
if job.get("conclusion") == "failure":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def poll_for_other_failures(
|
||||
gitea_url: str,
|
||||
owner: str,
|
||||
repo: str,
|
||||
token: str,
|
||||
run_id: int,
|
||||
job_name: str,
|
||||
current_index: int,
|
||||
stop_event: threading.Event,
|
||||
failed_event: threading.Event,
|
||||
) -> None:
|
||||
"""Background thread: poll API and signal if another runner fails."""
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
jobs = get_running_jobs(gitea_url, owner, repo, token, run_id)
|
||||
if any_other_runner_failed(jobs, job_name, current_index):
|
||||
click.echo(_("Another molecule runner failed. Stopping this runner early."))
|
||||
failed_event.set()
|
||||
return
|
||||
except requests.RequestException as exc:
|
||||
click.echo(_("API poll warning: {exc}", exc=exc))
|
||||
stop_event.wait(POLL_INTERVAL)
|
||||
|
||||
|
||||
def build_molecule_cmd(scenario: str) -> list[str]:
|
||||
"""Build the molecule command for a scenario."""
|
||||
cmd = ["molecule", "test"]
|
||||
if scenario != "default":
|
||||
cmd.extend(["-s", scenario])
|
||||
return cmd
|
||||
|
||||
|
||||
def build_env_for_pair(pair: str, base_env: dict[str, str]) -> dict[str, str]:
|
||||
"""Build environment for a single molecule pair."""
|
||||
scenario, platform_name, platform_image, platform_command = pair.split("|")
|
||||
env = base_env.copy()
|
||||
env["MOLECULE_PLATFORM_NAME"] = platform_name
|
||||
env["MOLECULE_PLATFORM_IMAGE"] = platform_image
|
||||
if platform_command:
|
||||
env["MOLECULE_PLATFORM_COMMAND"] = platform_command
|
||||
elif "MOLECULE_PLATFORM_COMMAND" in env:
|
||||
del env["MOLECULE_PLATFORM_COMMAND"]
|
||||
env["ANSIBLE_ALLOW_BROKEN_CONDITIONALS"] = "true"
|
||||
return env
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("pairs", nargs=-1, required=True)
|
||||
def cli(pairs: tuple[str, ...]) -> None:
|
||||
"""Run molecule pairs sequentially, stop if another CI runner fails."""
|
||||
gitea_url = os.environ.get("GITEA_URL", "")
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
run_id = int(os.environ.get("RUN_ID", "0"))
|
||||
job_name = os.environ.get("JOB_NAME", "molecule-tests")
|
||||
current_index = int(os.environ.get("MATRIX_INDEX", "0"))
|
||||
repository = os.environ.get("GITEA_REPOSITORY", "oblachno-oss/devx")
|
||||
owner, sep, repo = repository.partition("/")
|
||||
if not owner or not repo:
|
||||
owner, repo = "oblachno-oss", "devx"
|
||||
|
||||
if not all([gitea_url, token, run_id]):
|
||||
click.echo(_("GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."))
|
||||
|
||||
repo_root = Path(__file__).resolve().parent.parent.parent.parent
|
||||
role_dir = repo_root / "ansible" / "roles" / "gitea-runner"
|
||||
|
||||
base_env = os.environ.copy()
|
||||
base_env.setdefault("DOCKER_HOST", f"unix:///run/user/{os.getuid()}/docker.sock")
|
||||
base_env.setdefault("ANSIBLE_INJECT_INVOCATION", "1")
|
||||
|
||||
stop_event = threading.Event()
|
||||
failed_event = threading.Event()
|
||||
|
||||
if gitea_url and token and run_id:
|
||||
poller = threading.Thread(
|
||||
target=poll_for_other_failures,
|
||||
args=(
|
||||
gitea_url,
|
||||
owner,
|
||||
repo,
|
||||
token,
|
||||
run_id,
|
||||
job_name,
|
||||
current_index,
|
||||
stop_event,
|
||||
failed_event,
|
||||
),
|
||||
daemon=True,
|
||||
)
|
||||
poller.start()
|
||||
|
||||
try:
|
||||
for pair in pairs:
|
||||
if failed_event.is_set():
|
||||
sys.exit(1)
|
||||
|
||||
scenario = pair.split("|")[0]
|
||||
platform_name = pair.split("|")[1]
|
||||
click.echo(_("Running: {scenario} on {platform}", scenario=scenario, platform=platform_name))
|
||||
|
||||
cmd = build_molecule_cmd(scenario)
|
||||
env = build_env_for_pair(pair, base_env)
|
||||
|
||||
process = subprocess.Popen( # nosec B603
|
||||
cmd,
|
||||
cwd=str(role_dir),
|
||||
env=env,
|
||||
preexec_fn=os.setsid,
|
||||
)
|
||||
|
||||
try:
|
||||
while process.poll() is None:
|
||||
if failed_event.is_set():
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
||||
process.wait()
|
||||
sys.exit(1)
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
|
||||
process.wait()
|
||||
sys.exit(1)
|
||||
|
||||
rc = process.returncode
|
||||
if rc != 0:
|
||||
click.echo(_("FAILED: {pair} exited with code {code}", pair=pair, code=rc))
|
||||
sys.exit(rc)
|
||||
|
||||
click.echo(_("PASSED: {pair}", pair=pair))
|
||||
|
||||
click.echo(_("All molecule tests passed."))
|
||||
finally:
|
||||
stop_event.set()
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli()
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Supported OS platform matrix for molecule tests.
|
||||
|
||||
Single source of truth for the platform list used by both:
|
||||
- ``devx.molecule.distribute_molecule`` (CI parallel matrix)
|
||||
- local sequential runners
|
||||
|
||||
Keeping this in a dedicated module avoids cross-imports between
|
||||
dev tools and CI scripts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
#: Supported OS platform matrix.
|
||||
#: Each entry maps a short name to (image, command).
|
||||
#: The command must be systemd since rootless Docker requires
|
||||
#: loginctl/systemctl --user.
|
||||
PLATFORMS: list[dict[str, str]] = [
|
||||
{"name": "ubuntu-2204", "image": "geerlingguy/docker-ubuntu2204-ansible:latest", "command": "/lib/systemd/systemd"},
|
||||
{"name": "ubuntu-2404", "image": "geerlingguy/docker-ubuntu2404-ansible:latest", "command": "/lib/systemd/systemd"},
|
||||
{"name": "debian-12", "image": "geerlingguy/docker-debian12-ansible:latest", "command": "/lib/systemd/systemd"},
|
||||
{"name": "archlinux", "image": "marcstraube/archlinux-ansible:latest", "command": "/usr/lib/systemd/systemd"},
|
||||
]
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run unit tests and enforce a maximum execution-time budget.
|
||||
|
||||
Usage:
|
||||
python3 -m devx.tools.check_test_speed [--max-seconds N]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
|
||||
from devx.i18n import _
|
||||
|
||||
DEFAULT_MAX_SECONDS = 2.0
|
||||
TEST_COMMAND = ["make", "test-unit"]
|
||||
_TIMING_RE = re.compile(r"(\d+) passed.* in ([0-9.]+)s")
|
||||
|
||||
|
||||
def run_tests() -> tuple[str, str]:
|
||||
"""Execute the unit-test suite and return (stdout, stderr)."""
|
||||
result = subprocess.run( # nosec B603
|
||||
TEST_COMMAND,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
return result.stdout, result.stderr
|
||||
|
||||
|
||||
def parse_duration(output: str) -> float:
|
||||
"""Extract elapsed seconds from pytest summary line.
|
||||
|
||||
Raises:
|
||||
click.ClickException: when the timing line cannot be found.
|
||||
"""
|
||||
for line in output.splitlines():
|
||||
match = _TIMING_RE.search(line)
|
||||
if match:
|
||||
return float(match.group(2))
|
||||
raise click.ClickException(_("Could not parse test execution time from output."))
|
||||
|
||||
|
||||
def check_speed(duration: float, max_seconds: float) -> None:
|
||||
"""Validate duration is within budget; raise on violation."""
|
||||
if duration > max_seconds:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n"
|
||||
" Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n"
|
||||
" Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.",
|
||||
duration=duration,
|
||||
max=max_seconds,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def main(max_seconds: float) -> None:
|
||||
"""Run tests, parse timing, and enforce the budget."""
|
||||
stdout, stderr = run_tests()
|
||||
combined = stdout + "\n" + stderr
|
||||
click.echo(combined, err=False)
|
||||
|
||||
duration = parse_duration(combined)
|
||||
check_speed(duration, max_seconds)
|
||||
click.echo(
|
||||
_(
|
||||
"Unit tests passed in {duration:.2f}s (under {max}s limit).",
|
||||
duration=duration,
|
||||
max=max_seconds,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--max-seconds",
|
||||
type=float,
|
||||
default=DEFAULT_MAX_SECONDS,
|
||||
show_default=True,
|
||||
help="Maximum allowed execution time in seconds.",
|
||||
)
|
||||
def cli(max_seconds: float) -> None:
|
||||
main(max_seconds)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Configure repository: branch protection + repo settings via Gitea REST API.
|
||||
|
||||
Uses ``GiteaClient`` for branch protection and repo settings.
|
||||
The ``tea`` CLI is used for label creation if available, with a
|
||||
fallback to ``GiteaClient`` if tea is not installed.
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 -m devx.tools.configure_repo --repo my-repo
|
||||
REPO_TOKEN=<token> python3 -m devx.tools.configure_repo --repo my-repo --owner my-org
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http
|
||||
import os
|
||||
from typing import Any, cast
|
||||
|
||||
import click
|
||||
|
||||
from devx.api_clients import GiteaClient
|
||||
from devx.config import GITEA_API_URL, REPO_OWNER
|
||||
from devx.exceptions import APIError
|
||||
from devx.i18n import _
|
||||
|
||||
|
||||
def _default_status_checks() -> list[str]:
|
||||
"""Read status check contexts from DEVX_STATUS_CHECKS env var or use default."""
|
||||
env_checks = os.environ.get("DEVX_STATUS_CHECKS", "")
|
||||
if env_checks:
|
||||
return [c.strip() for c in env_checks.split(",") if c.strip()]
|
||||
return ["CI / quality (pull_request)"]
|
||||
|
||||
|
||||
def _default_branch_protection_config() -> dict[str, Any]:
|
||||
"""Build the default branch protection config.
|
||||
|
||||
The ``status_check_contexts`` are read from the ``DEVX_STATUS_CHECKS``
|
||||
environment variable (comma-separated) or default to just the quality
|
||||
check context.
|
||||
"""
|
||||
return {
|
||||
"branch_name": "master",
|
||||
"enable_push": True,
|
||||
"enable_push_whitelist": True,
|
||||
"push_whitelist_usernames": [],
|
||||
"enable_status_check": True,
|
||||
"status_check_contexts": _default_status_checks(),
|
||||
"required_approvals": 0,
|
||||
"dismiss_stale_approvals": True,
|
||||
"block_on_outdated_branch": True,
|
||||
"block_on_rejected_reviews": True,
|
||||
"block_on_official_review_requests": True,
|
||||
}
|
||||
|
||||
|
||||
def _default_repo_settings_config() -> dict[str, Any]:
|
||||
"""Build the default repository settings config."""
|
||||
return {
|
||||
"default_delete_branch_after_merge": True,
|
||||
}
|
||||
|
||||
|
||||
def _handle_http_error(e: APIError) -> None:
|
||||
"""Raise a user-friendly Click exception for HTTP errors."""
|
||||
if e.status == http.HTTPStatus.FORBIDDEN:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"HTTP {status} Forbidden — your token lacks admin rights.\n"
|
||||
"Make sure the token belongs to a repo owner or organisation admin.\n"
|
||||
"Alternatively, configure branch protection manually in Settings → Branches.",
|
||||
status=e.status,
|
||||
)
|
||||
)
|
||||
raise click.ClickException(_("HTTP error: {status} — {message}", status=e.status, message=e.message))
|
||||
|
||||
|
||||
def configure_repo(
|
||||
token: str,
|
||||
owner: str,
|
||||
repo: str,
|
||||
branch: str = "master",
|
||||
branch_protection_config: dict[str, Any] | None = None,
|
||||
repo_settings_config: dict[str, Any] | None = None,
|
||||
api_url: str | None = None,
|
||||
) -> None:
|
||||
"""Configure branch protection and repository settings via the Gitea API.
|
||||
|
||||
Args:
|
||||
token: Gitea API token with admin rights.
|
||||
owner: Repository owner (user or organisation).
|
||||
repo: Repository name.
|
||||
branch: Branch to protect (default: ``master``).
|
||||
branch_protection_config: Branch protection settings dict.
|
||||
If None, uses defaults from :func:`_default_branch_protection_config`.
|
||||
repo_settings_config: Repository settings dict.
|
||||
If None, uses defaults from :func:`_default_repo_settings_config`.
|
||||
api_url: Gitea API base URL. If None, uses ``GITEA_API_URL`` from config.
|
||||
"""
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
||||
|
||||
url = api_url or GITEA_API_URL
|
||||
client = GiteaClient(url, token, owner, repo)
|
||||
|
||||
bp_config = branch_protection_config or _default_branch_protection_config()
|
||||
rs_config = repo_settings_config or _default_repo_settings_config()
|
||||
|
||||
try:
|
||||
click.echo(_("Configuring branch protection for {branch}...", branch=branch))
|
||||
client.ensure_branch_protection(branch, bp_config)
|
||||
click.echo(_(" - Direct pushes: BLOCKED (require PR, whitelisted users can push)"))
|
||||
click.echo(
|
||||
_(
|
||||
" - Required approvals: {count}",
|
||||
count=bp_config["required_approvals"],
|
||||
)
|
||||
)
|
||||
click.echo(_(" - Dismiss stale approvals: yes"))
|
||||
click.echo(_(" - Block outdated branches: yes"))
|
||||
click.echo(_(" - Block rejected reviews: yes"))
|
||||
checks = ", ".join(cast(list[str], bp_config["status_check_contexts"]))
|
||||
click.echo(_(" - Required status checks: {checks}", checks=checks))
|
||||
|
||||
click.echo("")
|
||||
click.echo(_("Configuring repository settings..."))
|
||||
client.update_repo_settings(cast(dict[str, object], rs_config))
|
||||
click.echo(_(" - Auto-delete branch after merge: yes"))
|
||||
|
||||
click.echo("")
|
||||
click.echo(_("Repository configuration complete."))
|
||||
except APIError as e:
|
||||
_handle_http_error(e)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--repo", default=None, help="Repository name (default: from DEVX_REPO_NAME env var).")
|
||||
@click.option("--owner", default=None, help="Repository owner (default: from DEVX_REPO_OWNER env var).")
|
||||
@click.option("--branch", default="master", help="Branch to protect (default: master).")
|
||||
@click.option(
|
||||
"--api-url",
|
||||
default=None,
|
||||
help="Gitea API base URL (default: from DEVX_GITEA_API_URL env var).",
|
||||
)
|
||||
def main(repo: str | None, owner: str | None, branch: str, api_url: str | None) -> None:
|
||||
"""Configure branch protection and repository settings via the Gitea API."""
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
|
||||
if repo is None:
|
||||
repo = os.environ.get("DEVX_REPO_NAME", "")
|
||||
if not repo:
|
||||
raise click.ClickException(_("ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME."))
|
||||
|
||||
if owner is None:
|
||||
owner = REPO_OWNER
|
||||
|
||||
configure_repo(
|
||||
token=token,
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
branch=branch,
|
||||
api_url=api_url,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main() # pragma: no cover
|
||||
@@ -0,0 +1,268 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate self-contained SVG badge files from project metrics.
|
||||
|
||||
Runs pytest-cov, doc-coverage, lint checks, and version extraction,
|
||||
then writes SVG badge files that can be served as static files from
|
||||
the Gitea raw file API.
|
||||
|
||||
Usage:
|
||||
python3 -m devx.tools.generate_badges --output-dir .badges/
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
|
||||
_COVERAGE_RE = re.compile(r"TOTAL.*?(\d+(?:\.\d+)?)%")
|
||||
_PASSED_RE = re.compile(r"(\d+) passed")
|
||||
_DOC_COVERAGE_RE = re.compile(r"Doc coverage:\s+\d+/\d+\s+\((\d+)%")
|
||||
|
||||
# shields.io color names to hex values
|
||||
COLOR_HEX: dict[str, str] = {
|
||||
"brightgreen": "#4c1",
|
||||
"green": "#97ca00",
|
||||
"yellowgreen": "#a4a61d",
|
||||
"yellow": "#dfb317",
|
||||
"orange": "#fe7d37",
|
||||
"red": "#e05d44",
|
||||
"blue": "#007ec6",
|
||||
"lightgrey": "#9f9f9f",
|
||||
}
|
||||
|
||||
|
||||
def _find_package_init() -> Path | None:
|
||||
"""Find the first package __init__.py under src/ that defines __version__."""
|
||||
src_dir = REPO_ROOT / "src"
|
||||
if not src_dir.exists():
|
||||
return None
|
||||
for init_file in src_dir.rglob("__init__.py"):
|
||||
try:
|
||||
content = init_file.read_text()
|
||||
except OSError:
|
||||
continue
|
||||
if "__version__" in content:
|
||||
return init_file
|
||||
return None
|
||||
|
||||
|
||||
def _xml_escape(text: str) -> str:
|
||||
"""Escape XML special characters."""
|
||||
return text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
||||
|
||||
|
||||
def run_command(cmd: list[str]) -> tuple[int, str, str]:
|
||||
"""Run a command and return (returncode, stdout, stderr)."""
|
||||
result = subprocess.run( # nosec B603
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
cwd=str(REPO_ROOT),
|
||||
)
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
|
||||
def make_badge(label: str, message: str, color: str) -> dict[str, str | int]:
|
||||
"""Build a badge data dict."""
|
||||
return {
|
||||
"schemaVersion": 1,
|
||||
"label": label,
|
||||
"message": message,
|
||||
"color": color,
|
||||
}
|
||||
|
||||
|
||||
def render_svg(label: str, message: str, color: str) -> str:
|
||||
"""Render a shields.io-style SVG badge."""
|
||||
color_hex = COLOR_HEX.get(color, color if color.startswith("#") else "#9f9f9f")
|
||||
|
||||
# Approximate text width: 7px per character + 10px padding
|
||||
label_text = _xml_escape(label)
|
||||
message_text = _xml_escape(message)
|
||||
label_w = max(len(label) * 7 + 10, 30)
|
||||
message_w = max(len(message) * 7 + 10, 30)
|
||||
total_w = label_w + message_w
|
||||
|
||||
return f'''<svg xmlns="http://www.w3.org/2000/svg" width="{total_w}" height="20" role="img"
|
||||
aria-label="{label_text}: {message_text}">
|
||||
<title>{label_text}: {message_text}</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#fff" stop-opacity=".7"/>
|
||||
<stop offset=".1" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset=".9" stop-color="#000" stop-opacity=".3"/>
|
||||
<stop offset="1" stop-color="#bbb" stop-opacity=".1"/>
|
||||
</linearGradient>
|
||||
<clipPath id="r"><rect width="{total_w}" height="20" rx="3" fill="#fff"/></clipPath>
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="{label_w}" height="20" fill="#555"/>
|
||||
<rect x="{label_w}" width="{message_w}" height="20" fill="{color_hex}"/>
|
||||
<rect width="{total_w}" height="20" fill="url(#s)"/>
|
||||
</g>
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text x="{label_w // 2}" y="14">{label_text}</text>
|
||||
<text x="{label_w + message_w // 2}" y="14">{message_text}</text>
|
||||
</g>
|
||||
</svg>
|
||||
'''
|
||||
|
||||
|
||||
def extract_coverage(output: str) -> float | None:
|
||||
"""Extract total coverage percentage from pytest-cov output."""
|
||||
for line in output.splitlines():
|
||||
match = _COVERAGE_RE.search(line)
|
||||
if match:
|
||||
return float(match.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def extract_test_count(output: str) -> int | None:
|
||||
"""Extract number of passed tests from pytest output."""
|
||||
for line in output.splitlines():
|
||||
match = _PASSED_RE.search(line)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def extract_doc_coverage(output: str) -> int | None:
|
||||
"""Extract doc coverage percentage from doc_coverage.py output."""
|
||||
match = _DOC_COVERAGE_RE.search(output)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def read_version() -> str:
|
||||
"""Read __version__ from the package __init__.py."""
|
||||
init_file = _find_package_init()
|
||||
if init_file is None:
|
||||
return "unknown"
|
||||
content = init_file.read_text()
|
||||
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return "unknown"
|
||||
|
||||
|
||||
def coverage_color(pct: float) -> str:
|
||||
"""Map coverage percentage to a color."""
|
||||
if pct >= 100:
|
||||
return "brightgreen"
|
||||
if pct >= 90:
|
||||
return "green"
|
||||
if pct >= 80:
|
||||
return "yellowgreen"
|
||||
if pct >= 70:
|
||||
return "yellow"
|
||||
if pct >= 60:
|
||||
return "orange"
|
||||
return "red"
|
||||
|
||||
|
||||
def doc_coverage_color(pct: int) -> str:
|
||||
"""Map doc coverage percentage to a color."""
|
||||
if pct >= 100:
|
||||
return "brightgreen"
|
||||
if pct >= 90:
|
||||
return "green"
|
||||
if pct >= 80:
|
||||
return "yellowgreen"
|
||||
if pct >= 70:
|
||||
return "yellow"
|
||||
return "orange"
|
||||
|
||||
|
||||
def generate_badges(output_dir: Path) -> dict[str, dict[str, str | int]]:
|
||||
"""Generate all badge SVG files and return badge data as a dict."""
|
||||
badges: dict[str, dict[str, str | int]] = {}
|
||||
|
||||
# 1. Code coverage + test count (single pytest-cov run)
|
||||
rc, stdout, stderr = run_command(
|
||||
[
|
||||
".venv/bin/pytest",
|
||||
"tests/",
|
||||
"-v",
|
||||
"--cov=src",
|
||||
"--cov=scripts",
|
||||
"--cov-report=term-missing",
|
||||
"--cov-fail-under=0",
|
||||
]
|
||||
)
|
||||
combined = stdout + "\n" + stderr
|
||||
|
||||
coverage = extract_coverage(combined)
|
||||
if coverage is not None:
|
||||
badges["coverage"] = make_badge("coverage", f"{coverage:.0f}%", coverage_color(coverage))
|
||||
else:
|
||||
badges["coverage"] = make_badge("coverage", "unknown", "red")
|
||||
|
||||
test_count = extract_test_count(combined)
|
||||
if test_count is not None:
|
||||
badges["tests"] = make_badge("tests", f"{test_count} passing", "brightgreen" if rc == 0 else "red")
|
||||
else:
|
||||
badges["tests"] = make_badge("tests", "unknown", "red")
|
||||
|
||||
# 2. Documentation coverage
|
||||
rc, stdout, _ = run_command(
|
||||
[
|
||||
".venv/bin/python3",
|
||||
"scripts/ci/doc_coverage.py",
|
||||
]
|
||||
)
|
||||
doc_pct = extract_doc_coverage(stdout)
|
||||
if doc_pct is not None:
|
||||
badges["docs"] = make_badge("docs", f"{doc_pct}%", doc_coverage_color(doc_pct))
|
||||
else:
|
||||
badges["docs"] = make_badge("docs", "unknown", "red")
|
||||
|
||||
# 3. Code quality (ruff + pyright + bandit all pass)
|
||||
lint_rc, _, _ = run_command([".venv/bin/ruff", "check", "src/", "tests/", "scripts/"])
|
||||
format_rc, _, _ = run_command([".venv/bin/ruff", "format", "--check", "src/", "tests/", "scripts/"])
|
||||
type_rc, _, _ = run_command([".venv/bin/pyright"])
|
||||
bandit_rc, _, _ = run_command([".venv/bin/bandit", "-r", "src/", "scripts/"])
|
||||
|
||||
all_pass = all(rc == 0 for rc in [lint_rc, format_rc, type_rc, bandit_rc])
|
||||
badges["quality"] = make_badge("code quality", "A" if all_pass else "F", "brightgreen" if all_pass else "red")
|
||||
|
||||
# 4. Version
|
||||
version = read_version()
|
||||
badges["version"] = make_badge("version", f"v{version}", "blue")
|
||||
|
||||
# 5. Python version (static but nice)
|
||||
badges["python"] = make_badge("python", "3.12", "blue")
|
||||
|
||||
# Write SVG files
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
for name, badge in badges.items():
|
||||
svg = render_svg(str(badge["label"]), str(badge["message"]), str(badge["color"]))
|
||||
path = output_dir / f"{name}.svg"
|
||||
path.write_text(svg)
|
||||
click.echo(f" Generated: {path}")
|
||||
|
||||
return badges
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--output-dir",
|
||||
default=str(REPO_ROOT / ".badges"),
|
||||
help="Directory to write badge SVG files.",
|
||||
)
|
||||
def cli(output_dir: str) -> None:
|
||||
"""Generate self-contained SVG badge files from project metrics."""
|
||||
out = Path(output_dir)
|
||||
click.echo(f"Generating badges in {out}...")
|
||||
badges = generate_badges(out)
|
||||
click.echo(f"\nGenerated {len(badges)} badges:")
|
||||
for name, badge in badges.items():
|
||||
click.echo(f" {name}: {badge['label']}={badge['message']} ({badge['color']})")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Install checkmake if it is not already present.
|
||||
|
||||
Tries to install via Go if available, otherwise downloads the latest
|
||||
pre-built Linux binary from the official GitHub releases.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
CHECKMAKE_VERSION = "0.3.2"
|
||||
RELEASE_URL_TEMPLATE = (
|
||||
"https://github.com/checkmake/checkmake/releases/download/"
|
||||
f"v{CHECKMAKE_VERSION}/checkmake-v{CHECKMAKE_VERSION}.linux.{{arch}}"
|
||||
)
|
||||
TARGET_PATH = Path("/usr/local/bin/checkmake")
|
||||
|
||||
|
||||
def _arch() -> str:
|
||||
"""Return the architecture string used by checkmake releases."""
|
||||
machine = platform.machine().lower()
|
||||
if machine in {"x86_64", "amd64"}:
|
||||
return "amd64"
|
||||
if machine in {"aarch64", "arm64"}:
|
||||
return "arm64"
|
||||
raise click.ClickException(f"Unsupported architecture: {machine}")
|
||||
|
||||
|
||||
def _install_with_go() -> bool:
|
||||
"""Install checkmake using go install if Go is available."""
|
||||
go_bin = shutil.which("go")
|
||||
if go_bin is None:
|
||||
return False
|
||||
subprocess.run( # nosec B603
|
||||
[
|
||||
go_bin,
|
||||
"install",
|
||||
"github.com/checkmake/checkmake/cmd/checkmake@latest",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _download_binary() -> None:
|
||||
"""Download the prebuilt checkmake binary for the current architecture."""
|
||||
url = RELEASE_URL_TEMPLATE.format(arch=_arch())
|
||||
urllib.request.urlretrieve(url, TARGET_PATH) # nosec B310
|
||||
TARGET_PATH.chmod(0o755)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Install checkmake if not already present."""
|
||||
if shutil.which("checkmake") is not None:
|
||||
return
|
||||
|
||||
if not _install_with_go():
|
||||
_download_binary()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main() # pragma: no cover
|
||||
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Install CI/CD development tools that are not Python packages.
|
||||
|
||||
Handles installation of:
|
||||
- actionlint (workflow YAML linter)
|
||||
- git-cliff (changelog generator)
|
||||
- act_runner (Gitea Actions local runner, optional)
|
||||
- tea (Gitea CLI — official command-line tool for Gitea API operations)
|
||||
|
||||
Each tool is installed to ``~/.local/bin`` if not already on PATH.
|
||||
Idempotent: skips tools that are already available.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.tools.install_tools # install all
|
||||
python3 -m devx.tools.install_tools --tool actionlint # install one
|
||||
python3 -m devx.tools.install_tools --list # list status
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
TARGET_DIR = Path.home() / ".local" / "bin"
|
||||
|
||||
ACTIONLINT_VERSION = "1.7.12"
|
||||
|
||||
GIT_CLIFF_VERSION = "2.13.0"
|
||||
|
||||
ACT_RUNNER_VERSION = "0.2.11"
|
||||
|
||||
TEA_VERSION = "0.14.1"
|
||||
|
||||
|
||||
def _arch() -> str:
|
||||
"""Return the architecture string used by release assets."""
|
||||
machine = platform.machine().lower()
|
||||
if machine in {"x86_64", "amd64"}:
|
||||
return "amd64"
|
||||
if machine in {"aarch64", "arm64"}:
|
||||
return "arm64"
|
||||
raise click.ClickException(f"Unsupported architecture: {machine}")
|
||||
|
||||
|
||||
def _ensure_target_dir() -> Path:
|
||||
"""Ensure the target directory exists and return it."""
|
||||
TARGET_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return TARGET_DIR
|
||||
|
||||
|
||||
def _download(url: str, dest: Path) -> None:
|
||||
"""Download a file from ``url`` to ``dest``."""
|
||||
urllib.request.urlretrieve(url, dest) # nosec B310
|
||||
|
||||
|
||||
def _download_and_extract_tarball(url: str, binary_name: str) -> Path:
|
||||
"""Download a tarball, extract the binary, and install it to TARGET_DIR.
|
||||
|
||||
Returns the path to the installed binary.
|
||||
"""
|
||||
target_dir = _ensure_target_dir()
|
||||
dest = target_dir / binary_name
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tarball = Path(tmpdir) / "archive.tar.gz"
|
||||
_download(url, tarball)
|
||||
with tarfile.open(tarball, "r:gz") as tar:
|
||||
tar.extractall(tmpdir) # nosec B202
|
||||
# Find the binary in the extracted tree
|
||||
extracted = Path(tmpdir).rglob(binary_name)
|
||||
found = next(extracted, None)
|
||||
if found is None:
|
||||
raise click.ClickException(f"Binary {binary_name} not found in archive from {url}")
|
||||
shutil.copy2(found, dest)
|
||||
dest.chmod(0o755)
|
||||
return dest
|
||||
|
||||
|
||||
def _download_binary(url: str, binary_name: str) -> Path:
|
||||
"""Download a standalone binary and install it to TARGET_DIR.
|
||||
|
||||
Returns the path to the installed binary.
|
||||
"""
|
||||
target_dir = _ensure_target_dir()
|
||||
dest = target_dir / binary_name
|
||||
_download(url, dest)
|
||||
dest.chmod(0o755)
|
||||
return dest
|
||||
|
||||
|
||||
def _is_installed(name: str) -> bool:
|
||||
"""Check if a tool is already on PATH or in TARGET_DIR."""
|
||||
if shutil.which(name) is not None:
|
||||
return True
|
||||
return (TARGET_DIR / name).exists()
|
||||
|
||||
|
||||
def install_actionlint() -> bool:
|
||||
"""Install actionlint if not already present. Returns True if installed/skipped."""
|
||||
if _is_installed("actionlint"):
|
||||
click.echo("actionlint: already installed")
|
||||
return True
|
||||
arch = _arch()
|
||||
url = (
|
||||
f"https://github.com/rhysd/actionlint/releases/download/"
|
||||
f"v{ACTIONLINT_VERSION}/actionlint_{ACTIONLINT_VERSION}_linux_{arch}.tar.gz"
|
||||
)
|
||||
dest = _download_and_extract_tarball(url, "actionlint")
|
||||
click.echo(f"actionlint: installed to {dest}")
|
||||
return True
|
||||
|
||||
|
||||
def install_git_cliff() -> bool:
|
||||
"""Install git-cliff if not already present. Returns True if installed/skipped."""
|
||||
if _is_installed("git-cliff"):
|
||||
click.echo("git-cliff: already installed")
|
||||
return True
|
||||
arch = _arch()
|
||||
url = (
|
||||
f"https://github.com/orhun/git-cliff/releases/download/"
|
||||
f"v{GIT_CLIFF_VERSION}/git-cliff-{GIT_CLIFF_VERSION}-{arch}-unknown-linux-gnu.tar.gz"
|
||||
)
|
||||
dest = _download_and_extract_tarball(url, "git-cliff")
|
||||
click.echo(f"git-cliff: installed to {dest}")
|
||||
return True
|
||||
|
||||
|
||||
def install_act_runner() -> bool:
|
||||
"""Install act_runner if not already present. Returns True if installed/skipped."""
|
||||
if _is_installed("act_runner"):
|
||||
click.echo("act_runner: already installed")
|
||||
return True
|
||||
arch = _arch()
|
||||
url = (
|
||||
f"https://gitea.com/gitea/act_runner/releases/download/"
|
||||
f"v{ACT_RUNNER_VERSION}/act_runner-{ACT_RUNNER_VERSION}-linux-{arch}"
|
||||
)
|
||||
dest = _download_binary(url, "act_runner")
|
||||
click.echo(f"act_runner: installed to {dest}")
|
||||
return True
|
||||
|
||||
|
||||
def install_tea() -> bool:
|
||||
"""Install tea (Gitea CLI) if not already present. Returns True if installed/skipped."""
|
||||
if _is_installed("tea"):
|
||||
click.echo("tea: already installed")
|
||||
return True
|
||||
arch = _arch()
|
||||
url = f"https://dl.gitea.com/tea/{TEA_VERSION}/tea-{TEA_VERSION}-linux-{arch}"
|
||||
dest = _download_binary(url, "tea")
|
||||
click.echo(f"tea: installed to {dest}")
|
||||
return True
|
||||
|
||||
|
||||
TOOL_NAMES = ["actionlint", "git-cliff", "act_runner", "tea"]
|
||||
|
||||
|
||||
def _install_tool(name: str) -> bool:
|
||||
"""Install a single tool by name."""
|
||||
if name == "actionlint":
|
||||
return install_actionlint()
|
||||
if name == "git-cliff":
|
||||
return install_git_cliff()
|
||||
if name == "act_runner":
|
||||
return install_act_runner()
|
||||
if name == "tea":
|
||||
return install_tea()
|
||||
raise click.ClickException(f"Unknown tool: {name}")
|
||||
|
||||
|
||||
def list_tools() -> None:
|
||||
"""Print the installation status of all tools."""
|
||||
for name in TOOL_NAMES:
|
||||
status = "installed" if _is_installed(name) else "not installed"
|
||||
click.echo(f" {name}: {status}")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--tool",
|
||||
"tools",
|
||||
multiple=True,
|
||||
type=click.Choice(TOOL_NAMES),
|
||||
help="Install specific tool(s). Can be repeated.",
|
||||
)
|
||||
@click.option("--list", "list_status", is_flag=True, help="List tool installation status.")
|
||||
def main(tools: tuple[str, ...], list_status: bool) -> None:
|
||||
"""Install CI/CD development tools to ~/.local/bin."""
|
||||
if list_status:
|
||||
list_tools()
|
||||
return
|
||||
|
||||
tools_to_install = list(tools) if tools else TOOL_NAMES
|
||||
failed: list[str] = []
|
||||
for name in tools_to_install:
|
||||
try:
|
||||
_install_tool(name)
|
||||
except Exception as exc:
|
||||
click.echo(f" {name}: FAILED — {exc}", err=True)
|
||||
failed.append(name)
|
||||
|
||||
if failed:
|
||||
raise click.ClickException(f"Failed to install: {', '.join(failed)}")
|
||||
|
||||
# Remind user to add ~/.local/bin to PATH if not already there
|
||||
path_env = os.environ.get("PATH", "")
|
||||
if str(TARGET_DIR) not in path_env:
|
||||
click.echo(f"\nAdd {TARGET_DIR} to your PATH to use these tools.")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main() # pragma: no cover
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Project setup: install Python deps and pre-commit hooks.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.tools.setup --bin .venv/bin
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess # nosec B404
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
|
||||
def _run(cmd: list[str]) -> None:
|
||||
"""Run a command, streaming output to stdout/stderr."""
|
||||
click.echo(f" $ {' '.join(cmd)}")
|
||||
subprocess.run(cmd, check=True) # nosec B603
|
||||
|
||||
|
||||
def _install_python_deps(bin_dir: str, extras: str = "dev") -> None:
|
||||
"""Install the project with the specified extras in editable mode."""
|
||||
pip = str(Path(bin_dir) / "pip")
|
||||
_run([pip, "install", "-e", f".[{extras}]"])
|
||||
|
||||
|
||||
def _install_pre_commit_hooks(bin_dir: str) -> None:
|
||||
"""Install pre-commit hooks for commit-msg, pre-commit, and pre-push."""
|
||||
pre_commit = str(Path(bin_dir) / "pre-commit")
|
||||
for hook_type in ["pre-commit", "commit-msg", "pre-push"]:
|
||||
_run([pre_commit, "install", "--hook-type", hook_type])
|
||||
|
||||
|
||||
def _verify(bin_dir: str) -> None:
|
||||
"""Print versions of installed tools for verification."""
|
||||
devx = str(Path(bin_dir) / "devx")
|
||||
pre_commit = str(Path(bin_dir) / "pre-commit")
|
||||
for tool in [devx, pre_commit]:
|
||||
try:
|
||||
result = subprocess.run([tool, "--version"], capture_output=True, text=True, timeout=10) # nosec B603
|
||||
if result.returncode == 0:
|
||||
click.echo(f" {result.stdout.strip()}")
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--bin", "bin_dir", default=".venv/bin", help="Path to the virtualenv bin directory.")
|
||||
@click.option(
|
||||
"--extras",
|
||||
default="dev",
|
||||
help="Dependency group to install: ci, lint, build, twine, or dev (default: dev).",
|
||||
)
|
||||
@click.option(
|
||||
"--no-pre-commit",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Skip pre-commit hook installation.",
|
||||
)
|
||||
@click.option(
|
||||
"--no-tea-login",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Skip tea CLI login configuration (no-op, kept for backwards compatibility).",
|
||||
)
|
||||
def main(
|
||||
bin_dir: str,
|
||||
extras: str,
|
||||
no_pre_commit: bool,
|
||||
no_tea_login: bool,
|
||||
) -> None:
|
||||
"""Install Python deps and pre-commit hooks."""
|
||||
if not Path(bin_dir).exists():
|
||||
raise click.ClickException(f"Bin directory not found: {bin_dir}. Run 'python3 -m venv .venv' first.")
|
||||
|
||||
click.echo(f"Installing Python dependencies (extras: {extras})...")
|
||||
_install_python_deps(bin_dir, extras)
|
||||
|
||||
if not no_pre_commit:
|
||||
click.echo("Installing pre-commit hooks...")
|
||||
_install_pre_commit_hooks(bin_dir)
|
||||
|
||||
# --no-tea-login is a no-op (kept for backwards compatibility)
|
||||
_ = no_tea_login
|
||||
|
||||
click.echo("")
|
||||
click.echo("Setup complete.")
|
||||
click.echo("Activate the virtual environment with one of:")
|
||||
click.echo(" source .venv/bin/activate (generic)")
|
||||
click.echo(" source activate.sh (bash)")
|
||||
click.echo(" source activate.fish (fish)")
|
||||
click.echo(" source activate.zsh (zsh)")
|
||||
click.echo("")
|
||||
|
||||
_verify(bin_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main() # pragma: no cover
|
||||
@@ -0,0 +1,503 @@
|
||||
{
|
||||
"\nAll documentation coverage checks passed!": {
|
||||
"en": "\nAll documentation coverage checks passed!"
|
||||
},
|
||||
"\nAnsible files changed ({count}):": {
|
||||
"en": "\nAnsible files changed ({count}):"
|
||||
},
|
||||
"\nChecking CI script documentation in ci-cd-workflow.md...": {
|
||||
"en": "\nChecking CI script documentation in ci-cd-workflow.md..."
|
||||
},
|
||||
"\nChecking module documentation in architecture.md...": {
|
||||
"en": "\nChecking module documentation in architecture.md..."
|
||||
},
|
||||
"\nDoc coverage: {covered}/{total} ({pct}%)": {
|
||||
"en": "\nDoc coverage: {covered}/{total} ({pct}%)"
|
||||
},
|
||||
"\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}": {
|
||||
"en": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}"
|
||||
},
|
||||
"\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.": {
|
||||
"en": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce."
|
||||
},
|
||||
"\nIntegrity check FAILED ({count} issues):": {
|
||||
"en": "\nIntegrity check FAILED ({count} issues):"
|
||||
},
|
||||
"\nIntegrity check passed — all {count} pages verified.": {
|
||||
"en": "\nIntegrity check passed — all {count} pages verified."
|
||||
},
|
||||
"\nMissing documentation:": {
|
||||
"en": "\nMissing documentation:"
|
||||
},
|
||||
"\nResult: {status}": {
|
||||
"en": "\nResult: {status}"
|
||||
},
|
||||
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).": {
|
||||
"en": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments)."
|
||||
},
|
||||
"\nRunning full wiki integrity check...": {
|
||||
"en": "\nRunning full wiki integrity check..."
|
||||
},
|
||||
"\nUser-facing changes ({count}):": {
|
||||
"en": "\nUser-facing changes ({count}):"
|
||||
},
|
||||
"\nUser-facing files changed ({count}):": {
|
||||
"en": "\nUser-facing files changed ({count}):"
|
||||
},
|
||||
"\nVerification FAILED: {failures} page(s) have empty or mismatched content!": {
|
||||
"en": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!"
|
||||
},
|
||||
"\nVerification passed — all wiki pages have correct content.": {
|
||||
"en": "\nVerification passed — all wiki pages have correct content."
|
||||
},
|
||||
"\nVerifying wiki pages have content...": {
|
||||
"en": "\nVerifying wiki pages have content..."
|
||||
},
|
||||
"\nWorkflow-only changes ({count}):": {
|
||||
"en": "\nWorkflow-only changes ({count}):"
|
||||
},
|
||||
"\n[dry-run] Changelog:\n{changelog}": {
|
||||
"en": "\n[dry-run] Changelog:\n{changelog}"
|
||||
},
|
||||
" - Auto-delete branch after merge: yes": {
|
||||
"en": " - Auto-delete branch after merge: yes",
|
||||
"bg": " - Автоматично изтриване на клон след сливане: да",
|
||||
"de": " - Branch nach Merge automatisch löschen: ja",
|
||||
"ru": " - Автоудаление ветки после слияния: да",
|
||||
"zh": " - 合并后自动删除分支: 是"
|
||||
},
|
||||
" - Block outdated branches: yes": {
|
||||
"en": " - Block outdated branches: yes",
|
||||
"bg": " - Блокиране на остарели клонове: да",
|
||||
"de": " - Veraltete Branches blockieren: ja",
|
||||
"ru": " - Блокировать устаревшие ветки: да",
|
||||
"zh": " - 阻止过时分支: 是"
|
||||
},
|
||||
" - Block rejected reviews: yes": {
|
||||
"en": " - Block rejected reviews: yes",
|
||||
"bg": " - Блокиране на отхвърлени рецензии: да",
|
||||
"de": " - Abgelehnte Reviews blockieren: ja",
|
||||
"ru": " - Блокировать отклонённые ревью: да",
|
||||
"zh": " - 阻止被拒绝的审查: 是"
|
||||
},
|
||||
" - Direct pushes: BLOCKED (require PR, whitelisted users can push)": {
|
||||
"en": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)"
|
||||
},
|
||||
" - Dismiss stale approvals: yes": {
|
||||
"en": " - Dismiss stale approvals: yes",
|
||||
"bg": " - Анулиране на остарели одобрения: да",
|
||||
"de": " - Veraltete Genehmigungen ablehnen: ja",
|
||||
"ru": " - Отклонять устаревшие одобрения: да",
|
||||
"zh": " - 忽略过时审批: 是"
|
||||
},
|
||||
" - Required approvals: {count}": {
|
||||
"en": " - Required approvals: {count}",
|
||||
"bg": " - Необходими одобрения: {count}",
|
||||
"de": " - Erforderliche Genehmigungen: {count}",
|
||||
"ru": " - Требуемые одобрения: {count}",
|
||||
"zh": " - 必需审批数: {count}"
|
||||
},
|
||||
" - Required status checks: {checks}": {
|
||||
"en": " - Required status checks: {checks}",
|
||||
"bg": " - Необходими проверки на състоянието: {checks}",
|
||||
"de": " - Erforderliche Status-Checks: {checks}",
|
||||
"ru": " - Требуемые проверки статуса: {checks}",
|
||||
"zh": " - 必需状态检查: {checks}"
|
||||
},
|
||||
" Created: {title}": {
|
||||
"en": " Created: {title}"
|
||||
},
|
||||
" FAIL: {title} — content mismatch or empty!": {
|
||||
"en": " FAIL: {title} — content mismatch or empty!"
|
||||
},
|
||||
" MISSING: grm {cmd}": {
|
||||
"en": " MISSING: grm {cmd}"
|
||||
},
|
||||
" MISSING: {module}": {
|
||||
"en": " MISSING: {module}"
|
||||
},
|
||||
" MISSING: {script}": {
|
||||
"en": " MISSING: {script}"
|
||||
},
|
||||
" OK: grm {cmd}": {
|
||||
"en": " OK: grm {cmd}"
|
||||
},
|
||||
" OK: {module}": {
|
||||
"en": " OK: {module}"
|
||||
},
|
||||
" OK: {script}": {
|
||||
"en": " OK: {script}"
|
||||
},
|
||||
" OK: {title} ({chars} chars)": {
|
||||
"en": " OK: {title} ({chars} chars)"
|
||||
},
|
||||
" Updated: {title}": {
|
||||
"en": " Updated: {title}"
|
||||
},
|
||||
"API poll warning: {exc}": {
|
||||
"en": "API poll warning: {exc}"
|
||||
},
|
||||
"All molecule tests passed.": {
|
||||
"en": "All molecule tests passed."
|
||||
},
|
||||
"Another molecule runner failed. Stopping this runner early.": {
|
||||
"en": "Another molecule runner failed. Stopping this runner early."
|
||||
},
|
||||
"Bumping version: {current} -> v{new_version}": {
|
||||
"en": "Bumping version: {current} -> v{new_version}"
|
||||
},
|
||||
"Checking CLI command documentation...": {
|
||||
"en": "Checking CLI command documentation..."
|
||||
},
|
||||
"Command failed ({cmd}): {stderr}": {
|
||||
"en": "Command failed ({cmd}): {stderr}"
|
||||
},
|
||||
"Comparing {base}..{head} ({count} files changed)": {
|
||||
"en": "Comparing {base}..{head} ({count} files changed)"
|
||||
},
|
||||
"Configuring branch protection for {branch}...": {
|
||||
"en": "Configuring branch protection for {branch}...",
|
||||
"bg": "Конфигуриране на защита на клона {branch}...",
|
||||
"de": "Konfiguriere Branch-Schutz für {branch}...",
|
||||
"ru": "Настройка защиты ветки {branch}...",
|
||||
"zh": "正在配置 {branch} 的分支保护..."
|
||||
},
|
||||
"Configuring repository settings...": {
|
||||
"en": "Configuring repository settings...",
|
||||
"bg": "Конфигуриране на настройките на хранилището...",
|
||||
"de": "Repository-Einstellungen konfigurieren...",
|
||||
"ru": "Настройка параметров репозитория...",
|
||||
"zh": "正在配置仓库设置..."
|
||||
},
|
||||
"Could not extract conventional commit message from PR commits.": {
|
||||
"en": "Could not extract conventional commit message from PR commits."
|
||||
},
|
||||
"Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.": {
|
||||
"en": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task."
|
||||
},
|
||||
"Could not find __version__ in {file}": {
|
||||
"en": "Could not find __version__ in {file}"
|
||||
},
|
||||
"Could not parse test execution time from output.": {
|
||||
"en": "Could not parse test execution time from output."
|
||||
},
|
||||
"Created issue #{issue_id}: {title}": {
|
||||
"en": "Created issue #{issue_id}: {title}"
|
||||
},
|
||||
"Created release commit.": {
|
||||
"en": "Created release commit."
|
||||
},
|
||||
"Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.": {
|
||||
"en": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently."
|
||||
},
|
||||
"ERROR: REPO_TOKEN is not set.": {
|
||||
"en": "ERROR: REPO_TOKEN is not set.",
|
||||
"bg": "ГРЕШКА: REPO_TOKEN не е зададен.",
|
||||
"de": "FEHLER: REPO_TOKEN ist nicht gesetzt.",
|
||||
"ru": "ОШИБКА: REPO_TOKEN не задан.",
|
||||
"zh": "错误:未设置 REPO_TOKEN。"
|
||||
},
|
||||
"ERROR: VIKUNJA_TOKEN is not set.": {
|
||||
"en": "ERROR: VIKUNJA_TOKEN is not set.",
|
||||
"bg": "ГРЕШКА: VIKUNJA_TOKEN не е зададен.",
|
||||
"de": "FEHLER: VIKUNJA_TOKEN ist nicht gesetzt.",
|
||||
"ru": "ОШИБКА: VIKUNJA_TOKEN не задан.",
|
||||
"zh": "错误:未设置 VIKUNJA_TOKEN。"
|
||||
},
|
||||
"ERROR: mapping.json not found at {path}": {
|
||||
"en": "ERROR: mapping.json not found at {path}"
|
||||
},
|
||||
"FAILED: {pair} exited with code {code}": {
|
||||
"en": "FAILED: {pair} exited with code {code}"
|
||||
},
|
||||
"Failed to create issue via tea: {error}": {
|
||||
"en": "Failed to create issue via tea: {error}"
|
||||
},
|
||||
"Found {count} existing wiki pages.": {
|
||||
"en": "Found {count} existing wiki pages."
|
||||
},
|
||||
"GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.": {
|
||||
"en": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."
|
||||
},
|
||||
"HEAD is already a release commit ('{msg}'). Another release may have just completed. Skipping.": {
|
||||
"en": "HEAD is already a release commit ('{msg}'). Another release may have just completed. Skipping."
|
||||
},
|
||||
"HTTP error: {status} — {message}": {
|
||||
"en": "HTTP error: {status} — {message}",
|
||||
"bg": "HTTP грешка: {status} — {message}",
|
||||
"de": "HTTP-Fehler: {status} — {message}",
|
||||
"ru": "Ошибка HTTP: {status} — {message}",
|
||||
"zh": "HTTP 错误: {status} — {message}"
|
||||
},
|
||||
"HTTP {status} Forbidden — your token lacks admin rights.\nMake sure the token belongs to a repo owner or organisation admin.\nAlternatively, configure branch protection manually in Settings → Branches.": {
|
||||
"en": "HTTP {status} Forbidden — your token lacks admin rights.\nMake sure the token belongs to a repo owner or organisation admin.\nAlternatively, configure branch protection manually in Settings → Branches.",
|
||||
"bg": "HTTP {status} Забранено — вашият токен няма администраторски права.\nУверете се, че токенът принадлежи на собственик на хранилище или администратор на организация.\nАлтернативно, конфигурирайте защитата на клона ръчно в Настройки → Клонове.",
|
||||
"de": "HTTP {status} Verboten — Ihr Token hat keine Admin-Rechte.\nStellen Sie sicher, dass das Token einem Repository-Besitzer oder Organisations-Admin gehört.\nAlternativ können Sie den Branch-Schutz manuell unter Einstellungen → Branches konfigurieren.",
|
||||
"ru": "HTTP {status} Запрещено — у вашего токена нет прав администратора.\nУбедитесь, что токен принадлежит владельцу репозитория или администратору организации.\nЛибо настройте защиту ветки вручную в разделе Настройки → Ветки.",
|
||||
"zh": "HTTP {status} 禁止访问 — 您的令牌缺少管理员权限。\n请确保令牌属于仓库所有者或组织管理员。\n或者,您可以在 设置 → 分支 中手动配置分支保护。"
|
||||
},
|
||||
"Head branch is behind master. Pulling and rebasing...": {
|
||||
"en": "Head branch is behind master. Pulling and rebasing..."
|
||||
},
|
||||
"Infrastructure commit (no GRM-N task ID), skipping Vikunja update: {msg}": {
|
||||
"en": "Infrastructure commit (no GRM-N task ID), skipping Vikunja update: {msg}"
|
||||
},
|
||||
"Lint failed — refusing to release. Fix lint errors first.\n{stderr}": {
|
||||
"en": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}"
|
||||
},
|
||||
"Lint passed.": {
|
||||
"en": "Lint passed."
|
||||
},
|
||||
"Merge failed after rebase retry: {error}\nPlease rebase the PR manually.": {
|
||||
"en": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually."
|
||||
},
|
||||
"Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.": {
|
||||
"en": "Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.",
|
||||
"bg": "Сливането неуспешно с HTTP {status}: {message}\nПроверете дали PR е готов и имате права за сливане.",
|
||||
"de": "Merge fehlgeschlagen mit HTTP {status}: {message}\nBitte prüfen Sie, ob der PR bereit ist und Sie Merge-Rechte haben.",
|
||||
"ru": "Слияние не удалось: HTTP {status}: {message}\nПроверьте, что PR готов и у вас есть права на слияние.",
|
||||
"zh": "合并失败: HTTP {status}: {message}\n请检查 PR 是否准备就绪且您具有合并权限。"
|
||||
},
|
||||
"Molecule directory not found: {path}": {
|
||||
"en": "Molecule directory not found: {path}",
|
||||
"bg": "Директорията на molecule не е намерена: {path}",
|
||||
"de": "Molecule-Verzeichnis nicht gefunden: {path}",
|
||||
"ru": "Директория molecule не найдена: {path}",
|
||||
"zh": "未找到 molecule 目录: {path}"
|
||||
},
|
||||
"Nice! Gitea release {tag} created.": {
|
||||
"en": "Nice! Gitea release {tag} created.",
|
||||
"bg": "Отлично! Gitea release {tag} е създаден.",
|
||||
"de": "Prima! Gitea-Release {tag} erstellt.",
|
||||
"ru": "Отлично! Gitea release {tag} создан.",
|
||||
"zh": "不错!Gitea release {tag} 已创建。"
|
||||
},
|
||||
"Nice! PR #{pr_number} squash-merged with title: {merge_title}": {
|
||||
"en": "Nice! PR #{pr_number} squash-merged with title: {merge_title}",
|
||||
"bg": "Отлично! PR #{pr_number} е squash-merge-нат със заглавие: {merge_title}",
|
||||
"de": "Prima! PR #{pr_number} wurde mit Titel {merge_title} squash-gemergt.",
|
||||
"ru": "Отлично! PR #{pr_number} squash-merge с заголовком: {merge_title}",
|
||||
"zh": "不错!PR #{pr_number} 已 squash 合并,标题: {merge_title}"
|
||||
},
|
||||
"Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.": {
|
||||
"en": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered."
|
||||
},
|
||||
"Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.": {
|
||||
"en": "Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.",
|
||||
"bg": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) е обновена и маркирана като готова.",
|
||||
"de": "Prima! Vikunja-Aufgabe {task_id} (ID {vikunja_id}) aktualisiert und als erledigt markiert.",
|
||||
"ru": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) обновлена и отмечена как выполненная.",
|
||||
"zh": "不错!Vikunja 任务 {task_id} (ID {vikunja_id}) 已更新并标记为完成。"
|
||||
},
|
||||
"No changes between {base} and {head}.": {
|
||||
"en": "No changes between {base} and {head}."
|
||||
},
|
||||
"No staged changes — version and changelog already up to date.": {
|
||||
"en": "No staged changes — version and changelog already up to date."
|
||||
},
|
||||
"No tags found — treating all changes as user-facing.": {
|
||||
"en": "No tags found — treating all changes as user-facing."
|
||||
},
|
||||
"No unreleased changes found. Nothing to release.": {
|
||||
"en": "No unreleased changes found. Nothing to release."
|
||||
},
|
||||
"No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.": {
|
||||
"en": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release."
|
||||
},
|
||||
"Note: Self-approval not allowed. Posting COMMENT instead.": {
|
||||
"en": "Note: Self-approval not allowed. Posting COMMENT instead."
|
||||
},
|
||||
"Oops! Commit message must follow conventional commit format.\n Expected: <type>: <description>\n Got: {subject}\n Allowed types: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE": {
|
||||
"en": "Oops! Commit message must follow conventional commit format.\n Expected: <type>: <description>\n Got: {subject}\n Allowed types: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
|
||||
"bg": "Опа! Съобщението за commit трябва да следва конвенционален формат.\n Очаква се: <type>: <description>\n Получено: {subject}\n Разрешени типове: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
|
||||
"de": "Ups! Commit-Nachricht muss dem konventionellen Commit-Format folgen.\n Erwartet: <type>: <description>\n Erhalten: {subject}\n Erlaubte Typen: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
|
||||
"ru": "Ой! Сообщение коммита должно соответствовать формату conventional commit.\n Ожидается: <type>: <description>\n Получено: {subject}\n Допустимые типы: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
|
||||
"zh": "哎呀!提交消息必须遵循 conventional commit 格式。\n 预期格式: <type>: <description>\n 实际: {subject}\n 允许的类型: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE"
|
||||
},
|
||||
"Oops! Do not include task ID (GRM-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.": {
|
||||
"en": "Oops! Do not include task ID (GRM-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.",
|
||||
"bg": "Опа! Не включвайте идентификатор на задача (GRM-N) в commit-и от feature клонове.\n Идентификаторът ще бъде добавен автоматично при сливане чрез CI.",
|
||||
"de": "Ups! Keine Task-ID (GRM-N) in Feature-Branch-Commits einfügen.\n Die Task-ID wird beim Merge automatisch über CI hinzugefügt.",
|
||||
"ru": "Ой! Не включайте ID задачи (GRM-N) в коммиты feature-веток.\n ID задачи будет добавлен автоматически при слиянии через CI.",
|
||||
"zh": "哎呀!不要在 feature 分支的提交中包含任务 ID (GRM-N)。\n 任务 ID 将在通过 CI 合并时自动添加。"
|
||||
},
|
||||
"Oops! Master branch commit must follow conventional format after task ID.\n Expected: GRM-N: <type>: <description>\n Got: {subject}": {
|
||||
"en": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: GRM-N: <type>: <description>\n Got: {subject}",
|
||||
"bg": "Опа! Commit-ът в клона master трябва да следва конвенционален формат след идентификатора.\n Очаква се: GRM-N: <type>: <description>\n Получено: {subject}",
|
||||
"de": "Ups! Master-Branch-Commit muss nach der Task-ID dem konventionellen Format folgen.\n Erwartet: GRM-N: <type>: <description>\n Erhalten: {subject}",
|
||||
"ru": "Ой! Коммит в ветку master после ID задачи должен соответствовать conventional формату.\n Ожидается: GRM-N: <type>: <description>\n Получено: {subject}",
|
||||
"zh": "哎呀!master 分支提交在任务 ID 后必须遵循 conventional commit 格式。\n 预期格式: GRM-N: <type>: <description>\n 实际: {subject}"
|
||||
},
|
||||
"Oops! Master branch commits must start with a task ID.\n Expected: GRM-N: <conventional commit message>\n Got: {subject}": {
|
||||
"en": "Oops! Master branch commits must start with a task ID.\n Expected: GRM-N: <conventional commit message>\n Got: {subject}",
|
||||
"bg": "Опа! Commit-ите в клона master трябва да започват с идентификатор на задача.\n Очаква се: GRM-N: <conventional commit message>\n Получено: {subject}",
|
||||
"de": "Ups! Master-Branch-Commits müssen mit einer Task-ID beginnen.\n Erwartet: GRM-N: <conventional commit message>\n Erhalten: {subject}",
|
||||
"ru": "Ой! Коммиты в ветку master должны начинаться с ID задачи.\n Ожидается: GRM-N: <conventional commit message>\n Получено: {subject}",
|
||||
"zh": "哎呀!master 分支的提交必须以任务 ID 开头。\n 预期格式: GRM-N: <conventional commit message>\n 实际: {subject}"
|
||||
},
|
||||
"Oops! No task ID found in .taskid file or branch name '{branch}'.": {
|
||||
"en": "Oops! No task ID found in .taskid file or branch name '{branch}'."
|
||||
},
|
||||
"Oops! PR title must follow format 'GRM-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}": {
|
||||
"en": "Oops! PR title must follow format 'GRM-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}"
|
||||
},
|
||||
"Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}": {
|
||||
"en": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}"
|
||||
},
|
||||
"Oops! Package build failed:\n{stderr}": {
|
||||
"en": "Oops! Package build failed:\n{stderr}",
|
||||
"bg": "Опа! Сборката на пакета неуспешна:\n{stderr}",
|
||||
"de": "Ups! Paket-Build fehlgeschlagen:\n{stderr}",
|
||||
"ru": "Ой! Сборка пакета не удалась:\n{stderr}",
|
||||
"zh": "哎呀!包构建失败:\n{stderr}"
|
||||
},
|
||||
"Oops! PyPI publish failed:\n{stderr}": {
|
||||
"en": "Oops! PyPI publish failed:\n{stderr}",
|
||||
"bg": "Опа! Публикуването в PyPI неуспешно:\n{stderr}",
|
||||
"de": "Ups! PyPI-Veröffentlichung fehlgeschlagen:\n{stderr}",
|
||||
"ru": "Ой! Публикация в PyPI не удалась:\n{stderr}",
|
||||
"zh": "哎呀!PyPI 发布失败:\n{stderr}"
|
||||
},
|
||||
"PASSED: {pair}": {
|
||||
"en": "PASSED: {pair}"
|
||||
},
|
||||
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}": {
|
||||
"en": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}"
|
||||
},
|
||||
"PYPI_TOKEN not set — skipping PyPI publish. No worries, we'll just create the Gitea release.": {
|
||||
"en": "PYPI_TOKEN not set — skipping PyPI publish. No worries, we'll just create the Gitea release.",
|
||||
"bg": "PYPI_TOKEN не е зададен — пропускаме публикуването в PyPI. Без притеснения, просто ще създадем Gitea release.",
|
||||
"de": "PYPI_TOKEN nicht gesetzt — PyPI-Veröffentlichung wird übersprungen. Keine Sorge, wir erstellen einfach das Gitea-Release.",
|
||||
"ru": "PYPI_TOKEN не задан — пропускаем публикацию в PyPI. Не беспокойтесь, мы просто создадим Gitea release.",
|
||||
"zh": "未设置 PYPI_TOKEN — 跳过 PyPI 发布。别担心,我们直接创建 Gitea release。"
|
||||
},
|
||||
"Published to PyPI.": {
|
||||
"en": "Published to PyPI.",
|
||||
"bg": "Публикувано в PyPI.",
|
||||
"de": "In PyPI veröffentlicht.",
|
||||
"ru": "Опубликовано в PyPI.",
|
||||
"zh": "已发布到 PyPI。"
|
||||
},
|
||||
"Pushed release commit to master.": {
|
||||
"en": "Pushed release commit to master."
|
||||
},
|
||||
"Rebased and pushed. Retrying merge...": {
|
||||
"en": "Rebased and pushed. Retrying merge..."
|
||||
},
|
||||
"Release creation failed: {error}": {
|
||||
"en": "Release creation failed: {error}"
|
||||
},
|
||||
"Release must be run on master, currently on '{branch}'.": {
|
||||
"en": "Release must be run on master, currently on '{branch}'."
|
||||
},
|
||||
"Repository configuration complete.": {
|
||||
"en": "Repository configuration complete.",
|
||||
"bg": "Конфигурирането на хранилището е завършено.",
|
||||
"de": "Repository-Konfiguration abgeschlossen.",
|
||||
"ru": "Конфигурация репозитория завершена.",
|
||||
"zh": "仓库配置完成。"
|
||||
},
|
||||
"Runner index {index} out of range (0..{max})": {
|
||||
"en": "Runner index {index} out of range (0..{max})",
|
||||
"bg": "Индексът на runner {index} е извън диапазона (0..{max})",
|
||||
"de": "Runner-Index {index} außerhalb des Bereichs (0..{max})",
|
||||
"ru": "Индекс runner {index} вне диапазона (0..{max})",
|
||||
"zh": "Runner 索引 {index} 超出范围 (0..{max})"
|
||||
},
|
||||
"Running lint checks...": {
|
||||
"en": "Running lint checks..."
|
||||
},
|
||||
"Running tests...": {
|
||||
"en": "Running tests..."
|
||||
},
|
||||
"Running: {scenario} on {platform}": {
|
||||
"en": "Running: {scenario} on {platform}"
|
||||
},
|
||||
"Skipping commit push — no staged changes.": {
|
||||
"en": "Skipping commit push — no staged changes."
|
||||
},
|
||||
"Syncing {count} documentation pages to wiki...": {
|
||||
"en": "Syncing {count} documentation pages to wiki..."
|
||||
},
|
||||
"Tag v{version} already existed. Publish workflow should already have been triggered.": {
|
||||
"en": "Tag v{version} already existed. Publish workflow should already have been triggered."
|
||||
},
|
||||
"Tag {tag} already exists, skipping creation.": {
|
||||
"en": "Tag {tag} already exists, skipping creation."
|
||||
},
|
||||
"Task ID: {task_id}": {
|
||||
"en": "Task ID: {task_id}"
|
||||
},
|
||||
"Tests failed — refusing to release. Fix test failures first.\n{stderr}": {
|
||||
"en": "Tests failed — refusing to release. Fix test failures first.\n{stderr}"
|
||||
},
|
||||
"Tests passed.": {
|
||||
"en": "Tests passed."
|
||||
},
|
||||
"Unit tests passed in {duration:.2f}s (under {max}s limit).": {
|
||||
"en": "Unit tests passed in {duration:.2f}s (under {max}s limit)."
|
||||
},
|
||||
"Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.": {
|
||||
"en": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures."
|
||||
},
|
||||
"Updated version in {init}": {
|
||||
"en": "Updated version in {init}"
|
||||
},
|
||||
"Updated {changelog_file}": {
|
||||
"en": "Updated {changelog_file}"
|
||||
},
|
||||
"WARNING: --skip-tests passed — skipping test verification.": {
|
||||
"en": "WARNING: --skip-tests passed — skipping test verification."
|
||||
},
|
||||
"WARNING: File {file} is empty — skipping.": {
|
||||
"en": "WARNING: File {file} is empty — skipping."
|
||||
},
|
||||
"WARNING: File {file} not found — skipping.": {
|
||||
"en": "WARNING: File {file} not found — skipping."
|
||||
},
|
||||
"Warning: No task ID (GRM-N) found in commit message: {msg}. Skipping Vikunja update.": {
|
||||
"en": "Warning: No task ID (GRM-N) found in commit message: {msg}. Skipping Vikunja update."
|
||||
},
|
||||
"Warning: VIKUNJA_TOKEN not set, skipping title match validation.": {
|
||||
"en": "Warning: VIKUNJA_TOKEN not set, skipping title match validation."
|
||||
},
|
||||
"Warning: Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded — please update the Vikunja task manually.": {
|
||||
"en": "Warning: Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded — please update the Vikunja task manually."
|
||||
},
|
||||
"Warning: git-cliff generated empty changelog.": {
|
||||
"en": "Warning: git-cliff generated empty changelog."
|
||||
},
|
||||
"Wiki integrity check failed — {count} issue(s)": {
|
||||
"en": "Wiki integrity check failed — {count} issue(s)"
|
||||
},
|
||||
"Wiki verification failed — {failures} page(s) empty or mismatched": {
|
||||
"en": "Wiki verification failed — {failures} page(s) empty or mismatched"
|
||||
},
|
||||
"[dry-run] Would commit: release: v{version}": {
|
||||
"en": "[dry-run] Would commit: release: v{version}"
|
||||
},
|
||||
"[dry-run] Would create tag: v{version}": {
|
||||
"en": "[dry-run] Would create tag: v{version}"
|
||||
},
|
||||
"[dry-run] Would create tag: {tag}": {
|
||||
"en": "[dry-run] Would create tag: {tag}"
|
||||
},
|
||||
"[dry-run] Would push commit to master": {
|
||||
"en": "[dry-run] Would push commit to master"
|
||||
},
|
||||
"[dry-run] Would sync page: {title} ({chars} chars)": {
|
||||
"en": "[dry-run] Would sync page: {title} ({chars} chars)"
|
||||
},
|
||||
"[dry-run] Would update {changelog_file}": {
|
||||
"en": "[dry-run] Would update {changelog_file}"
|
||||
},
|
||||
"[dry-run] Would update {init}": {
|
||||
"en": "[dry-run] Would update {init}"
|
||||
},
|
||||
"git command failed ({cmd}): {stderr}": {
|
||||
"en": "git command failed ({cmd}): {stderr}"
|
||||
},
|
||||
"git-cliff returned empty version.": {
|
||||
"en": "git-cliff returned empty version."
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user