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