chore: Refactor the script for posting PRs for review on Slack (#43022)

This pull request refactors the GitHub Actions workflow for notifying
about stale Dashboard PRs by replacing custom JavaScript scripts and the
`actions/github-script` action with new TypeScript scripts that
communicate via standard input/output. This simplifies the workflow,
improves maintainability, and adds better error handling, especially for
API rate limits. The Slack notification script is also rewritten in
TypeScript and now reads PR data from stdin, making the workflow steps
more composable.
This commit is contained in:
Ivan Vasilov
2026-02-20 15:32:12 +01:00
committed by GitHub
parent 41a7b34985
commit 03660838ef
4 changed files with 340 additions and 273 deletions
+19 -22
View File
@@ -18,29 +18,26 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- name: Find Dashboard PRs older than 24 hours
id: find-prs
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
script: |
const findStalePRs = require('./scripts/actions/find-stale-dashboard-prs.js');
return await findStalePRs({ github, context, core });
sparse-checkout: |
scripts
- name: Send Slack notification
if: fromJSON(steps.find-prs.outputs.count) > 0
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
name: Install pnpm
with:
run_install: false
- name: Use Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install deps
run: pnpm install --frozen-lockfile
- name: Find stale Dashboard PRs and notify Slack
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_DASHBOARD_WEBHOOK_URL }}
STALE_PRS_JSON: ${{ steps.find-prs.outputs.stale_prs }}
with:
script: |
const sendSlackNotification = require('./scripts/actions/send-slack-pr-notification.js');
const stalePRs = JSON.parse(process.env.STALE_PRS_JSON);
const webhookUrl = process.env.SLACK_WEBHOOK_URL;
await sendSlackNotification(stalePRs, webhookUrl);
- name: No stale PRs found
if: fromJSON(steps.find-prs.outputs.count) == 0
run: |
echo "✓ No Dashboard PRs older than 24 hours found"
run: pnpm tsx scripts/actions/find-stale-dashboard-prs.ts | pnpm tsx scripts/actions/send-slack-pr-notification.ts
-226
View File
@@ -1,226 +0,0 @@
/**
* Finds stale Dashboard PRs (older than 24 hours) and fetches their status
* including review status and mergeable state.
*
* @param {Object} github - GitHub API client from actions/github-script
* @param {Object} context - GitHub Actions context
* @param {Object} core - GitHub Actions core utilities
* @returns {Array} Array of stale PRs with status information
*/
module.exports = async ({ github, context, core }) => {
const TWENTY_FOUR_HOURS_AGO = new Date(Date.now() - 24 * 60 * 60 * 1000)
const DASHBOARD_PATH = 'apps/studio/'
console.log(`Looking for PRs older than: ${TWENTY_FOUR_HOURS_AGO.toISOString()}`)
const stalePRs = []
let page = 1
let hasMore = true
// Fetch PRs page by page, newest first
while (hasMore && page <= 10) {
// Limit to 10 pages (1000 PRs) as safety measure
console.log(`Fetching page ${page}...`)
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
sort: 'created',
direction: 'desc',
per_page: 100,
page: page,
})
if (prs.length === 0) {
hasMore = false
break
}
// Check each PR
for (const pr of prs) {
// Skip PRs from forks - only check internal PRs
if (pr.head.repo && pr.head.repo.full_name !== context.repo.owner + '/' + context.repo.repo) {
console.log(`PR #${pr.number} is from a fork, skipping...`)
continue
}
// Skip dependabot PRs
if (pr.user.login === 'dependabot[bot]' || pr.user.login === 'dependabot') {
console.log(`PR #${pr.number} is from dependabot, skipping...`)
continue
}
// Skip draft PRs
if (pr.draft) {
console.log(`PR #${pr.number} is a draft, skipping...`)
continue
}
const createdAt = new Date(pr.created_at)
// If this PR is newer than 24 hours, skip it
if (createdAt > TWENTY_FOUR_HOURS_AGO) {
console.log(`PR #${pr.number} is too new, skipping...`)
continue
}
console.log(`Checking PR #${pr.number}: ${pr.title}`)
// Fetch files changed in this PR
const { data: files } = await github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
per_page: 100,
})
// Check if any file is under apps/studio/
const touchesDashboard = files.some((file) => file.filename.startsWith(DASHBOARD_PATH))
if (touchesDashboard) {
const hoursOld = Math.floor((Date.now() - createdAt.getTime()) / (1000 * 60 * 60))
const daysOld = Math.floor(hoursOld / 24)
// Fetch review status
let reviewStatus = 'no-reviews'
let reviewEmoji = ':eyes:'
try {
const { data: reviews } = await github.rest.pulls.listReviews({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
per_page: 100,
})
if (reviews.length === 0) {
reviewStatus = 'no-reviews'
reviewEmoji = ':eyes:'
} else {
// Get the most recent review from each reviewer
const latestReviews = {}
reviews.forEach((review) => {
if (
!latestReviews[review.user.login] ||
new Date(review.submitted_at) >
new Date(latestReviews[review.user.login].submitted_at)
) {
latestReviews[review.user.login] = review
}
})
// Check for most critical state (Changes Requested > Approved > Commented)
const states = Object.values(latestReviews).map((r) => r.state)
if (states.includes('CHANGES_REQUESTED')) {
reviewStatus = 'changes-requested'
reviewEmoji = ':warning:'
} else if (states.includes('APPROVED')) {
reviewStatus = 'approved'
reviewEmoji = ':heavy_check_mark:'
} else {
reviewStatus = 'commented'
reviewEmoji = ':speech_balloon:'
}
}
} catch (error) {
console.log(
`Warning: Could not fetch review status for PR #${pr.number}: ${error.message}`
)
}
// Get mergeable state
let mergeableStatus = 'unknown'
let mergeableEmoji = ':grey_question:'
// Fetch full PR details to get mergeable state (it's not always in the list response)
try {
const { data: fullPR } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
})
const mergeableState = fullPR.mergeable_state
switch (mergeableState) {
case 'clean':
mergeableStatus = 'ready'
mergeableEmoji = ':rocket:'
break
case 'dirty':
mergeableStatus = 'conflicts'
mergeableEmoji = ':collision:'
break
case 'blocked':
mergeableStatus = 'blocked'
mergeableEmoji = ':no_entry:'
break
case 'unstable':
mergeableStatus = 'unstable'
mergeableEmoji = ':warning:'
break
case 'behind':
mergeableStatus = 'behind'
mergeableEmoji = ':arrow_down:'
break
case 'draft':
mergeableStatus = 'draft'
mergeableEmoji = ':pencil2:'
break
default:
mergeableStatus = mergeableState || 'unknown'
mergeableEmoji = ':grey_question:'
}
} catch (error) {
console.log(
`Warning: Could not fetch mergeable state for PR #${pr.number}: ${error.message}`
)
}
// Skip PRs that have already been actioned (reviewed)
if (reviewStatus !== 'no-reviews') {
console.log(`PR #${pr.number} has already been reviewed (${reviewStatus}), skipping...`)
continue
}
// Skip PRs with merge conflicts
if (mergeableStatus === 'conflicts') {
console.log(`PR #${pr.number} has merge conflicts, skipping...`)
continue
}
stalePRs.push({
number: pr.number,
title: pr.title,
url: pr.html_url,
author: pr.user.login,
createdAt: pr.created_at,
hoursOld: hoursOld,
daysOld: daysOld,
fileCount: files.filter((f) => f.filename.startsWith(DASHBOARD_PATH)).length,
reviewStatus: reviewStatus,
reviewEmoji: reviewEmoji,
mergeableStatus: mergeableStatus,
mergeableEmoji: mergeableEmoji,
})
console.log(
`✓ Found stale Dashboard PR #${pr.number} (Review: ${reviewStatus}, Mergeable: ${mergeableStatus})`
)
}
}
page++
}
console.log(`Found ${stalePRs.length} stale Dashboard PRs`)
// Sort by age (newest first)
stalePRs.sort((a, b) => a.hoursOld - b.hoursOld)
// Store results for next step
core.setOutput('stale_prs', JSON.stringify(stalePRs))
core.setOutput('count', stalePRs.length)
return stalePRs
}
+266
View File
@@ -0,0 +1,266 @@
const TWENTY_FOUR_HOURS_AGO = new Date(Date.now() - 24 * 60 * 60 * 1000)
const DASHBOARD_PATH = 'apps/studio/'
const REPO_OWNER = 'supabase'
const REPO_NAME = 'supabase'
const GITHUB_TOKEN = process.env.GITHUB_TOKEN
class RateLimitError extends Error {
constructor(resetAt: string) {
super(`GitHub API rate limit exceeded. Resets at ${resetAt}`)
}
}
async function githubApi(path: string) {
const headers: Record<string, string> = {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
}
if (GITHUB_TOKEN) {
headers.Authorization = `Bearer ${GITHUB_TOKEN}`
}
const response = await fetch(`https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}${path}`, {
headers,
})
if (
response.status === 429 ||
(response.status === 403 && response.headers.get('x-ratelimit-remaining') === '0')
) {
const resetEpoch = response.headers.get('x-ratelimit-reset')
const resetAt = resetEpoch ? new Date(Number(resetEpoch) * 1000).toISOString() : 'unknown'
throw new RateLimitError(resetAt)
}
if (!response.ok) {
const errorText = await response.text()
throw new Error(`GitHub API error: ${response.status} ${response.statusText}\n${errorText}`)
}
return response.json()
}
interface StalePR {
number: number
title: string
url: string
author: string
createdAt: string
hoursOld: number
daysOld: number
fileCount: number
reviewStatus: string
reviewEmoji: string
mergeableStatus: string
mergeableEmoji: string
}
async function findStalePRs(): Promise<StalePR[]> {
console.error(`Looking for PRs older than: ${TWENTY_FOUR_HOURS_AGO.toISOString()}`)
const stalePRs: StalePR[] = []
let page = 1
let hasMore = true
outer: while (hasMore && page <= 10) {
console.error(`Fetching page ${page}...`)
let prs: any[]
try {
prs = await githubApi(
`/pulls?state=open&sort=created&direction=desc&per_page=100&page=${page}`
)
} catch (error: any) {
if (error instanceof RateLimitError) {
console.error(`Rate limited while listing PRs. ${error.message}`)
break
}
throw error
}
if (prs.length === 0) {
hasMore = false
break
}
for (const pr of prs) {
// Skip PRs from forks
if (pr.head.repo && pr.head.repo.full_name !== `${REPO_OWNER}/${REPO_NAME}`) {
console.error(`PR #${pr.number} is from a fork, skipping...`)
continue
}
// Skip dependabot PRs
if (pr.user.login === 'dependabot[bot]' || pr.user.login === 'dependabot') {
console.error(`PR #${pr.number} is from dependabot, skipping...`)
continue
}
// Skip draft PRs
if (pr.draft) {
console.error(`PR #${pr.number} is a draft, skipping...`)
continue
}
const createdAt = new Date(pr.created_at)
if (createdAt > TWENTY_FOUR_HOURS_AGO) {
console.error(`PR #${pr.number} is too new, skipping...`)
continue
}
console.error(`Checking PR #${pr.number}: ${pr.title}`)
let files: any[]
try {
files = await githubApi(`/pulls/${pr.number}/files?per_page=100`)
} catch (error: any) {
if (error instanceof RateLimitError) {
console.error(`Rate limited while fetching files. ${error.message}`)
break outer
}
throw error
}
const touchesDashboard = files.some((file: any) => file.filename.startsWith(DASHBOARD_PATH))
if (!touchesDashboard) continue
const hoursOld = Math.floor((Date.now() - createdAt.getTime()) / (1000 * 60 * 60))
const daysOld = Math.floor(hoursOld / 24)
// Fetch review status
let reviewStatus = 'no-reviews'
let reviewEmoji = ':eyes:'
try {
const reviews = await githubApi(`/pulls/${pr.number}/reviews?per_page=100`)
if (reviews.length > 0) {
const latestReviews: Record<string, any> = {}
reviews.forEach((review: any) => {
if (
!latestReviews[review.user.login] ||
new Date(review.submitted_at) >
new Date(latestReviews[review.user.login].submitted_at)
) {
latestReviews[review.user.login] = review
}
})
const states = Object.values(latestReviews).map((r) => r.state)
if (states.includes('CHANGES_REQUESTED')) {
reviewStatus = 'changes-requested'
reviewEmoji = ':warning:'
} else if (states.includes('APPROVED')) {
reviewStatus = 'approved'
reviewEmoji = ':heavy_check_mark:'
}
}
} catch (error: any) {
if (error instanceof RateLimitError) {
console.error(`Rate limited while fetching reviews. ${error.message}`)
break outer
}
console.error(
`Warning: Could not fetch review status for PR #${pr.number}: ${error.message}`
)
}
// Get mergeable state
let mergeableStatus = 'unknown'
let mergeableEmoji = ':grey_question:'
try {
const fullPR = await githubApi(`/pulls/${pr.number}`)
const mergeableState = fullPR.mergeable_state
switch (mergeableState) {
case 'clean':
mergeableStatus = 'ready'
mergeableEmoji = ':rocket:'
break
case 'dirty':
mergeableStatus = 'conflicts'
mergeableEmoji = ':collision:'
break
case 'blocked':
mergeableStatus = 'blocked'
mergeableEmoji = ':no_entry:'
break
case 'unstable':
mergeableStatus = 'unstable'
mergeableEmoji = ':warning:'
break
case 'behind':
mergeableStatus = 'behind'
mergeableEmoji = ':arrow_down:'
break
case 'draft':
mergeableStatus = 'draft'
mergeableEmoji = ':pencil2:'
break
default:
mergeableStatus = mergeableState || 'unknown'
mergeableEmoji = ':grey_question:'
}
} catch (error: any) {
if (error instanceof RateLimitError) {
console.error(`Rate limited while fetching mergeable state. ${error.message}`)
break outer
}
console.error(
`Warning: Could not fetch mergeable state for PR #${pr.number}: ${error.message}`
)
}
// Skip PRs that have already been reviewed
if (reviewStatus !== 'no-reviews') {
console.error(`PR #${pr.number} has already been reviewed (${reviewStatus}), skipping...`)
continue
}
// Skip PRs with merge conflicts
if (mergeableStatus === 'conflicts') {
console.error(`PR #${pr.number} has merge conflicts, skipping...`)
continue
}
stalePRs.push({
number: pr.number,
title: pr.title,
url: pr.html_url,
author: pr.user.login,
createdAt: pr.created_at,
hoursOld,
daysOld,
fileCount: files.filter((f: any) => f.filename.startsWith(DASHBOARD_PATH)).length,
reviewStatus,
reviewEmoji,
mergeableStatus,
mergeableEmoji,
})
console.error(
`Found stale Dashboard PR #${pr.number} (Review: ${reviewStatus}, Mergeable: ${mergeableStatus})`
)
}
page++
}
console.error(`Found ${stalePRs.length} stale Dashboard PRs`)
stalePRs.sort((a, b) => a.hoursOld - b.hoursOld)
return stalePRs
}
findStalePRs()
.then((stalePRs) => {
// Output JSON to stdout for piping to the next script
console.log(JSON.stringify(stalePRs))
})
.catch((error) => {
console.error('Error:', error.message)
process.exit(1)
})
@@ -1,31 +1,42 @@
/**
* Sends a Slack notification with stale Dashboard PRs
*
* @param {Array} stalePRs - Array of stale PRs from find-stale-dashboard-prs.js
* @param {string} webhookUrl - Slack webhook URL
*/
module.exports = async (stalePRs, webhookUrl) => {
const SLACK_WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL
if (!SLACK_WEBHOOK_URL) {
console.error('SLACK_WEBHOOK_URL environment variable is required')
process.exit(1)
}
interface StalePR {
number: number
title: string
url: string
author: string
createdAt: string
hoursOld: number
daysOld: number
fileCount: number
reviewStatus: string
reviewEmoji: string
mergeableStatus: string
mergeableEmoji: string
}
function escapeSlack(text: string) {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
async function sendSlackNotification(stalePRs: StalePR[]) {
const count = stalePRs.length
// Build PR blocks with proper escaping for Slack mrkdwn
const prBlocks = stalePRs.map((pr) => {
// Format age display
const remainingHours = pr.hoursOld % 24
const ageText = pr.daysOld > 0 ? `${pr.daysOld}d ${remainingHours}h` : `${pr.hoursOld}h`
// Escape special characters for Slack mrkdwn (escape &, <, >)
const escapeSlack = (text) => {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
// Truncate title if too long (max 3000 chars for entire text field)
const maxTitleLength = 200
const safeTitle =
pr.title.length > maxTitleLength
? escapeSlack(pr.title.substring(0, maxTitleLength) + '...')
: escapeSlack(pr.title)
// Format status text
const reviewStatusText =
pr.reviewStatus === 'approved'
? 'Approved'
@@ -59,13 +70,10 @@ module.exports = async (stalePRs, webhookUrl) => {
}
})
// Slack has a 50 block limit, we use 3 for header/intro/divider
// So we can show max 47 PRs
const MAX_PRS_TO_SHOW = 47
const prBlocksToShow = prBlocks.slice(0, MAX_PRS_TO_SHOW)
const hasMorePRs = prBlocks.length > MAX_PRS_TO_SHOW
// Build complete Slack message
const slackMessage = {
text: 'Dashboard PRs needing attention',
blocks: [
@@ -90,12 +98,9 @@ module.exports = async (stalePRs, webhookUrl) => {
],
}
// Send to Slack
const response = await fetch(webhookUrl, {
const response = await fetch(SLACK_WEBHOOK_URL!, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(slackMessage),
})
@@ -106,5 +111,30 @@ module.exports = async (stalePRs, webhookUrl) => {
)
}
console.log('Slack notification sent successfully!')
console.error('Slack notification sent successfully!')
}
// Read JSON from stdin
async function readStdin(): Promise<string> {
const chunks: Buffer[] = []
for await (const chunk of process.stdin) {
chunks.push(chunk)
}
return Buffer.concat(chunks).toString('utf-8')
}
readStdin()
.then(async (input) => {
const stalePRs: StalePR[] = JSON.parse(input)
if (stalePRs.length === 0) {
console.error('No stale PRs to notify about')
return
}
await sendSlackNotification(stalePRs)
})
.catch((error) => {
console.error('Error:', error.message)
process.exit(1)
})