Skip to content

Commit

Permalink
Add Action to add cc'ed people as reviewers (#9934)
Browse files Browse the repository at this point in the history
* 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.

* Fix lint and add tests

* Add Action to add cc'ed people as reviewers

This provides a mechanism for non-triager/reviewer/committer PR authors to request reviews through GitHub. Anyone that is referenced by `cc @username` in a PR body will be added as a reviewer (GitHub will limit the reviewers to those with actual permissions to leave reviews so the script to add can be simple).

* remove merge bot stuff

* Fix target triggers

Co-authored-by: driazati <driazati@users.noreply.github.com>
  • Loading branch information
driazati and driazati committed Jan 19, 2022
1 parent f9171f1 commit bae144c
Show file tree
Hide file tree
Showing 5 changed files with 243 additions and 48 deletions.
46 changes: 46 additions & 0 deletions .github/workflows/cc_bot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# 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.

# GH actions.
# We use it to cover windows and mac builds
# Jenkins is still the primary CI

name: PR

on:
# See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target
pull_request_target:
types: [assigned, opened, synchronize, reopened, edited, ready_for_review]

concurrency:
group: PR-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
cc-reviewers:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
submodules: "recursive"
- name: Add cc'ed reviewers
env:
PR: ${{ toJson(github.event.pull_request) }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eux
python tests/scripts/github_cc_reviewers.py
28 changes: 28 additions & 0 deletions tests/python/unittest/test_ci.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,41 @@
import pathlib
import subprocess
import sys
import json
import tempfile

import pytest

REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent.parent.parent


def test_cc_reviewers():
reviewers_script = REPO_ROOT / "tests" / "scripts" / "github_cc_reviewers.py"

def run(pr_body, expected_reviewers):
proc = subprocess.run(
[str(reviewers_script), "--dry-run"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env={"PR": json.dumps({"number": 1, "body": pr_body})},
encoding="utf-8",
)
if proc.returncode != 0:
raise RuntimeError(f"Process failed:\nstdout:\n{proc.stdout}\n\nstderr:\n{proc.stderr}")

assert proc.stdout.strip().endswith(f"Adding reviewers: {expected_reviewers}")

run(pr_body="abc", expected_reviewers=[])
run(pr_body="cc @abc", expected_reviewers=["abc"])
run(pr_body="cc @", expected_reviewers=[])
run(pr_body="cc @abc @def", expected_reviewers=["abc", "def"])
run(pr_body="some text cc @abc @def something else", expected_reviewers=["abc", "def"])
run(
pr_body="some text cc @abc @def something else\n\n another cc @zzz z",
expected_reviewers=["abc", "def", "zzz"],
)


def test_skip_ci():
skip_ci_script = REPO_ROOT / "tests" / "scripts" / "git_skip_ci.py"

Expand Down
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
95 changes: 95 additions & 0 deletions tests/scripts/git_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/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
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()
73 changes: 73 additions & 0 deletions tests/scripts/github_cc_reviewers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#!/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
import re
from typing import Dict, Any, List


from git_utils import git, GitHubRepo, parse_remote


def find_reviewers(body: str) -> List[str]:
print(f"Parsing body:\n{body}")
matches = re.findall(r"(cc( @[-A-Za-z0-9]+)+)", body, flags=re.MULTILINE)
matches = [full for full, last in matches]

print("Found matches:", matches)
reviewers = []
for match in matches:
if match.startswith("cc "):
match = match.replace("cc ", "")
users = [x.strip() for x in match.split("@")]
reviewers += users

reviewers = set(x for x in reviewers if x != "")
return sorted(list(reviewers))


if __name__ == "__main__":
help = "Add @cc'ed people in a PR body as reviewers"
parser = argparse.ArgumentParser(description=help)
parser.add_argument("--remote", default="origin", help="ssh remote to parse")
parser.add_argument(
"--dry-run",
action="store_true",
default=False,
help="run but don't send any request to GitHub",
)
args = parser.parse_args()

remote = git(["config", "--get", f"remote.{args.remote}.url"])
user, repo = parse_remote(remote)

pr = json.loads(os.environ["PR"])

number = pr["number"]
body = pr["body"]
if body is None:
body = ""

to_add = find_reviewers(body)
print("Adding reviewers:", to_add)

if not args.dry_run:
github = GitHubRepo(token=os.environ["GITHUB_TOKEN"], user=user, repo=repo)
github.post(f"pulls/{number}/requested_reviewers", {"reviewers": to_add})

0 comments on commit bae144c

Please sign in to comment.