Files
supabase/scripts/actions/find-stale-dashboard-prs.js

227 lines
7.2 KiB
JavaScript

/**
* 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
}