#!/usr/bin/env -S uv run --script --frozen --only-group release-tools # -*-python-*- import re import subprocess import time from typing import Annotated import typer from github import Auth, Github from github.PullRequest import PullRequest from github.Repository import Repository from rich.console import Console from rich.progress import track def prs_to_backport(repo: Repository, console: Console, skip: set[int]) -> list[int]: backport_prs = [] with console.status("Getting list of closed backport candidate PRs..."): issues = list(repo.get_issues(labels=["backport candidate"], state="closed")) for issue in track(issues, console=console, description="Fetching PR metadata..."): if issue.number in skip: continue if issue.pull_request is None: continue pr = repo.get_pull(issue.number) if pr.merged_at is None: print(f"PR {pr.number} does not have a merged_at time!") continue backport_prs.append((pr.number, pr.merged_at)) backport_prs.sort(key=lambda x: x[1]) return [pr[0] for pr in backport_prs] def wait_for_complete(repo: Repository, pr: PullRequest, console: Console) -> None: commit = repo.get_commit(pr.head.sha) with console.status("Waiting for tests to pass..."): while True: time.sleep(10) check_runs = commit.get_check_runs() if check_runs.totalCount == 0: continue all_completed = True for check in check_runs: if check.status != "completed": all_completed = False break elif check.conclusion not in ["success", "neutral", "skipped"]: raise Exception(f"{check.name} failed!") if all_completed: break pr.merge(merge_method="rebase") def mark_as_backported(repo: Repository, backport_pr: int, pr_number: int, commit: str) -> None: pr = repo.get_pull(pr_number) pr.create_issue_comment(f"Backported in #{backport_pr} ({commit})") pr.remove_from_labels("backport candidate") def validate_github_token(value: str) -> str: # https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/about-authentication-to-github#githubs-token-formats if value.startswith("github_"): return value if re.match(r"gh[pousr]_", value): return value raise typer.BadParameter("Github access tokens start with `github_`, or `gh`") def main( token: Annotated[ str, typer.Option( metavar="TOKEN", envvar="GITHUB_TOKEN", show_envvar=True, callback=validate_github_token, help="Github access token", ), ], skip: Annotated[list[int], typer.Option(default_factory=list)], ) -> int: """Make a backport PR""" console = Console(stderr=True, log_path=False) latest_tag = subprocess.check_output( ["git", "tag", "-l", "--sort=-committerdate", "--no-merged", "upstream/main"], text=True, ).splitlines()[0] target_branch = latest_tag.split(".")[0] + ".x" gh = Github(auth=Auth.Token(token)) repo = gh.get_repo("zulip/zulip") pr_ids = prs_to_backport(repo, console, set(skip)) branchname = f"backports-{target_branch}" subprocess.check_call(["git", "fetch", "upstream"]) subprocess.check_call( [ "git", "checkout", "-b", branchname, "--track", f"upstream/{target_branch}", ] ) successful_pr_id_commits = [] for pr_number in track(pr_ids, console=console, description="Backporting..."): try: subprocess.check_call( ["./tools/backport-pull-request", str(pr_number)], stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL, ) current_commit = subprocess.check_output( ["git", "rev-parse", "HEAD"], text=True ).strip() successful_pr_id_commits.append((pr_number, current_commit)) except subprocess.CalledProcessError: subprocess.check_call(["git", "cherry-pick", "--abort"]) if not successful_pr_id_commits: print("No PRs successfully backported!") return 1 body = f"Backport to {target_branch}:\n" for pr_number, _ in successful_pr_id_commits: body += f"- #{pr_number}\n" subprocess.check_call(["git", "push", "origin", f"HEAD:{branchname}"]) backport_pr = repo.create_pull( title=f"{target_branch} backports", body=body, head=f"{gh.get_user().login}:{branchname}", base=target_branch, ) wait_for_complete(repo, backport_pr, console) for pr_number, commit in track( successful_pr_id_commits, console=console, description="Commenting on backported PRs..." ): mark_as_backported(repo, backport_pr.number, pr_number, commit) subprocess.check_call(["git", "push", "origin", "--delete", branchname]) subprocess.check_call(["git", "checkout", target_branch]) subprocess.check_call(["git", "branch", "--delete", "--force", branchname]) subprocess.check_call(["git", "pull"]) return 0 if __name__ == "__main__": typer.run(main)