Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
157 lines
5.2 KiB
Python
Executable File
157 lines
5.2 KiB
Python
Executable File
#!/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)
|