diff --git a/.github/workflows/ready_for_merge.yml b/.github/workflows/ready_for_merge.yml index 28f745f0ddb1..4e574ea39773 100644 --- a/.github/workflows/ready_for_merge.yml +++ b/.github/workflows/ready_for_merge.yml @@ -40,4 +40,4 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -eux - python check_pr_is_ready.py --sha "$SHA" + python tests/scripts/github_check_pr_is_mergeable.py --sha "$SHA" diff --git a/tests/python/unittest/test_ci.py b/tests/python/unittest/test_ci.py index ac7e6cdd7c29..d3b777cb082b 100644 --- a/tests/python/unittest/test_ci.py +++ b/tests/python/unittest/test_ci.py @@ -18,6 +18,7 @@ import pathlib import subprocess import sys +import json import tempfile import pytest @@ -25,6 +26,81 @@ REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent.parent.parent +def test_pr_is_mergable(): + is_mergable_script = REPO_ROOT / "tests" / "scripts" / "github_check_pr_is_mergeable.py" + + def run(decision, statuses, mergeable): + # Mock out the response from GitHub's API + data = { + "reviewDecision": decision, + "commits": { + "nodes": [{"commit": {"statusCheckRollup": {"contexts": {"nodes": statuses}}}}] + }, + } + proc = subprocess.run( + [str(is_mergable_script), "--pr-json", json.dumps(data)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + if proc.returncode != 0: + raise RuntimeError(f"Process failed:\nstdout:\n{proc.stdout}\n\nstderr:\n{proc.stderr}") + + # Find the relevant string in the output + if mergeable: + assert "PR passed CI and is approved, labelling" in proc.stdout + else: + assert "PR is not ready for merge" in proc.stdout + + # mergeable should be true iff all statuses are successful and PR is approved + run(decision="CHANGES_REQUESTED", statuses=[], mergeable=False) + run(decision="APPROVED", statuses=[], mergeable=True) + run( + decision="CHANGES_REQUESTED", + statuses=[ + { + "context": "abc", + "state": "FAILED", + } + ], + mergeable=False, + ) + run( + decision="APPROVED", + statuses=[ + { + "context": "abc", + "state": "FAILED", + } + ], + mergeable=False, + ) + run( + decision="APPROVED", + statuses=[ + { + "context": "abc", + "state": "SUCCESS", + } + ], + mergeable=True, + ) + run( + decision="APPROVED", + statuses=[ + { + "context": "abc", + "state": "SUCCESS", + }, + { + "context": "abc2", + "state": "FAILURE", + }, + ], + mergeable=False, + ) + + def test_skip_ci(): skip_ci_script = REPO_ROOT / "tests" / "scripts" / "git_skip_ci.py" diff --git a/tests/scripts/git_skip_ci.py b/tests/scripts/git_skip_ci.py index ac63886c1d91..c4b88676c34f 100755 --- a/tests/scripts/git_skip_ci.py +++ b/tests/scripts/git_skip_ci.py @@ -19,7 +19,7 @@ import os import argparse -from .git_utils import git, GitHubRepo, parse_remote +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 index 262f20af4e23..f2927f1e3ab7 100644 --- a/tests/scripts/git_utils.py +++ b/tests/scripts/git_utils.py @@ -1,3 +1,21 @@ +#!/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 json import subprocess import re diff --git a/tests/scripts/github_check_pr_is_mergeable.py b/tests/scripts/github_check_pr_is_mergeable.py index 03859416ecd7..7a286edbe23e 100755 --- a/tests/scripts/github_check_pr_is_mergeable.py +++ b/tests/scripts/github_check_pr_is_mergeable.py @@ -22,7 +22,7 @@ from urllib import error from typing import Dict, Tuple, Any -from .git_utils import git, GitHubRepo, parse_remote +from git_utils import git, GitHubRepo, parse_remote def commit_query(repo: str, user: str, sha: str) -> str: @@ -102,25 +102,39 @@ def is_pr_ready(data: Any) -> bool: 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("--sha") parser.add_argument("--remote", default="origin", help="ssh remote to parse") parser.add_argument("--label", default="ready-for-merge", help="label to add") + parser.add_argument( + "--pr-json", help="(testing) PR data to use instead of fetching from GitHub" + ) 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] + is_testing = args.pr_json is not None + if not is_testing and args.sha is None: + print("--sha must be used outside of testing") + exit(1) + + if args.pr_json: + pr = json.loads(args.pr_json) + else: + 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]}) + if not is_testing: + 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)") + if not is_testing: + 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)")