| #!/usr/bin/env python3 |
| # ===-- merge-release-pr.py ------------------------------------------------===# |
| # |
| # Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions. |
| # See https://llvm.org/LICENSE.txt for license information. |
| # SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception |
| # |
| # ===------------------------------------------------------------------------===# |
| |
| """ |
| Helper script that will merge a Pull Request into a release branch. It will first |
| do some validations of the PR then rebase and finally push the changes to the |
| release branch. |
| |
| Usage: merge-release-pr.py <PR id> |
| By default it will push to the 'upstream' origin, but you can pass |
| --upstream-origin/-o <origin> if you want to change it. |
| |
| If you want to skip a specific validation, like the status checks you can |
| pass -s status_checks, this argument can be passed multiple times. |
| """ |
| |
| import argparse |
| import json |
| import subprocess |
| import sys |
| import time |
| from typing import List |
| |
| |
| class PRMerger: |
| def __init__(self, args): |
| self.args = args |
| |
| def run_gh(self, gh_cmd: str, args: List[str]) -> str: |
| cmd = ["gh", gh_cmd, "-Rllvm/llvm-project"] + args |
| p = subprocess.run(cmd, capture_output=True) |
| if p.returncode != 0: |
| print(p.stderr) |
| raise RuntimeError("Failed to run gh") |
| return p.stdout |
| |
| def validate_state(self, data): |
| """Validate the state of the PR, this means making sure that it is OPEN and not already merged or closed.""" |
| state = data["state"] |
| if state != "OPEN": |
| return False, f"state is {state.lower()}, not open" |
| return True |
| |
| def validate_target_branch(self, data): |
| """ |
| Validate that the PR is targetting a release/ branch. We could |
| validate the exact branch here, but I am not sure how to figure |
| out what we want except an argument and that might be a bit to |
| to much overhead. |
| """ |
| baseRefName: str = data["baseRefName"] |
| if not baseRefName.startswith("release/"): |
| return False, f"target branch is {baseRefName}, not a release branch" |
| return True |
| |
| def validate_approval(self, data): |
| """ |
| Validate the approval decision. This checks that the PR has been |
| approved. |
| """ |
| if data["reviewDecision"] != "APPROVED": |
| return False, "PR is not approved" |
| return True |
| |
| def validate_status_checks(self, data): |
| """ |
| Check that all the actions / status checks succeeded. Will also |
| fail if we have status checks in progress. |
| """ |
| failures = [] |
| pending = [] |
| for status in data["statusCheckRollup"]: |
| if "conclusion" in status and status["conclusion"] == "FAILURE": |
| failures.append(status) |
| if "status" in status and status["status"] == "IN_PROGRESS": |
| pending.append(status) |
| |
| if failures or pending: |
| errstr = "\n" |
| if failures: |
| errstr += " FAILED: " |
| errstr += ", ".join([d["name"] for d in failures]) |
| if pending: |
| if failures: |
| errstr += "\n" |
| errstr += " PENDING: " |
| errstr += ", ".join([d["name"] for d in pending]) |
| |
| return False, errstr |
| |
| return True |
| |
| def validate_commits(self, data): |
| """ |
| Validate that the PR contains just one commit. If it has more |
| we might want to squash. Which is something we could add to |
| this script in the future. |
| """ |
| if len(data["commits"]) > 1: |
| return False, f"More than 1 commit! {len(data['commits'])}" |
| return True |
| |
| def _normalize_pr(self, parg: str): |
| if parg.isdigit(): |
| return parg |
| elif parg.startswith("https://github.com/llvm/llvm-project/pull"): |
| # try to parse the following url https://github.com/llvm/llvm-project/pull/114089 |
| i = parg[parg.rfind("/") + 1 :] |
| if not i.isdigit(): |
| raise RuntimeError(f"{i} is not a number, malformatted input.") |
| return i |
| else: |
| raise RuntimeError( |
| f"PR argument must be PR ID or pull request URL - {parg} is wrong." |
| ) |
| |
| def load_pr_data(self): |
| self.args.pr = self._normalize_pr(self.args.pr) |
| fields_to_fetch = [ |
| "baseRefName", |
| "commits", |
| "headRefName", |
| "headRepository", |
| "headRepositoryOwner", |
| "reviewDecision", |
| "state", |
| "statusCheckRollup", |
| "title", |
| "url", |
| ] |
| print(f"> Loading PR {self.args.pr}...") |
| o = self.run_gh( |
| "pr", |
| ["view", self.args.pr, "--json", ",".join(fields_to_fetch)], |
| ) |
| self.prdata = json.loads(o) |
| |
| # save the baseRefName (target branch) so that we know where to push |
| self.target_branch = self.prdata["baseRefName"] |
| srepo = self.prdata["headRepository"]["name"] |
| sowner = self.prdata["headRepositoryOwner"]["login"] |
| self.source_url = f"https://github.com/{sowner}/{srepo}" |
| self.source_branch = self.prdata["headRefName"] |
| |
| if srepo != "llvm-project": |
| print("The target repo is NOT llvm-project, check the PR!") |
| sys.exit(1) |
| |
| if sowner == "llvm": |
| print( |
| "The source owner should never be github.com/llvm, double check the PR!" |
| ) |
| sys.exit(1) |
| |
| def validate_pr(self): |
| print(f"> Handling PR {self.args.pr} - {self.prdata['title']}") |
| print(f"> {self.prdata['url']}") |
| |
| VALIDATIONS = { |
| "state": self.validate_state, |
| "target_branch": self.validate_target_branch, |
| "approval": self.validate_approval, |
| "commits": self.validate_commits, |
| "status_checks": self.validate_status_checks, |
| } |
| |
| print() |
| print("> Validations:") |
| total_ok = True |
| for val_name, val_func in VALIDATIONS.items(): |
| try: |
| validation_data = val_func(self.prdata) |
| except: |
| validation_data = False |
| ok = None |
| skipped = ( |
| True |
| if (self.args.skip_validation and val_name in self.args.skip_validation) |
| else False |
| ) |
| if isinstance(validation_data, bool) and validation_data: |
| ok = "OK" |
| elif isinstance(validation_data, tuple) and not validation_data[0]: |
| failstr = validation_data[1] |
| if skipped: |
| ok = "SKIPPED: " |
| else: |
| total_ok = False |
| ok = "FAIL: " |
| ok += failstr |
| else: |
| ok = "FAIL! (Unknown)" |
| print(f" * {val_name}: {ok}") |
| return total_ok |
| |
| def rebase_pr(self): |
| print("> Fetching upstream") |
| subprocess.run(["git", "fetch", "--all"], check=True) |
| print("> Rebasing...") |
| subprocess.run( |
| ["git", "rebase", self.args.upstream + "/" + self.target_branch], check=True |
| ) |
| print("> Publish rebase...") |
| subprocess.run( |
| ["git", "push", "--force", self.source_url, f"HEAD:{self.source_branch}"] |
| ) |
| |
| def checkout_pr(self): |
| print("> Fetching PR changes...") |
| self.merge_branch = "llvm_merger_" + self.args.pr |
| self.run_gh( |
| "pr", |
| [ |
| "checkout", |
| self.args.pr, |
| "--force", |
| "--branch", |
| self.merge_branch, |
| ], |
| ) |
| |
| # get the branch information so that we can use it for |
| # pushing later. |
| p = subprocess.run( |
| ["git", "config", f"branch.{self.merge_branch}.merge"], |
| check=True, |
| capture_output=True, |
| text=True, |
| ) |
| upstream_branch = p.stdout.strip().replace("refs/heads/", "") |
| print(upstream_branch) |
| |
| def push_upstream(self): |
| print("> Pushing changes...") |
| subprocess.run( |
| ["git", "push", self.args.upstream, "HEAD:" + self.target_branch], |
| check=True, |
| ) |
| |
| def delete_local_branch(self): |
| print("> Deleting the old branch...") |
| subprocess.run(["git", "switch", "main"]) |
| subprocess.run(["git", "branch", "-D", f"llvm_merger_{self.args.pr}"]) |
| |
| |
| if __name__ == "__main__": |
| parser = argparse.ArgumentParser() |
| parser.add_argument( |
| "pr", |
| help="The Pull Request ID that should be merged into a release. Can be number or URL", |
| ) |
| parser.add_argument( |
| "--skip-validation", |
| "-s", |
| action="append", |
| help="Skip a specific validation, can be passed multiple times. I.e. -s status_checks -s approval", |
| ) |
| parser.add_argument( |
| "--upstream-origin", |
| "-o", |
| default="upstream", |
| dest="upstream", |
| help="The name of the origin that we should push to. (default: upstream)", |
| ) |
| parser.add_argument( |
| "--no-push", |
| action="store_true", |
| help="Run validations, rebase and fetch, but don't push.", |
| ) |
| parser.add_argument( |
| "--validate-only", action="store_true", help="Only run the validations." |
| ) |
| parser.add_argument( |
| "--rebase-only", action="store_true", help="Only rebase and exit" |
| ) |
| args = parser.parse_args() |
| |
| merger = PRMerger(args) |
| merger.load_pr_data() |
| |
| if args.rebase_only: |
| merger.checkout_pr() |
| merger.rebase_pr() |
| merger.delete_local_branch() |
| sys.exit(0) |
| |
| if not merger.validate_pr(): |
| print() |
| print( |
| "! Validations failed! Pass --skip-validation/-s <validation name> to pass this, can be passed multiple times" |
| ) |
| sys.exit(1) |
| |
| if args.validate_only: |
| print() |
| print("! --validate-only passed, will exit here") |
| sys.exit(0) |
| |
| merger.checkout_pr() |
| merger.rebase_pr() |
| |
| if args.no_push: |
| print() |
| print("! --no-push passed, will exit here") |
| sys.exit(0) |
| |
| merger.push_upstream() |
| merger.delete_local_branch() |
| |
| print() |
| print("> Done! Have a nice day!") |