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