| #!/usr/bin/env python3 |
| # |
| # ======- github-automation - LLVM GitHub Automation Routines--*- python -*--==# |
| # |
| # Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions. |
| # See https://llvm.org/LICENSE.txt for license information. |
| # SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception |
| # |
| # ==-------------------------------------------------------------------------==# |
| |
| import argparse |
| from git import Repo # type: ignore |
| import github |
| import os |
| import re |
| import requests |
| import sys |
| import time |
| from typing import List, Optional |
| |
| beginner_comment = """ |
| Hi! |
| |
| This 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: |
| |
| 1) Assign the issue to you. |
| 2) Fix the issue locally. |
| 3) [Run the test suite](https://llvm.org/docs/TestingGuide.html#unit-and-regression-tests) locally. |
| 3.1) 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. |
| 4) Create a `git` commit |
| 5) Run [`git clang-format HEAD~1`](https://clang.llvm.org/docs/ClangFormat.html#git-integration) to format your changes. |
| 6) Submit the patch to [Phabricator](https://reviews.llvm.org/). |
| 6.1) Detailed instructions can be found [here](https://llvm.org/docs/Phabricator.html#requesting-a-review-via-the-web-interface) |
| |
| For more instructions on how to submit a patch to LLVM, see our [documentation](https://llvm.org/docs/Contributing.html). |
| |
| If you have any further questions about this issue, don't hesitate to ask via a comment on this Github issue. |
| """ |
| |
| |
| class IssueSubscriber: |
| @property |
| def team_name(self) -> str: |
| return self._team_name |
| |
| def __init__(self, token: str, repo: str, issue_number: int, label_name: str): |
| self.repo = github.Github(token).get_repo(repo) |
| self.org = github.Github(token).get_organization(self.repo.organization.login) |
| self.issue = self.repo.get_issue(issue_number) |
| self._team_name = "issue-subscribers-{}".format(label_name).lower() |
| |
| def run(self) -> bool: |
| for team in self.org.get_teams(): |
| if self.team_name != team.name.lower(): |
| continue |
| |
| comment = "" |
| if team.slug == "issue-subscribers-good-first-issue": |
| comment = "{}\n".format(beginner_comment) |
| |
| comment += "@llvm/{}".format(team.slug) |
| self.issue.create_comment(comment) |
| return True |
| return False |
| |
| |
| def setup_llvmbot_git(git_dir="."): |
| """ |
| Configure the git repo in `git_dir` with the llvmbot account so |
| commits are attributed to llvmbot. |
| """ |
| repo = Repo(git_dir) |
| with repo.config_writer() as config: |
| config.set_value("user", "name", "llvmbot") |
| config.set_value("user", "email", "llvmbot@llvm.org") |
| |
| |
| def phab_api_call(phab_token: str, url: str, args: dict) -> dict: |
| """ |
| Make an API call to the Phabricator web service and return a dictionary |
| containing the json response. |
| """ |
| data = {"api.token": phab_token} |
| data.update(args) |
| response = requests.post(url, data=data) |
| return response.json() |
| |
| |
| def phab_login_to_github_login( |
| phab_token: str, repo: github.Repository.Repository, phab_login: str |
| ) -> Optional[str]: |
| """ |
| Tries to translate a Phabricator login to a github login by |
| finding a commit made in Phabricator's Differential. |
| The commit's SHA1 is then looked up in the github repo and |
| the committer's login associated with that commit is returned. |
| |
| :param str phab_token: The Conduit API token to use for communication with Pabricator |
| :param github.Repository.Repository repo: The github repo to use when looking for the SHA1 found in Differential |
| :param str phab_login: The Phabricator login to be translated. |
| """ |
| |
| args = { |
| "constraints[authors][0]": phab_login, |
| # PHID for "LLVM Github Monorepo" repository |
| "constraints[repositories][0]": "PHID-REPO-f4scjekhnkmh7qilxlcy", |
| "limit": 1, |
| } |
| # API documentation: https://reviews.llvm.org/conduit/method/diffusion.commit.search/ |
| r = phab_api_call( |
| phab_token, "https://reviews.llvm.org/api/diffusion.commit.search", args |
| ) |
| data = r["result"]["data"] |
| if len(data) == 0: |
| # Can't find any commits associated with this user |
| return None |
| |
| commit_sha = data[0]["fields"]["identifier"] |
| committer = repo.get_commit(commit_sha).committer |
| if not committer: |
| # This committer had an email address GitHub could not recognize, so |
| # it can't link the user to a GitHub account. |
| print(f"Warning: Can't find github account for {phab_login}") |
| return None |
| return committer.login |
| |
| |
| def phab_get_commit_approvers(phab_token: str, commit: github.Commit.Commit) -> list: |
| args = {"corpus": commit.commit.message} |
| # API documentation: https://reviews.llvm.org/conduit/method/differential.parsecommitmessage/ |
| r = phab_api_call( |
| phab_token, "https://reviews.llvm.org/api/differential.parsecommitmessage", args |
| ) |
| review_id = r["result"]["revisionIDFieldInfo"]["value"] |
| if not review_id: |
| # No Phabricator revision for this commit |
| return [] |
| |
| args = {"constraints[ids][0]": review_id, "attachments[reviewers]": True} |
| # API documentation: https://reviews.llvm.org/conduit/method/differential.revision.search/ |
| r = phab_api_call( |
| phab_token, "https://reviews.llvm.org/api/differential.revision.search", args |
| ) |
| reviewers = r["result"]["data"][0]["attachments"]["reviewers"]["reviewers"] |
| accepted = [] |
| for reviewer in reviewers: |
| if reviewer["status"] != "accepted": |
| continue |
| phid = reviewer["reviewerPHID"] |
| args = {"constraints[phids][0]": phid} |
| # API documentation: https://reviews.llvm.org/conduit/method/user.search/ |
| r = phab_api_call(phab_token, "https://reviews.llvm.org/api/user.search", args) |
| accepted.append(r["result"]["data"][0]["fields"]["username"]) |
| return accepted |
| |
| |
| def extract_commit_hash(arg: str): |
| """ |
| Extract the commit hash from the argument passed to /action github |
| comment actions. We currently only support passing the commit hash |
| directly or use the github URL, such as |
| https://github.com/llvm/llvm-project/commit/2832d7941f4207f1fcf813b27cf08cecc3086959 |
| """ |
| github_prefix = "https://github.com/llvm/llvm-project/commit/" |
| if arg.startswith(github_prefix): |
| return arg[len(github_prefix) :] |
| return arg |
| |
| |
| class ReleaseWorkflow: |
| |
| CHERRY_PICK_FAILED_LABEL = "release:cherry-pick-failed" |
| |
| """ |
| This class implements the sub-commands for the release-workflow command. |
| The current sub-commands are: |
| * create-branch |
| * create-pull-request |
| |
| The execute_command method will automatically choose the correct sub-command |
| based on the text in stdin. |
| """ |
| |
| def __init__( |
| self, |
| token: str, |
| repo: str, |
| issue_number: int, |
| branch_repo_name: str, |
| branch_repo_token: str, |
| llvm_project_dir: str, |
| phab_token: str, |
| ) -> None: |
| self._token = token |
| self._repo_name = repo |
| self._issue_number = issue_number |
| self._branch_repo_name = branch_repo_name |
| if branch_repo_token: |
| self._branch_repo_token = branch_repo_token |
| else: |
| self._branch_repo_token = self.token |
| self._llvm_project_dir = llvm_project_dir |
| self._phab_token = phab_token |
| |
| @property |
| def token(self) -> str: |
| return self._token |
| |
| @property |
| def repo_name(self) -> str: |
| return self._repo_name |
| |
| @property |
| def issue_number(self) -> int: |
| return self._issue_number |
| |
| @property |
| def branch_repo_name(self) -> str: |
| return self._branch_repo_name |
| |
| @property |
| def branch_repo_token(self) -> str: |
| return self._branch_repo_token |
| |
| @property |
| def llvm_project_dir(self) -> str: |
| return self._llvm_project_dir |
| |
| @property |
| def phab_token(self) -> str: |
| return self._phab_token |
| |
| @property |
| def repo(self) -> github.Repository.Repository: |
| return github.Github(self.token).get_repo(self.repo_name) |
| |
| @property |
| def issue(self) -> github.Issue.Issue: |
| return self.repo.get_issue(self.issue_number) |
| |
| @property |
| def push_url(self) -> str: |
| return "https://{}@github.com/{}".format( |
| self.branch_repo_token, self.branch_repo_name |
| ) |
| |
| @property |
| def branch_name(self) -> str: |
| return "issue{}".format(self.issue_number) |
| |
| @property |
| def release_branch_for_issue(self) -> Optional[str]: |
| issue = self.issue |
| milestone = issue.milestone |
| if milestone is None: |
| return None |
| m = re.search("branch: (.+)", milestone.description) |
| if m: |
| return m.group(1) |
| return None |
| |
| def print_release_branch(self) -> None: |
| print(self.release_branch_for_issue) |
| |
| def issue_notify_branch(self) -> None: |
| self.issue.create_comment( |
| "/branch {}/{}".format(self.branch_repo_name, self.branch_name) |
| ) |
| |
| def issue_notify_pull_request(self, pull: github.PullRequest.PullRequest) -> None: |
| self.issue.create_comment( |
| "/pull-request {}#{}".format(self.branch_repo_name, pull.number) |
| ) |
| |
| def make_ignore_comment(self, comment: str) -> str: |
| """ |
| Returns the comment string with a prefix that will cause |
| a Github workflow to skip parsing this comment. |
| |
| :param str comment: The comment to ignore |
| """ |
| return "<!--IGNORE-->\n" + comment |
| |
| def issue_notify_no_milestone(self, comment: List[str]) -> None: |
| message = "{}\n\nError: Command failed due to missing milestone.".format( |
| "".join([">" + line for line in comment]) |
| ) |
| self.issue.create_comment(self.make_ignore_comment(message)) |
| |
| @property |
| def action_url(self) -> str: |
| if os.getenv("CI"): |
| return "https://github.com/{}/actions/runs/{}".format( |
| os.getenv("GITHUB_REPOSITORY"), os.getenv("GITHUB_RUN_ID") |
| ) |
| return "" |
| |
| def issue_notify_cherry_pick_failure( |
| self, commit: str |
| ) -> github.IssueComment.IssueComment: |
| message = self.make_ignore_comment( |
| "Failed to cherry-pick: {}\n\n".format(commit) |
| ) |
| action_url = self.action_url |
| if action_url: |
| message += action_url + "\n\n" |
| 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>`" |
| issue = self.issue |
| comment = issue.create_comment(message) |
| issue.add_to_labels(self.CHERRY_PICK_FAILED_LABEL) |
| return comment |
| |
| def issue_notify_pull_request_failure( |
| self, branch: str |
| ) -> github.IssueComment.IssueComment: |
| message = "Failed to create pull request for {} ".format(branch) |
| message += self.action_url |
| return self.issue.create_comment(message) |
| |
| def issue_remove_cherry_pick_failed_label(self): |
| if self.CHERRY_PICK_FAILED_LABEL in [l.name for l in self.issue.labels]: |
| self.issue.remove_from_labels(self.CHERRY_PICK_FAILED_LABEL) |
| |
| def pr_request_review(self, pr: github.PullRequest.PullRequest): |
| """ |
| This function will try to find the best reviewers for `commits` and |
| then add a comment requesting review of the backport and assign the |
| pull request to the selected reviewers. |
| |
| The reviewers selected are those users who approved the patch in |
| Phabricator. |
| """ |
| reviewers = [] |
| for commit in pr.get_commits(): |
| approvers = phab_get_commit_approvers(self.phab_token, commit) |
| for a in approvers: |
| login = phab_login_to_github_login(self.phab_token, self.repo, a) |
| if not login: |
| continue |
| reviewers.append(login) |
| if len(reviewers): |
| message = "{} What do you think about merging this PR to the release branch?".format( |
| " ".join(["@" + r for r in reviewers]) |
| ) |
| pr.create_issue_comment(message) |
| pr.add_to_assignees(*reviewers) |
| |
| def create_branch(self, commits: List[str]) -> bool: |
| """ |
| This function attempts to backport `commits` into the branch associated |
| with `self.issue_number`. |
| |
| If this is successful, then the branch is pushed to `self.branch_repo_name`, if not, |
| a comment is added to the issue saying that the cherry-pick failed. |
| |
| :param list commits: List of commits to cherry-pick. |
| |
| """ |
| print("cherry-picking", commits) |
| branch_name = self.branch_name |
| local_repo = Repo(self.llvm_project_dir) |
| local_repo.git.checkout(self.release_branch_for_issue) |
| |
| for c in commits: |
| try: |
| local_repo.git.cherry_pick("-x", c) |
| except Exception as e: |
| self.issue_notify_cherry_pick_failure(c) |
| raise e |
| |
| push_url = self.push_url |
| print("Pushing to {} {}".format(push_url, branch_name)) |
| local_repo.git.push(push_url, "HEAD:{}".format(branch_name), force=True) |
| |
| self.issue_notify_branch() |
| self.issue_remove_cherry_pick_failed_label() |
| return True |
| |
| def check_if_pull_request_exists( |
| self, repo: github.Repository.Repository, head: str |
| ) -> bool: |
| pulls = repo.get_pulls(head=head) |
| return pulls.totalCount != 0 |
| |
| def create_pull_request(self, owner: str, repo_name: str, branch: str) -> bool: |
| """ |
| reate a pull request in `self.branch_repo_name`. The base branch of the |
| pull request will be chosen based on the the milestone attached to |
| the issue represented by `self.issue_number` For example if the milestone |
| is Release 13.0.1, then the base branch will be release/13.x. `branch` |
| will be used as the compare branch. |
| https://docs.github.com/en/get-started/quickstart/github-glossary#base-branch |
| https://docs.github.com/en/get-started/quickstart/github-glossary#compare-branch |
| """ |
| repo = github.Github(self.token).get_repo(self.branch_repo_name) |
| issue_ref = "{}#{}".format(self.repo_name, self.issue_number) |
| pull = None |
| release_branch_for_issue = self.release_branch_for_issue |
| if release_branch_for_issue is None: |
| return False |
| head_branch = branch |
| if not repo.fork: |
| # If the target repo is not a fork of llvm-project, we need to copy |
| # the branch into the target repo. GitHub only supports cross-repo pull |
| # requests on forked repos. |
| head_branch = f"{owner}-{branch}" |
| local_repo = Repo(self.llvm_project_dir) |
| push_done = False |
| for _ in range(0, 5): |
| try: |
| local_repo.git.fetch( |
| f"https://github.com/{owner}/{repo_name}", f"{branch}:{branch}" |
| ) |
| local_repo.git.push( |
| self.push_url, f"{branch}:{head_branch}", force=True |
| ) |
| push_done = True |
| break |
| except Exception as e: |
| print(e) |
| time.sleep(30) |
| continue |
| if not push_done: |
| raise Exception("Failed to mirror branch into {}".format(self.push_url)) |
| owner = repo.owner.login |
| |
| head = f"{owner}:{head_branch}" |
| if self.check_if_pull_request_exists(repo, head): |
| print("PR already exists...") |
| return True |
| try: |
| pull = repo.create_pull( |
| title=f"PR for {issue_ref}", |
| body="resolves {}".format(issue_ref), |
| base=release_branch_for_issue, |
| head=head, |
| maintainer_can_modify=False, |
| ) |
| |
| try: |
| if self.phab_token: |
| self.pr_request_review(pull) |
| except Exception as e: |
| print("error: Failed while searching for reviewers", e) |
| |
| except Exception as e: |
| self.issue_notify_pull_request_failure(branch) |
| raise e |
| |
| if pull is None: |
| return False |
| |
| self.issue_notify_pull_request(pull) |
| self.issue_remove_cherry_pick_failed_label() |
| |
| # TODO(tstellar): Do you really want to always return True? |
| return True |
| |
| def execute_command(self) -> bool: |
| """ |
| This function reads lines from STDIN and executes the first command |
| that it finds. The 2 supported commands are: |
| /cherry-pick commit0 <commit1> <commit2> <...> |
| /branch <owner>/<repo>/<branch> |
| """ |
| for line in sys.stdin: |
| line.rstrip() |
| m = re.search(r"/([a-z-]+)\s(.+)", line) |
| if not m: |
| continue |
| command = m.group(1) |
| args = m.group(2) |
| |
| if command == "cherry-pick": |
| arg_list = args.split() |
| commits = list(map(lambda a: extract_commit_hash(a), arg_list)) |
| return self.create_branch(commits) |
| |
| if command == "branch": |
| m = re.match("([^/]+)/([^/]+)/(.+)", args) |
| if m: |
| owner = m.group(1) |
| repo = m.group(2) |
| branch = m.group(3) |
| return self.create_pull_request(owner, repo, branch) |
| |
| print("Do not understand input:") |
| print(sys.stdin.readlines()) |
| return False |
| |
| |
| parser = argparse.ArgumentParser() |
| parser.add_argument( |
| "--token", type=str, required=True, help="GitHub authentiation token" |
| ) |
| parser.add_argument( |
| "--repo", |
| type=str, |
| default=os.getenv("GITHUB_REPOSITORY", "llvm/llvm-project"), |
| help="The GitHub repository that we are working with in the form of <owner>/<repo> (e.g. llvm/llvm-project)", |
| ) |
| subparsers = parser.add_subparsers(dest="command") |
| |
| issue_subscriber_parser = subparsers.add_parser("issue-subscriber") |
| issue_subscriber_parser.add_argument("--label-name", type=str, required=True) |
| issue_subscriber_parser.add_argument("--issue-number", type=int, required=True) |
| |
| release_workflow_parser = subparsers.add_parser("release-workflow") |
| release_workflow_parser.add_argument( |
| "--llvm-project-dir", |
| type=str, |
| default=".", |
| help="directory containing the llvm-project checout", |
| ) |
| release_workflow_parser.add_argument( |
| "--issue-number", type=int, required=True, help="The issue number to update" |
| ) |
| release_workflow_parser.add_argument( |
| "--phab-token", |
| type=str, |
| help="Phabricator conduit API token. See https://reviews.llvm.org/settings/user/<USER>/page/apitokens/", |
| ) |
| release_workflow_parser.add_argument( |
| "--branch-repo-token", |
| type=str, |
| help="GitHub authentication token to use for the repository where new branches will be pushed. Defaults to TOKEN.", |
| ) |
| release_workflow_parser.add_argument( |
| "--branch-repo", |
| type=str, |
| default="llvm/llvm-project-release-prs", |
| help="The name of the repo where new branches will be pushed (e.g. llvm/llvm-project)", |
| ) |
| release_workflow_parser.add_argument( |
| "sub_command", |
| type=str, |
| choices=["print-release-branch", "auto"], |
| help="Print to stdout the name of the release branch ISSUE_NUMBER should be backported to", |
| ) |
| |
| llvmbot_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", |
| ) |
| |
| args = parser.parse_args() |
| |
| if args.command == "issue-subscriber": |
| issue_subscriber = IssueSubscriber( |
| args.token, args.repo, args.issue_number, args.label_name |
| ) |
| issue_subscriber.run() |
| elif args.command == "release-workflow": |
| release_workflow = ReleaseWorkflow( |
| args.token, |
| args.repo, |
| args.issue_number, |
| args.branch_repo, |
| args.branch_repo_token, |
| args.llvm_project_dir, |
| args.phab_token, |
| ) |
| if not release_workflow.release_branch_for_issue: |
| release_workflow.issue_notify_no_milestone(sys.stdin.readlines()) |
| sys.exit(1) |
| if args.sub_command == "print-release-branch": |
| release_workflow.print_release_branch() |
| else: |
| if not release_workflow.execute_command(): |
| sys.exit(1) |
| elif args.command == "setup-llvmbot-git": |
| setup_llvmbot_git() |