diff --git a/.github/workflows/sheriff.yml b/.github/workflows/sheriff.yml new file mode 100644 index 000000000..03f10ee47 --- /dev/null +++ b/.github/workflows/sheriff.yml @@ -0,0 +1,52 @@ +name: PR Sheriff + +on: + schedule: + # Daily UTC afternoon pass. The script uses an aggressive 7/14 day window: + # warn contributor-blocked PRs after 7 days open, close after 14. + - cron: "17 15 * * *" + workflow_dispatch: + inputs: + dry_run: + description: "Print planned changes without mutating GitHub" + type: boolean + default: false + +permissions: + actions: read + checks: read + contents: read + issues: write + pull-requests: write + +concurrency: + group: pr-sheriff + cancel-in-progress: false + +jobs: + sheriff: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Run sheriff + env: + GH_TOKEN: ${{ github.token }} + run: | + mode="--apply" + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.dry_run }}" = "true" ]; then + mode="--dry-run" + fi + + node scripts/github/sheriff.mjs "$mode" \ + --repo "$GITHUB_REPOSITORY" \ + --warning-days 7 \ + --close-days 14 \ + --maintainers "pbakaus" \ + --regular-contributors "pbakaus,abdulwahabone" diff --git a/scripts/github/sheriff.mjs b/scripts/github/sheriff.mjs new file mode 100644 index 000000000..eed89c843 --- /dev/null +++ b/scripts/github/sheriff.mjs @@ -0,0 +1,720 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +export const LABEL_DEFS = [ + { name: 'waiting on contributor', color: 'fbca04', description: 'Waiting for the PR author to respond or make changes' }, + { name: 'needs maintainer review', color: '5319e7', description: 'Ready for a maintainer to review or decide' }, + { name: 'ready to merge', color: '0e8a16', description: 'Passing, resolved, and ready for a maintainer merge decision' }, + { name: 'stale', color: 'ededed', description: 'Inactive PR that may be closed soon' }, + { name: 'blocked: ci', color: 'd93f0b', description: 'Latest commit has failing required checks' }, + { name: 'blocked: review threads', color: 'd93f0b', description: 'Unresolved review feedback or requested changes remain' }, + { name: 'blocked: merge conflicts', color: 'd93f0b', description: 'PR cannot merge until conflicts are resolved' }, + { name: 'policy: needs issue', color: 'b60205', description: 'PR needs an issue and maintainer approval before continuing' }, + { name: 'policy: needs ai disclosure', color: 'b60205', description: 'PR needs required AI-assistance disclosure' }, + { name: 'policy: generated output', color: 'b60205', description: 'PR needs generated artifacts removed or regenerated appropriately' }, + { name: 'do not close', color: '1d76db', description: 'Maintainer opt-out from sheriff auto-close' }, +]; + +export const STATE_LABELS = [ + 'waiting on contributor', + 'needs maintainer review', + 'ready to merge', + 'stale', +]; + +export const BLOCKED_LABELS = [ + 'blocked: ci', + 'blocked: review threads', + 'blocked: merge conflicts', +]; + +export const AUTO_MANAGED_LABELS = new Set([...STATE_LABELS, ...BLOCKED_LABELS]); +export const WARNING_MARKER = ''; +export const CLOSE_MARKER = ''; + +const DEFAULT_MAINTAINERS = ['pbakaus']; +const DEFAULT_REGULAR_CONTRIBUTORS = ['pbakaus', 'abdulwahabone']; +const DEFAULT_EXEMPT_LABELS = ['do not close', 'security']; + +const REVIEW_BLOCKING_STATES = new Set(['CHANGES_REQUESTED']); +const FAILING_STATUS_STATES = new Set(['ERROR', 'FAILURE']); +const SHERIFF_WAIT_COMMAND = /^\/sheriff\s+wait\s*$/i; + +const PR_QUERY = ` +query($owner: String!, $name: String!, $after: String) { + repository(owner: $owner, name: $name) { + pullRequests(states: OPEN, first: 50, after: $after, orderBy: { field: UPDATED_AT, direction: DESC }) { + pageInfo { hasNextPage endCursor } + nodes { + number + title + url + isDraft + createdAt + updatedAt + mergeable + reviewDecision + author { login } + labels(first: 50) { nodes { name } } + comments(last: 50) { + nodes { + author { login } + createdAt + body + } + } + reviews(last: 50) { + nodes { + author { login } + state + submittedAt + body + } + } + commits(last: 1) { + nodes { + commit { + authoredDate + committedDate + author { user { login } } + committer { user { login } } + statusCheckRollup { state } + } + } + } + reviewThreads(first: 100) { + nodes { + isResolved + comments(last: 20) { + nodes { + author { login } + createdAt + body + } + } + } + } + timelineItems(last: 50, itemTypes: [LABELED_EVENT, UNLABELED_EVENT]) { + nodes { + __typename + ... on LabeledEvent { + createdAt + label { name } + actor { login } + } + ... on UnlabeledEvent { + createdAt + label { name } + actor { login } + } + } + } + } + } + } +}`; + +export function evaluatePullRequest(pr, options = {}) { + const now = toDate(options.now || new Date()); + const warningDays = Number.isFinite(options.warningDays) ? options.warningDays : 7; + const closeDays = Number.isFinite(options.closeDays) ? options.closeDays : 14; + const maintainers = loginSet(options.maintainers || DEFAULT_MAINTAINERS); + const regularContributors = loginSet(options.regularContributors || DEFAULT_REGULAR_CONTRIBUTORS); + const exemptLabels = new Set(options.exemptLabels || DEFAULT_EXEMPT_LABELS); + const autoCloseRegulars = options.autoCloseRegulars === true; + + const labels = new Set(pr.labels || []); + const author = normalizeLogin(pr.authorLogin || pr.author?.login || ''); + const daysOpen = Math.floor((now.getTime() - toDate(pr.createdAt).getTime()) / DAY_MS); + const latestContributorCommitAt = latestCommitBelongsToAuthor(pr, author) ? pr.latestCommitAt : null; + const latestContributorAt = latestDate([ + latestContributorCommitAt, + ...commentsBy(pr.comments, author).map((comment) => comment.createdAt), + ...reviewsBy(pr.reviews, author).map((review) => review.submittedAt), + ...reviewThreadCommentsBy(pr.reviewThreads, author).map((comment) => comment.createdAt), + ]); + const latestMaintainerWaitAt = latestMaintainerWaitCommand(pr, maintainers); + const latestBlockingReviewAt = pr.reviewDecision === 'CHANGES_REQUESTED' + ? latestDate((pr.reviews || []) + .filter((review) => REVIEW_BLOCKING_STATES.has(review.state)) + .map((review) => review.submittedAt)) + : null; + const unresolvedThreadBlockers = unresolvedThreadsNeedingContributor(pr.reviewThreads || [], author); + + const blockers = []; + if (pr.isDraft) { + blockers.push({ kind: 'draft' }); + } + + if (FAILING_STATUS_STATES.has(pr.statusState)) { + blockers.push({ kind: 'ci', label: 'blocked: ci', at: pr.latestCommitAt || pr.updatedAt }); + } + + if (pr.mergeable === 'CONFLICTING') { + blockers.push({ kind: 'merge-conflicts', label: 'blocked: merge conflicts', at: pr.updatedAt }); + } + + if (latestBlockingReviewAt && !isAfter(latestContributorAt, latestBlockingReviewAt)) { + blockers.push({ kind: 'changes-requested', label: 'blocked: review threads', at: latestBlockingReviewAt }); + } + + if (unresolvedThreadBlockers.length > 0) { + blockers.push({ + kind: 'review-threads', + label: 'blocked: review threads', + at: latestDate(unresolvedThreadBlockers.map((thread) => thread.latestCommentAt)), + }); + } + + if (latestMaintainerWaitAt && !isAfter(latestContributorAt, latestMaintainerWaitAt)) { + blockers.push({ kind: 'sheriff-wait', at: latestMaintainerWaitAt }); + } + + const waitingLabelAt = latestLabelEventAt(pr.labelEvents || [], 'waiting on contributor', 'LabeledEvent'); + if (labels.has('waiting on contributor') && waitingLabelAt && !isAfter(latestContributorAt, waitingLabelAt)) { + blockers.push({ kind: 'manual-waiting', at: waitingLabelAt }); + } + + const contributorActionRequired = blockers.length > 0; + const unresolvedThreadCount = (pr.reviewThreads || []).filter((thread) => !thread.isResolved).length; + const statusIsReady = pr.statusState === 'SUCCESS'; + const mergeableIsReady = pr.mergeable === 'MERGEABLE'; + const readyToMerge = !pr.isDraft + && !contributorActionRequired + && unresolvedThreadCount === 0 + && pr.reviewDecision !== 'CHANGES_REQUESTED' + && statusIsReady + && mergeableIsReady; + + const desiredLabels = new Set(); + if (contributorActionRequired) desiredLabels.add('waiting on contributor'); + else if (readyToMerge) desiredLabels.add('ready to merge'); + else desiredLabels.add('needs maintainer review'); + + for (const blocker of blockers) { + if (blocker.label) desiredLabels.add(blocker.label); + } + + const staleEligible = contributorActionRequired && daysOpen >= warningDays; + if (staleEligible) desiredLabels.add('stale'); + + const warningAlreadyPosted = labels.has('stale') || hasMarker(pr.comments, WARNING_MARKER); + const closeAlreadyPosted = hasMarker(pr.comments, CLOSE_MARKER); + const exemptFromClose = [...labels].some((label) => exemptLabels.has(label)); + const regularContributor = regularContributors.has(author); + const shouldWarn = staleEligible && !warningAlreadyPosted; + const shouldClose = contributorActionRequired + && daysOpen >= closeDays + && warningAlreadyPosted + && !closeAlreadyPosted + && !exemptFromClose + && (autoCloseRegulars || !regularContributor); + + const labelsToAdd = [...desiredLabels].filter((label) => !labels.has(label)).sort(); + const labelsToRemove = [...labels] + .filter((label) => AUTO_MANAGED_LABELS.has(label) && !desiredLabels.has(label)) + .sort(); + + return { + number: pr.number, + title: pr.title, + author, + daysOpen, + contributorActionRequired, + readyToMerge, + blockers: blockers.map((blocker) => blocker.kind), + desiredLabels: [...desiredLabels].sort(), + labelsToAdd, + labelsToRemove, + shouldWarn, + shouldClose, + warningComment: shouldWarn ? staleWarningComment(pr, { daysOpen, closeDays, now }) : '', + closeComment: shouldClose ? staleCloseComment(pr, { daysOpen }) : '', + }; +} + +export function normalizePullRequest(node) { + const latestCommit = node.commits?.nodes?.[0]?.commit || null; + return { + number: node.number, + title: node.title, + url: node.url, + isDraft: node.isDraft, + createdAt: node.createdAt, + updatedAt: node.updatedAt, + mergeable: node.mergeable, + reviewDecision: node.reviewDecision, + authorLogin: node.author?.login || '', + labels: (node.labels?.nodes || []).map((label) => label.name), + comments: (node.comments?.nodes || []).map(normalizeComment), + reviews: (node.reviews?.nodes || []).map((review) => ({ + authorLogin: review.author?.login || '', + state: review.state, + submittedAt: review.submittedAt, + body: review.body || '', + })), + latestCommitAt: latestCommit?.committedDate || latestCommit?.authoredDate || null, + latestCommitAuthorLogin: latestCommit?.author?.user?.login || '', + latestCommitCommitterLogin: latestCommit?.committer?.user?.login || '', + statusState: latestCommit?.statusCheckRollup?.state || null, + reviewThreads: (node.reviewThreads?.nodes || []).map((thread) => ({ + isResolved: thread.isResolved, + comments: (thread.comments?.nodes || []).map(normalizeComment), + })), + labelEvents: (node.timelineItems?.nodes || []) + .filter((event) => event.label?.name) + .map((event) => ({ + type: event.__typename, + label: event.label.name, + actorLogin: event.actor?.login || '', + createdAt: event.createdAt, + })), + }; +} + +export function mergeIssueLabelEvents(pr, events = []) { + const labelEvents = pr.labelEvents || []; + const existingKeys = new Set(labelEvents.map((event) => [ + event.type, + event.label, + event.actorLogin, + event.createdAt, + ].join('\0'))); + + for (const event of events) { + const type = issueEventType(event.event); + const label = event.label?.name; + if (!type || !label || !event.created_at) continue; + + const normalized = { + type, + label, + actorLogin: event.actor?.login || '', + createdAt: event.created_at, + }; + const key = [ + normalized.type, + normalized.label, + normalized.actorLogin, + normalized.createdAt, + ].join('\0'); + if (existingKeys.has(key)) continue; + existingKeys.add(key); + labelEvents.push(normalized); + } + + pr.labelEvents = labelEvents; + return pr; +} + +export function staleWarningComment(pr, { daysOpen, closeDays, now = new Date() }) { + const scheduledCloseAt = addDays(toDate(pr.createdAt), closeDays); + const earliestNewWarningCloseAt = addDays(toDate(now), 1); + const closeDate = formatDate(scheduledCloseAt > earliestNewWarningCloseAt + ? scheduledCloseAt + : earliestNewWarningCloseAt); + return [ + WARNING_MARKER, + `Thanks for the PR. Impeccable is moving quickly, and this PR is currently waiting on contributor action.`, + '', + `It has been open for ${daysOpen} days. Please address the outstanding review feedback, CI failure, merge conflict, draft state, or explicit maintainer wait request. PRs that are still waiting on contributor action after ${closeDays} days open are closed automatically.`, + '', + `If nothing changes, this PR may be closed on or after ${closeDate}. Happy to reopen when it is ready to continue.`, + ].join('\n'); +} + +export function staleCloseComment(pr, { daysOpen }) { + return [ + CLOSE_MARKER, + `Closing this because it has been open for ${daysOpen} days and is still waiting on contributor action.`, + '', + 'Please open a fresh PR, or ask for this one to be reopened, after the outstanding feedback is addressed.', + ].join('\n'); +} + +export async function main(argv = process.argv.slice(2)) { + const options = parseArgs(argv); + const repo = options.repo || process.env.GITHUB_REPOSITORY; + if (!repo || !repo.includes('/')) { + throw new Error('Missing repository. Pass --repo owner/name or set GITHUB_REPOSITORY.'); + } + + if (options.apply && options.ensureLabels) { + ensureLabels(repo, options); + } + + const prs = fetchOpenPullRequests(repo).map(normalizePullRequest); + hydrateMissingWaitingLabelEvents(repo, prs); + const plans = prs.map((pr) => evaluatePullRequest(pr, options)); + + for (const plan of plans) { + printPlan(plan, { apply: options.apply }); + if (options.apply) applyPlan(repo, plan, options); + } + + console.log(`${options.apply ? 'Applied' : 'Dry run'} sheriff pass for ${plans.length} open PR(s).`); +} + +export function parseArgs(argv) { + const options = { + apply: false, + ensureLabels: true, + warningDays: 7, + closeDays: 14, + maintainers: DEFAULT_MAINTAINERS, + regularContributors: DEFAULT_REGULAR_CONTRIBUTORS, + exemptLabels: DEFAULT_EXEMPT_LABELS, + autoCloseRegulars: false, + now: new Date(), + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--apply') options.apply = true; + else if (arg === '--dry-run') options.apply = false; + else if (arg === '--no-label-ensure') options.ensureLabels = false; + else if (arg === '--auto-close-regulars') options.autoCloseRegulars = true; + else if (arg === '--repo') options.repo = requireValue(argv, ++i, arg); + else if (arg === '--warning-days') options.warningDays = Number(requireValue(argv, ++i, arg)); + else if (arg === '--close-days') options.closeDays = Number(requireValue(argv, ++i, arg)); + else if (arg === '--maintainers') options.maintainers = splitList(requireValue(argv, ++i, arg)); + else if (arg === '--regular-contributors') options.regularContributors = splitList(requireValue(argv, ++i, arg)); + else if (arg === '--exempt-labels') options.exemptLabels = splitList(requireValue(argv, ++i, arg)); + else if (arg === '--now') options.now = new Date(requireValue(argv, ++i, arg)); + else if (arg === '--help' || arg === '-h') { + printHelp(); + process.exit(0); + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + if (!Number.isFinite(options.warningDays) || options.warningDays < 0) { + throw new Error('--warning-days must be a non-negative number.'); + } + if (!Number.isFinite(options.closeDays) || options.closeDays < options.warningDays) { + throw new Error('--close-days must be at least --warning-days.'); + } + if (Number.isNaN(options.now.getTime())) throw new Error('--now must be a valid date.'); + + return options; +} + +function latestMaintainerWaitCommand(pr, maintainers) { + return latestDate([ + ...(pr.comments || []) + .filter((comment) => maintainers.has(normalizeLogin(comment.authorLogin))) + .filter((comment) => hasSheriffWaitCommand(comment.body)) + .map((comment) => comment.createdAt), + ...(pr.reviews || []) + .filter((review) => maintainers.has(normalizeLogin(review.authorLogin))) + .filter((review) => hasSheriffWaitCommand(review.body)) + .map((review) => review.submittedAt), + ]); +} + +function hasSheriffWaitCommand(body) { + return String(body || '') + .split(/\r?\n/) + .some((line) => SHERIFF_WAIT_COMMAND.test(line.trim())); +} + +function unresolvedThreadsNeedingContributor(threads, author) { + return threads + .filter((thread) => !thread.isResolved) + .map((thread) => { + const comments = [...(thread.comments || [])].sort((a, b) => toDate(a.createdAt) - toDate(b.createdAt)); + const latest = comments[comments.length - 1] || null; + return { + latestCommentAt: latest?.createdAt || null, + latestCommentAuthor: normalizeLogin(latest?.authorLogin || ''), + }; + }) + .filter((thread) => thread.latestCommentAt) + .filter((thread) => thread.latestCommentAuthor !== author); +} + +function applyPlan(repo, plan, options) { + if (plan.shouldWarn) postComment(repo, plan.number, plan.warningComment, options); + for (const label of plan.labelsToRemove) removeLabel(repo, plan.number, label, options); + if (plan.labelsToAdd.length > 0) addLabels(repo, plan.number, plan.labelsToAdd, options); + if (plan.shouldClose) { + postComment(repo, plan.number, plan.closeComment, options); + closePullRequest(repo, plan.number, options); + } +} + +function hydrateMissingWaitingLabelEvents(repo, prs) { + for (const pr of prs) { + if (!(pr.labels || []).includes('waiting on contributor')) continue; + if (latestLabelEventAt(pr.labelEvents || [], 'waiting on contributor', 'LabeledEvent')) continue; + mergeIssueLabelEvents(pr, fetchIssueEvents(repo, pr.number)); + } +} + +function fetchIssueEvents(repo, number) { + const pages = runGhJson([ + 'api', + '--paginate', + '--slurp', + `repos/${repo}/issues/${number}/events?per_page=100`, + ]); + return Array.isArray(pages) ? pages.flat() : []; +} + +function fetchOpenPullRequests(repo) { + const [owner, name] = repo.split('/'); + const nodes = []; + let after = ''; + + while (true) { + const args = [ + 'api', + 'graphql', + '-f', + `query=${PR_QUERY}`, + '-F', + `owner=${owner}`, + '-F', + `name=${name}`, + ]; + if (after) args.push('-F', `after=${after}`); + + const data = runGhJson(args); + const root = data.data || data; + const connection = root.repository?.pullRequests; + nodes.push(...(connection?.nodes || [])); + if (!connection?.pageInfo?.hasNextPage) break; + after = connection.pageInfo.endCursor; + } + + return nodes; +} + +function ensureLabels(repo, options) { + for (const label of LABEL_DEFS) { + const encoded = encodeURIComponent(label.name); + const get = runGh(['api', `repos/${repo}/labels/${encoded}`], { allowFailure: true, quiet: true }); + if (get.status === 0) { + runGh([ + 'api', + '-X', + 'PATCH', + `repos/${repo}/labels/${encoded}`, + '-f', + `color=${label.color}`, + '-f', + `description=${label.description}`, + ], options); + continue; + } + + runGh([ + 'api', + '-X', + 'POST', + `repos/${repo}/labels`, + '-f', + `name=${label.name}`, + '-f', + `color=${label.color}`, + '-f', + `description=${label.description}`, + ], options); + } +} + +function addLabels(repo, number, labels, options) { + const args = ['api', '-X', 'POST', `repos/${repo}/issues/${number}/labels`]; + for (const label of labels) args.push('-f', `labels[]=${label}`); + runGh(args, options); +} + +function removeLabel(repo, number, label, options) { + runGh([ + 'api', + '-X', + 'DELETE', + `repos/${repo}/issues/${number}/labels/${encodeURIComponent(label)}`, + ], { ...options, allowFailure: true }); +} + +function postComment(repo, number, body, options) { + runGh([ + 'api', + '-X', + 'POST', + `repos/${repo}/issues/${number}/comments`, + '-f', + `body=${body}`, + ], options); +} + +function closePullRequest(repo, number, options) { + runGh([ + 'api', + '-X', + 'PATCH', + `repos/${repo}/issues/${number}`, + '-f', + 'state=closed', + '-f', + 'state_reason=not_planned', + ], options); +} + +function printPlan(plan, { apply }) { + const changes = [ + plan.labelsToAdd.length ? `add=${plan.labelsToAdd.join(',')}` : '', + plan.labelsToRemove.length ? `remove=${plan.labelsToRemove.join(',')}` : '', + plan.shouldWarn ? 'warn' : '', + plan.shouldClose ? 'close' : '', + ].filter(Boolean); + if (changes.length === 0) return; + console.log(`${apply ? 'apply' : 'dry-run'} #${plan.number} ${plan.title}: ${changes.join(' ')}`); +} + +function normalizeComment(comment) { + return { + authorLogin: comment.author?.login || '', + createdAt: comment.createdAt, + body: comment.body || '', + }; +} + +function commentsBy(comments = [], login) { + return comments.filter((comment) => normalizeLogin(comment.authorLogin) === login); +} + +function reviewsBy(reviews = [], login) { + return reviews.filter((review) => normalizeLogin(review.authorLogin) === login); +} + +function latestCommitBelongsToAuthor(pr, author) { + if (!pr.latestCommitAt || !author) return false; + return [ + pr.latestCommitAuthorLogin, + pr.latestCommitCommitterLogin, + ].map(normalizeLogin).filter(Boolean).includes(author); +} + +function reviewThreadCommentsBy(threads = [], login) { + return threads.flatMap((thread) => commentsBy(thread.comments, login)); +} + +function hasMarker(comments = [], marker) { + return comments.some((comment) => typeof comment.body === 'string' && comment.body.includes(marker)); +} + +function latestLabelEventAt(events, label, type) { + return latestDate(events + .filter((event) => event.type === type && event.label === label) + .map((event) => event.createdAt)); +} + +function issueEventType(event) { + if (event === 'labeled') return 'LabeledEvent'; + if (event === 'unlabeled') return 'UnlabeledEvent'; + return null; +} + +function latestDate(values) { + let latest = null; + for (const value of values) { + if (!value) continue; + const date = toDate(value); + if (Number.isNaN(date.getTime())) continue; + if (!latest || date > latest) latest = date; + } + return latest; +} + +function isAfter(left, right) { + if (!left || !right) return false; + return toDate(left).getTime() > toDate(right).getTime(); +} + +function toDate(value) { + return value instanceof Date ? value : new Date(value); +} + +function addDays(date, days) { + return new Date(date.getTime() + days * DAY_MS); +} + +function formatDate(date) { + return date.toISOString().slice(0, 10); +} + +function splitList(value) { + return String(value || '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +function loginSet(logins) { + return new Set(logins.map(normalizeLogin).filter(Boolean)); +} + +function normalizeLogin(login) { + return String(login || '').toLowerCase(); +} + +function requireValue(argv, index, flag) { + const value = argv[index]; + if (!value || value.startsWith('--')) throw new Error(`${flag} requires a value.`); + return value; +} + +function runGhJson(args) { + const result = runGh(args, { quiet: true }); + try { + return JSON.parse(result.stdout || '{}'); + } catch (err) { + throw new Error(`Failed to parse gh JSON output: ${err.message}`); + } +} + +function runGh(args, options = {}) { + const result = spawnSync('gh', args, { + encoding: 'utf-8', + env: process.env, + }); + if (!options.quiet && result.stdout) process.stdout.write(result.stdout); + if (!options.quiet && result.stderr) process.stderr.write(result.stderr); + if (result.error) throw result.error; + if (result.status !== 0 && !options.allowFailure) { + throw new Error(`gh ${args.join(' ')} failed with exit ${result.status}: ${result.stderr || result.stdout}`); + } + return result; +} + +function printHelp() { + console.log(`Usage: node scripts/github/sheriff.mjs [--repo owner/name] [--apply] + +Default mode is a dry run. The scheduled workflow runs with: + --apply --warning-days 7 --close-days 14 + +Options: + --apply mutate labels, comments, and stale PR state + --dry-run print changes without mutating GitHub + --repo owner/name repository to inspect (defaults to GITHUB_REPOSITORY) + --warning-days n warn waiting PRs after n days open (default: 7) + --close-days n close waiting PRs after n days open (default: 14) + --maintainers a,b maintainer logins allowed to use /sheriff wait + --regular-contributors a,b contributors exempt from auto-close unless --auto-close-regulars is set + --auto-close-regulars also auto-close regular contributor PRs + --no-label-ensure skip creating/updating sheriff labels +`); +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((err) => { + console.error(err.message); + process.exit(1); + }); +} diff --git a/scripts/test-suites.mjs b/scripts/test-suites.mjs index 9818bbfac..b54c5fb2f 100644 --- a/scripts/test-suites.mjs +++ b/scripts/test-suites.mjs @@ -29,7 +29,7 @@ export const SUITES = { /^site\/(pages|content|components|layouts)\//, /^README(\.npm)?\.md$/, /^cli\/bin\//, - /^tests\/(build|cleanup-deprecated|cli-ignores|context|context-signals|critique-storage|design-parser|docs-integrity|hook|hook-build|impeccable-paths|shiki-theme|skills-cli|target-args|test-suites|windows-path-fix|zip)\.test\.(js|mjs)$/, + /^tests\/(build|cleanup-deprecated|cli-ignores|context|context-signals|critique-storage|design-parser|docs-integrity|github-sheriff|hook|hook-build|impeccable-paths|shiki-theme|skills-cli|target-args|test-suites|windows-path-fix|zip)\.test\.(js|mjs)$/, /^tests\/lib\//, ], commands: [ @@ -58,6 +58,7 @@ export const SUITES = { 'tests/context-signals.test.mjs', 'tests/critique-storage.test.mjs', 'tests/design-parser.test.mjs', + 'tests/github-sheriff.test.mjs', 'tests/hook-build.test.mjs', 'tests/hook.test.mjs', 'tests/impeccable-paths.test.mjs', diff --git a/tests/github-sheriff.test.mjs b/tests/github-sheriff.test.mjs new file mode 100644 index 000000000..2dba43445 --- /dev/null +++ b/tests/github-sheriff.test.mjs @@ -0,0 +1,476 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + CLOSE_MARKER, + WARNING_MARKER, + evaluatePullRequest, + mergeIssueLabelEvents, + parseArgs, +} from '../scripts/github/sheriff.mjs'; + +const NOW = '2026-07-08T00:00:00Z'; + +describe('github sheriff', () => { + it('warns a contributor-blocked PR after one week open', () => { + const plan = evaluatePullRequest(pr({ + createdAt: '2026-06-30T00:00:00Z', + latestCommitAt: '2026-06-30T01:00:00Z', + comments: [ + comment('pbakaus', '2026-07-03T00:00:00Z', '/sheriff wait'), + ], + }), { now: NOW }); + + assert.equal(plan.contributorActionRequired, true); + assert.equal(plan.daysOpen, 8); + assert.deepEqual(plan.labelsToAdd, ['stale', 'waiting on contributor']); + assert.equal(plan.shouldWarn, true); + assert.equal(plan.shouldClose, false); + assert.match(plan.warningComment, /waiting on contributor action/); + assert.match(plan.warningComment, /2026-07-14/); + }); + + it('closes a non-regular contributor PR that is still waiting after two weeks open', () => { + const plan = evaluatePullRequest(pr({ + createdAt: '2026-06-20T00:00:00Z', + latestCommitAt: '2026-06-20T01:00:00Z', + labels: ['waiting on contributor', 'stale'], + labelEvents: [ + labelEvent('LabeledEvent', 'waiting on contributor', 'pbakaus', '2026-06-21T00:00:00Z'), + ], + comments: [ + comment('pbakaus', '2026-06-21T00:00:00Z', 'Please fix the review feedback.'), + comment('github-actions[bot]', '2026-06-27T00:00:00Z', WARNING_MARKER), + ], + }), { now: NOW }); + + assert.equal(plan.shouldWarn, false); + assert.equal(plan.shouldClose, true); + assert.match(plan.closeComment, /Closing this because/); + }); + + it('warns instead of closing old waiting PRs that have not received a sheriff warning yet', () => { + const plan = evaluatePullRequest(pr({ + createdAt: '2026-06-20T00:00:00Z', + latestCommitAt: '2026-06-20T01:00:00Z', + comments: [ + comment('pbakaus', '2026-06-21T00:00:00Z', '/sheriff wait'), + ], + }), { now: NOW }); + + assert.equal(plan.shouldWarn, true); + assert.equal(plan.shouldClose, false); + assert.match(plan.warningComment, /2026-07-09/); + assert.doesNotMatch(plan.warningComment, /2026-07-04/); + }); + + it('uses the stale label as durable proof that a warning already happened', () => { + const plan = evaluatePullRequest(pr({ + createdAt: '2026-06-20T00:00:00Z', + latestCommitAt: '2026-06-20T01:00:00Z', + labels: ['waiting on contributor', 'stale'], + labelEvents: [ + labelEvent('LabeledEvent', 'waiting on contributor', 'pbakaus', '2026-06-21T00:00:00Z'), + ], + comments: [ + comment('pbakaus', '2026-06-21T00:00:00Z', 'Please fix the review feedback.'), + ], + }), { now: NOW }); + + assert.equal(plan.shouldWarn, false); + assert.equal(plan.shouldClose, true); + }); + + it('does not infer contributor blockers from maintainer prose', () => { + for (const body of [ + 'LGTM, merging after CI.', + 'Thanks for the update!', + "I'll review this change tomorrow.", + 'Could you add a focused test for this?', + ]) { + const plan = evaluatePullRequest(pr({ + createdAt: '2026-07-01T00:00:00Z', + latestCommitAt: '2026-07-01T01:00:00Z', + statusState: 'SUCCESS', + mergeable: 'MERGEABLE', + comments: [ + comment('pbakaus', '2026-07-02T00:00:00Z', body), + ], + }), { now: NOW }); + + assert.equal(plan.contributorActionRequired, false, body); + assert.equal(plan.readyToMerge, true, body); + assert.deepEqual(plan.labelsToAdd, ['ready to merge'], body); + } + }); + + it('treats explicit maintainer wait commands as contributor blockers', () => { + const plan = evaluatePullRequest(pr({ + createdAt: '2026-07-04T00:00:00Z', + latestCommitAt: '2026-07-04T01:00:00Z', + comments: [ + comment('pbakaus', '2026-07-05T00:00:00Z', [ + 'This needs a maintainer-controlled wait state.', + '/sheriff wait', + ].join('\n')), + ], + }), { now: NOW }); + + assert.equal(plan.contributorActionRequired, true); + assert.deepEqual(plan.labelsToAdd, ['waiting on contributor']); + }); + + it('ignores sheriff wait commands from non-maintainers', () => { + const plan = evaluatePullRequest(pr({ + createdAt: '2026-07-01T00:00:00Z', + latestCommitAt: '2026-07-01T01:00:00Z', + statusState: 'SUCCESS', + mergeable: 'MERGEABLE', + comments: [ + comment('contrib', '2026-07-02T00:00:00Z', '/sheriff wait'), + ], + }), { now: NOW }); + + assert.equal(plan.contributorActionRequired, false); + assert.equal(plan.readyToMerge, true); + assert.deepEqual(plan.labelsToAdd, ['ready to merge']); + }); + + it('moves back to maintainer review after the contributor responds', () => { + const plan = evaluatePullRequest(pr({ + createdAt: '2026-06-25T00:00:00Z', + latestCommitAt: '2026-06-25T01:00:00Z', + labels: ['waiting on contributor', 'stale', 'blocked: review threads'], + comments: [ + comment('pbakaus', '2026-07-01T00:00:00Z', 'Can you split this up?'), + comment('contrib', '2026-07-02T00:00:00Z', 'Done, pushed the split.'), + ], + }), { now: NOW }); + + assert.equal(plan.contributorActionRequired, false); + assert.deepEqual(plan.labelsToAdd, ['needs maintainer review']); + assert.deepEqual(plan.labelsToRemove, ['blocked: review threads', 'stale', 'waiting on contributor']); + assert.equal(plan.shouldWarn, false); + assert.equal(plan.shouldClose, false); + }); + + it('preserves a current waiting label applied after the latest contributor action', () => { + const plan = evaluatePullRequest(pr({ + createdAt: '2026-07-04T00:00:00Z', + latestCommitAt: '2026-07-02T00:00:00Z', + labels: ['waiting on contributor'], + labelEvents: [ + labelEvent('LabeledEvent', 'waiting on contributor', 'pbakaus', '2026-07-03T00:00:00Z'), + ], + }), { now: NOW }); + + assert.equal(plan.contributorActionRequired, true); + assert.deepEqual(plan.labelsToAdd, []); + }); + + it('preserves a waiting label when the label event is hydrated from issue events', () => { + const source = pr({ + createdAt: '2026-07-04T00:00:00Z', + updatedAt: '2026-07-03T00:00:00Z', + latestCommitAt: '2026-07-02T00:00:00Z', + labels: ['waiting on contributor'], + labelEvents: [], + }); + mergeIssueLabelEvents(source, [ + issueEvent('labeled', 'waiting on contributor', 'pbakaus', '2026-07-03T00:00:00Z'), + ]); + + const plan = evaluatePullRequest(source, { now: NOW }); + + assert.equal(plan.contributorActionRequired, true); + assert.deepEqual(plan.labelsToAdd, []); + assert.deepEqual(plan.labelsToRemove, []); + }); + + it('does not pin a waiting label when no label timestamp is available', () => { + const plan = evaluatePullRequest(pr({ + createdAt: '2026-07-01T00:00:00Z', + updatedAt: '2026-07-04T00:00:00Z', + latestCommitAt: '2026-07-04T00:00:00Z', + labels: ['waiting on contributor', 'stale'], + labelEvents: [], + }), { now: NOW }); + + assert.equal(plan.contributorActionRequired, false); + assert.deepEqual(plan.labelsToAdd, ['needs maintainer review']); + assert.deepEqual(plan.labelsToRemove, ['stale', 'waiting on contributor']); + assert.equal(plan.shouldClose, false); + }); + + it('allows a zero-day warning window for manual dry runs', () => { + const parsed = parseArgs([ + '--warning-days', '0', + '--close-days', '0', + ]); + + assert.equal(parsed.warningDays, 0); + assert.equal(parsed.closeDays, 0); + }); + + it('clears a waiting label once the contributor acts after it was applied', () => { + const plan = evaluatePullRequest(pr({ + createdAt: '2026-07-01T00:00:00Z', + latestCommitAt: '2026-07-04T00:00:00Z', + labels: ['waiting on contributor'], + labelEvents: [ + labelEvent('LabeledEvent', 'waiting on contributor', 'pbakaus', '2026-07-03T00:00:00Z'), + ], + }), { now: NOW }); + + assert.equal(plan.contributorActionRequired, false); + assert.deepEqual(plan.labelsToAdd, ['needs maintainer review']); + assert.deepEqual(plan.labelsToRemove, ['waiting on contributor']); + }); + + it('does not treat a non-author head commit as contributor activity', () => { + const plan = evaluatePullRequest(pr({ + createdAt: '2026-07-04T00:00:00Z', + latestCommitAt: '2026-07-04T00:00:00Z', + latestCommitAuthorLogin: 'github-actions[bot]', + latestCommitCommitterLogin: 'web-flow', + labels: ['waiting on contributor'], + labelEvents: [ + labelEvent('LabeledEvent', 'waiting on contributor', 'pbakaus', '2026-07-02T00:00:00Z'), + ], + comments: [ + comment('pbakaus', '2026-07-02T00:00:00Z', '/sheriff wait'), + ], + }), { now: NOW }); + + assert.equal(plan.contributorActionRequired, true); + assert.deepEqual(plan.labelsToAdd, []); + assert.deepEqual(plan.labelsToRemove, []); + }); + + it('treats unresolved threads as contributor-blocked when the latest thread comment is not from the author', () => { + const plan = evaluatePullRequest(pr({ + createdAt: '2026-07-01T00:00:00Z', + reviewThreads: [ + { + isResolved: false, + comments: [ + comment('cursor', '2026-07-02T00:00:00Z', 'This path looks wrong.'), + ], + }, + ], + }), { now: NOW }); + + assert.equal(plan.contributorActionRequired, true); + assert.ok(plan.desiredLabels.includes('waiting on contributor')); + assert.ok(plan.desiredLabels.includes('blocked: review threads')); + }); + + it('does not close when the contributor is the latest unresolved-thread commenter', () => { + const plan = evaluatePullRequest(pr({ + createdAt: '2026-06-20T00:00:00Z', + labels: ['waiting on contributor', 'stale'], + labelEvents: [ + labelEvent('LabeledEvent', 'waiting on contributor', 'pbakaus', '2026-06-21T12:00:00Z'), + ], + reviewThreads: [ + { + isResolved: false, + comments: [ + comment('cursor', '2026-06-21T00:00:00Z', 'This path looks wrong.'), + comment('contrib', '2026-06-22T00:00:00Z', 'Fixed in the latest push.'), + ], + }, + ], + }), { now: NOW }); + + assert.equal(plan.contributorActionRequired, false); + assert.deepEqual(plan.labelsToAdd, ['needs maintainer review']); + assert.equal(plan.shouldClose, false); + }); + + it('marks failing checks as waiting on contributor', () => { + const plan = evaluatePullRequest(pr({ + createdAt: '2026-07-01T00:00:00Z', + statusState: 'FAILURE', + }), { now: NOW }); + + assert.equal(plan.contributorActionRequired, true); + assert.ok(plan.desiredLabels.includes('blocked: ci')); + }); + + it('marks passing resolved PRs as ready to merge', () => { + const plan = evaluatePullRequest(pr({ + createdAt: '2026-07-01T00:00:00Z', + statusState: 'SUCCESS', + mergeable: 'MERGEABLE', + }), { now: NOW }); + + assert.equal(plan.readyToMerge, true); + assert.deepEqual(plan.labelsToAdd, ['ready to merge']); + }); + + it('does not mark an unknown mergeability PR as ready', () => { + const plan = evaluatePullRequest(pr({ + createdAt: '2026-07-01T00:00:00Z', + statusState: 'SUCCESS', + mergeable: 'UNKNOWN', + }), { now: NOW }); + + assert.equal(plan.readyToMerge, false); + assert.deepEqual(plan.labelsToAdd, ['needs maintainer review']); + }); + + it('ignores stale changes-requested reviews after the current review decision clears', () => { + const plan = evaluatePullRequest(pr({ + createdAt: '2026-07-01T00:00:00Z', + latestCommitAt: '2026-07-01T01:00:00Z', + statusState: 'SUCCESS', + mergeable: 'MERGEABLE', + reviewDecision: 'APPROVED', + reviews: [ + { + authorLogin: 'pbakaus', + state: 'CHANGES_REQUESTED', + submittedAt: '2026-07-02T00:00:00Z', + body: 'Needs changes.', + }, + { + authorLogin: 'pbakaus', + state: 'APPROVED', + submittedAt: '2026-07-03T00:00:00Z', + body: 'Looks good now.', + }, + ], + }), { now: NOW }); + + assert.equal(plan.contributorActionRequired, false); + assert.equal(plan.readyToMerge, true); + assert.deepEqual(plan.labelsToAdd, ['ready to merge']); + }); + + it('blocks current changes-requested reviews until the contributor responds', () => { + const plan = evaluatePullRequest(pr({ + createdAt: '2026-07-04T00:00:00Z', + latestCommitAt: '2026-07-01T01:00:00Z', + statusState: 'SUCCESS', + mergeable: 'MERGEABLE', + reviewDecision: 'CHANGES_REQUESTED', + reviews: [ + { + authorLogin: 'pbakaus', + state: 'CHANGES_REQUESTED', + submittedAt: '2026-07-02T00:00:00Z', + body: 'Needs changes.', + }, + ], + }), { now: NOW }); + + assert.equal(plan.contributorActionRequired, true); + assert.deepEqual(plan.labelsToAdd, ['blocked: review threads', 'waiting on contributor']); + }); + + it('keeps changes-requested PRs in maintainer review after the contributor pushes', () => { + const plan = evaluatePullRequest(pr({ + createdAt: '2026-07-01T00:00:00Z', + latestCommitAt: '2026-07-03T00:00:00Z', + statusState: 'SUCCESS', + mergeable: 'MERGEABLE', + reviewDecision: 'CHANGES_REQUESTED', + reviews: [ + { + authorLogin: 'pbakaus', + state: 'CHANGES_REQUESTED', + submittedAt: '2026-07-02T00:00:00Z', + body: 'Needs changes.', + }, + ], + }), { now: NOW }); + + assert.equal(plan.contributorActionRequired, false); + assert.equal(plan.readyToMerge, false); + assert.deepEqual(plan.labelsToAdd, ['needs maintainer review']); + }); + + it('does not auto-close regular contributors by default', () => { + const plan = evaluatePullRequest(pr({ + authorLogin: 'abdulwahabone', + createdAt: '2026-06-20T00:00:00Z', + latestCommitAt: '2026-06-20T01:00:00Z', + comments: [ + comment('pbakaus', '2026-06-21T00:00:00Z', '/sheriff wait'), + ], + }), { now: NOW }); + + assert.equal(plan.shouldWarn, true); + assert.equal(plan.shouldClose, false); + }); + + it('keeps stale comments idempotent', () => { + const plan = evaluatePullRequest(pr({ + createdAt: '2026-06-20T00:00:00Z', + comments: [ + comment('pbakaus', '2026-06-21T00:00:00Z', '/sheriff wait'), + comment('github-actions[bot]', '2026-06-27T00:00:00Z', WARNING_MARKER), + comment('github-actions[bot]', '2026-07-04T00:00:00Z', CLOSE_MARKER), + ], + }), { now: NOW }); + + assert.equal(plan.shouldWarn, false); + assert.equal(plan.shouldClose, false); + }); + + it('parses aggressive stale windows from CLI args', () => { + const parsed = parseArgs([ + '--repo', 'pbakaus/impeccable', + '--apply', + '--warning-days', '7', + '--close-days', '14', + '--maintainers', 'pbakaus,other', + ]); + + assert.equal(parsed.apply, true); + assert.equal(parsed.repo, 'pbakaus/impeccable'); + assert.equal(parsed.warningDays, 7); + assert.equal(parsed.closeDays, 14); + assert.deepEqual(parsed.maintainers, ['pbakaus', 'other']); + }); +}); + +function pr(overrides = {}) { + return { + number: 123, + title: 'Test PR', + authorLogin: 'contrib', + isDraft: false, + createdAt: '2026-07-01T00:00:00Z', + updatedAt: '2026-07-01T00:00:00Z', + labels: [], + comments: [], + reviews: [], + reviewThreads: [], + labelEvents: [], + latestCommitAt: '2026-07-01T01:00:00Z', + latestCommitAuthorLogin: 'contrib', + latestCommitCommitterLogin: 'contrib', + statusState: null, + mergeable: 'MERGEABLE', + ...overrides, + }; +} + +function comment(authorLogin, createdAt, body) { + return { authorLogin, createdAt, body }; +} + +function labelEvent(type, label, actorLogin, createdAt) { + return { type, label, actorLogin, createdAt }; +} + +function issueEvent(event, label, actorLogin, createdAt) { + return { + event, + label: { name: label }, + actor: { login: actorLogin }, + created_at: createdAt, + }; +}