354 lines
13 KiB
Python
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()
|