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