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 html 14import github 15import os 16import re 17import requests 18import sys 19import time 20from typing import List, Optional 21 22beginner_comment = """ 23Hi! 24 25This issue may be a good introductory issue for people new to working on LLVM. If you would like to work on this issue, your first steps are: 26 271. In the comments of the issue, request for it to be assigned to you. 282. Fix the issue locally. 293. [Run the test suite](https://llvm.org/docs/TestingGuide.html#unit-and-regression-tests) locally. Remember that the subdirectories under `test/` create fine-grained testing targets, so you can e.g. use `make check-clang-ast` to only run Clang's AST tests. 304. Create a Git commit. 315. Run [`git clang-format HEAD~1`](https://clang.llvm.org/docs/ClangFormat.html#git-integration) to format your changes. 326. Open a [pull request](https://github.com/llvm/llvm-project/pulls) to the [upstream repository](https://github.com/llvm/llvm-project) on GitHub. Detailed instructions can be found [in GitHub's documentation](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request). 33 34If you have any further questions about this issue, don't hesitate to ask via a comment in the thread below. 35""" 36 37 38def _get_curent_team(team_name, teams) -> Optional[github.Team.Team]: 39 for team in teams: 40 if team_name == team.name.lower(): 41 return team 42 return None 43 44 45def escape_description(str): 46 # If the description of an issue/pull request is empty, the Github API 47 # library returns None instead of an empty string. Handle this here to 48 # avoid failures from trying to manipulate None. 49 if str is None: 50 return "" 51 # https://github.com/github/markup/issues/1168#issuecomment-494946168 52 str = html.escape(str, False) 53 # '@' followed by alphanum is a user name 54 str = re.sub("@(?=\w)", "@<!-- -->", str) 55 # '#' followed by digits is considered an issue number 56 str = re.sub("#(?=\d)", "#<!-- -->", str) 57 return str 58 59 60class IssueSubscriber: 61 @property 62 def team_name(self) -> str: 63 return self._team_name 64 65 def __init__(self, token: str, repo: str, issue_number: int, label_name: str): 66 self.repo = github.Github(token).get_repo(repo) 67 self.org = github.Github(token).get_organization(self.repo.organization.login) 68 self.issue = self.repo.get_issue(issue_number) 69 self._team_name = "issue-subscribers-{}".format(label_name).lower() 70 71 def run(self) -> bool: 72 team = _get_curent_team(self.team_name, self.org.get_teams()) 73 if not team: 74 print(f"couldn't find team named {self.team_name}") 75 return False 76 77 comment = "" 78 if team.slug == "issue-subscribers-good-first-issue": 79 comment = "{}\n".format(beginner_comment) 80 self.issue.create_comment(comment) 81 82 body = escape_description(self.issue.body) 83 comment = f""" 84@llvm/{team.slug} 85 86Author: {self.issue.user.name} ({self.issue.user.login}) 87 88<details> 89{body} 90</details> 91""" 92 93 self.issue.create_comment(comment) 94 return True 95 96 97def human_readable_size(size, decimal_places=2): 98 for unit in ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]: 99 if size < 1024.0 or unit == "PiB": 100 break 101 size /= 1024.0 102 return f"{size:.{decimal_places}f} {unit}" 103 104 105class PRSubscriber: 106 @property 107 def team_name(self) -> str: 108 return self._team_name 109 110 def __init__(self, token: str, repo: str, pr_number: int, label_name: str): 111 self.repo = github.Github(token).get_repo(repo) 112 self.org = github.Github(token).get_organization(self.repo.organization.login) 113 self.pr = self.repo.get_issue(pr_number).as_pull_request() 114 self._team_name = "pr-subscribers-{}".format( 115 label_name.replace("+", "x") 116 ).lower() 117 self.COMMENT_TAG = "<!--LLVM PR SUMMARY COMMENT-->\n" 118 119 def get_summary_comment(self) -> github.IssueComment.IssueComment: 120 for comment in self.pr.as_issue().get_comments(): 121 if self.COMMENT_TAG in comment.body: 122 return comment 123 return None 124 125 def run(self) -> bool: 126 patch = None 127 team = _get_curent_team(self.team_name, self.org.get_teams()) 128 if not team: 129 print(f"couldn't find team named {self.team_name}") 130 return False 131 132 # GitHub limits comments to 65,536 characters, let's limit the diff 133 # and the file list to 20kB each. 134 STAT_LIMIT = 20 * 1024 135 DIFF_LIMIT = 20 * 1024 136 137 # Get statistics for each file 138 diff_stats = f"{self.pr.changed_files} Files Affected:\n\n" 139 for file in self.pr.get_files(): 140 diff_stats += f"- ({file.status}) {file.filename} (" 141 if file.additions: 142 diff_stats += f"+{file.additions}" 143 if file.deletions: 144 diff_stats += f"-{file.deletions}" 145 diff_stats += ") " 146 if file.status == "renamed": 147 print(f"(from {file.previous_filename})") 148 diff_stats += "\n" 149 if len(diff_stats) > STAT_LIMIT: 150 break 151 152 # Get the diff 153 try: 154 patch = requests.get(self.pr.diff_url).text 155 except: 156 patch = "" 157 158 patch_link = f"Full diff: {self.pr.diff_url}\n" 159 if len(patch) > DIFF_LIMIT: 160 patch_link = f"\nPatch is {human_readable_size(len(patch))}, truncated to {human_readable_size(DIFF_LIMIT)} below, full version: {self.pr.diff_url}\n" 161 patch = patch[0:DIFF_LIMIT] + "...\n[truncated]\n" 162 team_mention = "@llvm/{}".format(team.slug) 163 164 body = escape_description(self.pr.body) 165 # Note: the comment is in markdown and the code below 166 # is sensible to line break 167 comment = f""" 168{self.COMMENT_TAG} 169{team_mention} 170 171Author: {self.pr.user.name} ({self.pr.user.login}) 172 173<details> 174<summary>Changes</summary> 175 176{body} 177 178--- 179{patch_link} 180 181{diff_stats} 182 183``````````diff 184{patch} 185`````````` 186 187</details> 188""" 189 190 summary_comment = self.get_summary_comment() 191 if not summary_comment: 192 self.pr.as_issue().create_comment(comment) 193 elif team_mention + "\n" in summary_comment.body: 194 print("Team {} already mentioned.".format(team.slug)) 195 else: 196 summary_comment.edit( 197 summary_comment.body.replace( 198 self.COMMENT_TAG, self.COMMENT_TAG + team_mention + "\n" 199 ) 200 ) 201 return True 202 203 def _get_curent_team(self) -> Optional[github.Team.Team]: 204 for team in self.org.get_teams(): 205 if self.team_name == team.name.lower(): 206 return team 207 return None 208 209 210def setup_llvmbot_git(git_dir="."): 211 """ 212 Configure the git repo in `git_dir` with the llvmbot account so 213 commits are attributed to llvmbot. 214 """ 215 repo = Repo(git_dir) 216 with repo.config_writer() as config: 217 config.set_value("user", "name", "llvmbot") 218 config.set_value("user", "email", "llvmbot@llvm.org") 219 220 221def phab_api_call(phab_token: str, url: str, args: dict) -> dict: 222 """ 223 Make an API call to the Phabricator web service and return a dictionary 224 containing the json response. 225 """ 226 data = {"api.token": phab_token} 227 data.update(args) 228 response = requests.post(url, data=data) 229 return response.json() 230 231 232def phab_login_to_github_login( 233 phab_token: str, repo: github.Repository.Repository, phab_login: str 234) -> Optional[str]: 235 """ 236 Tries to translate a Phabricator login to a github login by 237 finding a commit made in Phabricator's Differential. 238 The commit's SHA1 is then looked up in the github repo and 239 the committer's login associated with that commit is returned. 240 241 :param str phab_token: The Conduit API token to use for communication with Pabricator 242 :param github.Repository.Repository repo: The github repo to use when looking for the SHA1 found in Differential 243 :param str phab_login: The Phabricator login to be translated. 244 """ 245 246 args = { 247 "constraints[authors][0]": phab_login, 248 # PHID for "LLVM Github Monorepo" repository 249 "constraints[repositories][0]": "PHID-REPO-f4scjekhnkmh7qilxlcy", 250 "limit": 1, 251 } 252 # API documentation: https://reviews.llvm.org/conduit/method/diffusion.commit.search/ 253 r = phab_api_call( 254 phab_token, "https://reviews.llvm.org/api/diffusion.commit.search", args 255 ) 256 data = r["result"]["data"] 257 if len(data) == 0: 258 # Can't find any commits associated with this user 259 return None 260 261 commit_sha = data[0]["fields"]["identifier"] 262 committer = repo.get_commit(commit_sha).committer 263 if not committer: 264 # This committer had an email address GitHub could not recognize, so 265 # it can't link the user to a GitHub account. 266 print(f"Warning: Can't find github account for {phab_login}") 267 return None 268 return committer.login 269 270 271def phab_get_commit_approvers(phab_token: str, commit: github.Commit.Commit) -> list: 272 args = {"corpus": commit.commit.message} 273 # API documentation: https://reviews.llvm.org/conduit/method/differential.parsecommitmessage/ 274 r = phab_api_call( 275 phab_token, "https://reviews.llvm.org/api/differential.parsecommitmessage", args 276 ) 277 review_id = r["result"]["revisionIDFieldInfo"]["value"] 278 if not review_id: 279 # No Phabricator revision for this commit 280 return [] 281 282 args = {"constraints[ids][0]": review_id, "attachments[reviewers]": True} 283 # API documentation: https://reviews.llvm.org/conduit/method/differential.revision.search/ 284 r = phab_api_call( 285 phab_token, "https://reviews.llvm.org/api/differential.revision.search", args 286 ) 287 reviewers = r["result"]["data"][0]["attachments"]["reviewers"]["reviewers"] 288 accepted = [] 289 for reviewer in reviewers: 290 if reviewer["status"] != "accepted": 291 continue 292 phid = reviewer["reviewerPHID"] 293 args = {"constraints[phids][0]": phid} 294 # API documentation: https://reviews.llvm.org/conduit/method/user.search/ 295 r = phab_api_call(phab_token, "https://reviews.llvm.org/api/user.search", args) 296 accepted.append(r["result"]["data"][0]["fields"]["username"]) 297 return accepted 298 299 300def extract_commit_hash(arg: str): 301 """ 302 Extract the commit hash from the argument passed to /action github 303 comment actions. We currently only support passing the commit hash 304 directly or use the github URL, such as 305 https://github.com/llvm/llvm-project/commit/2832d7941f4207f1fcf813b27cf08cecc3086959 306 """ 307 github_prefix = "https://github.com/llvm/llvm-project/commit/" 308 if arg.startswith(github_prefix): 309 return arg[len(github_prefix) :] 310 return arg 311 312 313class ReleaseWorkflow: 314 CHERRY_PICK_FAILED_LABEL = "release:cherry-pick-failed" 315 316 """ 317 This class implements the sub-commands for the release-workflow command. 318 The current sub-commands are: 319 * create-branch 320 * create-pull-request 321 322 The execute_command method will automatically choose the correct sub-command 323 based on the text in stdin. 324 """ 325 326 def __init__( 327 self, 328 token: str, 329 repo: str, 330 issue_number: int, 331 branch_repo_name: str, 332 branch_repo_token: str, 333 llvm_project_dir: str, 334 phab_token: str, 335 ) -> None: 336 self._token = token 337 self._repo_name = repo 338 self._issue_number = issue_number 339 self._branch_repo_name = branch_repo_name 340 if branch_repo_token: 341 self._branch_repo_token = branch_repo_token 342 else: 343 self._branch_repo_token = self.token 344 self._llvm_project_dir = llvm_project_dir 345 self._phab_token = phab_token 346 347 @property 348 def token(self) -> str: 349 return self._token 350 351 @property 352 def repo_name(self) -> str: 353 return self._repo_name 354 355 @property 356 def issue_number(self) -> int: 357 return self._issue_number 358 359 @property 360 def branch_repo_name(self) -> str: 361 return self._branch_repo_name 362 363 @property 364 def branch_repo_token(self) -> str: 365 return self._branch_repo_token 366 367 @property 368 def llvm_project_dir(self) -> str: 369 return self._llvm_project_dir 370 371 @property 372 def phab_token(self) -> str: 373 return self._phab_token 374 375 @property 376 def repo(self) -> github.Repository.Repository: 377 return github.Github(self.token).get_repo(self.repo_name) 378 379 @property 380 def issue(self) -> github.Issue.Issue: 381 return self.repo.get_issue(self.issue_number) 382 383 @property 384 def push_url(self) -> str: 385 return "https://{}@github.com/{}".format( 386 self.branch_repo_token, self.branch_repo_name 387 ) 388 389 @property 390 def branch_name(self) -> str: 391 return "issue{}".format(self.issue_number) 392 393 @property 394 def release_branch_for_issue(self) -> Optional[str]: 395 issue = self.issue 396 milestone = issue.milestone 397 if milestone is None: 398 return None 399 m = re.search("branch: (.+)", milestone.description) 400 if m: 401 return m.group(1) 402 return None 403 404 def print_release_branch(self) -> None: 405 print(self.release_branch_for_issue) 406 407 def issue_notify_branch(self) -> None: 408 self.issue.create_comment( 409 "/branch {}/{}".format(self.branch_repo_name, self.branch_name) 410 ) 411 412 def issue_notify_pull_request(self, pull: github.PullRequest.PullRequest) -> None: 413 self.issue.create_comment( 414 "/pull-request {}#{}".format(self.branch_repo_name, pull.number) 415 ) 416 417 def make_ignore_comment(self, comment: str) -> str: 418 """ 419 Returns the comment string with a prefix that will cause 420 a Github workflow to skip parsing this comment. 421 422 :param str comment: The comment to ignore 423 """ 424 return "<!--IGNORE-->\n" + comment 425 426 def issue_notify_no_milestone(self, comment: List[str]) -> None: 427 message = "{}\n\nError: Command failed due to missing milestone.".format( 428 "".join([">" + line for line in comment]) 429 ) 430 self.issue.create_comment(self.make_ignore_comment(message)) 431 432 @property 433 def action_url(self) -> str: 434 if os.getenv("CI"): 435 return "https://github.com/{}/actions/runs/{}".format( 436 os.getenv("GITHUB_REPOSITORY"), os.getenv("GITHUB_RUN_ID") 437 ) 438 return "" 439 440 def issue_notify_cherry_pick_failure( 441 self, commit: str 442 ) -> github.IssueComment.IssueComment: 443 message = self.make_ignore_comment( 444 "Failed to cherry-pick: {}\n\n".format(commit) 445 ) 446 action_url = self.action_url 447 if action_url: 448 message += action_url + "\n\n" 449 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>`" 450 issue = self.issue 451 comment = issue.create_comment(message) 452 issue.add_to_labels(self.CHERRY_PICK_FAILED_LABEL) 453 return comment 454 455 def issue_notify_pull_request_failure( 456 self, branch: str 457 ) -> github.IssueComment.IssueComment: 458 message = "Failed to create pull request for {} ".format(branch) 459 message += self.action_url 460 return self.issue.create_comment(message) 461 462 def issue_remove_cherry_pick_failed_label(self): 463 if self.CHERRY_PICK_FAILED_LABEL in [l.name for l in self.issue.labels]: 464 self.issue.remove_from_labels(self.CHERRY_PICK_FAILED_LABEL) 465 466 def pr_request_review(self, pr: github.PullRequest.PullRequest): 467 """ 468 This function will try to find the best reviewers for `commits` and 469 then add a comment requesting review of the backport and assign the 470 pull request to the selected reviewers. 471 472 The reviewers selected are those users who approved the patch in 473 Phabricator. 474 """ 475 reviewers = [] 476 for commit in pr.get_commits(): 477 approvers = phab_get_commit_approvers(self.phab_token, commit) 478 for a in approvers: 479 login = phab_login_to_github_login(self.phab_token, self.repo, a) 480 if not login: 481 continue 482 reviewers.append(login) 483 if len(reviewers): 484 message = "{} What do you think about merging this PR to the release branch?".format( 485 " ".join(["@" + r for r in reviewers]) 486 ) 487 pr.create_issue_comment(message) 488 pr.add_to_assignees(*reviewers) 489 490 def create_branch(self, commits: List[str]) -> bool: 491 """ 492 This function attempts to backport `commits` into the branch associated 493 with `self.issue_number`. 494 495 If this is successful, then the branch is pushed to `self.branch_repo_name`, if not, 496 a comment is added to the issue saying that the cherry-pick failed. 497 498 :param list commits: List of commits to cherry-pick. 499 500 """ 501 print("cherry-picking", commits) 502 branch_name = self.branch_name 503 local_repo = Repo(self.llvm_project_dir) 504 local_repo.git.checkout(self.release_branch_for_issue) 505 506 for c in commits: 507 try: 508 local_repo.git.cherry_pick("-x", c) 509 except Exception as e: 510 self.issue_notify_cherry_pick_failure(c) 511 raise e 512 513 push_url = self.push_url 514 print("Pushing to {} {}".format(push_url, branch_name)) 515 local_repo.git.push(push_url, "HEAD:{}".format(branch_name), force=True) 516 517 self.issue_notify_branch() 518 self.issue_remove_cherry_pick_failed_label() 519 return True 520 521 def check_if_pull_request_exists( 522 self, repo: github.Repository.Repository, head: str 523 ) -> bool: 524 pulls = repo.get_pulls(head=head) 525 return pulls.totalCount != 0 526 527 def create_pull_request(self, owner: str, repo_name: str, branch: str) -> bool: 528 """ 529 reate a pull request in `self.branch_repo_name`. The base branch of the 530 pull request will be chosen based on the the milestone attached to 531 the issue represented by `self.issue_number` For example if the milestone 532 is Release 13.0.1, then the base branch will be release/13.x. `branch` 533 will be used as the compare branch. 534 https://docs.github.com/en/get-started/quickstart/github-glossary#base-branch 535 https://docs.github.com/en/get-started/quickstart/github-glossary#compare-branch 536 """ 537 repo = github.Github(self.token).get_repo(self.branch_repo_name) 538 issue_ref = "{}#{}".format(self.repo_name, self.issue_number) 539 pull = None 540 release_branch_for_issue = self.release_branch_for_issue 541 if release_branch_for_issue is None: 542 return False 543 head_branch = branch 544 if not repo.fork: 545 # If the target repo is not a fork of llvm-project, we need to copy 546 # the branch into the target repo. GitHub only supports cross-repo pull 547 # requests on forked repos. 548 head_branch = f"{owner}-{branch}" 549 local_repo = Repo(self.llvm_project_dir) 550 push_done = False 551 for _ in range(0, 5): 552 try: 553 local_repo.git.fetch( 554 f"https://github.com/{owner}/{repo_name}", f"{branch}:{branch}" 555 ) 556 local_repo.git.push( 557 self.push_url, f"{branch}:{head_branch}", force=True 558 ) 559 push_done = True 560 break 561 except Exception as e: 562 print(e) 563 time.sleep(30) 564 continue 565 if not push_done: 566 raise Exception("Failed to mirror branch into {}".format(self.push_url)) 567 owner = repo.owner.login 568 569 head = f"{owner}:{head_branch}" 570 if self.check_if_pull_request_exists(repo, head): 571 print("PR already exists...") 572 return True 573 try: 574 pull = repo.create_pull( 575 title=f"PR for {issue_ref}", 576 body="resolves {}".format(issue_ref), 577 base=release_branch_for_issue, 578 head=head, 579 maintainer_can_modify=False, 580 ) 581 582 try: 583 if self.phab_token: 584 self.pr_request_review(pull) 585 except Exception as e: 586 print("error: Failed while searching for reviewers", e) 587 588 except Exception as e: 589 self.issue_notify_pull_request_failure(branch) 590 raise e 591 592 if pull is None: 593 return False 594 595 self.issue_notify_pull_request(pull) 596 self.issue_remove_cherry_pick_failed_label() 597 598 # TODO(tstellar): Do you really want to always return True? 599 return True 600 601 def execute_command(self) -> bool: 602 """ 603 This function reads lines from STDIN and executes the first command 604 that it finds. The 2 supported commands are: 605 /cherry-pick commit0 <commit1> <commit2> <...> 606 /branch <owner>/<repo>/<branch> 607 """ 608 for line in sys.stdin: 609 line.rstrip() 610 m = re.search(r"/([a-z-]+)\s(.+)", line) 611 if not m: 612 continue 613 command = m.group(1) 614 args = m.group(2) 615 616 if command == "cherry-pick": 617 arg_list = args.split() 618 commits = list(map(lambda a: extract_commit_hash(a), arg_list)) 619 return self.create_branch(commits) 620 621 if command == "branch": 622 m = re.match("([^/]+)/([^/]+)/(.+)", args) 623 if m: 624 owner = m.group(1) 625 repo = m.group(2) 626 branch = m.group(3) 627 return self.create_pull_request(owner, repo, branch) 628 629 print("Do not understand input:") 630 print(sys.stdin.readlines()) 631 return False 632 633 634parser = argparse.ArgumentParser() 635parser.add_argument( 636 "--token", type=str, required=True, help="GitHub authentiation token" 637) 638parser.add_argument( 639 "--repo", 640 type=str, 641 default=os.getenv("GITHUB_REPOSITORY", "llvm/llvm-project"), 642 help="The GitHub repository that we are working with in the form of <owner>/<repo> (e.g. llvm/llvm-project)", 643) 644subparsers = parser.add_subparsers(dest="command") 645 646issue_subscriber_parser = subparsers.add_parser("issue-subscriber") 647issue_subscriber_parser.add_argument("--label-name", type=str, required=True) 648issue_subscriber_parser.add_argument("--issue-number", type=int, required=True) 649 650pr_subscriber_parser = subparsers.add_parser("pr-subscriber") 651pr_subscriber_parser.add_argument("--label-name", type=str, required=True) 652pr_subscriber_parser.add_argument("--issue-number", type=int, required=True) 653 654release_workflow_parser = subparsers.add_parser("release-workflow") 655release_workflow_parser.add_argument( 656 "--llvm-project-dir", 657 type=str, 658 default=".", 659 help="directory containing the llvm-project checout", 660) 661release_workflow_parser.add_argument( 662 "--issue-number", type=int, required=True, help="The issue number to update" 663) 664release_workflow_parser.add_argument( 665 "--phab-token", 666 type=str, 667 help="Phabricator conduit API token. See https://reviews.llvm.org/settings/user/<USER>/page/apitokens/", 668) 669release_workflow_parser.add_argument( 670 "--branch-repo-token", 671 type=str, 672 help="GitHub authentication token to use for the repository where new branches will be pushed. Defaults to TOKEN.", 673) 674release_workflow_parser.add_argument( 675 "--branch-repo", 676 type=str, 677 default="llvm/llvm-project-release-prs", 678 help="The name of the repo where new branches will be pushed (e.g. llvm/llvm-project)", 679) 680release_workflow_parser.add_argument( 681 "sub_command", 682 type=str, 683 choices=["print-release-branch", "auto"], 684 help="Print to stdout the name of the release branch ISSUE_NUMBER should be backported to", 685) 686 687llvmbot_git_config_parser = subparsers.add_parser( 688 "setup-llvmbot-git", 689 help="Set the default user and email for the git repo in LLVM_PROJECT_DIR to llvmbot", 690) 691 692args = parser.parse_args() 693 694if args.command == "issue-subscriber": 695 issue_subscriber = IssueSubscriber( 696 args.token, args.repo, args.issue_number, args.label_name 697 ) 698 issue_subscriber.run() 699elif args.command == "pr-subscriber": 700 pr_subscriber = PRSubscriber( 701 args.token, args.repo, args.issue_number, args.label_name 702 ) 703 pr_subscriber.run() 704elif args.command == "release-workflow": 705 release_workflow = ReleaseWorkflow( 706 args.token, 707 args.repo, 708 args.issue_number, 709 args.branch_repo, 710 args.branch_repo_token, 711 args.llvm_project_dir, 712 args.phab_token, 713 ) 714 if not release_workflow.release_branch_for_issue: 715 release_workflow.issue_notify_no_milestone(sys.stdin.readlines()) 716 sys.exit(1) 717 if args.sub_command == "print-release-branch": 718 release_workflow.print_release_branch() 719 else: 720 if not release_workflow.execute_command(): 721 sys.exit(1) 722elif args.command == "setup-llvmbot-git": 723 setup_llvmbot_git() 724