1a2adebf4STom Stellard#!/usr/bin/env python3
2a2adebf4STom Stellard#
3a2adebf4STom Stellard# ======- github-automation - LLVM GitHub Automation Routines--*- python -*--==#
4a2adebf4STom Stellard#
5a2adebf4STom Stellard# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
6a2adebf4STom Stellard# See https://llvm.org/LICENSE.txt for license information.
7a2adebf4STom Stellard# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
8a2adebf4STom Stellard#
9a2adebf4STom Stellard# ==-------------------------------------------------------------------------==#
10a2adebf4STom Stellard
11a2adebf4STom Stellardimport argparse
12daf82a51STom Stellardfrom git import Repo # type: ignore
13a2adebf4STom Stellardimport github
14a2adebf4STom Stellardimport os
15daf82a51STom Stellardimport re
16daf82a51STom Stellardimport sys
17*17d4796cSTom Stellardimport time
18daf82a51STom Stellardfrom typing import *
19a2adebf4STom Stellard
20a2adebf4STom Stellardclass IssueSubscriber:
21a2adebf4STom Stellard
22a2adebf4STom Stellard    @property
23a2adebf4STom Stellard    def team_name(self) -> str:
24a2adebf4STom Stellard        return self._team_name
25a2adebf4STom Stellard
26a2adebf4STom Stellard    def __init__(self, token:str, repo:str, issue_number:int, label_name:str):
27a2adebf4STom Stellard        self.repo = github.Github(token).get_repo(repo)
28a2adebf4STom Stellard        self.org = github.Github(token).get_organization(self.repo.organization.login)
29a2adebf4STom Stellard        self.issue = self.repo.get_issue(issue_number)
30a2adebf4STom Stellard        self._team_name = 'issue-subscribers-{}'.format(label_name).lower()
31a2adebf4STom Stellard
32a2adebf4STom Stellard    def run(self) -> bool:
33a2adebf4STom Stellard        for team in self.org.get_teams():
34a2adebf4STom Stellard            if self.team_name != team.name.lower():
35a2adebf4STom Stellard                continue
36a2adebf4STom Stellard            comment = '@llvm/{}'.format(team.slug)
37a2adebf4STom Stellard            self.issue.create_comment(comment)
38a2adebf4STom Stellard            return True
39a2adebf4STom Stellard        return False
40a2adebf4STom Stellard
41daf82a51STom Stellarddef setup_llvmbot_git(git_dir = '.'):
42daf82a51STom Stellard    """
43daf82a51STom Stellard    Configure the git repo in `git_dir` with the llvmbot account so
44daf82a51STom Stellard    commits are attributed to llvmbot.
45daf82a51STom Stellard    """
46daf82a51STom Stellard    repo = Repo(git_dir)
47daf82a51STom Stellard    with repo.config_writer() as config:
48daf82a51STom Stellard        config.set_value('user', 'name', 'llvmbot')
49daf82a51STom Stellard        config.set_value('user', 'email', '[email protected]')
50daf82a51STom Stellard
51daf82a51STom Stellardclass ReleaseWorkflow:
52daf82a51STom Stellard
533929f913STom Stellard    CHERRY_PICK_FAILED_LABEL = 'release:cherry-pick-failed'
543929f913STom Stellard
55daf82a51STom Stellard    """
56daf82a51STom Stellard    This class implements the sub-commands for the release-workflow command.
57daf82a51STom Stellard    The current sub-commands are:
58daf82a51STom Stellard        * create-branch
59daf82a51STom Stellard        * create-pull-request
60daf82a51STom Stellard
61daf82a51STom Stellard    The execute_command method will automatically choose the correct sub-command
62daf82a51STom Stellard    based on the text in stdin.
63daf82a51STom Stellard    """
64daf82a51STom Stellard
65daf82a51STom Stellard    def __init__(self, token:str, repo:str, issue_number:int,
66daf82a51STom Stellard                       branch_repo_name:str, branch_repo_token:str,
67daf82a51STom Stellard                       llvm_project_dir:str) -> None:
68daf82a51STom Stellard        self._token = token
69daf82a51STom Stellard        self._repo_name = repo
70daf82a51STom Stellard        self._issue_number = issue_number
71daf82a51STom Stellard        self._branch_repo_name = branch_repo_name
72daf82a51STom Stellard        if branch_repo_token:
73daf82a51STom Stellard            self._branch_repo_token = branch_repo_token
74daf82a51STom Stellard        else:
75daf82a51STom Stellard            self._branch_repo_token = self.token
76daf82a51STom Stellard        self._llvm_project_dir = llvm_project_dir
77daf82a51STom Stellard
78daf82a51STom Stellard    @property
79daf82a51STom Stellard    def token(self) -> str:
80daf82a51STom Stellard        return self._token
81daf82a51STom Stellard
82daf82a51STom Stellard    @property
83daf82a51STom Stellard    def repo_name(self) -> str:
84daf82a51STom Stellard        return self._repo_name
85daf82a51STom Stellard
86daf82a51STom Stellard    @property
87daf82a51STom Stellard    def issue_number(self) -> int:
88daf82a51STom Stellard        return self._issue_number
89daf82a51STom Stellard
90daf82a51STom Stellard    @property
91daf82a51STom Stellard    def branch_repo_name(self) -> str:
92daf82a51STom Stellard        return self._branch_repo_name
93daf82a51STom Stellard
94daf82a51STom Stellard    @property
95daf82a51STom Stellard    def branch_repo_token(self) -> str:
96daf82a51STom Stellard        return self._branch_repo_token
97daf82a51STom Stellard
98daf82a51STom Stellard    @property
99daf82a51STom Stellard    def llvm_project_dir(self) -> str:
100daf82a51STom Stellard        return self._llvm_project_dir
101daf82a51STom Stellard
102daf82a51STom Stellard    @property
103daf82a51STom Stellard    def __repo(self) -> github.Repository.Repository:
104daf82a51STom Stellard        return github.Github(self.token).get_repo(self.repo_name)
105daf82a51STom Stellard
106daf82a51STom Stellard    @property
107daf82a51STom Stellard    def issue(self) -> github.Issue.Issue:
108daf82a51STom Stellard        return self.__repo.get_issue(self.issue_number)
109daf82a51STom Stellard
110daf82a51STom Stellard    @property
111daf82a51STom Stellard    def push_url(self) -> str:
112daf82a51STom Stellard        return 'https://{}@github.com/{}'.format(self.branch_repo_token, self.branch_repo_name)
113daf82a51STom Stellard
114daf82a51STom Stellard    @property
115daf82a51STom Stellard    def branch_name(self) -> str:
116daf82a51STom Stellard        return 'issue{}'.format(self.issue_number)
117daf82a51STom Stellard
118daf82a51STom Stellard    @property
119daf82a51STom Stellard    def release_branch_for_issue(self) -> Optional[str]:
120daf82a51STom Stellard        issue = self.issue
121daf82a51STom Stellard        milestone = issue.milestone
122daf82a51STom Stellard        if milestone is None:
123daf82a51STom Stellard            return None
124daf82a51STom Stellard        m = re.search('branch: (.+)',milestone.description)
125daf82a51STom Stellard        if m:
126daf82a51STom Stellard            return m.group(1)
127daf82a51STom Stellard        return None
128daf82a51STom Stellard
129daf82a51STom Stellard    def print_release_branch(self) -> None:
130daf82a51STom Stellard        print(self.release_branch_for_issue)
131daf82a51STom Stellard
132daf82a51STom Stellard    def issue_notify_branch(self) -> None:
133daf82a51STom Stellard        self.issue.create_comment('/branch {}/{}'.format(self.branch_repo_name, self.branch_name))
134daf82a51STom Stellard
135daf82a51STom Stellard    def issue_notify_pull_request(self, pull:github.PullRequest.PullRequest) -> None:
136daf82a51STom Stellard        self.issue.create_comment('/pull-request {}#{}'.format(self.branch_repo_name, pull.number))
137daf82a51STom Stellard
13851eaaa30STom Stellard    def make_ignore_comment(self, comment: str) -> str:
13951eaaa30STom Stellard        """
14051eaaa30STom Stellard        Returns the comment string with a prefix that will cause
14151eaaa30STom Stellard        a Github workflow to skip parsing this comment.
14251eaaa30STom Stellard
14351eaaa30STom Stellard        :param str comment: The comment to ignore
14451eaaa30STom Stellard        """
14551eaaa30STom Stellard        return "<!--IGNORE-->\n"+comment
14651eaaa30STom Stellard
14751eaaa30STom Stellard    def issue_notify_no_milestone(self, comment:List[str]) -> None:
14851eaaa30STom Stellard        message = "{}\n\nError: Command failed due to missing milestone.".format(''.join(['>' + line for line in comment]))
14951eaaa30STom Stellard        self.issue.create_comment(self.make_ignore_comment(message))
15051eaaa30STom Stellard
151daf82a51STom Stellard    @property
152daf82a51STom Stellard    def action_url(self) -> str:
153daf82a51STom Stellard        if os.getenv('CI'):
154daf82a51STom Stellard            return 'https://github.com/{}/actions/runs/{}'.format(os.getenv('GITHUB_REPOSITORY'), os.getenv('GITHUB_RUN_ID'))
155daf82a51STom Stellard        return ""
156daf82a51STom Stellard
157daf82a51STom Stellard    def issue_notify_cherry_pick_failure(self, commit:str) -> github.IssueComment.IssueComment:
15851eaaa30STom Stellard        message = self.make_ignore_comment("Failed to cherry-pick: {}\n\n".format(commit))
159daf82a51STom Stellard        action_url = self.action_url
160daf82a51STom Stellard        if action_url:
161daf82a51STom Stellard            message += action_url + "\n\n"
162daf82a51STom Stellard        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>`"
163daf82a51STom Stellard        issue = self.issue
164daf82a51STom Stellard        comment = issue.create_comment(message)
1653929f913STom Stellard        issue.add_to_labels(self.CHERRY_PICK_FAILED_LABEL)
166daf82a51STom Stellard        return comment
167daf82a51STom Stellard
168daf82a51STom Stellard    def issue_notify_pull_request_failure(self, branch:str) -> github.IssueComment.IssueComment:
169daf82a51STom Stellard        message = "Failed to create pull request for {} ".format(branch)
170daf82a51STom Stellard        message += self.action_url
171daf82a51STom Stellard        return self.issue.create_comment(message)
172daf82a51STom Stellard
1733929f913STom Stellard    def issue_remove_cherry_pick_failed_label(self):
1743929f913STom Stellard        if self.CHERRY_PICK_FAILED_LABEL in [l.name for l in self.issue.labels]:
1753929f913STom Stellard            self.issue.remove_from_labels(self.CHERRY_PICK_FAILED_LABEL)
176daf82a51STom Stellard
177daf82a51STom Stellard    def create_branch(self, commits:List[str]) -> bool:
178daf82a51STom Stellard        """
179daf82a51STom Stellard        This function attempts to backport `commits` into the branch associated
180daf82a51STom Stellard        with `self.issue_number`.
181daf82a51STom Stellard
182daf82a51STom Stellard        If this is successful, then the branch is pushed to `self.branch_repo_name`, if not,
183daf82a51STom Stellard        a comment is added to the issue saying that the cherry-pick failed.
184daf82a51STom Stellard
185daf82a51STom Stellard        :param list commits: List of commits to cherry-pick.
186daf82a51STom Stellard
187daf82a51STom Stellard        """
188daf82a51STom Stellard        print('cherry-picking', commits)
189daf82a51STom Stellard        branch_name = self.branch_name
190daf82a51STom Stellard        local_repo = Repo(self.llvm_project_dir)
191daf82a51STom Stellard        local_repo.git.checkout(self.release_branch_for_issue)
192daf82a51STom Stellard
193daf82a51STom Stellard        for c in commits:
194daf82a51STom Stellard            try:
195daf82a51STom Stellard                local_repo.git.cherry_pick('-x', c)
196daf82a51STom Stellard            except Exception as e:
197daf82a51STom Stellard                self.issue_notify_cherry_pick_failure(c)
198daf82a51STom Stellard                raise e
199daf82a51STom Stellard
200daf82a51STom Stellard        push_url = self.push_url
201daf82a51STom Stellard        print('Pushing to {} {}'.format(push_url, branch_name))
2021889170aSTobias Hieta        local_repo.git.push(push_url, 'HEAD:{}'.format(branch_name), force=True)
203daf82a51STom Stellard
204daf82a51STom Stellard        self.issue_notify_branch()
2053929f913STom Stellard        self.issue_remove_cherry_pick_failed_label()
206daf82a51STom Stellard        return True
207daf82a51STom Stellard
20849ad577cSTobias Hieta    def check_if_pull_request_exists(self, repo:github.Repository.Repository, head:str) -> bool:
20949ad577cSTobias Hieta        pulls = repo.get_pulls(head=head)
210fde9ef52STobias Hieta        return pulls.totalCount != 0
211daf82a51STom Stellard
212daf82a51STom Stellard    def create_pull_request(self, owner:str, branch:str) -> bool:
213daf82a51STom Stellard        """
214daf82a51STom Stellard        reate a pull request in `self.branch_repo_name`.  The base branch of the
215daf82a51STom Stellard        pull request will be choosen based on the the milestone attached to
216daf82a51STom Stellard        the issue represented by `self.issue_number`  For example if the milestone
217daf82a51STom Stellard        is Release 13.0.1, then the base branch will be release/13.x. `branch`
218daf82a51STom Stellard        will be used as the compare branch.
219daf82a51STom Stellard        https://docs.github.com/en/get-started/quickstart/github-glossary#base-branch
220daf82a51STom Stellard        https://docs.github.com/en/get-started/quickstart/github-glossary#compare-branch
221daf82a51STom Stellard        """
222daf82a51STom Stellard        repo = github.Github(self.token).get_repo(self.branch_repo_name)
223daf82a51STom Stellard        issue_ref = '{}#{}'.format(self.repo_name, self.issue_number)
224daf82a51STom Stellard        pull = None
225daf82a51STom Stellard        release_branch_for_issue = self.release_branch_for_issue
226daf82a51STom Stellard        if release_branch_for_issue is None:
227daf82a51STom Stellard            return False
228*17d4796cSTom Stellard        head_branch = branch
229*17d4796cSTom Stellard        if not repo.fork:
230*17d4796cSTom Stellard            # If the target repo is not a fork of llvm-project, we need to copy
231*17d4796cSTom Stellard            # the branch into the target repo.  GitHub only supports cross-repo pull
232*17d4796cSTom Stellard            # requests on forked repos.
233*17d4796cSTom Stellard            head_branch = f'{owner}-{branch}'
234*17d4796cSTom Stellard            local_repo = Repo(self.llvm_project_dir)
235*17d4796cSTom Stellard            push_done = False
236*17d4796cSTom Stellard            for i in range(0,5):
237*17d4796cSTom Stellard                try:
238*17d4796cSTom Stellard                    local_repo.git.fetch(f'https://github.com/{owner}/llvm-project', f'{branch}:{branch}')
239*17d4796cSTom Stellard                    local_repo.git.push(self.push_url, f'{branch}:{head_branch}', force=True)
240*17d4796cSTom Stellard                    push_done = True
241*17d4796cSTom Stellard                    break
242*17d4796cSTom Stellard                except Exception as e:
243*17d4796cSTom Stellard                    print(e)
244*17d4796cSTom Stellard                    time.sleep(30)
245*17d4796cSTom Stellard                    continue
246*17d4796cSTom Stellard            if not push_done:
247*17d4796cSTom Stellard                raise Exception("Failed to mirror branch into {}".format(self.push_url))
248*17d4796cSTom Stellard            owner = repo.owner.login
249*17d4796cSTom Stellard
250*17d4796cSTom Stellard        head = f"{owner}:{head_branch}"
25149ad577cSTobias Hieta        if self.check_if_pull_request_exists(repo, head):
25249ad577cSTobias Hieta            print("PR already exists...")
25349ad577cSTobias Hieta            return True
254daf82a51STom Stellard        try:
25549ad577cSTobias Hieta            pull = repo.create_pull(title=f"PR for {issue_ref}",
256daf82a51STom Stellard                                    body='resolves {}'.format(issue_ref),
257daf82a51STom Stellard                                    base=release_branch_for_issue,
25849ad577cSTobias Hieta                                    head=head,
259daf82a51STom Stellard                                    maintainer_can_modify=False)
260daf82a51STom Stellard        except Exception as e:
261daf82a51STom Stellard            self.issue_notify_pull_request_failure(branch)
262daf82a51STom Stellard            raise e
263daf82a51STom Stellard
264daf82a51STom Stellard        if pull is None:
265daf82a51STom Stellard            return False
266daf82a51STom Stellard
267daf82a51STom Stellard        self.issue_notify_pull_request(pull)
2683929f913STom Stellard        self.issue_remove_cherry_pick_failed_label()
269daf82a51STom Stellard
270daf82a51STom Stellard        # TODO(tstellar): Do you really want to always return True?
271daf82a51STom Stellard        return True
272daf82a51STom Stellard
273daf82a51STom Stellard
274daf82a51STom Stellard    def execute_command(self) -> bool:
275daf82a51STom Stellard        """
276daf82a51STom Stellard        This function reads lines from STDIN and executes the first command
277daf82a51STom Stellard        that it finds.  The 2 supported commands are:
278daf82a51STom Stellard        /cherry-pick commit0 <commit1> <commit2> <...>
279daf82a51STom Stellard        /branch <owner>/<repo>/<branch>
280daf82a51STom Stellard        """
281daf82a51STom Stellard        for line in sys.stdin:
282daf82a51STom Stellard            line.rstrip()
283daf82a51STom Stellard            m = re.search("/([a-z-]+)\s(.+)", line)
284daf82a51STom Stellard            if not m:
285daf82a51STom Stellard                continue
286daf82a51STom Stellard            command = m.group(1)
287daf82a51STom Stellard            args = m.group(2)
288daf82a51STom Stellard
289daf82a51STom Stellard            if command == 'cherry-pick':
290daf82a51STom Stellard                return self.create_branch(args.split())
291daf82a51STom Stellard
292daf82a51STom Stellard            if command == 'branch':
293daf82a51STom Stellard                m = re.match('([^/]+)/([^/]+)/(.+)', args)
294daf82a51STom Stellard                if m:
295daf82a51STom Stellard                    owner = m.group(1)
296daf82a51STom Stellard                    branch = m.group(3)
297daf82a51STom Stellard                    return self.create_pull_request(owner, branch)
298daf82a51STom Stellard
299daf82a51STom Stellard        print("Do not understand input:")
300daf82a51STom Stellard        print(sys.stdin.readlines())
301daf82a51STom Stellard        return False
302a2adebf4STom Stellard
303a2adebf4STom Stellardparser = argparse.ArgumentParser()
304daf82a51STom Stellardparser.add_argument('--token', type=str, required=True, help='GitHub authentiation token')
305daf82a51STom Stellardparser.add_argument('--repo', type=str, default=os.getenv('GITHUB_REPOSITORY', 'llvm/llvm-project'),
306daf82a51STom Stellard                    help='The GitHub repository that we are working with in the form of <owner>/<repo> (e.g. llvm/llvm-project)')
307a2adebf4STom Stellardsubparsers = parser.add_subparsers(dest='command')
308a2adebf4STom Stellard
309a2adebf4STom Stellardissue_subscriber_parser = subparsers.add_parser('issue-subscriber')
310a2adebf4STom Stellardissue_subscriber_parser.add_argument('--label-name', type=str, required=True)
311a2adebf4STom Stellardissue_subscriber_parser.add_argument('--issue-number', type=int, required=True)
312a2adebf4STom Stellard
313daf82a51STom Stellardrelease_workflow_parser = subparsers.add_parser('release-workflow')
314daf82a51STom Stellardrelease_workflow_parser.add_argument('--llvm-project-dir', type=str, default='.', help='directory containing the llvm-project checout')
315daf82a51STom Stellardrelease_workflow_parser.add_argument('--issue-number', type=int, required=True, help='The issue number to update')
316daf82a51STom Stellardrelease_workflow_parser.add_argument('--branch-repo-token', type=str,
317daf82a51STom Stellard                                     help='GitHub authentication token to use for the repository where new branches will be pushed. Defaults to TOKEN.')
31821c060c4STom Stellardrelease_workflow_parser.add_argument('--branch-repo', type=str, default='llvm/llvm-project-release-prs',
319daf82a51STom Stellard                                     help='The name of the repo where new branches will be pushed (e.g. llvm/llvm-project)')
320daf82a51STom Stellardrelease_workflow_parser.add_argument('sub_command', type=str, choices=['print-release-branch', 'auto'],
321daf82a51STom Stellard                                     help='Print to stdout the name of the release branch ISSUE_NUMBER should be backported to')
322daf82a51STom Stellard
323daf82a51STom Stellardllvmbot_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')
324daf82a51STom Stellard
325a2adebf4STom Stellardargs = parser.parse_args()
326a2adebf4STom Stellard
327a2adebf4STom Stellardif args.command == 'issue-subscriber':
328a2adebf4STom Stellard    issue_subscriber = IssueSubscriber(args.token, args.repo, args.issue_number, args.label_name)
329a2adebf4STom Stellard    issue_subscriber.run()
330daf82a51STom Stellardelif args.command == 'release-workflow':
331daf82a51STom Stellard    release_workflow = ReleaseWorkflow(args.token, args.repo, args.issue_number,
332daf82a51STom Stellard                                       args.branch_repo, args.branch_repo_token,
333daf82a51STom Stellard                                       args.llvm_project_dir)
33451eaaa30STom Stellard    if not release_workflow.release_branch_for_issue:
33551eaaa30STom Stellard        release_workflow.issue_notify_no_milestone(sys.stdin.readlines())
33651eaaa30STom Stellard        sys.exit(1)
337daf82a51STom Stellard    if args.sub_command == 'print-release-branch':
338daf82a51STom Stellard        release_workflow.print_release_branch()
339daf82a51STom Stellard    else:
340daf82a51STom Stellard        if not release_workflow.execute_command():
341daf82a51STom Stellard            sys.exit(1)
342daf82a51STom Stellardelif args.command == 'setup-llvmbot-git':
343daf82a51STom Stellard    setup_llvmbot_git()
344