Skip to content

Commit f3e950a

Browse files
authored
[Utils] Add new merge-release-pr.py script. (#101630)
This script helps the release managers merge backport PR's. It does the following things: * Validate the PR, checks approval, target branch and many other things. * Rebases the PR * Checkout the PR locally * Pushes the PR to the release branch * Deletes the local branch I have found the script very helpful to merge the PR's.
1 parent 80eea01 commit f3e950a

File tree

1 file changed

+254
-0
lines changed

1 file changed

+254
-0
lines changed
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
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+
"""
11+
Helper script that will merge a Pull Request into a release branch. It will first
12+
do some validations of the PR then rebase and finally push the changes to the
13+
release branch.
14+
15+
Usage: merge-release-pr.py <PR id>
16+
By default it will push to the 'upstream' origin, but you can pass
17+
--upstream-origin/-o <origin> if you want to change it.
18+
19+
If you want to skip a specific validation, like the status checks you can
20+
pass -s status_checks, this argument can be passed multiple times.
21+
"""
22+
23+
import argparse
24+
import json
25+
import subprocess
26+
import sys
27+
import time
28+
from typing import List
29+
30+
31+
class 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 validate_pr(self):
110+
fields_to_fetch = [
111+
"baseRefName",
112+
"reviewDecision",
113+
"title",
114+
"statusCheckRollup",
115+
"url",
116+
"state",
117+
"commits",
118+
]
119+
o = self.run_gh(
120+
"pr",
121+
["view", self.args.pr, "--json", ",".join(fields_to_fetch)],
122+
)
123+
prdata = json.loads(o)
124+
125+
# save the baseRefName (target branch) so that we know where to push
126+
self.target_branch = prdata["baseRefName"]
127+
128+
print(f"> Handling PR {self.args.pr} - {prdata['title']}")
129+
print(f"> {prdata['url']}")
130+
131+
VALIDATIONS = {
132+
"state": self.validate_state,
133+
"target_branch": self.validate_target_branch,
134+
"approval": self.validate_approval,
135+
"commits": self.validate_commits,
136+
"status_checks": self.validate_status_checks,
137+
}
138+
139+
print()
140+
print("> Validations:")
141+
total_ok = True
142+
for val_name, val_func in VALIDATIONS.items():
143+
try:
144+
validation_data = val_func(prdata)
145+
except:
146+
validation_data = False
147+
ok = None
148+
skipped = (
149+
True
150+
if (self.args.skip_validation and val_name in self.args.skip_validation)
151+
else False
152+
)
153+
if isinstance(validation_data, bool) and validation_data:
154+
ok = "OK"
155+
elif isinstance(validation_data, tuple) and not validation_data[0]:
156+
failstr = validation_data[1]
157+
if skipped:
158+
ok = "SKIPPED: "
159+
else:
160+
total_ok = False
161+
ok = "FAIL: "
162+
ok += failstr
163+
else:
164+
ok = "FAIL! (Unknown)"
165+
print(f" * {val_name}: {ok}")
166+
return total_ok
167+
168+
def rebase_pr(self):
169+
print("> Rebasing")
170+
self.run_gh("pr", ["update-branch", "--rebase", self.args.pr])
171+
print("> Waiting for GitHub to update PR")
172+
time.sleep(4)
173+
174+
def checkout_pr(self):
175+
print("> Fetching PR changes...")
176+
self.run_gh(
177+
"pr",
178+
[
179+
"checkout",
180+
self.args.pr,
181+
"--force",
182+
"--branch",
183+
"llvm_merger_" + self.args.pr,
184+
],
185+
)
186+
187+
def push_upstream(self):
188+
print("> Pushing changes...")
189+
subprocess.run(
190+
["git", "push", self.args.upstream, "HEAD:" + self.target_branch],
191+
check=True,
192+
)
193+
194+
def delete_local_branch(self):
195+
print("> Deleting the old branch...")
196+
subprocess.run(["git", "switch", "main"])
197+
subprocess.run(["git", "branch", "-D", f"llvm_merger_{self.args.pr}"])
198+
199+
200+
if __name__ == "__main__":
201+
parser = argparse.ArgumentParser()
202+
parser.add_argument(
203+
"pr",
204+
help="The Pull Request ID that should be merged into a release.",
205+
)
206+
parser.add_argument(
207+
"--skip-validation",
208+
"-s",
209+
action="append",
210+
help="Skip a specific validation, can be passed multiple times. I.e. -s status_checks -s approval",
211+
)
212+
parser.add_argument(
213+
"--upstream-origin",
214+
"-o",
215+
default="upstream",
216+
dest="upstream",
217+
help="The name of the origin that we should push to. (default: upstream)",
218+
)
219+
parser.add_argument(
220+
"--no-push",
221+
action="store_true",
222+
help="Run validations, rebase and fetch, but don't push.",
223+
)
224+
parser.add_argument(
225+
"--validate-only", action="store_true", help="Only run the validations."
226+
)
227+
args = parser.parse_args()
228+
229+
merger = PRMerger(args)
230+
if not merger.validate_pr():
231+
print()
232+
print(
233+
"! Validations failed! Pass --skip-validation/-s <validation name> to pass this, can be passed multiple times"
234+
)
235+
sys.exit(1)
236+
237+
if args.validate_only:
238+
print()
239+
print("! --validate-only passed, will exit here")
240+
sys.exit(0)
241+
242+
merger.rebase_pr()
243+
merger.checkout_pr()
244+
245+
if args.no_push:
246+
print()
247+
print("! --no-push passed, will exit here")
248+
sys.exit(0)
249+
250+
merger.push_upstream()
251+
merger.delete_local_branch()
252+
253+
print()
254+
print("> Done! Have a nice day!")

0 commit comments

Comments
 (0)