Skip to content

Commit

Permalink
Update
Browse files Browse the repository at this point in the history
  • Loading branch information
driazati committed Jan 21, 2022
1 parent 25c8f4c commit ea2665a
Show file tree
Hide file tree
Showing 4 changed files with 304 additions and 1 deletion.
44 changes: 44 additions & 0 deletions .github/workflows/ready_for_merge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# 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: Label Mergeable PRs

on:
schedule:
- cron: "0/15 * * * *"
workflow_dispatch:

concurrency:
group: Mergeable-${{ github.event.pull_request.number || github.sha }}-${{ github.event_name == 'workflow_dispatch' }}
cancel-in-progress: true

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
submodules: "recursive"
- name: Check and mark PRs ready for merge
env:
SHA: ${{ github.event.pull_request.head.sha || github.event.commit.sha }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eux
python tests/scripts/github_label_mergeable_prs.py || echo task failed
86 changes: 86 additions & 0 deletions tests/python/unittest/test_ci.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,92 @@ def run(pr_body, expected_reviewers):
)


def test_pr_is_mergeable():
is_mergeable_script = REPO_ROOT / "tests" / "scripts" / "github_label_mergeable_prs.py"

def run(decision, statuses, mergeable):
# Mock out the response from GitHub's API
pr = {
"reviewDecision": decision,
"number": 123,
"commits": {
"nodes": [{"commit": {"statusCheckRollup": {"contexts": {"nodes": statuses}}}}]
},
}
data = {
"data": {
"repository": {
"pullRequests": {
"nodes": [pr],
"edges": [],
}
}
}
}
proc = subprocess.run(
[str(is_mergeable_script), "--pr-json", json.dumps(data), "--dry-run"],
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 123 passed CI and is approved, labelling" in proc.stdout
else:
assert "PR 123 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"

Expand Down
11 changes: 10 additions & 1 deletion tests/scripts/git_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ def headers(self):
}

def graphql(self, query: str) -> Dict[str, Any]:
return self._post("https://api.github.com/graphql", {"query": query})
res = self._post("https://api.github.com/graphql", {"query": compress_query(query)})
if "data" not in res:
raise RuntimeError(f"Error querying GraphQL: {res}")
return res

def _post(self, full_url: str, body: Dict[str, Any]) -> Dict[str, Any]:
print("Requesting POST to", full_url, "with", body)
Expand Down Expand Up @@ -70,6 +73,12 @@ def delete(self, url: str) -> Dict[str, Any]:
return response


def compress_query(query: str) -> str:
query = query.replace("\n", "")
query = re.sub("\s+", " ", query)
return query


def parse_remote(remote: str) -> Tuple[str, str]:
"""
Get a GitHub (user, repo) pair out of a git remote
Expand Down
164 changes: 164 additions & 0 deletions tests/scripts/github_label_mergeable_prs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
#!/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 Any

from git_utils import git, GitHubRepo, parse_remote


_pr_query_fields = """
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 prs_query(user: str, repo: str, cursor: str = None):
after = ""
if cursor is not None:
after = f', before:"{cursor}"'
return f"""
{{
repository(name: "{repo}", owner: "{user}") {{
pullRequests(states: [OPEN], last: 10{after}) {{
edges {{
cursor
}}
nodes {{
{_pr_query_fields}
}}
}}
}}
}}
"""


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)

commit = data["commits"]["nodes"][0]["commit"]

if commit["statusCheckRollup"] is None:
# No statuses, not mergeable
return False

statuses = 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("--remote", default="origin", help="ssh remote to parse")
parser.add_argument("--dry-run", action="store_true", help="don't submit to GitHub")
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)

if args.pr_json:
pr = json.loads(args.pr_json)
else:
pass

if args.pr_json:
r = json.loads(args.pr_json)
else:
q = prs_query(user, repo)
r = github.graphql(q)

# Loop until all PRs have been checked
while True:
prs = r["data"]["repository"]["pullRequests"]["nodes"]

for pr in prs:
print(f"Checking PR {pr['number']}")
if is_pr_ready(pr):
print(f"PR {pr['number']} passed CI and is approved, labelling...")
if not args.dry_run:
github.post(f"issues/{pr['number']}/labels", {"labels": [args.label]})
else:
print(f"PR {pr['number']} is not ready for merge")
if not args.dry_run:
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)")

edges = r["data"]["repository"]["pullRequests"]["edges"]
if len(edges) == 0:
# No more results to check
break

cursor = edges[0]["cursor"]
r = github.graphql(prs_query(user, repo, cursor))

0 comments on commit ea2665a

Please sign in to comment.