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