#!/usr/bin/env python3 """Pre-merge validation gate for auto-merge preconditions. Validates that a PR satisfies auto-merge requirements BEFORE expensive jobs (molecule tests, staging deploy) run. This catches issues early: 1. Branch name contains a task ID (e.g., ``DEVX-256-fix-foo``). 2. PR title follows ``{PREFIX}-N: `` format. 3. PR title task ID matches the branch task ID. 4. PR title matches the Vikunja task title (requires ``VIKUNJA_TOKEN``). 5. Branch is not behind master (would trigger a rebase retry cycle). Exit code 0 = ready for auto-merge (preconditions satisfied). Exit code 1 = NOT ready — fix issues before pushing. Usage:: # CI (with VIKUNJA_TOKEN and CI_GITEA_API_TOKEN): python3 -m devx.ci.check_auto_merge_ready \\ --branch "$HEAD_REF" \\ --pr-title "$PR_TITLE" \\ --repo "$REPOSITORY" \\ --pr-number "$PR_NUMBER" # Local (pre-push hook, no PR yet — validates branch + title format only): python3 -m devx.ci.check_auto_merge_ready --branch "$(git rev-parse --abbrev-ref HEAD)" # Local (with PR number, fetches title from Gitea): python3 -m devx.ci.check_auto_merge_ready --branch "$(git rev-parse --abbrev-ref HEAD)" \\ --repo owner/repo --pr-number 123 If ``VIKUNJA_TOKEN`` is not set, the Vikunja title match check is skipped (with a warning) — this allows local pre-push hooks to run without CI secrets. In CI, the token is always set and the check is mandatory. If ``CI_GITEA_API_TOKEN`` is not set and ``--pr-number`` is not provided, only branch-name and PR-title-format checks run (local mode). """ from __future__ import annotations import subprocess # nosec B404 import click from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from devx.api_clients import GiteaClient, VikunjaClient from devx.ci.auto_merge import extract_task_id from devx.config import ( GITEA_API_URL, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID, ) from devx.exceptions import APIError from devx.i18n import _ from devx.tokens import get_ci_token, get_vikunja_token load_dotenv() def is_branch_behind_master(branch: str) -> bool: """Check if the local branch is behind origin/master. Fetches origin first (best-effort) then compares commit counts. Returns ``True`` if master has commits not in branch. """ try: subprocess.run( # nosec B603, B607 ["git", "fetch", "origin", "master", "--quiet"], check=False, capture_output=True, timeout=30, ) result = subprocess.run( # nosec B603, B607 ["git", "rev-list", "--count", f"origin/master..{branch}"], capture_output=True, text=True, check=False, timeout=10, ) if result.returncode != 0: return False # Can't determine — don't block result = subprocess.run( # nosec B603, B607 ["git", "rev-list", "--count", f"{branch}..origin/master"], capture_output=True, text=True, check=False, timeout=10, ) if result.returncode != 0: return False behind = int(result.stdout.strip() or "0") except (subprocess.TimeoutExpired, FileNotFoundError, ValueError): return False # Don't block on git errors return behind > 0 def get_pr_title_from_gitea(repo: str, pr_number: int) -> str | None: """Fetch the PR title from the Gitea API. Returns ``None`` if no token is set or the PR cannot be fetched. """ try: token = get_ci_token() except click.ClickException: return None if "/" not in repo: return None owner, repo_name = repo.split("/", 1) client = GiteaClient(GITEA_API_URL, token, owner, repo_name) try: pr = client.get_pr(pr_number) return str(pr.get("title", "")) except APIError: return None def get_vikunja_title_optional(task_id: str) -> str | None: """Fetch the Vikunja task title, returning None if token is not set. Unlike :func:`devx.ci.auto_merge.get_vikunja_task_title`, this does NOT raise when ``VIKUNJA_TOKEN`` is missing — it returns ``None`` so the caller can skip the check in local mode. """ try: token = get_vikunja_token() except click.ClickException: return None client = VikunjaClient(VIKUNJA_API_URL, token) from devx.config import DEFAULT_PER_PAGE page = 1 while True: tasks = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE) if not tasks: break matches = [t for t in tasks if t.get("identifier") == task_id] if matches: return str(matches[0].get("title", "")) if len(tasks) < DEFAULT_PER_PAGE: break page += 1 return None @click.command() @click.option("--branch", required=True, help=_("Branch name (e.g., DEVX-256-fix-foo)")) @click.option("--pr-title", default=None, help=_("PR title (auto-fetched if --pr-number given)")) @click.option("--repo", default=None, help=_("Repository in owner/name format")) @click.option("--pr-number", type=int, default=None, help=_("PR number (to fetch title from Gitea)")) @click.option("--skip-vikunja", is_flag=True, help=_("Skip Vikunja title match check")) @click.option("--skip-behind-check", is_flag=True, help=_("Skip branch-behind-master check")) def cli( branch: str, pr_title: str | None, repo: str | None, pr_number: int | None, skip_vikunja: bool, skip_behind_check: bool, ) -> None: """Validate auto-merge preconditions before expensive CI jobs.""" import re from devx.config import TASK_PREFIX pr_title_re = re.compile(rf"^{TASK_PREFIX}-\d+:\s+.+") # noqa: PLW1503 errors: list[str] = [] # 1. Branch task ID task_id = extract_task_id(branch) if not task_id: errors.append( _( "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.", branch=branch, prefix=TASK_PREFIX, ), ) # Can't continue — no task ID to validate against for e in errors: click.echo(f"ERROR: {e}", err=True) raise click.ClickException(_("Branch name must contain a task ID.")) click.echo(f"[pre-merge-check] Task ID: {task_id}") # 2. Resolve PR title if pr_title is None and pr_number is not None and repo is not None: pr_title = get_pr_title_from_gitea(repo, pr_number) if pr_title: click.echo(f"[pre-merge-check] PR title (from Gitea): {pr_title}") if pr_title is None: # Local mode without PR — only validate branch name if pr_number is not None: raise click.ClickException( _("Could not fetch PR title from Gitea (CI_GITEA_TOKEN not set or PR not found).") ) click.echo("[pre-merge-check] No PR title provided — running branch-name-only check (local mode).") click.echo("[pre-merge-check] Branch name OK. Push to create PR, then CI will validate the title.") return # 3. PR title format if not pr_title_re.match(pr_title): errors.append( _( "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}", prefix=TASK_PREFIX, title=pr_title, ), ) # 4. PR title task ID matches branch task ID if not pr_title.startswith(f"{task_id}:"): errors.append( _( "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}", task_id=task_id, title=pr_title, ), ) # 5. Vikunja task title match (skip if no token or --skip-vikunja) if not skip_vikunja: vikunja_title = get_vikunja_title_optional(task_id) if vikunja_title is None: try: get_vikunja_token() token_set = True except click.ClickException: token_set = False if token_set: errors.append( _( "Could not find Vikunja task {task_id} in project {project_id}.", task_id=task_id, project_id=VIKUNJA_PROJECT_ID, ), ) else: click.echo("[pre-merge-check] WARNING: VIKUNJA_TOKEN not set — skipping Vikunja title match check.") else: # Defensive check: warn if the Vikunja task title already includes # the task ID prefix. The expected PR title is # f"{task_id}: {vikunja_title}" — if vikunja_title already starts # with "{task_id}:", the PR title will have a double prefix. if vikunja_title.startswith(f"{task_id}:"): errors.append( _( "Vikunja task title '{title}' starts with '{prefix}:'. " "The task title should NOT include the '{prefix}' prefix — " "it is automatically added to the PR title. " "Update the Vikunja task title to remove the prefix.", title=vikunja_title, prefix=task_id, ), ) else: expected = f"{task_id}: {vikunja_title}" if pr_title != expected: errors.append( _( "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}", expected=expected, title=pr_title, ), ) else: click.echo(f"[pre-merge-check] Vikunja title match OK: {expected}") # 6. Branch behind master (skip if --skip-behind-check) if not skip_behind_check: if is_branch_behind_master(branch): errors.append( _("Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master") ) else: click.echo("[pre-merge-check] Branch is up-to-date with origin/master.") if errors: click.echo("", err=True) click.echo("=" * 60, err=True) click.echo("Pre-merge validation FAILED — fix these before pushing:", err=True) click.echo("=" * 60, err=True) for e in errors: click.echo(f" - {e}", err=True) # Remediation hints for the most common failure: PR title format title_errors = [ e for e in errors if "PR title must follow format" in str(e) or "PR title task ID mismatch" in str(e) ] if title_errors and pr_number is not None and repo is not None: click.echo("", err=True) click.echo("REMEDIATION:", err=True) click.echo( _( " Fix the PR title with:\n" " python3 -m devx.ci.fix_pr_title --repo {repo} --pr-number {pr}\n" " Or manually set the PR title to: '{expected}'", repo=repo, pr=pr_number, expected=f"{task_id}: <Vikunja task title>", ), err=True, ) raise click.ClickException(_("Pre-merge validation failed.")) click.echo("[pre-merge-check] All auto-merge preconditions satisfied.") if __name__ == "__main__": # pragma: no cover cli() # pragma: no cover