Skip to content

Commit 3399bc4

Browse files
committed
Add bot to ping reviewers after no activity
1 parent 74a2fa8 commit 3399bc4

File tree

3 files changed

+310
-0
lines changed

3 files changed

+310
-0
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
name: Ping Reviewers
2+
on:
3+
schedule:
4+
- cron: "0/15 * * * *"
5+
workflow_dispatch:
6+
7+
concurrency:
8+
group: ping
9+
cancel-in-progress: true
10+
11+
jobs:
12+
ping:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v2
16+
- name: Ping reviewers
17+
env:
18+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
19+
run: |
20+
set -eux
21+
python tests/scripts/ping_reviewers.py --wait-time-minutes 1440

tests/python/unittest/test_ci.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,72 @@ def run(pr_body, expected_reviewers):
5353
)
5454

5555

56+
def test_ping_reviewers():
57+
reviewers_script = REPO_ROOT / "tests" / "scripts" / "ping_reviewers.py"
58+
59+
def run(pr, check):
60+
data = {
61+
"data": {
62+
"repository": {
63+
"pullRequests": {
64+
"nodes": [pr],
65+
"edges": [],
66+
}
67+
}
68+
}
69+
}
70+
proc = subprocess.run(
71+
[
72+
str(reviewers_script),
73+
"--dry-run",
74+
"--wait-time-minutes",
75+
"1",
76+
"--cutoff-pr-number",
77+
"5",
78+
"--allowlist",
79+
"user",
80+
"--pr-json",
81+
json.dumps(data),
82+
],
83+
stdout=subprocess.PIPE,
84+
stderr=subprocess.PIPE,
85+
encoding="utf-8",
86+
)
87+
if proc.returncode != 0:
88+
raise RuntimeError(f"Process failed:\nstdout:\n{proc.stdout}\n\nstderr:\n{proc.stderr}")
89+
90+
assert check in proc.stdout
91+
92+
run(
93+
{
94+
"isDraft": True,
95+
},
96+
"Checking 0 PRs",
97+
)
98+
99+
run(
100+
{
101+
"isDraft": False,
102+
"number": 2,
103+
},
104+
"Checking 0 PRs",
105+
)
106+
107+
run(
108+
{
109+
"number": 123,
110+
"url": "https://github.com/apache/tvm/pull/123",
111+
"body": "cc @someone",
112+
"isDraft": False,
113+
"author": {"login": "user"},
114+
"reviews": {"nodes": []},
115+
"publishedAt": "2022-01-18T17:54:19Z",
116+
"comments": {"nodes": []},
117+
},
118+
"Pinging reviewers ['someone'] on https://github.com/apache/tvm/pull/123",
119+
)
120+
121+
56122
def test_skip_ci():
57123
skip_ci_script = REPO_ROOT / "tests" / "scripts" / "git_skip_ci.py"
58124

tests/scripts/ping_reviewers.py

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
#!/usr/bin/env python3
2+
# Licensed to the Apache Software Foundation (ASF) under one
3+
# or more contributor license agreements. See the NOTICE file
4+
# distributed with this work for additional information
5+
# regarding copyright ownership. The ASF licenses this file
6+
# to you under the Apache License, Version 2.0 (the
7+
# "License"); you may not use this file except in compliance
8+
# with the License. You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing,
13+
# software distributed under the License is distributed on an
14+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
# KIND, either express or implied. See the License for the
16+
# specific language governing permissions and limitations
17+
# under the License.
18+
19+
import os
20+
import argparse
21+
import re
22+
import datetime
23+
import json
24+
from typing import Dict, Any, List
25+
26+
from git_utils import git, GitHubRepo, parse_remote
27+
28+
29+
def prs_query(user: str, repo: str, cursor: str = None):
30+
after = ""
31+
if cursor is not None:
32+
after = f', before:"{cursor}"'
33+
return f"""
34+
{{
35+
repository(name: "{repo}", owner: "{user}") {{
36+
pullRequests(states: [OPEN], last: 10{after}) {{
37+
edges {{
38+
cursor
39+
}}
40+
nodes {{
41+
number
42+
url
43+
body
44+
isDraft
45+
author {{
46+
login
47+
}}
48+
reviews(last:100) {{
49+
nodes {{
50+
author {{ login }}
51+
comments(last:100) {{
52+
nodes {{
53+
updatedAt
54+
bodyText
55+
}}
56+
}}
57+
}}
58+
}}
59+
publishedAt
60+
comments(last:100) {{
61+
nodes {{
62+
authorAssociation
63+
bodyText
64+
updatedAt
65+
author {{
66+
login
67+
}}
68+
}}
69+
}}
70+
}}
71+
}}
72+
}}
73+
}}
74+
"""
75+
76+
77+
def find_reviewers(body: str) -> List[str]:
78+
matches = re.findall(r"(cc( @[-A-Za-z0-9]+)+)", body, flags=re.MULTILINE)
79+
matches = [full for full, last in matches]
80+
81+
reviewers = []
82+
for match in matches:
83+
if match.startswith("cc "):
84+
match = match.replace("cc ", "")
85+
users = [x.strip() for x in match.split("@")]
86+
reviewers += users
87+
88+
reviewers = set(x for x in reviewers if x != "")
89+
return list(reviewers)
90+
91+
92+
def check_pr(pr, wait_time):
93+
published_at = datetime.datetime.strptime(pr["publishedAt"], "%Y-%m-%dT%H:%M:%SZ")
94+
last_action = published_at
95+
96+
# GitHub counts comments left as part of a review separately than standalone
97+
# comments
98+
reviews = pr["reviews"]["nodes"]
99+
review_comments = []
100+
for review in reviews:
101+
review_comments += review["comments"]["nodes"]
102+
103+
# Collate all comments
104+
comments = pr["comments"]["nodes"] + review_comments
105+
106+
# Find the last date of any comment
107+
for comment in comments:
108+
commented_at = datetime.datetime.strptime(comment["updatedAt"], "%Y-%m-%dT%H:%M:%SZ")
109+
if commented_at > last_action:
110+
last_action = commented_at
111+
112+
time_since_last_action = datetime.datetime.utcnow() - last_action
113+
114+
# Find reviewers in the PR's body
115+
pr_body_reviewers = find_reviewers(pr["body"])
116+
117+
# Pull out reviewers from any cc @... text in a comment
118+
cc_reviewers = [find_reviewers(c["bodyText"]) for c in comments]
119+
cc_reviewers = [r for revs in cc_reviewers for r in revs]
120+
121+
# Anyone that has left a review as a reviewer (this may include the PR
122+
# author since their responses count as reviews)
123+
review_reviewers = list(set(r["author"]["login"] for r in reviews))
124+
125+
reviewers = cc_reviewers + review_reviewers + pr_body_reviewers
126+
127+
if time_since_last_action > wait_time:
128+
print(
129+
" Pinging reviewers",
130+
reviewers,
131+
"on",
132+
pr["url"],
133+
"since it has been",
134+
time_since_last_action,
135+
"since anything happened on that PR",
136+
)
137+
return reviewers
138+
139+
return None
140+
141+
142+
def ping_reviewers(pr, reviewers):
143+
reviewers = [f"@{r}" for r in reviewers]
144+
text = (
145+
"It has been a while since this PR was updated, "
146+
+ " ".join(reviewers)
147+
+ " please leave a review or address the outstanding comments"
148+
)
149+
r = github.post(f"issues/{pr['number']}/comments", {"body": text})
150+
print(r)
151+
152+
153+
if __name__ == "__main__":
154+
help = "Comment on languishing issues and PRs"
155+
parser = argparse.ArgumentParser(description=help)
156+
parser.add_argument("--remote", default="origin", help="ssh remote to parse")
157+
parser.add_argument("--wait-time-minutes", required=True, type=int, help="ssh remote to parse")
158+
parser.add_argument("--cutoff-pr-number", default=0, type=int, help="ssh remote to parse")
159+
parser.add_argument("--dry-run", action="store_true", help="don't update GitHub")
160+
parser.add_argument("--allowlist", help="filter by these PR authors")
161+
parser.add_argument("--pr-json", help="(testing) data for testing to use instead of GitHub")
162+
args = parser.parse_args()
163+
164+
remote = git(["config", "--get", f"remote.{args.remote}.url"])
165+
user, repo = parse_remote(remote)
166+
167+
wait_time = datetime.timedelta(minutes=int(args.wait_time_minutes))
168+
cutoff_pr_number = int(args.cutoff_pr_number)
169+
print(
170+
"Running with:\n"
171+
f" time cutoff: {wait_time}\n"
172+
f" number cutoff: {cutoff_pr_number}\n"
173+
f" dry run: {args.dry_run}\n"
174+
f" user/repo: {user}/{repo}\n",
175+
end="",
176+
)
177+
178+
# [slow rollout]
179+
# This code is here to gate this feature to a limited set of people before
180+
# deploying it for everyone to avoid spamming in the case of bugs or
181+
# ongoing development.
182+
if args.allowlist:
183+
author_allowlist = args.allowlist.split(",")
184+
else:
185+
github = GitHubRepo(token=os.environ["GITHUB_TOKEN"], user=user, repo=repo)
186+
allowlist_issue = github.get("issues/9983")
187+
author_allowlist = set(find_reviewers(allowlist_issue["body"]))
188+
189+
if args.pr_json:
190+
r = json.loads(args.pr_json)
191+
else:
192+
q = prs_query(user, repo)
193+
r = github.graphql(q)
194+
195+
# Loop until all PRs have been checked
196+
while True:
197+
prs = r["data"]["repository"]["pullRequests"]["nodes"]
198+
199+
# Don't look at draft PRs at all
200+
prs = [pr for pr in prs if not pr["isDraft"]]
201+
202+
# Don't look at super old PRs
203+
prs = [pr for pr in prs if pr["number"] > cutoff_pr_number]
204+
205+
# [slow rollout]
206+
prs = [pr for pr in prs if pr["author"]["login"] in author_allowlist]
207+
208+
print(f"Checking {len(prs)} PRs: {[pr['number'] for pr in prs]}")
209+
210+
# Ping reviewers on each PR in the response if necessary
211+
for pr in prs:
212+
print("Checking", pr["url"])
213+
reviewers = check_pr(pr, wait_time)
214+
if reviewers is not None and not args.dry_run:
215+
ping_reviewers(pr, reviewers)
216+
217+
edges = r["data"]["repository"]["pullRequests"]["edges"]
218+
if len(edges) == 0:
219+
# No more results to check
220+
break
221+
222+
cursor = edges[0]["cursor"]
223+
r = github.graphql(prs_query(user, repo, cursor))

0 commit comments

Comments
 (0)