Skip to content

Commit 0b2ce4f

Browse files
authored
PR Review Notifications (#3322)
* Add pr review workflows * Make workflows reusable
1 parent b223105 commit 0b2ce4f

6 files changed

Lines changed: 409 additions & 0 deletions

File tree

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import {
2+
escapeSlackMrkdwn,
3+
requireEnv,
4+
requireJsonEnv,
5+
} from './slack-utils.mjs';
6+
7+
export default async ({ github, context }) => {
8+
requireEnv('SLACK_BOT_TOKEN');
9+
const { members: userMap } = requireJsonEnv('SLACK_USER_MAP');
10+
11+
const now = new Date();
12+
const REMINDER_THRESHOLD_HOURS = 15;
13+
14+
const pullRequests = await github.paginate(github.rest.pulls.list, {
15+
owner: context.repo.owner,
16+
repo: context.repo.repo,
17+
state: 'open',
18+
per_page: 100,
19+
});
20+
21+
console.log(`Found ${pullRequests.length} open PRs`);
22+
23+
const reviewerPRs = new Map();
24+
25+
for (const pr of pullRequests) {
26+
if (pr.draft) {
27+
console.log(`Skipping draft PR #${pr.number}`);
28+
continue;
29+
}
30+
31+
if (pr.head.repo.full_name !== pr.base.repo.full_name) {
32+
console.log(`Skipping fork PR #${pr.number}`);
33+
continue;
34+
}
35+
36+
const prAgeHours = (now - new Date(pr.created_at)) / (1000 * 60 * 60);
37+
if (prAgeHours < REMINDER_THRESHOLD_HOURS) {
38+
console.log(
39+
`PR #${pr.number} is only ${prAgeHours.toFixed(1)}hrs old, skipping`,
40+
);
41+
continue;
42+
}
43+
44+
const reviews = await github.paginate(github.rest.pulls.listReviews, {
45+
owner: context.repo.owner,
46+
repo: context.repo.repo,
47+
pull_number: pr.number,
48+
per_page: 100,
49+
});
50+
51+
const approvals = reviews.filter((r) => r.state === 'APPROVED');
52+
if (approvals.length >= 1) {
53+
console.log(
54+
`PR #${pr.number} has ${approvals.length} approval(s), skipping`,
55+
);
56+
continue;
57+
}
58+
59+
const { data: reviewRequests } =
60+
await github.rest.pulls.listRequestedReviewers({
61+
owner: context.repo.owner,
62+
repo: context.repo.repo,
63+
pull_number: pr.number,
64+
});
65+
66+
const pendingReviewers = new Map();
67+
68+
for (const reviewer of reviewRequests.users) {
69+
if (reviewer.login === pr.user?.login) continue;
70+
const slackUserId = userMap[reviewer.login];
71+
if (!slackUserId) {
72+
console.warn(`No Slack mapping for ${reviewer.login}, skipping`);
73+
continue;
74+
}
75+
pendingReviewers.set(reviewer.login, slackUserId);
76+
}
77+
78+
for (const [login, slackUserId] of pendingReviewers) {
79+
if (!reviewerPRs.has(login)) {
80+
reviewerPRs.set(login, { slackUserId, prs: [] });
81+
}
82+
reviewerPRs.get(login).prs.push(pr);
83+
}
84+
}
85+
86+
for (const [login, { slackUserId, prs }] of reviewerPRs) {
87+
try {
88+
console.log(
89+
`Sending aggregated reminder to ${login} for ${prs.length} PR(s)`,
90+
);
91+
92+
const prListText = prs
93+
.map(
94+
(pr) =>
95+
`\u2022 *<${pr.html_url}|#${pr.number}: ${escapeSlackMrkdwn(pr.title)}>* by ${pr.user?.login} (opened ${new Date(pr.created_at).toLocaleDateString()})`,
96+
)
97+
.join('\n');
98+
99+
const blocks = [
100+
{
101+
type: 'header',
102+
text: {
103+
type: 'plain_text',
104+
text: `${prs.length === 1 ? '1 PR' : `${prs.length} PRs`} awaiting your review in ${context.repo.repo}`,
105+
},
106+
},
107+
{
108+
type: 'section',
109+
text: {
110+
type: 'mrkdwn',
111+
text: prListText,
112+
},
113+
},
114+
];
115+
116+
const response = await fetch('https://slack.com/api/chat.postMessage', {
117+
method: 'POST',
118+
headers: {
119+
Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}`,
120+
'Content-Type': 'application/json',
121+
},
122+
body: JSON.stringify({
123+
channel: slackUserId,
124+
text: `${prs.length} PR(s) awaiting your review`,
125+
blocks,
126+
}),
127+
});
128+
129+
const result = await response.json();
130+
if (!result.ok) {
131+
console.error(`Failed to remind ${login}: ${result.error}`);
132+
} else {
133+
console.log(`Successfully reminded ${login}`);
134+
}
135+
} catch (error) {
136+
console.error(`Failed to remind ${login}: ${error.message}`);
137+
}
138+
}
139+
};
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { requireJsonEnv } from './slack-utils.mjs';
2+
3+
export default async ({ context, core }) => {
4+
const eventAction = context.payload.action;
5+
const pr = context.payload.pull_request;
6+
7+
const { members: userMap } = requireJsonEnv('SLACK_USER_MAP');
8+
9+
const resolveSlackUser = (login) => {
10+
const slackUserId = userMap[login];
11+
if (!slackUserId) {
12+
console.warn(`No Slack mapping for ${login}, skipping`);
13+
return null;
14+
}
15+
return { login, slackUserId };
16+
};
17+
18+
const prAuthor = pr.user?.login;
19+
const reviewersToNotify = [];
20+
21+
if (eventAction === 'review_requested') {
22+
const requestedReviewer = context.payload.requested_reviewer;
23+
24+
if (requestedReviewer) {
25+
console.log(`Individual reviewer added: ${requestedReviewer.login}`);
26+
27+
if (requestedReviewer.login === prAuthor) {
28+
console.log(`Skipping PR author ${prAuthor}`);
29+
} else {
30+
const resolved = resolveSlackUser(requestedReviewer.login);
31+
if (resolved) reviewersToNotify.push(resolved);
32+
}
33+
} else {
34+
console.log(
35+
'Team review requested, skipping (only individual reviewers are notified)',
36+
);
37+
}
38+
} else if (eventAction === 'ready_for_review') {
39+
console.log(
40+
'PR converted from draft, resolving individually requested reviewers...',
41+
);
42+
43+
const requestedReviewers = pr.requested_reviewers || [];
44+
console.log(
45+
`Found ${requestedReviewers.length} individually requested reviewers`,
46+
);
47+
48+
for (const reviewer of requestedReviewers) {
49+
if (reviewer.login === prAuthor) continue;
50+
const resolved = resolveSlackUser(reviewer.login);
51+
if (resolved) reviewersToNotify.push(resolved);
52+
}
53+
}
54+
55+
console.log(`Total reviewers to notify: ${reviewersToNotify.length}`);
56+
for (const r of reviewersToNotify) core.setSecret(r.slackUserId);
57+
core.setOutput('reviewers', JSON.stringify(reviewersToNotify));
58+
};
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import {
2+
escapeSlackMrkdwn,
3+
requireEnv,
4+
requireJsonEnv,
5+
} from './slack-utils.mjs';
6+
7+
export default async ({ context }) => {
8+
requireEnv('SLACK_BOT_TOKEN');
9+
const reviewers = requireJsonEnv('REVIEWERS_JSON');
10+
11+
if (reviewers.length === 0) {
12+
console.log('No reviewers to notify, exiting');
13+
return;
14+
}
15+
16+
const pr = context.payload.pull_request;
17+
18+
for (const reviewer of reviewers) {
19+
try {
20+
console.log(`Notifying ${reviewer.login} for PR #${pr.number}`);
21+
22+
const response = await fetch('https://slack.com/api/chat.postMessage', {
23+
method: 'POST',
24+
headers: {
25+
Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}`,
26+
'Content-Type': 'application/json',
27+
},
28+
body: JSON.stringify({
29+
channel: reviewer.slackUserId,
30+
text: `PR Review Requested: ${escapeSlackMrkdwn(pr.title)}`,
31+
blocks: [
32+
{
33+
type: 'header',
34+
text: {
35+
type: 'plain_text',
36+
text: 'PR Review Requested',
37+
},
38+
},
39+
{
40+
type: 'section',
41+
text: {
42+
type: 'mrkdwn',
43+
text: `*<${pr.html_url}|#${pr.number}: ${escapeSlackMrkdwn(pr.title)}>*\nYou have been requested to review this PR.`,
44+
},
45+
},
46+
{
47+
type: 'section',
48+
fields: [
49+
{
50+
type: 'mrkdwn',
51+
text: `*Author:*\n${pr.user?.login}`,
52+
},
53+
{
54+
type: 'mrkdwn',
55+
text: `*Repository:*\n${context.repo.owner}/${context.repo.repo}`,
56+
},
57+
{
58+
type: 'mrkdwn',
59+
text: `*Branch:*\n\`${escapeSlackMrkdwn(pr.head.ref)}\``,
60+
},
61+
{
62+
type: 'mrkdwn',
63+
text: `*Changes:*\n+${pr.additions} / -${pr.deletions}`,
64+
},
65+
],
66+
},
67+
{
68+
type: 'actions',
69+
elements: [
70+
{
71+
type: 'button',
72+
text: { type: 'plain_text', text: 'Review PR' },
73+
style: 'primary',
74+
url: pr.html_url,
75+
},
76+
],
77+
},
78+
],
79+
}),
80+
});
81+
82+
const result = await response.json();
83+
if (!result.ok) {
84+
console.error(`Failed to notify ${reviewer.login}: ${result.error}`);
85+
} else {
86+
console.log(`Successfully notified ${reviewer.login}`);
87+
}
88+
} catch (error) {
89+
console.error(`Failed to notify ${reviewer.login}: ${error.message}`);
90+
}
91+
}
92+
};

.github/scripts/slack-utils.mjs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
export const escapeSlackMrkdwn = (text) =>
2+
text
3+
.replace(/&/g, '&amp;')
4+
.replace(/</g, '&lt;')
5+
.replace(/>/g, '&gt;')
6+
.replace(/([*_~`])/g, '\u200B$1');
7+
8+
export const requireEnv = (name) => {
9+
const value = process.env[name];
10+
if (!value) {
11+
throw new Error(`Missing required environment variable: ${name}`);
12+
}
13+
return value;
14+
};
15+
16+
export const requireJsonEnv = (name) => {
17+
const raw = requireEnv(name);
18+
try {
19+
return JSON.parse(raw);
20+
} catch {
21+
throw new Error(`Invalid JSON in environment variable: ${name}`);
22+
}
23+
};
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
name: PR Review Notification
2+
3+
on:
4+
pull_request:
5+
types: [review_requested, ready_for_review]
6+
workflow_call:
7+
secrets:
8+
SLACK_BOT_TOKEN:
9+
required: true
10+
SLACK_USER_MAP:
11+
required: true
12+
13+
permissions:
14+
contents: read
15+
pull-requests: read
16+
17+
jobs:
18+
notify-reviewer:
19+
runs-on: ubuntu-latest
20+
timeout-minutes: 10
21+
if: github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository
22+
permissions:
23+
contents: read
24+
pull-requests: read
25+
steps:
26+
- name: Checkout workflow source
27+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
28+
with:
29+
repository: temporalio/ui
30+
ref: ${{ github.workflow_sha }}
31+
path: ._reusable
32+
33+
- name: Get Reviewers to Notify
34+
id: get-reviewers
35+
uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0
36+
env:
37+
SLACK_USER_MAP: ${{ secrets.SLACK_USER_MAP }}
38+
with:
39+
script: |
40+
const { default: script } = await import(`${process.env.GITHUB_WORKSPACE}/._reusable/.github/scripts/get-reviewers-to-notify.mjs`);
41+
await script({ context, core });
42+
43+
- name: Send Slack Notifications
44+
uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0
45+
env:
46+
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
47+
REVIEWERS_JSON: ${{ steps.get-reviewers.outputs.reviewers }}
48+
with:
49+
script: |
50+
const { default: script } = await import(`${process.env.GITHUB_WORKSPACE}/._reusable/.github/scripts/send-review-notifications.mjs`);
51+
await script({ context });

0 commit comments

Comments
 (0)