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