PR Review SLA Slack Alerts #1714
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: PR Review SLA Slack Alerts | |
| on: | |
| schedule: | |
| - cron: "*/15 * * * 1-5" | |
| workflow_dispatch: | |
| inputs: | |
| dry_run: | |
| description: "Log alerts instead of posting to Slack" | |
| type: boolean | |
| required: false | |
| default: true | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| issues: write | |
| concurrency: | |
| group: pr-review-sla-${{ github.repository }} | |
| cancel-in-progress: false | |
| jobs: | |
| review_sla_alerts: | |
| # Keep this automation separate and opt-in. | |
| if: ${{ github.event_name == 'workflow_dispatch' || vars.ENABLE_PR_REVIEW_SLA_ALERTS == 'true' }} | |
| runs-on: ubuntu-latest | |
| env: | |
| # Configure these in repo Variables to tune urgency windows. | |
| DEFAULT_SLA_HOURS: "24" | |
| SLA_BY_LABEL: '{"review:urgent":1,"review:standard":24,"review:low":48}' | |
| ALERT_LABEL: "review-alerted" | |
| BUSINESS_TIMEZONE: "America/New_York" | |
| HOLIDAY_DATES: ${{ vars.PR_REVIEW_SLA_HOLIDAY_DATES || '' }} | |
| SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} | |
| SLACK_CHANNEL: "acf-head-start-eng-reviews" | |
| DRY_RUN: ${{ github.event.inputs.dry_run || vars.PR_REVIEW_SLA_DRY_RUN || 'true' }} | |
| steps: | |
| - name: Skip weekends and holidays | |
| id: business-day-check | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const tz = process.env.BUSINESS_TIMEZONE || 'America/New_York'; | |
| const holidays = (process.env.HOLIDAY_DATES || '') | |
| .split(',') | |
| .map((d) => d.trim()) | |
| .filter(Boolean); | |
| const dayName = new Intl.DateTimeFormat('en-US', { | |
| timeZone: tz, | |
| weekday: 'short', | |
| }).format(new Date()); | |
| const dateKey = new Intl.DateTimeFormat('en-CA', { | |
| timeZone: tz, | |
| year: 'numeric', | |
| month: '2-digit', | |
| day: '2-digit', | |
| }).format(new Date()); | |
| const isWeekend = dayName === 'Sat' || dayName === 'Sun'; | |
| const isHoliday = holidays.includes(dateKey); | |
| const shouldRun = !isWeekend && !isHoliday; | |
| core.info(`Business-day check (${tz}): date=${dateKey}, day=${dayName}, holiday=${isHoliday}`); | |
| if (!shouldRun) { | |
| core.info('Skipping PR review SLA workflow on weekend/holiday.'); | |
| } | |
| core.setOutput('should_run', shouldRun ? 'true' : 'false'); | |
| - name: Ensure alert label exists | |
| id: ensure-alert-label | |
| if: steps.business-day-check.outputs.should_run == 'true' | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const name = process.env.ALERT_LABEL; | |
| const dryRun = String(process.env.DRY_RUN || '').toLowerCase() === 'true'; | |
| let labelReady = false; | |
| try { | |
| await github.rest.issues.getLabel({ owner, repo, name }); | |
| core.info(`Label ${name} already exists.`); | |
| labelReady = true; | |
| } catch (error) { | |
| if (error.status === 404) { | |
| if (dryRun) { | |
| core.info(`DRY_RUN enabled: would create label ${name}, but not mutating repository state.`); | |
| labelReady = true; | |
| } else { | |
| try { | |
| await github.rest.issues.createLabel({ | |
| owner, | |
| repo, | |
| name, | |
| color: 'D4C5F9', | |
| description: 'PR has triggered an overdue review Slack alert', | |
| }); | |
| core.info(`Created label ${name}.`); | |
| labelReady = true; | |
| } catch (createError) { | |
| if (createError.status === 403) { | |
| core.setFailed(`Could not create label ${name}: GITHUB_TOKEN lacks permission (${createError.message}).`); | |
| return; | |
| } else { | |
| throw createError; | |
| } | |
| } | |
| } | |
| } else if (error.status === 403) { | |
| core.setFailed(`Could not validate label ${name}: GITHUB_TOKEN lacks permission (${error.message}).`); | |
| return; | |
| } else { | |
| throw error; | |
| } | |
| } | |
| core.setOutput('label_ready', labelReady ? 'true' : 'false'); | |
| - name: Find overdue PRs and notify Slack | |
| if: steps.business-day-check.outputs.should_run == 'true' && steps.ensure-alert-label.outputs.label_ready == 'true' | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const defaultSlaHours = Number(process.env.DEFAULT_SLA_HOURS || '48'); | |
| const slaByLabel = JSON.parse(process.env.SLA_BY_LABEL || '{}'); | |
| const alertLabel = process.env.ALERT_LABEL || 'review-alerted'; | |
| const slackBotToken = process.env.SLACK_BOT_TOKEN; | |
| const channel = process.env.SLACK_CHANNEL || ''; | |
| const dryRun = `${process.env.DRY_RUN}`.toLowerCase() === 'true'; | |
| if (!dryRun && !slackBotToken) { | |
| core.setFailed('Missing required secret: SLACK_BOT_TOKEN'); | |
| return; | |
| } | |
| if (!dryRun && !channel) { | |
| core.setFailed('Missing required configuration: SLACK_CHANNEL'); | |
| return; | |
| } | |
| const now = new Date(); | |
| const tryMutateLabel = async (operation, fn) => { | |
| try { | |
| await fn(); | |
| return true; | |
| } catch (error) { | |
| if (error.status === 403) { | |
| core.warning(`Skipping label ${operation}: GITHUB_TOKEN lacks permission (${error.message}).`); | |
| return false; | |
| } | |
| throw error; | |
| } | |
| }; | |
| const pulls = await github.paginate(github.rest.pulls.list, { | |
| owner, | |
| repo, | |
| state: 'open', | |
| per_page: 100, | |
| }); | |
| core.info(`Evaluating ${pulls.length} open PR(s).`); | |
| const sortedLabelKeys = Object.keys(slaByLabel).sort((a, b) => { | |
| return Number(slaByLabel[a]) - Number(slaByLabel[b]); | |
| }); | |
| let notified = 0; | |
| for (const pr of pulls) { | |
| if (pr.draft) { | |
| core.info(`Skipping PR #${pr.number}: draft PR.`); | |
| continue; | |
| } | |
| const hasRequestedReviewers = | |
| (pr.requested_reviewers || []).length > 0 | |
| || (pr.requested_teams || []).length > 0; | |
| if (!hasRequestedReviewers) { | |
| core.info(`Skipping PR #${pr.number}: no requested reviewers or teams.`); | |
| continue; | |
| } | |
| const matchingLabels = (pr.labels || []) | |
| .map((l) => l.name) | |
| .filter((name) => sortedLabelKeys.includes(name)); | |
| const selectedLabel = matchingLabels | |
| .sort((a, b) => Number(slaByLabel[a]) - Number(slaByLabel[b]))[0] || null; | |
| const effectiveLabel = selectedLabel || 'review:standard'; | |
| const slaHours = selectedLabel ? Number(slaByLabel[selectedLabel]) : defaultSlaHours; | |
| const commits = await github.paginate(github.rest.pulls.listCommits, { | |
| owner, | |
| repo, | |
| pull_number: pr.number, | |
| per_page: 100, | |
| }); | |
| const latestCommit = commits[commits.length - 1]; | |
| const lastCommitDate = new Date( | |
| latestCommit?.commit?.committer?.date | |
| || latestCommit?.commit?.author?.date | |
| || pr.created_at, | |
| ); | |
| const reviews = await github.paginate(github.rest.pulls.listReviews, { | |
| owner, | |
| repo, | |
| pull_number: pr.number, | |
| per_page: 100, | |
| }); | |
| const hasAnyApproval = reviews.some((r) => r.state === 'APPROVED'); | |
| if (hasAnyApproval) { | |
| core.info(`Skipping PR #${pr.number}: PR has already been approved.`); | |
| if (!dryRun && (pr.labels || []).some((l) => l.name === alertLabel)) { | |
| await tryMutateLabel('remove', async () => { | |
| await github.rest.issues.removeLabel({ | |
| owner, | |
| repo, | |
| issue_number: pr.number, | |
| name: alertLabel, | |
| }); | |
| }); | |
| } | |
| continue; | |
| } | |
| const hasReviewSinceLastCommit = reviews.some((r) => { | |
| if (!['CHANGES_REQUESTED', 'COMMENTED'].includes(r.state)) { | |
| return false; | |
| } | |
| return new Date(r.submitted_at) >= lastCommitDate; | |
| }); | |
| if (hasReviewSinceLastCommit) { | |
| core.info(`Skipping PR #${pr.number}: review already submitted after latest commit.`); | |
| if (!dryRun && (pr.labels || []).some((l) => l.name === alertLabel)) { | |
| await tryMutateLabel('remove', async () => { | |
| await github.rest.issues.removeLabel({ | |
| owner, | |
| repo, | |
| issue_number: pr.number, | |
| name: alertLabel, | |
| }); | |
| }); | |
| } | |
| continue; | |
| } | |
| const elapsedHours = (now.getTime() - lastCommitDate.getTime()) / 36e5; | |
| if (elapsedHours < slaHours) { | |
| const roundedElapsed = Math.round(elapsedHours * 100) / 100; | |
| core.info(`Skipping PR #${pr.number}: SLA window not reached (${roundedElapsed}h elapsed < ${slaHours}h SLA).`); | |
| continue; | |
| } | |
| if ((pr.labels || []).some((l) => l.name === alertLabel)) { | |
| core.info(`Skipping PR #${pr.number}: already has ${alertLabel} label.`); | |
| continue; | |
| } | |
| const reviewers = [ | |
| ...(pr.requested_reviewers || []).map((u) => `@${u.login}`), | |
| ...(pr.requested_teams || []).map((t) => `@${t.slug}`), | |
| ]; | |
| const overdueHours = Math.max(0, elapsedHours - slaHours); | |
| const roundedOverdue = Math.round(overdueHours * 10) / 10; | |
| const urgencyText = selectedLabel || 'review:standard (default)'; | |
| const reviewTarget = reviewers.length ? reviewers.join(', ') : '(none)'; | |
| let alertIcon = ':eyes:'; | |
| const quotes = [ | |
| 'All good things come to those who review.', | |
| 'Time and tide wait for no PR.', | |
| 'Soon is the enemy of done; review now.', | |
| 'A stitch in time saves nine merge conflicts.', | |
| 'The best time to review was earlier. The second best time is now.', | |
| 'Minutes are tiny, but they add up to big delays.', | |
| 'Every pending PR tells a story. Help it reach the ending.', | |
| 'Today\'s quick review prevents tomorrow\'s long incident.', | |
| 'The clock is undefeated, but we can still beat the queue.', | |
| 'Review early, merge calmly.', | |
| 'Time flies like an arrow; PRs queue like a freeway.', | |
| 'Progress loves momentum. A review keeps it moving.', | |
| 'Small feedback now beats large rewrites later.', | |
| 'With great merge power comes great review responsibility.', | |
| 'If you have time to scroll, you have time to review.', | |
| 'This PR is marinating. Chef says it\'s ready.', | |
| 'A PR in hand is worth two in rebasing.', | |
| 'Review kindness is a team superpower.', | |
| 'Later is a risky dependency.', | |
| 'The fastest path to done is one thoughtful review.', | |
| ]; | |
| if (overdueHours >= 3) { | |
| alertIcon = ':rotating_light:'; | |
| } else if (overdueHours >= 1) { | |
| alertIcon = ':coffee:'; | |
| } else if (overdueHours >= 0.25) { | |
| alertIcon = ':hourglass_flowing_sand:'; | |
| } | |
| const borderColorByPriority = { | |
| 'review:urgent': '#D92D20', | |
| 'review:standard': '#F79009', | |
| 'review:low': '#12B76A', | |
| }; | |
| const borderColor = borderColorByPriority[effectiveLabel] || '#667085'; | |
| const quoteSeed = (pr.number * 31) + Math.round(overdueHours * 10); | |
| const quote = quotes[Math.abs(quoteSeed) % quotes.length]; | |
| const text = [ | |
| `*PR:* ${alertIcon} <${pr.html_url}|#${pr.number} ${pr.title}>`, | |
| `*Leave a review:* <${pr.html_url}/files|Open changed files>`, | |
| `*Author:* @${pr.user.login}`, | |
| `*Requested reviewers:* ${reviewTarget}`, | |
| `*Timeframe:* ${urgencyText}`, | |
| `*Past SLA by:* ${roundedOverdue}h`, | |
| '', | |
| `_"${quote}"_`, | |
| ].join('\n'); | |
| if (dryRun) { | |
| core.info(`[DRY RUN] Would notify Slack for PR #${pr.number}`); | |
| core.info(`[DRY RUN] Border color: ${borderColor}`); | |
| core.info(text); | |
| } else { | |
| const labelAdded = await tryMutateLabel('add', async () => { | |
| await github.rest.issues.addLabels({ | |
| owner, | |
| repo, | |
| issue_number: pr.number, | |
| labels: [alertLabel], | |
| }); | |
| }); | |
| if (!labelAdded) { | |
| core.warning(`Skipping Slack notification for PR #${pr.number}: unable to add dedupe label ${alertLabel}.`); | |
| continue; | |
| } | |
| const payload = { | |
| channel, | |
| text: `PR #${pr.number} review reminder`, | |
| attachments: [ | |
| { | |
| color: borderColor, | |
| text, | |
| }, | |
| ], | |
| }; | |
| try { | |
| const response = await fetch('https://slack.com/api/chat.postMessage', { | |
| method: 'POST', | |
| headers: { | |
| 'Authorization': `Bearer ${slackBotToken}`, | |
| 'Content-Type': 'application/json;charset=utf-8', | |
| }, | |
| body: JSON.stringify(payload), | |
| }); | |
| if (!response.ok) { | |
| const body = await response.text(); | |
| throw new Error(`Slack API request failed (${response.status}): ${body}`); | |
| } | |
| const data = await response.json(); | |
| if (!data.ok) { | |
| throw new Error(`Slack notification failed: ${data.error || 'unknown_error'}`); | |
| } | |
| } catch (error) { | |
| await tryMutateLabel('remove', async () => { | |
| await github.rest.issues.removeLabel({ | |
| owner, | |
| repo, | |
| issue_number: pr.number, | |
| name: alertLabel, | |
| }); | |
| }); | |
| throw error; | |
| } | |
| core.info(`Slack notified for PR #${pr.number}`); | |
| } | |
| notified += 1; | |
| } | |
| core.info(`Completed. Notified on ${notified} PR(s).`); |