Skip to content

Commit d08d3e0

Browse files
committed
Add automation to make reviews easier to manage
1 parent 8638fa9 commit d08d3e0

File tree

4 files changed

+258
-2
lines changed

4 files changed

+258
-2
lines changed

.dryrunsecurity.yaml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@ allowedAuthors:
6060
usernames:
6161
- mtesauro
6262
- devGregA
63-
- grendel513
6463
- cneill
6564
- Maffooch
6665
- blakeaowens
@@ -71,4 +70,3 @@ allowedAuthors:
7170
- valentijnscholten
7271
notificationList:
7372
- '@mtesauro'
74-
- '@grendel513'

.github/CODEOWNERS

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Any kind of package updates only need 2 approvals,
2+
# So let's add three folks here
3+
requirements.txt @cneill @mtesauro @Maffooch
4+
# Any dockerfile or compose changes will need to be viewed by
5+
# these people
6+
Dockerfile.* @mtesauro @Maffooch
7+
docker-compose.* @mtesauro @Maffooch
8+
/docker/ @mtesauro @Maffooch
9+
# Documentation changes
10+
/docs/ @paulOsinski @valentijnscholten @Maffooch
11+
# Kubernetes should be reviewed by reviewed first by those that know it
12+
/helm/ @cneill @kiblik
13+
# Anything UI related needs to be checked out by those with the eye for it
14+
/dojo/static/ @blakeaowens @Maffooch
15+
/dojo/templates/ @blakeaowens @Maffooch
16+
# Any model changes should be closely looked at
17+
/dojo/models.py @Maffooch

.github/pr-reminder.py

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import os
2+
import requests
3+
from datetime import datetime, timedelta
4+
5+
# Set up the GitHub and Slack tokens from environment variables
6+
GH_TOKEN = os.getenv("GH_TOKEN")
7+
SLACK_TOKEN = os.getenv("SLACK_TOKEN")
8+
REPO_OWNER = "DefectDojo"
9+
REPO_NAME = "django-DefectDojo"
10+
GITHUB_USER_NAME_TO_SLACK_EMAIL = {
11+
"Maffooch": "cody@defectdojo.com",
12+
"mtesauro": "matt@defectdojo.com",
13+
"devGregA": "greg@defectdojo.com",
14+
"blakeaowens": "blake@defectdojo.com",
15+
"dogboat": "sean@defectdojo.com",
16+
"cneill": "charles@defectdojo.com",
17+
"hblankenship": "harold@defectdojo.com",
18+
}
19+
20+
21+
# Helper function to calculate the prior Thursday from a given date
22+
def get_prior_thursday(date: datetime) -> str:
23+
# Calculate the day of the week (0=Monday, 1=Tuesday, ..., 6=Sunday)
24+
weekday = date.weekday()
25+
# If today is Thursday (weekday 3), return the same day.
26+
if weekday == 3:
27+
return date
28+
# Calculate how many days to subtract to reach the most recent Thursday
29+
days_to_subtract = (
30+
weekday - 3
31+
) % 7 # (weekday - 3) gives the number of days past Thursday
32+
prior_thursday = date - timedelta(days=days_to_subtract)
33+
34+
return prior_thursday.strftime("%Y-%m-%d")
35+
36+
37+
# Helper function to get Slack User ID from Slack Email
38+
def get_slack_user_id(slack_email: str) -> int:
39+
headers = {"Authorization": f"Bearer {SLACK_TOKEN}"}
40+
params = {"email": slack_email}
41+
response = requests.get(
42+
"https://slack.com/api/users.lookupByEmail", headers=headers, params=params
43+
)
44+
45+
if response.status_code != 200 or not response.json().get("ok"):
46+
print(f"Error fetching Slack user ID for email {slack_email}: {response.text}")
47+
return None
48+
49+
slack_user_id = response.json().get("user", {}).get("id")
50+
return slack_user_id
51+
52+
53+
# Helper function to fetch pull requests from GitHub
54+
def get_pull_requests() -> dict:
55+
headers = {"Authorization": f"token {GH_TOKEN}"}
56+
response = requests.get(
57+
f"https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/pulls", headers=headers
58+
)
59+
60+
if response.status_code != 200:
61+
print(f"Error fetching PRs: {response.text}")
62+
response.raise_for_status()
63+
64+
return response.json()
65+
66+
67+
# Helper function to get PR reviews (approved, changes requested, or pending)
68+
def get_pr_reviews(pull_request: dict) -> list[dict]:
69+
pr_number = pull_request["number"]
70+
reviews_url = f"https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/pulls/{pr_number}/reviews"
71+
headers = {"Authorization": f"token {GH_TOKEN}"}
72+
response = requests.get(reviews_url, headers=headers)
73+
74+
if response.status_code != 200:
75+
print(f"Error fetching reviews for PR {pr_number}: {response.text}")
76+
return []
77+
78+
reviews = response.json()
79+
# Dictionary to store the latest review for each user
80+
latest_reviews = {}
81+
# Iterate over each review to find the latest one for each user
82+
for review in reviews:
83+
user = review["user"]["login"]
84+
submitted_at = review["submitted_at"]
85+
state = review["state"]
86+
# Convert the submitted_at timestamp to a datetime object for comparison
87+
review_time = datetime.strptime(submitted_at, "%Y-%m-%dT%H:%M:%SZ")
88+
# If the user doesn't have a review or the current one is later, update
89+
if (
90+
user not in latest_reviews
91+
or review_time > latest_reviews[user]["submitted_at"]
92+
and state != "COMMENTED"
93+
):
94+
latest_reviews[user] = {
95+
"user": user,
96+
"state": state,
97+
"submitted_at": review_time,
98+
"url": review["html_url"],
99+
}
100+
# Determine if there are any pending reviewers
101+
latest_reviews.update(
102+
{
103+
user_dict.get("login"): {
104+
"user": user_dict.get("login"),
105+
"state": "PENDING",
106+
}
107+
for user_dict in pull_request.get("requested_reviewers", [])
108+
}
109+
)
110+
# Return the latest review state and URL for each user
111+
return latest_reviews.values()
112+
113+
114+
# Helper function to send a message via Slack
115+
def send_slack_message(slack_user_id: int, message: str) -> None:
116+
headers = {
117+
"Content-Type": "application/json",
118+
"Authorization": f"Bearer {SLACK_TOKEN}",
119+
}
120+
payload = {"channel": slack_user_id, "text": message}
121+
response = requests.post(
122+
"https://slack.com/api/chat.postMessage", json=payload, headers=headers
123+
)
124+
125+
if response.status_code != 200 or not response.json().get("ok"):
126+
print(f"Error sending Slack message: {response.text}")
127+
response.raise_for_status()
128+
129+
130+
# Helper function to format the PR message with review statuses
131+
def format_pr_message(pull_request: dict, reviews: list[dict]) -> str:
132+
repo_name = pull_request["head"]["repo"]["name"]
133+
pull_request_title = pull_request["title"]
134+
pull_request_url = pull_request["html_url"]
135+
pull_request_number = pull_request["number"]
136+
constructed_title = f"{repo_name} (#{pull_request_number}): {pull_request_title}"
137+
message = f"• <{pull_request_url}|{constructed_title}>"
138+
# Fetch the milestone due date and URL
139+
if (milestone := pull_request.get("milestone")) is not None and (
140+
(milestone_due_date := milestone.get("due_on"))
141+
and (milestone_url := milestone.get("html_url"))
142+
and (milestone_title := milestone.get("title"))
143+
):
144+
message += f"\n Merge by: {get_prior_thursday(datetime.strptime(milestone_due_date, '%Y-%m-%dT%H:%M:%SZ'))} for release <{milestone_url}|{milestone_title}>"
145+
# Format reviews and append to the message (only latest review status per user)
146+
message += "\n Review Status:\n"
147+
for review in reviews:
148+
user = review["user"]
149+
state = review["state"]
150+
if url := review.get("url"):
151+
message += f" • {user}: <{url}|{state.lower().capitalize()}>\n"
152+
else:
153+
message += f" • {user}: {state.lower().capitalize()}\n"
154+
155+
return message
156+
157+
158+
# Main function to process PRs and notify Slack users
159+
def notify_reviewers():
160+
try:
161+
user_pr_map = {}
162+
slack_email_to_slack_id = {}
163+
pull_requests = get_pull_requests()
164+
# Logging all fetched PR details
165+
print(f"Fetched {len(pull_requests)} PRs from GitHub.")
166+
for pull_request in pull_requests:
167+
title = pull_request["title"]
168+
pr_number = pull_request["number"]
169+
print(f"Processing PR: {pr_number} - {title}")
170+
reviews = get_pr_reviews(pull_request)
171+
print(f"Found {len(reviews)} reviews for PR {pr_number}.")
172+
message = format_pr_message(pull_request, reviews)
173+
# Map Slack users to PR messages
174+
for review in reviews:
175+
github_username = review["user"]
176+
if github_username not in user_pr_map:
177+
user_pr_map[github_username] = ""
178+
# Determine if we should prune any non pending reviews
179+
if f"{github_username}: Pending" in message:
180+
user_pr_map[github_username] += message + "\n"
181+
# Add the Header at the beginning of the list
182+
header_message = "Here are the PRs that are still requiring review:"
183+
# Add Tips and Tricks at the end of the list
184+
tips_message = "*Tips and Tricks*\n"
185+
tips_message += (
186+
"• This is how to remove a PR from the list: Approve, Request changes, or leave a general comment.\n"
187+
"• If someone else has requested changes, then leave a general comment to remove the pending review from yourself."
188+
)
189+
# Send Slack messages to reviewers
190+
for github_username, pr_list in user_pr_map.items():
191+
if pr_list:
192+
if slack_email := GITHUB_USER_NAME_TO_SLACK_EMAIL.get(github_username):
193+
if slack_user_id := slack_email_to_slack_id.get(
194+
slack_email, get_slack_user_id(slack_email)
195+
):
196+
message_content = f"Hello {github_username}! {header_message}\n{pr_list}\n{tips_message}"
197+
# print("\n\n", message_content, "\n\n")
198+
send_slack_message(slack_user_id, message_content)
199+
except Exception as e:
200+
print(f"Error occurred: {e}")
201+
raise
202+
203+
204+
if __name__ == "__main__":
205+
notify_reviewers()
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: Notify Pending PR Reviewers
2+
3+
on:
4+
workflow_dispatch:
5+
schedule:
6+
- cron: '0 16 * * 1-5' # 11:00 AM CT M-F
7+
8+
jobs:
9+
notify-reviewers:
10+
runs-on: ubuntu-latest
11+
12+
steps:
13+
- name: Checkout repository
14+
uses: actions/checkout@v2
15+
with:
16+
# Only checkout the master branch to avoid changes to this script
17+
# This is to reduce the possibilities of a secret leak from modifying
18+
# this action, or the python script that is called down below
19+
ref: master
20+
21+
- name: Set up Python
22+
uses: actions/setup-python@v2
23+
with:
24+
python-version: "3.12"
25+
26+
- name: Install dependencies
27+
run: |
28+
python -m pip install --upgrade pip
29+
pip install requests
30+
31+
- name: Run PR reminder script
32+
env:
33+
GH_TOKEN: ${{ secrets.GH_TOKEN }}
34+
SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }}
35+
run: |
36+
python3 .github/pr-reminder.py

0 commit comments

Comments
 (0)