1#!/usr/bin/env python3 2# ===-- 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-exception 7# 8# ===------------------------------------------------------------------------===# 9 10""" 11Helper script that will merge a Pull Request into a release branch. It will first 12do some validations of the PR then rebase and finally push the changes to the 13release branch. 14 15Usage: merge-release-pr.py <PR id> 16By default it will push to the 'upstream' origin, but you can pass 17--upstream-origin/-o <origin> if you want to change it. 18 19If you want to skip a specific validation, like the status checks you can 20pass -s status_checks, this argument can be passed multiple times. 21""" 22 23import argparse 24import json 25import subprocess 26import sys 27import time 28from typing import List 29 30 31class PRMerger: 32 def __init__(self, args): 33 self.args = args 34 35 def run_gh(self, gh_cmd: str, args: List[str]) -> str: 36 cmd = ["gh", gh_cmd, "-Rllvm/llvm-project"] + args 37 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.stdout 42 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 True 49 50 def validate_target_branch(self, data): 51 """ 52 Validate that the PR is targetting a release/ branch. We could 53 validate the exact branch here, but I am not sure how to figure 54 out what we want except an argument and that might be a bit to 55 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 True 61 62 def validate_approval(self, data): 63 """ 64 Validate the approval decision. This checks that the PR has been 65 approved. 66 """ 67 if data["reviewDecision"] != "APPROVED": 68 return False, "PR is not approved" 69 return True 70 71 def validate_status_checks(self, data): 72 """ 73 Check that all the actions / status checks succeeded. Will also 74 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, errstr 96 97 return True 98 99 def validate_commits(self, data): 100 """ 101 Validate that the PR contains just one commit. If it has more 102 we might want to squash. Which is something we could add to 103 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 True 108 109 def _normalize_pr(self, parg: str): 110 if parg.isdigit(): 111 return parg 112 elif parg.startswith("https://github.com/llvm/llvm-project/pull"): 113 # try to parse the following url https://github.com/llvm/llvm-project/pull/114089 114 i = parg[parg.rfind("/") + 1 :] 115 if not i.isdigit(): 116 raise RuntimeError(f"{i} is not a number, malformatted input.") 117 return i 118 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 push 145 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 = True 176 for val_name, val_func in VALIDATIONS.items(): 177 try: 178 validation_data = val_func(self.prdata) 179 except: 180 validation_data = False 181 ok = None 182 skipped = ( 183 True 184 if (self.args.skip_validation and val_name in self.args.skip_validation) 185 else False 186 ) 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 = False 195 ok = "FAIL: " 196 ok += failstr 197 else: 198 ok = "FAIL! (Unknown)" 199 print(f" * {val_name}: {ok}") 200 return total_ok 201 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=True 208 ) 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.pr 217 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 for 229 # 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