#!/usr/bin/env python3 """Auto-merge PR by extracting task ID from branch and constructing merge title. Waits for CI checks to complete before attempting the merge. PR title format: ``GRM-N: `` Merge commit format: ``GRM-N `` The conventional commit message is taken from the first commit on the PR branch (the branch HEAD). This allows the PR title to be a human-friendly Vikunja task title while the squashed commit follows conventional commits. Usage: REPO_TOKEN= python3 scripts/auto_merge.py [label_name] """ import os import re import subprocess # nosec B404 import time from typing import Any import click from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from gitea_runner_manager.api_clients import GiteaClient, VikunjaClient from gitea_runner_manager.config import ( CONVENTIONAL_RE, DEFAULT_PER_PAGE, GITEA_API_URL, TASK_ID_RE, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID, ) from gitea_runner_manager.exceptions import APIError from gitea_runner_manager.i18n import _ READY_TO_MERGE = "ready-to-merge" MAX_WAIT_SECONDS = 180 # 3 minutes max — CI should already be running POLL_INTERVAL_SECONDS = 15 # Poll every 15 seconds 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 # PR title: GRM-N: PR_TITLE_RE = re.compile(r"^GRM-\d+:\s+.+") load_dotenv(override=True) def extract_task_id(branch: str) -> str: """Extract GRM-N task identifier from branch name.""" 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: ``GRM-N: `` """ if not PR_TITLE_RE.match(pr_title): raise click.ClickException( _( "Oops! PR title must follow format 'GRM-N: '.\n" " Expected: {task_id}: \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 GRM-N identifier. Returns empty string if VIKUNJA_TOKEN is not set (skip validation). """ 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 return "" 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. """ vikunja_title = get_vikunja_task_title(task_id) if not vikunja_title: click.echo(_("Warning: could not fetch Vikunja task title, 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 has_approval_review(client: GiteaClient, pr_number: str) -> bool: """Check whether the PR has at least one substantive APPROVE review. A substantive review has a body longer than 20 characters (not just "LGTM" or "OK"). This ensures the reviewer actually reviewed the PR rather than rubber-stamping it. Falls back to checking that no REQUEST_CHANGES reviews are pending when self-approval is not possible (single-token workflow). """ reviews = client.get_pr_reviews(pr_number) has_approved = False has_changes_requested = False for r in reviews: state = r.get("state", "") if state == "APPROVED": body = str(r.get("body", "")).strip() if len(body) > 20 or r.get("comments", []): has_approved = True elif state == "REQUEST_CHANGES": has_changes_requested = True if has_approved: return True # In single-token workflows, self-approval is not allowed. # Allow merge if no changes are requested (the automated pr-review # job and CI quality gate serve as the review enforcement). if not has_changes_requested: click.echo( _("No APPROVE review found, but no REQUEST_CHANGES either. Proceeding (single-token workflow fallback).") ) return True return False 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 "" def has_ready_to_merge_label(client: GiteaClient, pr_number: str) -> bool: """Check whether the PR has the ready-to-merge label via the API.""" labels = client.get_pr_labels(pr_number) return any(label.get("name") == READY_TO_MERGE for label in labels) def wait_for_ci( client: GiteaClient, sha: str, max_wait: int = MAX_WAIT_SECONDS, poll_interval: int = POLL_INTERVAL_SECONDS ) -> bool: """Poll commit statuses until all CI checks are complete (not pending). Returns True if all checks are successful, False if any failed or timed out. Uses the combined status endpoint which returns one entry per context (deduplicated server-side). Filters to "CI /" contexts only, excluding "Auto-merge / merge" and other non-CI contexts. """ elapsed = 0 while elapsed < max_wait: statuses = client.get_commit_status(sha) if not statuses: click.echo(_("No CI checks reported yet, waiting...")) time.sleep(poll_interval) elapsed += poll_interval continue # Combined endpoint already deduplicates — one entry per context. # Filter to CI contexts only (excludes "Auto-merge / merge" etc). ci_statuses = {s.get("context", ""): s for s in statuses if s.get("context", "").startswith("CI /")} if not ci_statuses: click.echo(_("No CI checks found yet, waiting...")) time.sleep(poll_interval) elapsed += poll_interval continue pending = [ctx for ctx, s in ci_statuses.items() if s.get("status") in ("pending", "waiting")] if not pending: # All CI checks are complete — check if they all succeeded. # "skipped" jobs are considered passing (conditional jobs that didn't run). failed = [ctx for ctx, s in ci_statuses.items() if s.get("status") not in ("success", "ok", "skipped")] if failed: click.echo(_("CI checks failed: {failed}", failed=", ".join(sorted(failed)))) return False click.echo(_("All CI checks passed.")) return True click.echo( _( "Waiting for CI checks: {pending} ({elapsed}s elapsed)", pending=", ".join(sorted(pending)), elapsed=elapsed, ) ) time.sleep(poll_interval) elapsed += poll_interval click.echo(_("Timed out waiting for CI checks after {max_wait}s.", max_wait=max_wait)) return False @click.command() @click.argument("branch") @click.argument("pr_title") @click.argument("repo") @click.argument("pr_number") @click.argument("label_name", required=False, default="") def main(branch: str, pr_title: str, repo: str, pr_number: str, label_name: 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) # Gitea Actions may not populate github.event.label.name; fall back to API check. if label_name != READY_TO_MERGE and not has_ready_to_merge_label(client, pr_number): click.echo(_("Label '{label}' is not '{rtm}', skipping.", label=label_name, rtm=READY_TO_MERGE)) return task_id = extract_task_id(branch) if not task_id: raise click.ClickException( _( "Oops! No task ID (GRM-N) found in branch name '{branch}'.", branch=branch, ) ) validate_pr_title(pr_title, task_id) validate_pr_title_matches_vikunja(pr_title, task_id) # Enforce APPROVE review before merge (Gap 2 fix) if not has_approval_review(client, pr_number): raise click.ClickException( _( "Cannot merge: PR #{pr_number} has no APPROVE review. " "Please review and approve before adding the ready-to-merge label.", pr_number=pr_number, ) ) click.echo(_("PR has at least one APPROVE review.")) # Wait for CI checks to complete before attempting merge. pr = client.get_pr(pr_number) sha = pr.get("head", {}).get("sha", "") if sha: click.echo(_("Waiting for CI checks on commit {sha}...", sha=sha[:8])) if not wait_for_ci(client, sha): raise click.ClickException( _("Cannot merge: CI checks did not pass. Please fix failing checks and re-label.") ) else: click.echo(_("Warning: could not determine PR head SHA, proceeding without CI wait.")) # Build merge title: GRM-N 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}\n" "Please rebase the PR manually and re-add the ready-to-merge label.", 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()