From be555cdf38b9278ab905421946689a93bf452ecc Mon Sep 17 00:00:00 2001 From: driazati Date: Thu, 13 Jan 2022 14:37:50 -0800 Subject: [PATCH] Add action to label mergeable PRs Developers often have to ping a committer once their PRs are both passing in CI and are approved. This helps facilitate this process by marking such PRs with a label `ready-for-merge` so committers can easily filter for outstanding PRs that need attention. --- .github/workflows/ready_for_merge.yml | 43 ++++++ tests/scripts/git_skip_ci.py | 49 +------ tests/scripts/git_utils.py | 77 +++++++++++ tests/scripts/github_check_pr_is_mergeable.py | 126 ++++++++++++++++++ 4 files changed, 247 insertions(+), 48 deletions(-) create mode 100644 .github/workflows/ready_for_merge.yml create mode 100644 tests/scripts/git_utils.py create mode 100755 tests/scripts/github_check_pr_is_mergeable.py diff --git a/.github/workflows/ready_for_merge.yml b/.github/workflows/ready_for_merge.yml new file mode 100644 index 000000000000..28f745f0ddb1 --- /dev/null +++ b/.github/workflows/ready_for_merge.yml @@ -0,0 +1,43 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# Label PRs that have passed CI and are approved + +name: Merge + +on: + status: + pull_request_review: + +concurrency: + group: Merge-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + submodules: "recursive" + - name: Check if PR is ready + env: + SHA: ${{ github.event.pull_request.head.sha || github.event.commit.sha }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -eux + python check_pr_is_ready.py --sha "$SHA" diff --git a/tests/scripts/git_skip_ci.py b/tests/scripts/git_skip_ci.py index 73fcc6490ab8..ac63886c1d91 100755 --- a/tests/scripts/git_skip_ci.py +++ b/tests/scripts/git_skip_ci.py @@ -17,56 +17,9 @@ # under the License. import os -import json import argparse -import subprocess -import re -from urllib import request -from typing import Dict, Tuple, Any - -class GitHubRepo: - def __init__(self, user, repo, token): - self.token = token - self.user = user - self.repo = repo - self.base = f"https://api.github.com/repos/{user}/{repo}/" - - def headers(self): - return { - "Authorization": f"Bearer {self.token}", - } - - def get(self, url: str) -> Dict[str, Any]: - url = self.base + url - print("Requesting", url) - req = request.Request(url, headers=self.headers()) - with request.urlopen(req) as response: - response = json.loads(response.read()) - return response - - -def parse_remote(remote: str) -> Tuple[str, str]: - """ - Get a GitHub (user, repo) pair out of a git remote - """ - if remote.startswith("https://"): - # Parse HTTP remote - parts = remote.split("/") - if len(parts) < 2: - raise RuntimeError(f"Unable to parse remote '{remote}'") - return parts[-2], parts[-1].replace(".git", "") - else: - # Parse SSH remote - m = re.search(r":(.*)/(.*)\.git", remote) - if m is None or len(m.groups()) != 2: - raise RuntimeError(f"Unable to parse remote '{remote}'") - return m.groups() - - -def git(command): - proc = subprocess.run(["git"] + command, stdout=subprocess.PIPE, check=True) - return proc.stdout.decode().strip() +from .git_utils import git, GitHubRepo, parse_remote if __name__ == "__main__": diff --git a/tests/scripts/git_utils.py b/tests/scripts/git_utils.py new file mode 100644 index 000000000000..262f20af4e23 --- /dev/null +++ b/tests/scripts/git_utils.py @@ -0,0 +1,77 @@ +import json +import subprocess +import re +from urllib import request +from typing import Dict, Tuple, Any + + +class GitHubRepo: + def __init__(self, user, repo, token): + self.token = token + self.user = user + self.repo = repo + self.base = f"https://api.github.com/repos/{user}/{repo}/" + + def headers(self): + return { + "Authorization": f"Bearer {self.token}", + } + + def graphql(self, query: str) -> Dict[str, Any]: + return self._post("https://api.github.com/graphql", {"query": query}) + + def _post(self, full_url: str, body: Dict[str, Any]) -> Dict[str, Any]: + print("Requesting", full_url) + req = request.Request(full_url, headers=self.headers(), method="POST") + req.add_header("Content-Type", "application/json; charset=utf-8") + data = json.dumps(body) + data = data.encode("utf-8") + req.add_header("Content-Length", len(data)) + + with request.urlopen(req, data) as response: + response = json.loads(response.read()) + return response + + def post(self, url: str, data: Dict[str, Any]) -> Dict[str, Any]: + return self._post(self.base + url, data) + + def get(self, url: str) -> Dict[str, Any]: + url = self.base + url + print("Requesting", url) + req = request.Request(url, headers=self.headers()) + with request.urlopen(req) as response: + response = json.loads(response.read()) + return response + + def delete(self, url: str) -> Dict[str, Any]: + url = self.base + url + print("Requesting", url) + req = request.Request(url, headers=self.headers(), method="DELETE") + with request.urlopen(req) as response: + response = json.loads(response.read()) + return response + + +def parse_remote(remote: str) -> Tuple[str, str]: + """ + Get a GitHub (user, repo) pair out of a git remote + """ + if remote.startswith("https://"): + # Parse HTTP remote + parts = remote.split("/") + if len(parts) < 2: + raise RuntimeError(f"Unable to parse remote '{remote}'") + return parts[-2], parts[-1].replace(".git", "") + else: + # Parse SSH remote + m = re.search(r":(.*)/(.*)\.git", remote) + if m is None or len(m.groups()) != 2: + raise RuntimeError(f"Unable to parse remote '{remote}'") + return m.groups() + + +def git(command): + command = ["git"] + command + print("Running", command) + proc = subprocess.run(command, stdout=subprocess.PIPE, check=True) + return proc.stdout.decode().strip() diff --git a/tests/scripts/github_check_pr_is_mergeable.py b/tests/scripts/github_check_pr_is_mergeable.py new file mode 100755 index 000000000000..03859416ecd7 --- /dev/null +++ b/tests/scripts/github_check_pr_is_mergeable.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import os +import json +import argparse +from urllib import error +from typing import Dict, Tuple, Any + +from .git_utils import git, GitHubRepo, parse_remote + + +def commit_query(repo: str, user: str, sha: str) -> str: + """ + Build the GraphQL query to find a PR linked from a commit along with its + latest build status + """ + return f""" + {{ + repository(name: "{repo}", owner: "{user}") {{ + object(oid: "{sha}") {{ + ... on Commit {{ + associatedPullRequests(last:1) {{ + nodes {{ + number + reviewDecision + commits(last:1) {{ + nodes {{ + commit {{ + statusCheckRollup {{ + contexts(last:100) {{ + nodes {{ + ... on CheckRun {{ + conclusion + status + name + checkSuite {{ + workflowRun {{ + workflow {{ + name + }} + }} + }} + }} + ... on StatusContext {{ + context + state + }} + }} + }} + }} + }} + }} + }} + }} + }} + }} + }} + }} + }}""" + + +def is_pr_ready(data: Any) -> bool: + """ + Returns true if a PR is approved and all of its statuses are SUCCESS + """ + approved = data["reviewDecision"] == "APPROVED" + print("Is approved?", approved) + + statuses = data["commits"]["nodes"][0]["commit"]["statusCheckRollup"]["contexts"]["nodes"] + unified_statuses = [] + for status in statuses: + if "context" in status: + # Parse non-GHA status + unified_statuses.append((status["context"], status["state"] == "SUCCESS")) + else: + # Parse GitHub Actions item + workflow = status["checkSuite"]["workflowRun"]["workflow"]["name"] + name = f"{workflow} / {status['name']}" + unified_statuses.append((name, status["conclusion"] == "SUCCESS")) + + print("Got statuses:", json.dumps(unified_statuses, indent=2)) + passed_ci = all(status for name, status in unified_statuses) + return approved and passed_ci + + +if __name__ == "__main__": + help = "Adds label to PRs that have passed CI and are approved" + parser = argparse.ArgumentParser(description=help) + parser.add_argument("--sha", required=True) + parser.add_argument("--remote", default="origin", help="ssh remote to parse") + parser.add_argument("--label", default="ready-for-merge", help="label to add") + args = parser.parse_args() + + remote = git(["config", "--get", f"remote.{args.remote}.url"]) + user, repo = parse_remote(remote) + github = GitHubRepo(token=os.environ["GITHUB_TOKEN"], user=user, repo=repo) + + data = github.graphql(commit_query(repo, user, args.sha)) + pr = data["data"]["repository"]["object"]["associatedPullRequests"]["nodes"][0] + + if is_pr_ready(pr): + print("PR passed CI and is approved, labelling...") + github.post(f"issues/{pr['number']}/labels", {"labels": [args.label]}) + else: + print("PR is not ready for merge") + try: + github.delete(f"issues/{pr['number']}/labels/{args.label}") + except error.HTTPError as e: + print(e) + print("Failed to remove label (it may not have been there at all)")