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