mirror of
https://github.com/supabase/supabase.git
synced 2026-05-06 08:56:46 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
+55
-25
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
// 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)
|
||||
})
|
||||
Reference in New Issue
Block a user