mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
Add PR sheriff automation (#356)
* Add GitHub sheriff test coverage * Fix sheriff bot review feedback * Make sheriff maintainer waits explicit * Fix sheriff waiting label edge cases * Fix stale review blockers in sheriff * Fix sheriff contributor commit detection
This commit is contained in:
@@ -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 = '<!-- impeccable-sheriff:stale-warning -->';
|
||||
export const CLOSE_MARKER = '<!-- impeccable-sheriff:auto-close -->';
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user