brintos

brintos / llvm-project-archived public Read only

0
0
Text · 10.2 KiB · ee908e0 Raw
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