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 """ 53 This class implements the sub-commands for the release-workflow command. 54 The current sub-commands are: 55 * create-branch 56 * create-pull-request 57 58 The execute_command method will automatically choose the correct sub-command 59 based on the text in stdin. 60 """ 61 62 def __init__(self, token:str, repo:str, issue_number:int, 63 branch_repo_name:str, branch_repo_token:str, 64 llvm_project_dir:str) -> None: 65 self._token = token 66 self._repo_name = repo 67 self._issue_number = issue_number 68 self._branch_repo_name = branch_repo_name 69 if branch_repo_token: 70 self._branch_repo_token = branch_repo_token 71 else: 72 self._branch_repo_token = self.token 73 self._llvm_project_dir = llvm_project_dir 74 75 @property 76 def token(self) -> str: 77 return self._token 78 79 @property 80 def repo_name(self) -> str: 81 return self._repo_name 82 83 @property 84 def issue_number(self) -> int: 85 return self._issue_number 86 87 @property 88 def branch_repo_name(self) -> str: 89 return self._branch_repo_name 90 91 @property 92 def branch_repo_token(self) -> str: 93 return self._branch_repo_token 94 95 @property 96 def llvm_project_dir(self) -> str: 97 return self._llvm_project_dir 98 99 @property 100 def __repo(self) -> github.Repository.Repository: 101 return github.Github(self.token).get_repo(self.repo_name) 102 103 @property 104 def issue(self) -> github.Issue.Issue: 105 return self.__repo.get_issue(self.issue_number) 106 107 @property 108 def push_url(self) -> str: 109 return 'https://{}@github.com/{}'.format(self.branch_repo_token, self.branch_repo_name) 110 111 @property 112 def branch_name(self) -> str: 113 return 'issue{}'.format(self.issue_number) 114 115 @property 116 def release_branch_for_issue(self) -> Optional[str]: 117 issue = self.issue 118 milestone = issue.milestone 119 if milestone is None: 120 return None 121 m = re.search('branch: (.+)',milestone.description) 122 if m: 123 return m.group(1) 124 return None 125 126 def print_release_branch(self) -> None: 127 print(self.release_branch_for_issue) 128 129 def issue_notify_branch(self) -> None: 130 self.issue.create_comment('/branch {}/{}'.format(self.branch_repo_name, self.branch_name)) 131 132 def issue_notify_pull_request(self, pull:github.PullRequest.PullRequest) -> None: 133 self.issue.create_comment('/pull-request {}#{}'.format(self.branch_repo_name, pull.number)) 134 135 @property 136 def action_url(self) -> str: 137 if os.getenv('CI'): 138 return 'https://github.com/{}/actions/runs/{}'.format(os.getenv('GITHUB_REPOSITORY'), os.getenv('GITHUB_RUN_ID')) 139 return "" 140 141 def issue_notify_cherry_pick_failure(self, commit:str) -> github.IssueComment.IssueComment: 142 message = "<!--IGNORE-->\nFailed to cherry-pick: {}\n\n".format(commit) 143 action_url = self.action_url 144 if action_url: 145 message += action_url + "\n\n" 146 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>`" 147 issue = self.issue 148 comment = issue.create_comment(message) 149 issue.add_to_labels('release:cherry-pick-failed') 150 return comment 151 152 def issue_notify_pull_request_failure(self, branch:str) -> github.IssueComment.IssueComment: 153 message = "Failed to create pull request for {} ".format(branch) 154 message += self.action_url 155 return self.issue.create_comment(message) 156 157 158 def create_branch(self, commits:List[str]) -> bool: 159 """ 160 This function attempts to backport `commits` into the branch associated 161 with `self.issue_number`. 162 163 If this is successful, then the branch is pushed to `self.branch_repo_name`, if not, 164 a comment is added to the issue saying that the cherry-pick failed. 165 166 :param list commits: List of commits to cherry-pick. 167 168 """ 169 print('cherry-picking', commits) 170 branch_name = self.branch_name 171 local_repo = Repo(self.llvm_project_dir) 172 local_repo.git.checkout(self.release_branch_for_issue) 173 174 for c in commits: 175 try: 176 local_repo.git.cherry_pick('-x', c) 177 except Exception as e: 178 self.issue_notify_cherry_pick_failure(c) 179 raise e 180 181 push_url = self.push_url 182 print('Pushing to {} {}'.format(push_url, branch_name)) 183 local_repo.git.push(push_url, 'HEAD:{}'.format(branch_name)) 184 185 self.issue_notify_branch() 186 return True 187 188 189 def create_pull_request(self, owner:str, branch:str) -> bool: 190 """ 191 reate a pull request in `self.branch_repo_name`. The base branch of the 192 pull request will be choosen based on the the milestone attached to 193 the issue represented by `self.issue_number` For example if the milestone 194 is Release 13.0.1, then the base branch will be release/13.x. `branch` 195 will be used as the compare branch. 196 https://docs.github.com/en/get-started/quickstart/github-glossary#base-branch 197 https://docs.github.com/en/get-started/quickstart/github-glossary#compare-branch 198 """ 199 repo = github.Github(self.token).get_repo(self.branch_repo_name) 200 issue_ref = '{}#{}'.format(self.repo_name, self.issue_number) 201 pull = None 202 release_branch_for_issue = self.release_branch_for_issue 203 if release_branch_for_issue is None: 204 return False 205 try: 206 pull = repo.create_pull(title='PR for {}'.format(issue_ref), 207 body='resolves {}'.format(issue_ref), 208 base=release_branch_for_issue, 209 head='{}:{}'.format(owner, branch), 210 maintainer_can_modify=False) 211 except Exception as e: 212 self.issue_notify_pull_request_failure(branch) 213 raise e 214 215 if pull is None: 216 return False 217 218 self.issue_notify_pull_request(pull) 219 220 # TODO(tstellar): Do you really want to always return True? 221 return True 222 223 224 def execute_command(self) -> bool: 225 """ 226 This function reads lines from STDIN and executes the first command 227 that it finds. The 2 supported commands are: 228 /cherry-pick commit0 <commit1> <commit2> <...> 229 /branch <owner>/<repo>/<branch> 230 """ 231 for line in sys.stdin: 232 line.rstrip() 233 m = re.search("/([a-z-]+)\s(.+)", line) 234 if not m: 235 continue 236 command = m.group(1) 237 args = m.group(2) 238 239 if command == 'cherry-pick': 240 return self.create_branch(args.split()) 241 242 if command == 'branch': 243 m = re.match('([^/]+)/([^/]+)/(.+)', args) 244 if m: 245 owner = m.group(1) 246 branch = m.group(3) 247 return self.create_pull_request(owner, branch) 248 249 print("Do not understand input:") 250 print(sys.stdin.readlines()) 251 return False 252 253parser = argparse.ArgumentParser() 254parser.add_argument('--token', type=str, required=True, help='GitHub authentiation token') 255parser.add_argument('--repo', type=str, default=os.getenv('GITHUB_REPOSITORY', 'llvm/llvm-project'), 256 help='The GitHub repository that we are working with in the form of <owner>/<repo> (e.g. llvm/llvm-project)') 257subparsers = parser.add_subparsers(dest='command') 258 259issue_subscriber_parser = subparsers.add_parser('issue-subscriber') 260issue_subscriber_parser.add_argument('--label-name', type=str, required=True) 261issue_subscriber_parser.add_argument('--issue-number', type=int, required=True) 262 263release_workflow_parser = subparsers.add_parser('release-workflow') 264release_workflow_parser.add_argument('--llvm-project-dir', type=str, default='.', help='directory containing the llvm-project checout') 265release_workflow_parser.add_argument('--issue-number', type=int, required=True, help='The issue number to update') 266release_workflow_parser.add_argument('--branch-repo-token', type=str, 267 help='GitHub authentication token to use for the repository where new branches will be pushed. Defaults to TOKEN.') 268release_workflow_parser.add_argument('--branch-repo', type=str, default='llvmbot/llvm-project', 269 help='The name of the repo where new branches will be pushed (e.g. llvm/llvm-project)') 270release_workflow_parser.add_argument('sub_command', type=str, choices=['print-release-branch', 'auto'], 271 help='Print to stdout the name of the release branch ISSUE_NUMBER should be backported to') 272 273llvmbot_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') 274 275args = parser.parse_args() 276 277if args.command == 'issue-subscriber': 278 issue_subscriber = IssueSubscriber(args.token, args.repo, args.issue_number, args.label_name) 279 issue_subscriber.run() 280elif args.command == 'release-workflow': 281 release_workflow = ReleaseWorkflow(args.token, args.repo, args.issue_number, 282 args.branch_repo, args.branch_repo_token, 283 args.llvm_project_dir) 284 if args.sub_command == 'print-release-branch': 285 release_workflow.print_release_branch() 286 else: 287 if not release_workflow.execute_command(): 288 sys.exit(1) 289elif args.command == 'setup-llvmbot-git': 290 setup_llvmbot_git() 291