Files
grm/scripts/ci/auto_merge.py
T

354 lines
13 KiB
Python

#!/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: <vikunja task title>``
Merge commit format: ``GRM-N <conventional commit message>``
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=<token> python3 scripts/auto_merge.py <branch> <pr_title> <repo> <pr_number> [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: <vikunja task title>
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: <vikunja task title>``
"""
if not PR_TITLE_RE.match(pr_title):
raise click.ClickException(
_(
"Oops! PR title must follow format 'GRM-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 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 <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}\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()