Skip to content

Commit

Permalink
Add action to label mergeable PRs
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
driazati committed Jan 13, 2022
1 parent 7485413 commit be555cd
Show file tree
Hide file tree
Showing 4 changed files with 247 additions and 48 deletions.
43 changes: 43 additions & 0 deletions .github/workflows/ready_for_merge.yml
Original file line number Diff line number Diff line change
@@ -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"
49 changes: 1 addition & 48 deletions tests/scripts/git_skip_ci.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__":
Expand Down
77 changes: 77 additions & 0 deletions tests/scripts/git_utils.py
Original file line number Diff line number Diff line change
@@ -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()
126 changes: 126 additions & 0 deletions tests/scripts/github_check_pr_is_mergeable.py
Original file line number Diff line number Diff line change
@@ -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)")

0 comments on commit be555cd

Please sign in to comment.