diff --git a/.github/workflows/dashboard-pr-reminder.yml b/.github/workflows/dashboard-pr-reminder.yml index 876b0d6c4ab..76d464d0f4e 100644 --- a/.github/workflows/dashboard-pr-reminder.yml +++ b/.github/workflows/dashboard-pr-reminder.yml @@ -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 diff --git a/scripts/actions/find-stale-dashboard-prs.js b/scripts/actions/find-stale-dashboard-prs.js deleted file mode 100644 index e772025275a..00000000000 --- a/scripts/actions/find-stale-dashboard-prs.js +++ /dev/null @@ -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 -} diff --git a/scripts/actions/find-stale-dashboard-prs.ts b/scripts/actions/find-stale-dashboard-prs.ts new file mode 100644 index 00000000000..c73f52e0aa4 --- /dev/null +++ b/scripts/actions/find-stale-dashboard-prs.ts @@ -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 = { + 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 { + 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 = {} + 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) + }) diff --git a/scripts/actions/send-slack-pr-notification.js b/scripts/actions/send-slack-pr-notification.ts similarity index 65% rename from scripts/actions/send-slack-pr-notification.js rename to scripts/actions/send-slack-pr-notification.ts index c9e56b04123..7396c8a7af7 100644 --- a/scripts/actions/send-slack-pr-notification.js +++ b/scripts/actions/send-slack-pr-notification.ts @@ -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, '>') +} + +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, '>') - } - - // 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 { + 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) + })