318 lines · python
1#!/usr/bin/env python32# ===-- merge-release-pr.py ------------------------------------------------===#3#4# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.5# See https://llvm.org/LICENSE.txt for license information.6# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception7#8# ===------------------------------------------------------------------------===#9 10"""11Helper script that will merge a Pull Request into a release branch. It will first12do some validations of the PR then rebase and finally push the changes to the13release branch.14 15Usage: merge-release-pr.py <PR id>16By default it will push to the 'upstream' origin, but you can pass17--upstream-origin/-o <origin> if you want to change it.18 19If you want to skip a specific validation, like the status checks you can20pass -s status_checks, this argument can be passed multiple times.21"""22 23import argparse24import json25import subprocess26import sys27import time28from typing import List29 30 31class PRMerger:32 def __init__(self, args):33 self.args = args34 35 def run_gh(self, gh_cmd: str, args: List[str]) -> str:36 cmd = ["gh", gh_cmd, "-Rllvm/llvm-project"] + args37 p = subprocess.run(cmd, capture_output=True)38 if p.returncode != 0:39 print(p.stderr)40 raise RuntimeError("Failed to run gh")41 return p.stdout42 43 def validate_state(self, data):44 """Validate the state of the PR, this means making sure that it is OPEN and not already merged or closed."""45 state = data["state"]46 if state != "OPEN":47 return False, f"state is {state.lower()}, not open"48 return True49 50 def validate_target_branch(self, data):51 """52 Validate that the PR is targetting a release/ branch. We could53 validate the exact branch here, but I am not sure how to figure54 out what we want except an argument and that might be a bit to55 to much overhead.56 """57 baseRefName: str = data["baseRefName"]58 if not baseRefName.startswith("release/"):59 return False, f"target branch is {baseRefName}, not a release branch"60 return True61 62 def validate_approval(self, data):63 """64 Validate the approval decision. This checks that the PR has been65 approved.66 """67 if data["reviewDecision"] != "APPROVED":68 return False, "PR is not approved"69 return True70 71 def validate_status_checks(self, data):72 """73 Check that all the actions / status checks succeeded. Will also74 fail if we have status checks in progress.75 """76 failures = []77 pending = []78 for status in data["statusCheckRollup"]:79 if "conclusion" in status and status["conclusion"] == "FAILURE":80 failures.append(status)81 if "status" in status and status["status"] == "IN_PROGRESS":82 pending.append(status)83 84 if failures or pending:85 errstr = "\n"86 if failures:87 errstr += " FAILED: "88 errstr += ", ".join([d["name"] for d in failures])89 if pending:90 if failures:91 errstr += "\n"92 errstr += " PENDING: "93 errstr += ", ".join([d["name"] for d in pending])94 95 return False, errstr96 97 return True98 99 def validate_commits(self, data):100 """101 Validate that the PR contains just one commit. If it has more102 we might want to squash. Which is something we could add to103 this script in the future.104 """105 if len(data["commits"]) > 1:106 return False, f"More than 1 commit! {len(data['commits'])}"107 return True108 109 def _normalize_pr(self, parg: str):110 if parg.isdigit():111 return parg112 elif parg.startswith("https://github.com/llvm/llvm-project/pull"):113 # try to parse the following url https://github.com/llvm/llvm-project/pull/114089114 i = parg[parg.rfind("/") + 1 :]115 if not i.isdigit():116 raise RuntimeError(f"{i} is not a number, malformatted input.")117 return i118 else:119 raise RuntimeError(120 f"PR argument must be PR ID or pull request URL - {parg} is wrong."121 )122 123 def load_pr_data(self):124 self.args.pr = self._normalize_pr(self.args.pr)125 fields_to_fetch = [126 "baseRefName",127 "commits",128 "headRefName",129 "headRepository",130 "headRepositoryOwner",131 "reviewDecision",132 "state",133 "statusCheckRollup",134 "title",135 "url",136 ]137 print(f"> Loading PR {self.args.pr}...")138 o = self.run_gh(139 "pr",140 ["view", self.args.pr, "--json", ",".join(fields_to_fetch)],141 )142 self.prdata = json.loads(o)143 144 # save the baseRefName (target branch) so that we know where to push145 self.target_branch = self.prdata["baseRefName"]146 srepo = self.prdata["headRepository"]["name"]147 sowner = self.prdata["headRepositoryOwner"]["login"]148 self.source_url = f"https://github.com/{sowner}/{srepo}"149 self.source_branch = self.prdata["headRefName"]150 151 if srepo != "llvm-project":152 print("The target repo is NOT llvm-project, check the PR!")153 sys.exit(1)154 155 if sowner == "llvm":156 print(157 "The source owner should never be github.com/llvm, double check the PR!"158 )159 sys.exit(1)160 161 def validate_pr(self):162 print(f"> Handling PR {self.args.pr} - {self.prdata['title']}")163 print(f"> {self.prdata['url']}")164 165 VALIDATIONS = {166 "state": self.validate_state,167 "target_branch": self.validate_target_branch,168 "approval": self.validate_approval,169 "commits": self.validate_commits,170 "status_checks": self.validate_status_checks,171 }172 173 print()174 print("> Validations:")175 total_ok = True176 for val_name, val_func in VALIDATIONS.items():177 try:178 validation_data = val_func(self.prdata)179 except:180 validation_data = False181 ok = None182 skipped = (183 True184 if (self.args.skip_validation and val_name in self.args.skip_validation)185 else False186 )187 if isinstance(validation_data, bool) and validation_data:188 ok = "OK"189 elif isinstance(validation_data, tuple) and not validation_data[0]:190 failstr = validation_data[1]191 if skipped:192 ok = "SKIPPED: "193 else:194 total_ok = False195 ok = "FAIL: "196 ok += failstr197 else:198 ok = "FAIL! (Unknown)"199 print(f" * {val_name}: {ok}")200 return total_ok201 202 def rebase_pr(self):203 print("> Fetching upstream")204 subprocess.run(["git", "fetch", "--all"], check=True)205 print("> Rebasing...")206 subprocess.run(207 ["git", "rebase", self.args.upstream + "/" + self.target_branch], check=True208 )209 print("> Publish rebase...")210 subprocess.run(211 ["git", "push", "--force", self.source_url, f"HEAD:{self.source_branch}"]212 )213 214 def checkout_pr(self):215 print("> Fetching PR changes...")216 self.merge_branch = "llvm_merger_" + self.args.pr217 self.run_gh(218 "pr",219 [220 "checkout",221 self.args.pr,222 "--force",223 "--branch",224 self.merge_branch,225 ],226 )227 228 # get the branch information so that we can use it for229 # pushing later.230 p = subprocess.run(231 ["git", "config", f"branch.{self.merge_branch}.merge"],232 check=True,233 capture_output=True,234 text=True,235 )236 upstream_branch = p.stdout.strip().replace("refs/heads/", "")237 print(upstream_branch)238 239 def push_upstream(self):240 print("> Pushing changes...")241 subprocess.run(242 ["git", "push", self.args.upstream, "HEAD:" + self.target_branch],243 check=True,244 )245 246 def delete_local_branch(self):247 print("> Deleting the old branch...")248 subprocess.run(["git", "switch", "main"])249 subprocess.run(["git", "branch", "-D", f"llvm_merger_{self.args.pr}"])250 251 252if __name__ == "__main__":253 parser = argparse.ArgumentParser()254 parser.add_argument(255 "pr",256 help="The Pull Request ID that should be merged into a release. Can be number or URL",257 )258 parser.add_argument(259 "--skip-validation",260 "-s",261 action="append",262 help="Skip a specific validation, can be passed multiple times. I.e. -s status_checks -s approval",263 )264 parser.add_argument(265 "--upstream-origin",266 "-o",267 default="upstream",268 dest="upstream",269 help="The name of the origin that we should push to. (default: upstream)",270 )271 parser.add_argument(272 "--no-push",273 action="store_true",274 help="Run validations, rebase and fetch, but don't push.",275 )276 parser.add_argument(277 "--validate-only", action="store_true", help="Only run the validations."278 )279 parser.add_argument(280 "--rebase-only", action="store_true", help="Only rebase and exit"281 )282 args = parser.parse_args()283 284 merger = PRMerger(args)285 merger.load_pr_data()286 287 if args.rebase_only:288 merger.checkout_pr()289 merger.rebase_pr()290 merger.delete_local_branch()291 sys.exit(0)292 293 if not merger.validate_pr():294 print()295 print(296 "! Validations failed! Pass --skip-validation/-s <validation name> to pass this, can be passed multiple times"297 )298 sys.exit(1)299 300 if args.validate_only:301 print()302 print("! --validate-only passed, will exit here")303 sys.exit(0)304 305 merger.checkout_pr()306 merger.rebase_pr()307 308 if args.no_push:309 print()310 print("! --no-push passed, will exit here")311 sys.exit(0)312 313 merger.push_upstream()314 merger.delete_local_branch()315 316 print()317 print("> Done! Have a nice day!")318