xref: /llvm-project/llvm/utils/release/merge-release-pr.py (revision e2b4a700fd927e50a68ac0a42e4807a104495186)
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