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 github
14import os
15import re
16import sys
17from typing import *
18
19class IssueSubscriber:
20
21    @property
22    def team_name(self) -> str:
23        return self._team_name
24
25    def __init__(self, token:str, repo:str, issue_number:int, label_name:str):
26        self.repo = github.Github(token).get_repo(repo)
27        self.org = github.Github(token).get_organization(self.repo.organization.login)
28        self.issue = self.repo.get_issue(issue_number)
29        self._team_name = 'issue-subscribers-{}'.format(label_name).lower()
30
31    def run(self) -> bool:
32        for team in self.org.get_teams():
33            if self.team_name != team.name.lower():
34                continue
35            comment = '@llvm/{}'.format(team.slug)
36            self.issue.create_comment(comment)
37            return True
38        return False
39
40def setup_llvmbot_git(git_dir = '.'):
41    """
42    Configure the git repo in `git_dir` with the llvmbot account so
43    commits are attributed to llvmbot.
44    """
45    repo = Repo(git_dir)
46    with repo.config_writer() as config:
47        config.set_value('user', 'name', 'llvmbot')
48        config.set_value('user', 'email', '[email protected]')
49
50class ReleaseWorkflow:
51
52    CHERRY_PICK_FAILED_LABEL = 'release:cherry-pick-failed'
53
54    """
55    This class implements the sub-commands for the release-workflow command.
56    The current sub-commands are:
57        * create-branch
58        * create-pull-request
59
60    The execute_command method will automatically choose the correct sub-command
61    based on the text in stdin.
62    """
63
64    def __init__(self, token:str, repo:str, issue_number:int,
65                       branch_repo_name:str, branch_repo_token:str,
66                       llvm_project_dir:str) -> None:
67        self._token = token
68        self._repo_name = repo
69        self._issue_number = issue_number
70        self._branch_repo_name = branch_repo_name
71        if branch_repo_token:
72            self._branch_repo_token = branch_repo_token
73        else:
74            self._branch_repo_token = self.token
75        self._llvm_project_dir = llvm_project_dir
76
77    @property
78    def token(self) -> str:
79        return self._token
80
81    @property
82    def repo_name(self) -> str:
83        return self._repo_name
84
85    @property
86    def issue_number(self) -> int:
87        return self._issue_number
88
89    @property
90    def branch_repo_name(self) -> str:
91        return self._branch_repo_name
92
93    @property
94    def branch_repo_token(self) -> str:
95        return self._branch_repo_token
96
97    @property
98    def llvm_project_dir(self) -> str:
99        return self._llvm_project_dir
100
101    @property
102    def __repo(self) -> github.Repository.Repository:
103        return github.Github(self.token).get_repo(self.repo_name)
104
105    @property
106    def issue(self) -> github.Issue.Issue:
107        return self.__repo.get_issue(self.issue_number)
108
109    @property
110    def push_url(self) -> str:
111        return 'https://{}@github.com/{}'.format(self.branch_repo_token, self.branch_repo_name)
112
113    @property
114    def branch_name(self) -> str:
115        return 'issue{}'.format(self.issue_number)
116
117    @property
118    def release_branch_for_issue(self) -> Optional[str]:
119        issue = self.issue
120        milestone = issue.milestone
121        if milestone is None:
122            return None
123        m = re.search('branch: (.+)',milestone.description)
124        if m:
125            return m.group(1)
126        return None
127
128    def print_release_branch(self) -> None:
129        print(self.release_branch_for_issue)
130
131    def issue_notify_branch(self) -> None:
132        self.issue.create_comment('/branch {}/{}'.format(self.branch_repo_name, self.branch_name))
133
134    def issue_notify_pull_request(self, pull:github.PullRequest.PullRequest) -> None:
135        self.issue.create_comment('/pull-request {}#{}'.format(self.branch_repo_name, pull.number))
136
137    def make_ignore_comment(self, comment: str) -> str:
138        """
139        Returns the comment string with a prefix that will cause
140        a Github workflow to skip parsing this comment.
141
142        :param str comment: The comment to ignore
143        """
144        return "<!--IGNORE-->\n"+comment
145
146    def issue_notify_no_milestone(self, comment:List[str]) -> None:
147        message = "{}\n\nError: Command failed due to missing milestone.".format(''.join(['>' + line for line in comment]))
148        self.issue.create_comment(self.make_ignore_comment(message))
149
150    @property
151    def action_url(self) -> str:
152        if os.getenv('CI'):
153            return 'https://github.com/{}/actions/runs/{}'.format(os.getenv('GITHUB_REPOSITORY'), os.getenv('GITHUB_RUN_ID'))
154        return ""
155
156    def issue_notify_cherry_pick_failure(self, commit:str) -> github.IssueComment.IssueComment:
157        message = self.make_ignore_comment("Failed to cherry-pick: {}\n\n".format(commit))
158        action_url = self.action_url
159        if action_url:
160            message += action_url + "\n\n"
161        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>`"
162        issue = self.issue
163        comment = issue.create_comment(message)
164        issue.add_to_labels(self.CHERRY_PICK_FAILED_LABEL)
165        return comment
166
167    def issue_notify_pull_request_failure(self, branch:str) -> github.IssueComment.IssueComment:
168        message = "Failed to create pull request for {} ".format(branch)
169        message += self.action_url
170        return self.issue.create_comment(message)
171
172    def issue_remove_cherry_pick_failed_label(self):
173        if self.CHERRY_PICK_FAILED_LABEL in [l.name for l in self.issue.labels]:
174            self.issue.remove_from_labels(self.CHERRY_PICK_FAILED_LABEL)
175
176    def create_branch(self, commits:List[str]) -> bool:
177        """
178        This function attempts to backport `commits` into the branch associated
179        with `self.issue_number`.
180
181        If this is successful, then the branch is pushed to `self.branch_repo_name`, if not,
182        a comment is added to the issue saying that the cherry-pick failed.
183
184        :param list commits: List of commits to cherry-pick.
185
186        """
187        print('cherry-picking', commits)
188        branch_name = self.branch_name
189        local_repo = Repo(self.llvm_project_dir)
190        local_repo.git.checkout(self.release_branch_for_issue)
191
192        for c in commits:
193            try:
194                local_repo.git.cherry_pick('-x', c)
195            except Exception as e:
196                self.issue_notify_cherry_pick_failure(c)
197                raise e
198
199        push_url = self.push_url
200        print('Pushing to {} {}'.format(push_url, branch_name))
201        local_repo.git.push(push_url, 'HEAD:{}'.format(branch_name), force=True)
202
203        self.issue_notify_branch()
204        self.issue_remove_cherry_pick_failed_label()
205        return True
206
207    def check_if_pull_request_exists(self, repo:github.Repository.Repository, head:str) -> bool:
208        pulls = repo.get_pulls(head=head)
209        return pulls.totalCount != 0
210
211    def create_pull_request(self, owner:str, branch:str) -> bool:
212        """
213        reate a pull request in `self.branch_repo_name`.  The base branch of the
214        pull request will be choosen based on the the milestone attached to
215        the issue represented by `self.issue_number`  For example if the milestone
216        is Release 13.0.1, then the base branch will be release/13.x. `branch`
217        will be used as the compare branch.
218        https://docs.github.com/en/get-started/quickstart/github-glossary#base-branch
219        https://docs.github.com/en/get-started/quickstart/github-glossary#compare-branch
220        """
221        repo = github.Github(self.token).get_repo(self.branch_repo_name)
222        issue_ref = '{}#{}'.format(self.repo_name, self.issue_number)
223        pull = None
224        release_branch_for_issue = self.release_branch_for_issue
225        if release_branch_for_issue is None:
226            return False
227        head = f"{owner}:{branch}"
228        if self.check_if_pull_request_exists(repo, head):
229            print("PR already exists...")
230            return True
231        try:
232            pull = repo.create_pull(title=f"PR for {issue_ref}",
233                                    body='resolves {}'.format(issue_ref),
234                                    base=release_branch_for_issue,
235                                    head=head,
236                                    maintainer_can_modify=False)
237        except Exception as e:
238            self.issue_notify_pull_request_failure(branch)
239            raise e
240
241        if pull is None:
242            return False
243
244        self.issue_notify_pull_request(pull)
245        self.issue_remove_cherry_pick_failed_label()
246
247        # TODO(tstellar): Do you really want to always return True?
248        return True
249
250
251    def execute_command(self) -> bool:
252        """
253        This function reads lines from STDIN and executes the first command
254        that it finds.  The 2 supported commands are:
255        /cherry-pick commit0 <commit1> <commit2> <...>
256        /branch <owner>/<repo>/<branch>
257        """
258        for line in sys.stdin:
259            line.rstrip()
260            m = re.search("/([a-z-]+)\s(.+)", line)
261            if not m:
262                continue
263            command = m.group(1)
264            args = m.group(2)
265
266            if command == 'cherry-pick':
267                return self.create_branch(args.split())
268
269            if command == 'branch':
270                m = re.match('([^/]+)/([^/]+)/(.+)', args)
271                if m:
272                    owner = m.group(1)
273                    branch = m.group(3)
274                    return self.create_pull_request(owner, branch)
275
276        print("Do not understand input:")
277        print(sys.stdin.readlines())
278        return False
279
280parser = argparse.ArgumentParser()
281parser.add_argument('--token', type=str, required=True, help='GitHub authentiation token')
282parser.add_argument('--repo', type=str, default=os.getenv('GITHUB_REPOSITORY', 'llvm/llvm-project'),
283                    help='The GitHub repository that we are working with in the form of <owner>/<repo> (e.g. llvm/llvm-project)')
284subparsers = parser.add_subparsers(dest='command')
285
286issue_subscriber_parser = subparsers.add_parser('issue-subscriber')
287issue_subscriber_parser.add_argument('--label-name', type=str, required=True)
288issue_subscriber_parser.add_argument('--issue-number', type=int, required=True)
289
290release_workflow_parser = subparsers.add_parser('release-workflow')
291release_workflow_parser.add_argument('--llvm-project-dir', type=str, default='.', help='directory containing the llvm-project checout')
292release_workflow_parser.add_argument('--issue-number', type=int, required=True, help='The issue number to update')
293release_workflow_parser.add_argument('--branch-repo-token', type=str,
294                                     help='GitHub authentication token to use for the repository where new branches will be pushed. Defaults to TOKEN.')
295release_workflow_parser.add_argument('--branch-repo', type=str, default='llvm/llvm-project-release-prs',
296                                     help='The name of the repo where new branches will be pushed (e.g. llvm/llvm-project)')
297release_workflow_parser.add_argument('sub_command', type=str, choices=['print-release-branch', 'auto'],
298                                     help='Print to stdout the name of the release branch ISSUE_NUMBER should be backported to')
299
300llvmbot_git_config_parser = subparsers.add_parser('setup-llvmbot-git', help='Set the default user and email for the git repo in LLVM_PROJECT_DIR to llvmbot')
301
302args = parser.parse_args()
303
304if args.command == 'issue-subscriber':
305    issue_subscriber = IssueSubscriber(args.token, args.repo, args.issue_number, args.label_name)
306    issue_subscriber.run()
307elif args.command == 'release-workflow':
308    release_workflow = ReleaseWorkflow(args.token, args.repo, args.issue_number,
309                                       args.branch_repo, args.branch_repo_token,
310                                       args.llvm_project_dir)
311    if not release_workflow.release_branch_for_issue:
312        release_workflow.issue_notify_no_milestone(sys.stdin.readlines())
313        sys.exit(1)
314    if args.sub_command == 'print-release-branch':
315        release_workflow.print_release_branch()
316    else:
317        if not release_workflow.execute_command():
318            sys.exit(1)
319elif args.command == 'setup-llvmbot-git':
320    setup_llvmbot_git()
321