Public Access
315 lines
12 KiB
Python
315 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Pre-merge validation gate for auto-merge preconditions.
|
|
|
|
Validates that a PR satisfies auto-merge requirements BEFORE expensive
|
|
jobs (molecule tests, staging deploy) run. This catches issues early:
|
|
|
|
1. Branch name contains a task ID (e.g., ``DEVX-256-fix-foo``).
|
|
2. PR title follows ``{PREFIX}-N: <title>`` format.
|
|
3. PR title task ID matches the branch task ID.
|
|
4. PR title matches the Vikunja task title (requires ``VIKUNJA_TOKEN``).
|
|
5. Branch is not behind master (would trigger a rebase retry cycle).
|
|
|
|
Exit code 0 = ready for auto-merge (preconditions satisfied).
|
|
Exit code 1 = NOT ready — fix issues before pushing.
|
|
|
|
Usage::
|
|
|
|
# CI (with VIKUNJA_TOKEN and 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
|