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