Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 185 additions & 0 deletions .github/workflows/reusable-slack-pr-review-notification.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
# Reusable workflow: slack-pr-review-notification — GitHub -> Slack review pings.
# Feeds ONE Slack Workflow Builder workflow ("PR Summary") via its webhook
# trigger; the Slack workflow branches on `event_type` to pick wording and drops
# `mentions` (pre-rendered <@SLACK_ID> tokens) into the message so the right
# people are @-tagged.
#
# Three behaviors, all inherited from the CALLER's `on:` block (a reusable's own
# trigger is workflow_call, so all branching keys off github.event_name /
# github.event.*, which carry the caller's originating event):
# - review_requested -> ping the reviewer who was just requested
# - review submitted -> ping the PR author (approved / changes_requested / commented)
# - synchronize -> branch got a new revision: ping everyone who has
# already reviewed or commented (minus the author)
#
# Slack member IDs are resolved from a github-login -> ID map stored as the
# secret SLACK_PR_REVIEW_USER_MAP (a JSON object, e.g. {"jariy17":"U03..."}).
# An unmapped (or unparseable) login falls back to a plain "@login" (visible,
# but not a real ping) so the message is never wrong, just un-pinged.
#
# Requires two inherited secrets, both fetched from Secrets Manager:
# - SLACK_PR_REVIEW_WEBHOOK_URL the "PR Summary" Slack workflow webhook URL
# - SLACK_PR_REVIEW_USER_MAP JSON github-login -> Slack member ID

name: Reusable - Slack PR Review Notification

# caller-on:
# pull_request:
# types: [review_requested, synchronize]
# pull_request_review:
# types: [submitted]

on:
workflow_call:
inputs:
runner:
description: "Runner to use: 'ubuntu' (GitHub-hosted, default) or 'codebuild' (self-hosted CodeBuild fleet)"
required: false
type: string
default: ubuntu

permissions:
pull-requests: read
contents: read
id-token: write

jobs:
notify:
runs-on: ${{ inputs.runner == 'codebuild' && format('codebuild-agentcore-e2e-{0}-{1}', github.run_id, github.run_attempt) || 'ubuntu-latest' }}
permissions:
pull-requests: read
contents: read
id-token: write
steps:
- name: Fetch secrets from Secrets Manager
uses: aws/agentcore-devx-devtools/.github/actions/fetch-secrets@31aa3b031a86664e29861d68956e44b07cf21a74
with:
role-arn: ${{ secrets.WORKFLOW_SECRETS_READER_ROLE_ARN }}
shared: SLACK_PR_REVIEW_WEBHOOK_URL, SLACK_PR_REVIEW_USER_MAP

- name: Resolve event, targets and mentions
id: meta
# The login->Slack-ID map is a JSON secret; passed through env: (never
# interpolated into the script body) so its value stays masked.
env:
USER_MAP: ${{ env.SLACK_PR_REVIEW_USER_MAP }}
uses: actions/github-script@v9
with:
script: |
// github-login -> Slack member ID, from the SLACK_PR_REVIEW_USER_MAP
// JSON secret. The `mentions` output is a COMMA-SEPARATED list of raw
// Slack IDs (e.g. "U03...,U05...") to feed a Person-type variable in
// the Slack workflow — that renders a real notifying pill. An unmapped
// login has no ID to tag, so it is dropped (and warned), never faked.
let SLACK = {};
try { SLACK = JSON.parse(process.env.USER_MAP || "{}"); }
catch (e) { core.warning(`SLACK_PR_REVIEW_USER_MAP is not valid JSON: ${e.message}`); }
const idsFor = (logins) => {
const ids = [];
for (const l of logins) {
if (!l) continue;
if (SLACK[l]) ids.push(SLACK[l]);
else core.warning(`No Slack ID mapped for GitHub login "${l}" — not tagged.`);
}
return [...new Set(ids)].join(",");
};

const ev = context.eventName;
const p = context.payload;
const pr = p.pull_request;
const author = pr.user.login;

let eventType = "", actor = "", reviewState = "", mainMention = "", reviewerIds = [], send = false;

if (ev === "pull_request" && p.action === "review_requested") {
// requested_reviewer is absent for team requests — only ping individuals.
const reviewer = p.requested_reviewer && p.requested_reviewer.login;
if (reviewer) {
eventType = "review_requested";
actor = author;
mainMention = idsFor([reviewer]);
send = true;
}
} else if (ev === "pull_request_review" && p.action === "submitted") {
eventType = "review_submitted";
actor = p.review.user.login;
reviewState = p.review.state; // approved | changes_requested | commented
mainMention = idsFor([author]);
send = actor !== author; // don't ping a self-review
} else if (ev === "pull_request" && p.action === "synchronize") {
// New revision pushed: ping every reviewer on the PR — those still
// pending (requested but haven't acted) PLUS everyone who already
// reviewed or commented. requested_reviewers is on the payload (no
// API call); reviews + comments need two paginated reads.
const { owner, repo } = context.repo;
const [reviews, comments] = await Promise.all([
github.paginate(github.rest.pulls.listReviews,
{ owner, repo, pull_number: pr.number, per_page: 100 }),
github.paginate(github.rest.issues.listComments,
{ owner, repo, issue_number: pr.number, per_page: 100 }),
]);
const logins = new Set(
[...(pr.requested_reviewers || []).map(u => u && u.login),
...reviews.map(r => r.user && r.user.login),
...comments.map(c => c.user && c.user.login)]
.filter(l => l && l !== author)
);
const allIds = idsFor([...logins]).split(",").filter(Boolean);
if (allIds.length) {
// Slack Person vars hold one user each, so we expose up to 5
// (mention_1..mention_5). A 6th+ reviewer is dropped, not silent.
if (allIds.length > 5) {
core.warning(`${allIds.length} reviewers to ping; only the first 5 fit mention_1..5.`);
}
reviewerIds = allIds.slice(0, 5);
eventType = "branch_updated";
actor = author;
send = true;
}
}

core.setOutput("send", String(send));
core.setOutput("event_type", eventType);
core.setOutput("actor", actor);
core.setOutput("review_state", reviewState);
// Slack Person vars are REQUIRED and reject empty strings, so every
// output must carry a valid Slack ID. We pad unused slots with a
// filler ID; Slack dedupes repeated mentions, so a padded slot adds
// no visible pill. Filler = the first available real ID on this
// event, else a configured FALLBACK (a bot/self ID that's harmless
// to "mention" invisibly).
const FALLBACK = SLACK.jariy17 || Object.values(SLACK)[0] || "";
const filler = mainMention || reviewerIds[0] || FALLBACK;

core.setOutput("main_mention", mainMention || filler);
for (let i = 0; i < 5; i++) {
core.setOutput(`mention_${i + 1}`, reviewerIds[i] || filler);
}

- name: Send review notification to Slack
if: steps.meta.outputs.send == 'true'
# pr_title is attacker-controllable, so it's passed through env: and
# toJSON'd rather than interpolated into the YAML payload (workflow
# injection guard, matching reusable-slack-issue-notification). Every
# payload sends the same key set so the Slack workflow can branch
# reliably; keys that don't apply to an event are sent empty.
env:
PR_TITLE: ${{ github.event.pull_request.title }}
uses: slackapi/slack-github-action@v3.0.3
with:
webhook: ${{ env.SLACK_PR_REVIEW_WEBHOOK_URL }}
webhook-type: webhook-trigger
payload: |
event_type: "${{ steps.meta.outputs.event_type }}"
repository: "${{ github.repository }}"
pr_number: "${{ github.event.pull_request.number }}"
pr_title: ${{ toJSON(env.PR_TITLE) }}
pr_url: "${{ github.event.pull_request.html_url }}"
actor: "${{ steps.meta.outputs.actor }}"
review_state: "${{ steps.meta.outputs.review_state }}"
main_mention: "${{ steps.meta.outputs.main_mention }}"
mention_1: "${{ steps.meta.outputs.mention_1 }}"
mention_2: "${{ steps.meta.outputs.mention_2 }}"
mention_3: "${{ steps.meta.outputs.mention_3 }}"
mention_4: "${{ steps.meta.outputs.mention_4 }}"
mention_5: "${{ steps.meta.outputs.mention_5 }}"