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 @property 138 def action_url(self) -> str: 139 if os.getenv('CI'): 140 return 'https://github.com/{}/actions/runs/{}'.format(os.getenv('GITHUB_REPOSITORY'), os.getenv('GITHUB_RUN_ID')) 141 return "" 142 143 def issue_notify_cherry_pick_failure(self, commit:str) -> github.IssueComment.IssueComment: 144 message = "<!--IGNORE-->\nFailed to cherry-pick: {}\n\n".format(commit) 145 action_url = self.action_url 146 if action_url: 147 message += action_url + "\n\n" 148 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>`" 149 issue = self.issue 150 comment = issue.create_comment(message) 151 issue.add_to_labels(self.CHERRY_PICK_FAILED_LABEL) 152 return comment 153 154 def issue_notify_pull_request_failure(self, branch:str) -> github.IssueComment.IssueComment: 155 message = "Failed to create pull request for {} ".format(branch) 156 message += self.action_url 157 return self.issue.create_comment(message) 158 159 def issue_remove_cherry_pick_failed_label(self): 160 if self.CHERRY_PICK_FAILED_LABEL in [l.name for l in self.issue.labels]: 161 self.issue.remove_from_labels(self.CHERRY_PICK_FAILED_LABEL) 162 163 def create_branch(self, commits:List[str]) -> bool: 164 """ 165 This function attempts to backport `commits` into the branch associated 166 with `self.issue_number`. 167 168 If this is successful, then the branch is pushed to `self.branch_repo_name`, if not, 169 a comment is added to the issue saying that the cherry-pick failed. 170 171 :param list commits: List of commits to cherry-pick. 172 173 """ 174 print('cherry-picking', commits) 175 branch_name = self.branch_name 176 local_repo = Repo(self.llvm_project_dir) 177 local_repo.git.checkout(self.release_branch_for_issue) 178 179 for c in commits: 180 try: 181 local_repo.git.cherry_pick('-x', c) 182 except Exception as e: 183 self.issue_notify_cherry_pick_failure(c) 184 raise e 185 186 push_url = self.push_url 187 print('Pushing to {} {}'.format(push_url, branch_name)) 188 local_repo.git.push(push_url, 'HEAD:{}'.format(branch_name), force=True) 189 190 self.issue_notify_branch() 191 self.issue_remove_cherry_pick_failed_label() 192 return True 193 194 def check_if_pull_request_exists(self, repo:github.Repository.Repository, head:str) -> bool: 195 pulls = repo.get_pulls(head=head) 196 return pulls.totalCount != 0 197 198 def create_pull_request(self, owner:str, branch:str) -> bool: 199 """ 200 reate a pull request in `self.branch_repo_name`. The base branch of the 201 pull request will be choosen based on the the milestone attached to 202 the issue represented by `self.issue_number` For example if the milestone 203 is Release 13.0.1, then the base branch will be release/13.x. `branch` 204 will be used as the compare branch. 205 https://docs.github.com/en/get-started/quickstart/github-glossary#base-branch 206 https://docs.github.com/en/get-started/quickstart/github-glossary#compare-branch 207 """ 208 repo = github.Github(self.token).get_repo(self.branch_repo_name) 209 issue_ref = '{}#{}'.format(self.repo_name, self.issue_number) 210 pull = None 211 release_branch_for_issue = self.release_branch_for_issue 212 if release_branch_for_issue is None: 213 return False 214 head = f"{owner}:{branch}" 215 if self.check_if_pull_request_exists(repo, head): 216 print("PR already exists...") 217 return True 218 try: 219 pull = repo.create_pull(title=f"PR for {issue_ref}", 220 body='resolves {}'.format(issue_ref), 221 base=release_branch_for_issue, 222 head=head, 223 maintainer_can_modify=False) 224 except Exception as e: 225 self.issue_notify_pull_request_failure(branch) 226 raise e 227 228 if pull is None: 229 return False 230 231 self.issue_notify_pull_request(pull) 232 self.issue_remove_cherry_pick_failed_label() 233 234 # TODO(tstellar): Do you really want to always return True? 235 return True 236 237 238 def execute_command(self) -> bool: 239 """ 240 This function reads lines from STDIN and executes the first command 241 that it finds. The 2 supported commands are: 242 /cherry-pick commit0 <commit1> <commit2> <...> 243 /branch <owner>/<repo>/<branch> 244 """ 245 for line in sys.stdin: 246 line.rstrip() 247 m = re.search("/([a-z-]+)\s(.+)", line) 248 if not m: 249 continue 250 command = m.group(1) 251 args = m.group(2) 252 253 if command == 'cherry-pick': 254 return self.create_branch(args.split()) 255 256 if command == 'branch': 257 m = re.match('([^/]+)/([^/]+)/(.+)', args) 258 if m: 259 owner = m.group(1) 260 branch = m.group(3) 261 return self.create_pull_request(owner, branch) 262 263 print("Do not understand input:") 264 print(sys.stdin.readlines()) 265 return False 266 267parser = argparse.ArgumentParser() 268parser.add_argument('--token', type=str, required=True, help='GitHub authentiation token') 269parser.add_argument('--repo', type=str, default=os.getenv('GITHUB_REPOSITORY', 'llvm/llvm-project'), 270 help='The GitHub repository that we are working with in the form of <owner>/<repo> (e.g. llvm/llvm-project)') 271subparsers = parser.add_subparsers(dest='command') 272 273issue_subscriber_parser = subparsers.add_parser('issue-subscriber') 274issue_subscriber_parser.add_argument('--label-name', type=str, required=True) 275issue_subscriber_parser.add_argument('--issue-number', type=int, required=True) 276 277release_workflow_parser = subparsers.add_parser('release-workflow') 278release_workflow_parser.add_argument('--llvm-project-dir', type=str, default='.', help='directory containing the llvm-project checout') 279release_workflow_parser.add_argument('--issue-number', type=int, required=True, help='The issue number to update') 280release_workflow_parser.add_argument('--branch-repo-token', type=str, 281 help='GitHub authentication token to use for the repository where new branches will be pushed. Defaults to TOKEN.') 282release_workflow_parser.add_argument('--branch-repo', type=str, default='llvm/llvm-project-release-prs', 283 help='The name of the repo where new branches will be pushed (e.g. llvm/llvm-project)') 284release_workflow_parser.add_argument('sub_command', type=str, choices=['print-release-branch', 'auto'], 285 help='Print to stdout the name of the release branch ISSUE_NUMBER should be backported to') 286 287llvmbot_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') 288 289args = parser.parse_args() 290 291if args.command == 'issue-subscriber': 292 issue_subscriber = IssueSubscriber(args.token, args.repo, args.issue_number, args.label_name) 293 issue_subscriber.run() 294elif args.command == 'release-workflow': 295 release_workflow = ReleaseWorkflow(args.token, args.repo, args.issue_number, 296 args.branch_repo, args.branch_repo_token, 297 args.llvm_project_dir) 298 if args.sub_command == 'print-release-branch': 299 release_workflow.print_release_branch() 300 else: 301 if not release_workflow.execute_command(): 302 sys.exit(1) 303elif args.command == 'setup-llvmbot-git': 304 setup_llvmbot_git() 305