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 195 def create_pull_request(self, owner:str, branch:str) -> bool: 196 """ 197 reate a pull request in `self.branch_repo_name`. The base branch of the 198 pull request will be choosen based on the the milestone attached to 199 the issue represented by `self.issue_number` For example if the milestone 200 is Release 13.0.1, then the base branch will be release/13.x. `branch` 201 will be used as the compare branch. 202 https://docs.github.com/en/get-started/quickstart/github-glossary#base-branch 203 https://docs.github.com/en/get-started/quickstart/github-glossary#compare-branch 204 """ 205 repo = github.Github(self.token).get_repo(self.branch_repo_name) 206 issue_ref = '{}#{}'.format(self.repo_name, self.issue_number) 207 pull = None 208 release_branch_for_issue = self.release_branch_for_issue 209 if release_branch_for_issue is None: 210 return False 211 try: 212 pull = repo.create_pull(title='PR for {}'.format(issue_ref), 213 body='resolves {}'.format(issue_ref), 214 base=release_branch_for_issue, 215 head='{}:{}'.format(owner, branch), 216 maintainer_can_modify=False) 217 except Exception as e: 218 self.issue_notify_pull_request_failure(branch) 219 raise e 220 221 if pull is None: 222 return False 223 224 self.issue_notify_pull_request(pull) 225 self.issue_remove_cherry_pick_failed_label() 226 227 # TODO(tstellar): Do you really want to always return True? 228 return True 229 230 231 def execute_command(self) -> bool: 232 """ 233 This function reads lines from STDIN and executes the first command 234 that it finds. The 2 supported commands are: 235 /cherry-pick commit0 <commit1> <commit2> <...> 236 /branch <owner>/<repo>/<branch> 237 """ 238 for line in sys.stdin: 239 line.rstrip() 240 m = re.search("/([a-z-]+)\s(.+)", line) 241 if not m: 242 continue 243 command = m.group(1) 244 args = m.group(2) 245 246 if command == 'cherry-pick': 247 return self.create_branch(args.split()) 248 249 if command == 'branch': 250 m = re.match('([^/]+)/([^/]+)/(.+)', args) 251 if m: 252 owner = m.group(1) 253 branch = m.group(3) 254 return self.create_pull_request(owner, branch) 255 256 print("Do not understand input:") 257 print(sys.stdin.readlines()) 258 return False 259 260parser = argparse.ArgumentParser() 261parser.add_argument('--token', type=str, required=True, help='GitHub authentiation token') 262parser.add_argument('--repo', type=str, default=os.getenv('GITHUB_REPOSITORY', 'llvm/llvm-project'), 263 help='The GitHub repository that we are working with in the form of <owner>/<repo> (e.g. llvm/llvm-project)') 264subparsers = parser.add_subparsers(dest='command') 265 266issue_subscriber_parser = subparsers.add_parser('issue-subscriber') 267issue_subscriber_parser.add_argument('--label-name', type=str, required=True) 268issue_subscriber_parser.add_argument('--issue-number', type=int, required=True) 269 270release_workflow_parser = subparsers.add_parser('release-workflow') 271release_workflow_parser.add_argument('--llvm-project-dir', type=str, default='.', help='directory containing the llvm-project checout') 272release_workflow_parser.add_argument('--issue-number', type=int, required=True, help='The issue number to update') 273release_workflow_parser.add_argument('--branch-repo-token', type=str, 274 help='GitHub authentication token to use for the repository where new branches will be pushed. Defaults to TOKEN.') 275release_workflow_parser.add_argument('--branch-repo', type=str, default='llvmbot/llvm-project', 276 help='The name of the repo where new branches will be pushed (e.g. llvm/llvm-project)') 277release_workflow_parser.add_argument('sub_command', type=str, choices=['print-release-branch', 'auto'], 278 help='Print to stdout the name of the release branch ISSUE_NUMBER should be backported to') 279 280llvmbot_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') 281 282args = parser.parse_args() 283 284if args.command == 'issue-subscriber': 285 issue_subscriber = IssueSubscriber(args.token, args.repo, args.issue_number, args.label_name) 286 issue_subscriber.run() 287elif args.command == 'release-workflow': 288 release_workflow = ReleaseWorkflow(args.token, args.repo, args.issue_number, 289 args.branch_repo, args.branch_repo_token, 290 args.llvm_project_dir) 291 if args.sub_command == 'print-release-branch': 292 release_workflow.print_release_branch() 293 else: 294 if not release_workflow.execute_command(): 295 sys.exit(1) 296elif args.command == 'setup-llvmbot-git': 297 setup_llvmbot_git() 298