openpencil/.github/scripts/close-invalid-prs.ts
Danila Poyarkov 280b51e666
ci: gate invalid pull requests with CodeRabbit (#327)
* ci: gate invalid pull requests with CodeRabbit

* fix: harden CodeRabbit PR hygiene gate

* fix: make invalid PR label creation idempotent
2026-06-07 00:36:47 +03:00

141 lines
4.1 KiB
TypeScript

import { readFile } from 'node:fs/promises'
const owner = process.env.GITHUB_REPOSITORY_OWNER
const repo = process.env.GITHUB_REPOSITORY?.split('/')[1]
const eventPath = process.env.GITHUB_EVENT_PATH
const token = process.env.GITHUB_TOKEN
if (!owner || !repo || !eventPath || !token) {
throw new Error('Missing required GitHub Actions environment')
}
interface GitHubEvent {
sender?: { login?: string }
review?: { state?: string; body?: string }
pull_request?: { number?: number }
issue?: { number?: number; pull_request?: unknown }
comment?: { body?: string }
}
interface PullRequestResponse {
state: string
author_association: string
}
class GitHubAPIError extends Error {
readonly status: number
constructor(message: string, status: number) {
super(message)
this.status = status
}
}
const event = JSON.parse(await readFile(eventPath, 'utf8')) as GitHubEvent
const sender = event.sender?.login ?? ''
const coderabbitAuthors = new Set(['coderabbitai[bot]', 'coderabbitai'])
if (!coderabbitAuthors.has(sender)) {
console.log(`Ignoring sender: ${sender}`)
process.exit(0)
}
let issueNumber: number | undefined
let text = ''
let shouldInspect = false
if (event.review && event.pull_request) {
issueNumber = event.pull_request.number
text = `${event.review.state ?? ''}\n${event.review.body ?? ''}`
shouldInspect = event.review.state === 'changes_requested'
} else if (event.comment && event.issue?.pull_request) {
issueNumber = event.issue.number
text = event.comment.body ?? ''
shouldInspect = true
}
if (!issueNumber || !shouldInspect) {
console.log('No actionable PR hygiene signal found.')
process.exit(0)
}
const hygieneFailed = text
.split('\n')
.some((line) => /\bPR Hygiene\b/i.test(line) && /(?:❌|\berror\b|\bfailed\b|\bfail\b)/i.test(line))
if (!hygieneFailed) {
console.log('CodeRabbit signal did not reference a failed PR Hygiene check.')
process.exit(0)
}
async function github<T>(path: string, options: RequestInit = {}): Promise<T | null> {
const response = await fetch(`https://api.github.com${path}`, {
...options,
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${token}`,
'X-GitHub-Api-Version': '2022-11-28',
...options.headers
}
})
if (!response.ok) {
const body = await response.text()
throw new GitHubAPIError(`${options.method ?? 'GET'} ${path} failed: ${response.status} ${body}`, response.status)
}
if (response.status === 204) return null
return response.json() as Promise<T>
}
const pr = await github<PullRequestResponse>(`/repos/${owner}/${repo}/pulls/${issueNumber}`)
if (!pr) throw new Error(`PR #${issueNumber} returned no data`)
const trustedAssociations = new Set(['OWNER', 'MEMBER', 'COLLABORATOR'])
if (trustedAssociations.has(pr.author_association)) {
console.log(`Not closing trusted author association: ${pr.author_association}`)
process.exit(0)
}
if (pr.state !== 'open') {
console.log(`PR is already ${pr.state}.`)
process.exit(0)
}
const label = 'invalid'
try {
await github<unknown>(`/repos/${owner}/${repo}/labels/${encodeURIComponent(label)}`)
} catch (error) {
if (!(error instanceof GitHubAPIError) || error.status !== 404) throw error
try {
await github<unknown>(`/repos/${owner}/${repo}/labels`, {
method: 'POST',
body: JSON.stringify({
name: label,
color: 'd73a4a',
description: 'Does not meet contribution requirements'
})
})
} catch (createError) {
if (!(createError instanceof GitHubAPIError) || createError.status !== 422) throw createError
}
}
await github<unknown>(`/repos/${owner}/${repo}/issues/${issueNumber}/labels`, {
method: 'POST',
body: JSON.stringify({ labels: [label] })
})
await github<unknown>(`/repos/${owner}/${repo}/issues/${issueNumber}/comments`, {
method: 'POST',
body: JSON.stringify({
body: 'Closing this as invalid because CodeRabbit failed the PR Hygiene check. See `CONTRIBUTING.md` and the PR template before opening a new PR.'
})
})
await github<unknown>(`/repos/${owner}/${repo}/pulls/${issueNumber}`, {
method: 'PATCH',
body: JSON.stringify({ state: 'closed' })
})