xref: /llvm-project/llvm/utils/git/github-automation.py (revision 6a4489a73337907d52e7eaf3716f3de9008e6e53)
1#!/usr/bin/env python3
2#
3# ======- github-automation - LLVM GitHub Automation Routines--*- python -*--==#
4#
5# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
6# See https://llvm.org/LICENSE.txt for license information.
7# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
8#
9# ==-------------------------------------------------------------------------==#
10
11import argparse
12from git import Repo  # type: ignore
13import html
14import github
15import os
16import re
17import requests
18import sys
19import time
20from typing import List, Optional
21
22beginner_comment = """
23Hi!
24
25This issue may be a good introductory issue for people new to working on LLVM. If you would like to work on this issue, your first steps are:
26
271. In the comments of the issue, request for it to be assigned to you.
282. Fix the issue locally.
293. [Run the test suite](https://llvm.org/docs/TestingGuide.html#unit-and-regression-tests) locally. Remember that the subdirectories under `test/` create fine-grained testing targets, so you can e.g. use `make check-clang-ast` to only run Clang's AST tests.
304. Create a Git commit.
315. Run [`git clang-format HEAD~1`](https://clang.llvm.org/docs/ClangFormat.html#git-integration) to format your changes.
326. Open a [pull request](https://github.com/llvm/llvm-project/pulls) to the [upstream repository](https://github.com/llvm/llvm-project) on GitHub. Detailed instructions can be found [in GitHub's documentation](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request).
33
34If you have any further questions about this issue, don't hesitate to ask via a comment in the thread below.
35"""
36
37
38def _get_curent_team(team_name, teams) -> Optional[github.Team.Team]:
39    for team in teams:
40        if team_name == team.name.lower():
41            return team
42    return None
43
44
45def escape_description(str):
46    # If the description of an issue/pull request is empty, the Github API
47    # library returns None instead of an empty string. Handle this here to
48    # avoid failures from trying to manipulate None.
49    if str is None:
50        return ""
51    # https://github.com/github/markup/issues/1168#issuecomment-494946168
52    str = html.escape(str, False)
53    # '@' followed by alphanum is a user name
54    str = re.sub("@(?=\w)", "@<!-- -->", str)
55    # '#' followed by digits is considered an issue number
56    str = re.sub("#(?=\d)", "#<!-- -->", str)
57    return str
58
59
60class IssueSubscriber:
61    @property
62    def team_name(self) -> str:
63        return self._team_name
64
65    def __init__(self, token: str, repo: str, issue_number: int, label_name: str):
66        self.repo = github.Github(token).get_repo(repo)
67        self.org = github.Github(token).get_organization(self.repo.organization.login)
68        self.issue = self.repo.get_issue(issue_number)
69        self._team_name = "issue-subscribers-{}".format(label_name).lower()
70
71    def run(self) -> bool:
72        team = _get_curent_team(self.team_name, self.org.get_teams())
73        if not team:
74            print(f"couldn't find team named {self.team_name}")
75            return False
76
77        comment = ""
78        if team.slug == "issue-subscribers-good-first-issue":
79            comment = "{}\n".format(beginner_comment)
80            self.issue.create_comment(comment)
81
82        body = escape_description(self.issue.body)
83        comment = f"""
84@llvm/{team.slug}
85
86Author: {self.issue.user.name} ({self.issue.user.login})
87
88<details>
89{body}
90</details>
91"""
92
93        self.issue.create_comment(comment)
94        return True
95
96
97def human_readable_size(size, decimal_places=2):
98    for unit in ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]:
99        if size < 1024.0 or unit == "PiB":
100            break
101        size /= 1024.0
102    return f"{size:.{decimal_places}f} {unit}"
103
104
105class PRSubscriber:
106    @property
107    def team_name(self) -> str:
108        return self._team_name
109
110    def __init__(self, token: str, repo: str, pr_number: int, label_name: str):
111        self.repo = github.Github(token).get_repo(repo)
112        self.org = github.Github(token).get_organization(self.repo.organization.login)
113        self.pr = self.repo.get_issue(pr_number).as_pull_request()
114        self._team_name = "pr-subscribers-{}".format(
115            label_name.replace("+", "x")
116        ).lower()
117        self.COMMENT_TAG = "<!--LLVM PR SUMMARY COMMENT-->\n"
118
119    def get_summary_comment(self) -> github.IssueComment.IssueComment:
120        for comment in self.pr.as_issue().get_comments():
121            if self.COMMENT_TAG in comment.body:
122                return comment
123        return None
124
125    def run(self) -> bool:
126        patch = None
127        team = _get_curent_team(self.team_name, self.org.get_teams())
128        if not team:
129            print(f"couldn't find team named {self.team_name}")
130            return False
131
132        # GitHub limits comments to 65,536 characters, let's limit the diff
133        # and the file list to 20kB each.
134        STAT_LIMIT = 20 * 1024
135        DIFF_LIMIT = 20 * 1024
136
137        # Get statistics for each file
138        diff_stats = f"{self.pr.changed_files} Files Affected:\n\n"
139        for file in self.pr.get_files():
140            diff_stats += f"- ({file.status}) {file.filename} ("
141            if file.additions:
142                diff_stats += f"+{file.additions}"
143            if file.deletions:
144                diff_stats += f"-{file.deletions}"
145            diff_stats += ") "
146            if file.status == "renamed":
147                print(f"(from {file.previous_filename})")
148            diff_stats += "\n"
149            if len(diff_stats) > STAT_LIMIT:
150                break
151
152        # Get the diff
153        try:
154            patch = requests.get(self.pr.diff_url).text
155        except:
156            patch = ""
157
158        patch_link = f"Full diff: {self.pr.diff_url}\n"
159        if len(patch) > DIFF_LIMIT:
160            patch_link = f"\nPatch is {human_readable_size(len(patch))}, truncated to {human_readable_size(DIFF_LIMIT)} below, full version: {self.pr.diff_url}\n"
161            patch = patch[0:DIFF_LIMIT] + "...\n[truncated]\n"
162        team_mention = "@llvm/{}".format(team.slug)
163
164        body = escape_description(self.pr.body)
165        # Note: the comment is in markdown and the code below
166        # is sensible to line break
167        comment = f"""
168{self.COMMENT_TAG}
169{team_mention}
170
171Author: {self.pr.user.name} ({self.pr.user.login})
172
173<details>
174<summary>Changes</summary>
175
176{body}
177
178---
179{patch_link}
180
181{diff_stats}
182
183``````````diff
184{patch}
185``````````
186
187</details>
188"""
189
190        summary_comment = self.get_summary_comment()
191        if not summary_comment:
192            self.pr.as_issue().create_comment(comment)
193        elif team_mention + "\n" in summary_comment.body:
194            print("Team {} already mentioned.".format(team.slug))
195        else:
196            summary_comment.edit(
197                summary_comment.body.replace(
198                    self.COMMENT_TAG, self.COMMENT_TAG + team_mention + "\n"
199                )
200            )
201        return True
202
203    def _get_curent_team(self) -> Optional[github.Team.Team]:
204        for team in self.org.get_teams():
205            if self.team_name == team.name.lower():
206                return team
207        return None
208
209
210def setup_llvmbot_git(git_dir="."):
211    """
212    Configure the git repo in `git_dir` with the llvmbot account so
213    commits are attributed to llvmbot.
214    """
215    repo = Repo(git_dir)
216    with repo.config_writer() as config:
217        config.set_value("user", "name", "llvmbot")
218        config.set_value("user", "email", "llvmbot@llvm.org")
219
220
221def phab_api_call(phab_token: str, url: str, args: dict) -> dict:
222    """
223    Make an API call to the Phabricator web service and return a dictionary
224    containing the json response.
225    """
226    data = {"api.token": phab_token}
227    data.update(args)
228    response = requests.post(url, data=data)
229    return response.json()
230
231
232def phab_login_to_github_login(
233    phab_token: str, repo: github.Repository.Repository, phab_login: str
234) -> Optional[str]:
235    """
236    Tries to translate a Phabricator login to a github login by
237    finding a commit made in Phabricator's Differential.
238    The commit's SHA1 is then looked up in the github repo and
239    the committer's login associated with that commit is returned.
240
241    :param str phab_token: The Conduit API token to use for communication with Pabricator
242    :param github.Repository.Repository repo: The github repo to use when looking for the SHA1 found in Differential
243    :param str phab_login: The Phabricator login to be translated.
244    """
245
246    args = {
247        "constraints[authors][0]": phab_login,
248        # PHID for "LLVM Github Monorepo" repository
249        "constraints[repositories][0]": "PHID-REPO-f4scjekhnkmh7qilxlcy",
250        "limit": 1,
251    }
252    # API documentation: https://reviews.llvm.org/conduit/method/diffusion.commit.search/
253    r = phab_api_call(
254        phab_token, "https://reviews.llvm.org/api/diffusion.commit.search", args
255    )
256    data = r["result"]["data"]
257    if len(data) == 0:
258        # Can't find any commits associated with this user
259        return None
260
261    commit_sha = data[0]["fields"]["identifier"]
262    committer = repo.get_commit(commit_sha).committer
263    if not committer:
264        # This committer had an email address GitHub could not recognize, so
265        # it can't link the user to a GitHub account.
266        print(f"Warning: Can't find github account for {phab_login}")
267        return None
268    return committer.login
269
270
271def phab_get_commit_approvers(phab_token: str, commit: github.Commit.Commit) -> list:
272    args = {"corpus": commit.commit.message}
273    # API documentation: https://reviews.llvm.org/conduit/method/differential.parsecommitmessage/
274    r = phab_api_call(
275        phab_token, "https://reviews.llvm.org/api/differential.parsecommitmessage", args
276    )
277    review_id = r["result"]["revisionIDFieldInfo"]["value"]
278    if not review_id:
279        # No Phabricator revision for this commit
280        return []
281
282    args = {"constraints[ids][0]": review_id, "attachments[reviewers]": True}
283    # API documentation: https://reviews.llvm.org/conduit/method/differential.revision.search/
284    r = phab_api_call(
285        phab_token, "https://reviews.llvm.org/api/differential.revision.search", args
286    )
287    reviewers = r["result"]["data"][0]["attachments"]["reviewers"]["reviewers"]
288    accepted = []
289    for reviewer in reviewers:
290        if reviewer["status"] != "accepted":
291            continue
292        phid = reviewer["reviewerPHID"]
293        args = {"constraints[phids][0]": phid}
294        # API documentation: https://reviews.llvm.org/conduit/method/user.search/
295        r = phab_api_call(phab_token, "https://reviews.llvm.org/api/user.search", args)
296        accepted.append(r["result"]["data"][0]["fields"]["username"])
297    return accepted
298
299
300def extract_commit_hash(arg: str):
301    """
302    Extract the commit hash from the argument passed to /action github
303    comment actions. We currently only support passing the commit hash
304    directly or use the github URL, such as
305    https://github.com/llvm/llvm-project/commit/2832d7941f4207f1fcf813b27cf08cecc3086959
306    """
307    github_prefix = "https://github.com/llvm/llvm-project/commit/"
308    if arg.startswith(github_prefix):
309        return arg[len(github_prefix) :]
310    return arg
311
312
313class ReleaseWorkflow:
314    CHERRY_PICK_FAILED_LABEL = "release:cherry-pick-failed"
315
316    """
317    This class implements the sub-commands for the release-workflow command.
318    The current sub-commands are:
319        * create-branch
320        * create-pull-request
321
322    The execute_command method will automatically choose the correct sub-command
323    based on the text in stdin.
324    """
325
326    def __init__(
327        self,
328        token: str,
329        repo: str,
330        issue_number: int,
331        branch_repo_name: str,
332        branch_repo_token: str,
333        llvm_project_dir: str,
334        phab_token: str,
335    ) -> None:
336        self._token = token
337        self._repo_name = repo
338        self._issue_number = issue_number
339        self._branch_repo_name = branch_repo_name
340        if branch_repo_token:
341            self._branch_repo_token = branch_repo_token
342        else:
343            self._branch_repo_token = self.token
344        self._llvm_project_dir = llvm_project_dir
345        self._phab_token = phab_token
346
347    @property
348    def token(self) -> str:
349        return self._token
350
351    @property
352    def repo_name(self) -> str:
353        return self._repo_name
354
355    @property
356    def issue_number(self) -> int:
357        return self._issue_number
358
359    @property
360    def branch_repo_name(self) -> str:
361        return self._branch_repo_name
362
363    @property
364    def branch_repo_token(self) -> str:
365        return self._branch_repo_token
366
367    @property
368    def llvm_project_dir(self) -> str:
369        return self._llvm_project_dir
370
371    @property
372    def phab_token(self) -> str:
373        return self._phab_token
374
375    @property
376    def repo(self) -> github.Repository.Repository:
377        return github.Github(self.token).get_repo(self.repo_name)
378
379    @property
380    def issue(self) -> github.Issue.Issue:
381        return self.repo.get_issue(self.issue_number)
382
383    @property
384    def push_url(self) -> str:
385        return "https://{}@github.com/{}".format(
386            self.branch_repo_token, self.branch_repo_name
387        )
388
389    @property
390    def branch_name(self) -> str:
391        return "issue{}".format(self.issue_number)
392
393    @property
394    def release_branch_for_issue(self) -> Optional[str]:
395        issue = self.issue
396        milestone = issue.milestone
397        if milestone is None:
398            return None
399        m = re.search("branch: (.+)", milestone.description)
400        if m:
401            return m.group(1)
402        return None
403
404    def print_release_branch(self) -> None:
405        print(self.release_branch_for_issue)
406
407    def issue_notify_branch(self) -> None:
408        self.issue.create_comment(
409            "/branch {}/{}".format(self.branch_repo_name, self.branch_name)
410        )
411
412    def issue_notify_pull_request(self, pull: github.PullRequest.PullRequest) -> None:
413        self.issue.create_comment(
414            "/pull-request {}#{}".format(self.branch_repo_name, pull.number)
415        )
416
417    def make_ignore_comment(self, comment: str) -> str:
418        """
419        Returns the comment string with a prefix that will cause
420        a Github workflow to skip parsing this comment.
421
422        :param str comment: The comment to ignore
423        """
424        return "<!--IGNORE-->\n" + comment
425
426    def issue_notify_no_milestone(self, comment: List[str]) -> None:
427        message = "{}\n\nError: Command failed due to missing milestone.".format(
428            "".join([">" + line for line in comment])
429        )
430        self.issue.create_comment(self.make_ignore_comment(message))
431
432    @property
433    def action_url(self) -> str:
434        if os.getenv("CI"):
435            return "https://github.com/{}/actions/runs/{}".format(
436                os.getenv("GITHUB_REPOSITORY"), os.getenv("GITHUB_RUN_ID")
437            )
438        return ""
439
440    def issue_notify_cherry_pick_failure(
441        self, commit: str
442    ) -> github.IssueComment.IssueComment:
443        message = self.make_ignore_comment(
444            "Failed to cherry-pick: {}\n\n".format(commit)
445        )
446        action_url = self.action_url
447        if action_url:
448            message += action_url + "\n\n"
449        message += "Please manually backport the fix and push it to your github fork.  Once this is done, please add a comment like this:\n\n`/branch <user>/<repo>/<branch>`"
450        issue = self.issue
451        comment = issue.create_comment(message)
452        issue.add_to_labels(self.CHERRY_PICK_FAILED_LABEL)
453        return comment
454
455    def issue_notify_pull_request_failure(
456        self, branch: str
457    ) -> github.IssueComment.IssueComment:
458        message = "Failed to create pull request for {} ".format(branch)
459        message += self.action_url
460        return self.issue.create_comment(message)
461
462    def issue_remove_cherry_pick_failed_label(self):
463        if self.CHERRY_PICK_FAILED_LABEL in [l.name for l in self.issue.labels]:
464            self.issue.remove_from_labels(self.CHERRY_PICK_FAILED_LABEL)
465
466    def pr_request_review(self, pr: github.PullRequest.PullRequest):
467        """
468        This function will try to find the best reviewers for `commits` and
469        then add a comment requesting review of the backport and assign the
470        pull request to the selected reviewers.
471
472        The reviewers selected are those users who approved the patch in
473        Phabricator.
474        """
475        reviewers = []
476        for commit in pr.get_commits():
477            approvers = phab_get_commit_approvers(self.phab_token, commit)
478            for a in approvers:
479                login = phab_login_to_github_login(self.phab_token, self.repo, a)
480                if not login:
481                    continue
482                reviewers.append(login)
483        if len(reviewers):
484            message = "{} What do you think about merging this PR to the release branch?".format(
485                " ".join(["@" + r for r in reviewers])
486            )
487            pr.create_issue_comment(message)
488            pr.add_to_assignees(*reviewers)
489
490    def create_branch(self, commits: List[str]) -> bool:
491        """
492        This function attempts to backport `commits` into the branch associated
493        with `self.issue_number`.
494
495        If this is successful, then the branch is pushed to `self.branch_repo_name`, if not,
496        a comment is added to the issue saying that the cherry-pick failed.
497
498        :param list commits: List of commits to cherry-pick.
499
500        """
501        print("cherry-picking", commits)
502        branch_name = self.branch_name
503        local_repo = Repo(self.llvm_project_dir)
504        local_repo.git.checkout(self.release_branch_for_issue)
505
506        for c in commits:
507            try:
508                local_repo.git.cherry_pick("-x", c)
509            except Exception as e:
510                self.issue_notify_cherry_pick_failure(c)
511                raise e
512
513        push_url = self.push_url
514        print("Pushing to {} {}".format(push_url, branch_name))
515        local_repo.git.push(push_url, "HEAD:{}".format(branch_name), force=True)
516
517        self.issue_notify_branch()
518        self.issue_remove_cherry_pick_failed_label()
519        return True
520
521    def check_if_pull_request_exists(
522        self, repo: github.Repository.Repository, head: str
523    ) -> bool:
524        pulls = repo.get_pulls(head=head)
525        return pulls.totalCount != 0
526
527    def create_pull_request(self, owner: str, repo_name: str, branch: str) -> bool:
528        """
529        reate a pull request in `self.branch_repo_name`.  The base branch of the
530        pull request will be chosen based on the the milestone attached to
531        the issue represented by `self.issue_number`  For example if the milestone
532        is Release 13.0.1, then the base branch will be release/13.x. `branch`
533        will be used as the compare branch.
534        https://docs.github.com/en/get-started/quickstart/github-glossary#base-branch
535        https://docs.github.com/en/get-started/quickstart/github-glossary#compare-branch
536        """
537        repo = github.Github(self.token).get_repo(self.branch_repo_name)
538        issue_ref = "{}#{}".format(self.repo_name, self.issue_number)
539        pull = None
540        release_branch_for_issue = self.release_branch_for_issue
541        if release_branch_for_issue is None:
542            return False
543        head_branch = branch
544        if not repo.fork:
545            # If the target repo is not a fork of llvm-project, we need to copy
546            # the branch into the target repo.  GitHub only supports cross-repo pull
547            # requests on forked repos.
548            head_branch = f"{owner}-{branch}"
549            local_repo = Repo(self.llvm_project_dir)
550            push_done = False
551            for _ in range(0, 5):
552                try:
553                    local_repo.git.fetch(
554                        f"https://github.com/{owner}/{repo_name}", f"{branch}:{branch}"
555                    )
556                    local_repo.git.push(
557                        self.push_url, f"{branch}:{head_branch}", force=True
558                    )
559                    push_done = True
560                    break
561                except Exception as e:
562                    print(e)
563                    time.sleep(30)
564                    continue
565            if not push_done:
566                raise Exception("Failed to mirror branch into {}".format(self.push_url))
567            owner = repo.owner.login
568
569        head = f"{owner}:{head_branch}"
570        if self.check_if_pull_request_exists(repo, head):
571            print("PR already exists...")
572            return True
573        try:
574            pull = repo.create_pull(
575                title=f"PR for {issue_ref}",
576                body="resolves {}".format(issue_ref),
577                base=release_branch_for_issue,
578                head=head,
579                maintainer_can_modify=False,
580            )
581
582            try:
583                if self.phab_token:
584                    self.pr_request_review(pull)
585            except Exception as e:
586                print("error: Failed while searching for reviewers", e)
587
588        except Exception as e:
589            self.issue_notify_pull_request_failure(branch)
590            raise e
591
592        if pull is None:
593            return False
594
595        self.issue_notify_pull_request(pull)
596        self.issue_remove_cherry_pick_failed_label()
597
598        # TODO(tstellar): Do you really want to always return True?
599        return True
600
601    def execute_command(self) -> bool:
602        """
603        This function reads lines from STDIN and executes the first command
604        that it finds.  The 2 supported commands are:
605        /cherry-pick commit0 <commit1> <commit2> <...>
606        /branch <owner>/<repo>/<branch>
607        """
608        for line in sys.stdin:
609            line.rstrip()
610            m = re.search(r"/([a-z-]+)\s(.+)", line)
611            if not m:
612                continue
613            command = m.group(1)
614            args = m.group(2)
615
616            if command == "cherry-pick":
617                arg_list = args.split()
618                commits = list(map(lambda a: extract_commit_hash(a), arg_list))
619                return self.create_branch(commits)
620
621            if command == "branch":
622                m = re.match("([^/]+)/([^/]+)/(.+)", args)
623                if m:
624                    owner = m.group(1)
625                    repo = m.group(2)
626                    branch = m.group(3)
627                    return self.create_pull_request(owner, repo, branch)
628
629        print("Do not understand input:")
630        print(sys.stdin.readlines())
631        return False
632
633
634parser = argparse.ArgumentParser()
635parser.add_argument(
636    "--token", type=str, required=True, help="GitHub authentiation token"
637)
638parser.add_argument(
639    "--repo",
640    type=str,
641    default=os.getenv("GITHUB_REPOSITORY", "llvm/llvm-project"),
642    help="The GitHub repository that we are working with in the form of <owner>/<repo> (e.g. llvm/llvm-project)",
643)
644subparsers = parser.add_subparsers(dest="command")
645
646issue_subscriber_parser = subparsers.add_parser("issue-subscriber")
647issue_subscriber_parser.add_argument("--label-name", type=str, required=True)
648issue_subscriber_parser.add_argument("--issue-number", type=int, required=True)
649
650pr_subscriber_parser = subparsers.add_parser("pr-subscriber")
651pr_subscriber_parser.add_argument("--label-name", type=str, required=True)
652pr_subscriber_parser.add_argument("--issue-number", type=int, required=True)
653
654release_workflow_parser = subparsers.add_parser("release-workflow")
655release_workflow_parser.add_argument(
656    "--llvm-project-dir",
657    type=str,
658    default=".",
659    help="directory containing the llvm-project checout",
660)
661release_workflow_parser.add_argument(
662    "--issue-number", type=int, required=True, help="The issue number to update"
663)
664release_workflow_parser.add_argument(
665    "--phab-token",
666    type=str,
667    help="Phabricator conduit API token. See https://reviews.llvm.org/settings/user/<USER>/page/apitokens/",
668)
669release_workflow_parser.add_argument(
670    "--branch-repo-token",
671    type=str,
672    help="GitHub authentication token to use for the repository where new branches will be pushed. Defaults to TOKEN.",
673)
674release_workflow_parser.add_argument(
675    "--branch-repo",
676    type=str,
677    default="llvm/llvm-project-release-prs",
678    help="The name of the repo where new branches will be pushed (e.g. llvm/llvm-project)",
679)
680release_workflow_parser.add_argument(
681    "sub_command",
682    type=str,
683    choices=["print-release-branch", "auto"],
684    help="Print to stdout the name of the release branch ISSUE_NUMBER should be backported to",
685)
686
687llvmbot_git_config_parser = subparsers.add_parser(
688    "setup-llvmbot-git",
689    help="Set the default user and email for the git repo in LLVM_PROJECT_DIR to llvmbot",
690)
691
692args = parser.parse_args()
693
694if args.command == "issue-subscriber":
695    issue_subscriber = IssueSubscriber(
696        args.token, args.repo, args.issue_number, args.label_name
697    )
698    issue_subscriber.run()
699elif args.command == "pr-subscriber":
700    pr_subscriber = PRSubscriber(
701        args.token, args.repo, args.issue_number, args.label_name
702    )
703    pr_subscriber.run()
704elif args.command == "release-workflow":
705    release_workflow = ReleaseWorkflow(
706        args.token,
707        args.repo,
708        args.issue_number,
709        args.branch_repo,
710        args.branch_repo_token,
711        args.llvm_project_dir,
712        args.phab_token,
713    )
714    if not release_workflow.release_branch_for_issue:
715        release_workflow.issue_notify_no_milestone(sys.stdin.readlines())
716        sys.exit(1)
717    if args.sub_command == "print-release-branch":
718        release_workflow.print_release_branch()
719    else:
720        if not release_workflow.execute_command():
721            sys.exit(1)
722elif args.command == "setup-llvmbot-git":
723    setup_llvmbot_git()
724