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