xref: /llvm-project/llvm/utils/git/github-automation.py (revision f33e9276e2e2e34f7dd372f80612709e11d4b74f)
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
210class PRGreeter:
211    def __init__(self, token: str, repo: str, pr_number: int):
212        repo = github.Github(token).get_repo(repo)
213        self.pr = repo.get_issue(pr_number).as_pull_request()
214
215    def run(self) -> bool:
216        # We assume that this is only called for a PR that has just been opened
217        # by a user new to LLVM and/or GitHub itself.
218
219        # This text is using Markdown formatting.
220        comment = f"""\
221Thank you for submitting a Pull Request (PR) to the LLVM Project!
222
223This PR will be automatically labeled and the relevant teams will be
224notified.
225
226If you wish to, you can add reviewers by using the "Reviewers" section on this page.
227
228If this is not working for you, it is probably because you do not have write
229permissions for the repository. In which case you can instead tag reviewers by
230name in a comment by using `@` followed by their GitHub username.
231
232If you have received no comments on your PR for a week, you can request a review
233by "ping"ing the PR by adding a comment “Ping”. The common courtesy "ping" rate
234is once a week. Please remember that you are asking for valuable time from other developers.
235
236If you have further questions, they may be answered by the [LLVM GitHub User Guide](https://llvm.org/docs/GitHub.html).
237
238You can also ask questions in a comment on this PR, on the [LLVM Discord](https://discord.com/invite/xS7Z362) or on the [forums](https://discourse.llvm.org/)."""
239        self.pr.as_issue().create_comment(comment)
240        return True
241
242
243def setup_llvmbot_git(git_dir="."):
244    """
245    Configure the git repo in `git_dir` with the llvmbot account so
246    commits are attributed to llvmbot.
247    """
248    repo = Repo(git_dir)
249    with repo.config_writer() as config:
250        config.set_value("user", "name", "llvmbot")
251        config.set_value("user", "email", "llvmbot@llvm.org")
252
253
254def phab_api_call(phab_token: str, url: str, args: dict) -> dict:
255    """
256    Make an API call to the Phabricator web service and return a dictionary
257    containing the json response.
258    """
259    data = {"api.token": phab_token}
260    data.update(args)
261    response = requests.post(url, data=data)
262    return response.json()
263
264
265def phab_login_to_github_login(
266    phab_token: str, repo: github.Repository.Repository, phab_login: str
267) -> Optional[str]:
268    """
269    Tries to translate a Phabricator login to a github login by
270    finding a commit made in Phabricator's Differential.
271    The commit's SHA1 is then looked up in the github repo and
272    the committer's login associated with that commit is returned.
273
274    :param str phab_token: The Conduit API token to use for communication with Pabricator
275    :param github.Repository.Repository repo: The github repo to use when looking for the SHA1 found in Differential
276    :param str phab_login: The Phabricator login to be translated.
277    """
278
279    args = {
280        "constraints[authors][0]": phab_login,
281        # PHID for "LLVM Github Monorepo" repository
282        "constraints[repositories][0]": "PHID-REPO-f4scjekhnkmh7qilxlcy",
283        "limit": 1,
284    }
285    # API documentation: https://reviews.llvm.org/conduit/method/diffusion.commit.search/
286    r = phab_api_call(
287        phab_token, "https://reviews.llvm.org/api/diffusion.commit.search", args
288    )
289    data = r["result"]["data"]
290    if len(data) == 0:
291        # Can't find any commits associated with this user
292        return None
293
294    commit_sha = data[0]["fields"]["identifier"]
295    committer = repo.get_commit(commit_sha).committer
296    if not committer:
297        # This committer had an email address GitHub could not recognize, so
298        # it can't link the user to a GitHub account.
299        print(f"Warning: Can't find github account for {phab_login}")
300        return None
301    return committer.login
302
303
304def phab_get_commit_approvers(phab_token: str, commit: github.Commit.Commit) -> list:
305    args = {"corpus": commit.commit.message}
306    # API documentation: https://reviews.llvm.org/conduit/method/differential.parsecommitmessage/
307    r = phab_api_call(
308        phab_token, "https://reviews.llvm.org/api/differential.parsecommitmessage", args
309    )
310    review_id = r["result"]["revisionIDFieldInfo"]["value"]
311    if not review_id:
312        # No Phabricator revision for this commit
313        return []
314
315    args = {"constraints[ids][0]": review_id, "attachments[reviewers]": True}
316    # API documentation: https://reviews.llvm.org/conduit/method/differential.revision.search/
317    r = phab_api_call(
318        phab_token, "https://reviews.llvm.org/api/differential.revision.search", args
319    )
320    reviewers = r["result"]["data"][0]["attachments"]["reviewers"]["reviewers"]
321    accepted = []
322    for reviewer in reviewers:
323        if reviewer["status"] != "accepted":
324            continue
325        phid = reviewer["reviewerPHID"]
326        args = {"constraints[phids][0]": phid}
327        # API documentation: https://reviews.llvm.org/conduit/method/user.search/
328        r = phab_api_call(phab_token, "https://reviews.llvm.org/api/user.search", args)
329        accepted.append(r["result"]["data"][0]["fields"]["username"])
330    return accepted
331
332
333def extract_commit_hash(arg: str):
334    """
335    Extract the commit hash from the argument passed to /action github
336    comment actions. We currently only support passing the commit hash
337    directly or use the github URL, such as
338    https://github.com/llvm/llvm-project/commit/2832d7941f4207f1fcf813b27cf08cecc3086959
339    """
340    github_prefix = "https://github.com/llvm/llvm-project/commit/"
341    if arg.startswith(github_prefix):
342        return arg[len(github_prefix) :]
343    return arg
344
345
346class ReleaseWorkflow:
347    CHERRY_PICK_FAILED_LABEL = "release:cherry-pick-failed"
348
349    """
350    This class implements the sub-commands for the release-workflow command.
351    The current sub-commands are:
352        * create-branch
353        * create-pull-request
354
355    The execute_command method will automatically choose the correct sub-command
356    based on the text in stdin.
357    """
358
359    def __init__(
360        self,
361        token: str,
362        repo: str,
363        issue_number: int,
364        branch_repo_name: str,
365        branch_repo_token: str,
366        llvm_project_dir: str,
367        phab_token: str,
368    ) -> None:
369        self._token = token
370        self._repo_name = repo
371        self._issue_number = issue_number
372        self._branch_repo_name = branch_repo_name
373        if branch_repo_token:
374            self._branch_repo_token = branch_repo_token
375        else:
376            self._branch_repo_token = self.token
377        self._llvm_project_dir = llvm_project_dir
378        self._phab_token = phab_token
379
380    @property
381    def token(self) -> str:
382        return self._token
383
384    @property
385    def repo_name(self) -> str:
386        return self._repo_name
387
388    @property
389    def issue_number(self) -> int:
390        return self._issue_number
391
392    @property
393    def branch_repo_name(self) -> str:
394        return self._branch_repo_name
395
396    @property
397    def branch_repo_token(self) -> str:
398        return self._branch_repo_token
399
400    @property
401    def llvm_project_dir(self) -> str:
402        return self._llvm_project_dir
403
404    @property
405    def phab_token(self) -> str:
406        return self._phab_token
407
408    @property
409    def repo(self) -> github.Repository.Repository:
410        return github.Github(self.token).get_repo(self.repo_name)
411
412    @property
413    def issue(self) -> github.Issue.Issue:
414        return self.repo.get_issue(self.issue_number)
415
416    @property
417    def push_url(self) -> str:
418        return "https://{}@github.com/{}".format(
419            self.branch_repo_token, self.branch_repo_name
420        )
421
422    @property
423    def branch_name(self) -> str:
424        return "issue{}".format(self.issue_number)
425
426    @property
427    def release_branch_for_issue(self) -> Optional[str]:
428        issue = self.issue
429        milestone = issue.milestone
430        if milestone is None:
431            return None
432        m = re.search("branch: (.+)", milestone.description)
433        if m:
434            return m.group(1)
435        return None
436
437    def print_release_branch(self) -> None:
438        print(self.release_branch_for_issue)
439
440    def issue_notify_branch(self) -> None:
441        self.issue.create_comment(
442            "/branch {}/{}".format(self.branch_repo_name, self.branch_name)
443        )
444
445    def issue_notify_pull_request(self, pull: github.PullRequest.PullRequest) -> None:
446        self.issue.create_comment(
447            "/pull-request {}#{}".format(self.repo_name, pull.number)
448        )
449
450    def make_ignore_comment(self, comment: str) -> str:
451        """
452        Returns the comment string with a prefix that will cause
453        a Github workflow to skip parsing this comment.
454
455        :param str comment: The comment to ignore
456        """
457        return "<!--IGNORE-->\n" + comment
458
459    def issue_notify_no_milestone(self, comment: List[str]) -> None:
460        message = "{}\n\nError: Command failed due to missing milestone.".format(
461            "".join([">" + line for line in comment])
462        )
463        self.issue.create_comment(self.make_ignore_comment(message))
464
465    @property
466    def action_url(self) -> str:
467        if os.getenv("CI"):
468            return "https://github.com/{}/actions/runs/{}".format(
469                os.getenv("GITHUB_REPOSITORY"), os.getenv("GITHUB_RUN_ID")
470            )
471        return ""
472
473    def issue_notify_cherry_pick_failure(
474        self, commit: str
475    ) -> github.IssueComment.IssueComment:
476        message = self.make_ignore_comment(
477            "Failed to cherry-pick: {}\n\n".format(commit)
478        )
479        action_url = self.action_url
480        if action_url:
481            message += action_url + "\n\n"
482        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>`"
483        issue = self.issue
484        comment = issue.create_comment(message)
485        issue.add_to_labels(self.CHERRY_PICK_FAILED_LABEL)
486        return comment
487
488    def issue_notify_pull_request_failure(
489        self, branch: str
490    ) -> github.IssueComment.IssueComment:
491        message = "Failed to create pull request for {} ".format(branch)
492        message += self.action_url
493        return self.issue.create_comment(message)
494
495    def issue_remove_cherry_pick_failed_label(self):
496        if self.CHERRY_PICK_FAILED_LABEL in [l.name for l in self.issue.labels]:
497            self.issue.remove_from_labels(self.CHERRY_PICK_FAILED_LABEL)
498
499    def get_main_commit(self, cherry_pick_sha: str) -> github.Commit.Commit:
500        commit = self.repo.get_commit(cherry_pick_sha)
501        message = commit.commit.message
502        m = re.search("\(cherry picked from commit ([0-9a-f]+)\)", message)
503        if not m:
504            return None
505        return self.repo.get_commit(m.group(1))
506
507    def pr_request_review(self, pr: github.PullRequest.PullRequest):
508        """
509        This function will try to find the best reviewers for `commits` and
510        then add a comment requesting review of the backport and add them as
511        reviewers.
512
513        The reviewers selected are those users who approved the pull request
514        for the main branch.
515        """
516        reviewers = []
517        for commit in pr.get_commits():
518            main_commit = self.get_main_commit(commit.sha)
519            if not main_commit:
520                continue
521            for pull in main_commit.get_pulls():
522                for review in pull.get_reviews():
523                    if review.state != "APPROVED":
524                        continue
525                reviewers.append(review.user.login)
526        if len(reviewers):
527            message = "{} What do you think about merging this PR to the release branch?".format(
528                " ".join(["@" + r for r in reviewers])
529            )
530            pr.create_issue_comment(message)
531            pr.create_review_request(reviewers)
532
533    def create_branch(self, commits: List[str]) -> bool:
534        """
535        This function attempts to backport `commits` into the branch associated
536        with `self.issue_number`.
537
538        If this is successful, then the branch is pushed to `self.branch_repo_name`, if not,
539        a comment is added to the issue saying that the cherry-pick failed.
540
541        :param list commits: List of commits to cherry-pick.
542
543        """
544        print("cherry-picking", commits)
545        branch_name = self.branch_name
546        local_repo = Repo(self.llvm_project_dir)
547        local_repo.git.checkout(self.release_branch_for_issue)
548
549        for c in commits:
550            try:
551                local_repo.git.cherry_pick("-x", c)
552            except Exception as e:
553                self.issue_notify_cherry_pick_failure(c)
554                raise e
555
556        push_url = self.push_url
557        print("Pushing to {} {}".format(push_url, branch_name))
558        local_repo.git.push(push_url, "HEAD:{}".format(branch_name), force=True)
559
560        self.issue_notify_branch()
561        self.issue_remove_cherry_pick_failed_label()
562        return True
563
564    def check_if_pull_request_exists(
565        self, repo: github.Repository.Repository, head: str
566    ) -> bool:
567        pulls = repo.get_pulls(head=head)
568        return pulls.totalCount != 0
569
570    def create_pull_request(self, owner: str, repo_name: str, branch: str) -> bool:
571        """
572        Create a pull request in `self.repo_name`.  The base branch of the
573        pull request will be chosen based on the the milestone attached to
574        the issue represented by `self.issue_number`  For example if the milestone
575        is Release 13.0.1, then the base branch will be release/13.x. `branch`
576        will be used as the compare branch.
577        https://docs.github.com/en/get-started/quickstart/github-glossary#base-branch
578        https://docs.github.com/en/get-started/quickstart/github-glossary#compare-branch
579        """
580        repo = github.Github(self.token).get_repo(self.repo_name)
581        issue_ref = "{}#{}".format(self.repo_name, self.issue_number)
582        pull = None
583        release_branch_for_issue = self.release_branch_for_issue
584        if release_branch_for_issue is None:
585            return False
586        head_branch = branch
587        if not repo.fork:
588            # If the target repo is not a fork of llvm-project, we need to copy
589            # the branch into the target repo.  GitHub only supports cross-repo pull
590            # requests on forked repos.
591            head_branch = f"{owner}-{branch}"
592            local_repo = Repo(self.llvm_project_dir)
593            push_done = False
594            for _ in range(0, 5):
595                try:
596                    local_repo.git.fetch(
597                        f"https://github.com/{owner}/{repo_name}", f"{branch}:{branch}"
598                    )
599                    local_repo.git.push(
600                        self.push_url, f"{branch}:{head_branch}", force=True
601                    )
602                    push_done = True
603                    break
604                except Exception as e:
605                    print(e)
606                    time.sleep(30)
607                    continue
608            if not push_done:
609                raise Exception("Failed to mirror branch into {}".format(self.push_url))
610            owner = repo.owner.login
611
612        head = f"{owner}:{head_branch}"
613        if self.check_if_pull_request_exists(repo, head):
614            print("PR already exists...")
615            return True
616        try:
617            pull = repo.create_pull(
618                title=f"PR for {issue_ref}",
619                body="resolves {}".format(issue_ref),
620                base=release_branch_for_issue,
621                head=head,
622                maintainer_can_modify=False,
623            )
624
625            pull.as_issue().edit(milestone=self.issue.milestone)
626
627            try:
628                self.pr_request_review(pull)
629            except Exception as e:
630                print("error: Failed while searching for reviewers", e)
631
632        except Exception as e:
633            self.issue_notify_pull_request_failure(branch)
634            raise e
635
636        if pull is None:
637            return False
638
639        self.issue_notify_pull_request(pull)
640        self.issue_remove_cherry_pick_failed_label()
641
642        # TODO(tstellar): Do you really want to always return True?
643        return True
644
645    def execute_command(self) -> bool:
646        """
647        This function reads lines from STDIN and executes the first command
648        that it finds.  The 2 supported commands are:
649        /cherry-pick commit0 <commit1> <commit2> <...>
650        /branch <owner>/<repo>/<branch>
651        """
652        for line in sys.stdin:
653            line.rstrip()
654            m = re.search(r"/([a-z-]+)\s(.+)", line)
655            if not m:
656                continue
657            command = m.group(1)
658            args = m.group(2)
659
660            if command == "cherry-pick":
661                arg_list = args.split()
662                commits = list(map(lambda a: extract_commit_hash(a), arg_list))
663                return self.create_branch(commits)
664
665            if command == "branch":
666                m = re.match("([^/]+)/([^/]+)/(.+)", args)
667                if m:
668                    owner = m.group(1)
669                    repo = m.group(2)
670                    branch = m.group(3)
671                    return self.create_pull_request(owner, repo, branch)
672
673        print("Do not understand input:")
674        print(sys.stdin.readlines())
675        return False
676
677
678parser = argparse.ArgumentParser()
679parser.add_argument(
680    "--token", type=str, required=True, help="GitHub authentiation token"
681)
682parser.add_argument(
683    "--repo",
684    type=str,
685    default=os.getenv("GITHUB_REPOSITORY", "llvm/llvm-project"),
686    help="The GitHub repository that we are working with in the form of <owner>/<repo> (e.g. llvm/llvm-project)",
687)
688subparsers = parser.add_subparsers(dest="command")
689
690issue_subscriber_parser = subparsers.add_parser("issue-subscriber")
691issue_subscriber_parser.add_argument("--label-name", type=str, required=True)
692issue_subscriber_parser.add_argument("--issue-number", type=int, required=True)
693
694pr_subscriber_parser = subparsers.add_parser("pr-subscriber")
695pr_subscriber_parser.add_argument("--label-name", type=str, required=True)
696pr_subscriber_parser.add_argument("--issue-number", type=int, required=True)
697
698pr_greeter_parser = subparsers.add_parser("pr-greeter")
699pr_greeter_parser.add_argument("--issue-number", type=int, required=True)
700
701release_workflow_parser = subparsers.add_parser("release-workflow")
702release_workflow_parser.add_argument(
703    "--llvm-project-dir",
704    type=str,
705    default=".",
706    help="directory containing the llvm-project checout",
707)
708release_workflow_parser.add_argument(
709    "--issue-number", type=int, required=True, help="The issue number to update"
710)
711release_workflow_parser.add_argument(
712    "--phab-token",
713    type=str,
714    help="Phabricator conduit API token. See https://reviews.llvm.org/settings/user/<USER>/page/apitokens/",
715)
716release_workflow_parser.add_argument(
717    "--branch-repo-token",
718    type=str,
719    help="GitHub authentication token to use for the repository where new branches will be pushed. Defaults to TOKEN.",
720)
721release_workflow_parser.add_argument(
722    "--branch-repo",
723    type=str,
724    default="llvmbot/llvm-project",
725    help="The name of the repo where new branches will be pushed (e.g. llvm/llvm-project)",
726)
727release_workflow_parser.add_argument(
728    "sub_command",
729    type=str,
730    choices=["print-release-branch", "auto"],
731    help="Print to stdout the name of the release branch ISSUE_NUMBER should be backported to",
732)
733
734llvmbot_git_config_parser = subparsers.add_parser(
735    "setup-llvmbot-git",
736    help="Set the default user and email for the git repo in LLVM_PROJECT_DIR to llvmbot",
737)
738
739args = parser.parse_args()
740
741if args.command == "issue-subscriber":
742    issue_subscriber = IssueSubscriber(
743        args.token, args.repo, args.issue_number, args.label_name
744    )
745    issue_subscriber.run()
746elif args.command == "pr-subscriber":
747    pr_subscriber = PRSubscriber(
748        args.token, args.repo, args.issue_number, args.label_name
749    )
750    pr_subscriber.run()
751elif args.command == "pr-greeter":
752    pr_greeter = PRGreeter(args.token, args.repo, args.issue_number)
753    pr_greeter.run()
754elif args.command == "release-workflow":
755    release_workflow = ReleaseWorkflow(
756        args.token,
757        args.repo,
758        args.issue_number,
759        args.branch_repo,
760        args.branch_repo_token,
761        args.llvm_project_dir,
762        args.phab_token,
763    )
764    if not release_workflow.release_branch_for_issue:
765        release_workflow.issue_notify_no_milestone(sys.stdin.readlines())
766        sys.exit(1)
767    if args.sub_command == "print-release-branch":
768        release_workflow.print_release_branch()
769    else:
770        if not release_workflow.execute_command():
771            sys.exit(1)
772elif args.command == "setup-llvmbot-git":
773    setup_llvmbot_git()
774