mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
859 lines
29 KiB
JavaScript
859 lines
29 KiB
JavaScript
#!/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: 'needs triage', color: 'd4c5f9', description: 'New or reopened issue awaiting maintainer triage' },
|
|
{ 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 DEFAULT_TRUSTED_MARKER_AUTHORS = ['github-actions', 'github-actions[bot]'];
|
|
|
|
const REVIEW_BLOCKING_STATES = new Set(['CHANGES_REQUESTED']);
|
|
const FAILING_STATUS_STATES = new Set(['ERROR', 'FAILURE']);
|
|
const CONTRIBUTOR_POLICY_LABELS = new Set([
|
|
'policy: needs issue',
|
|
'policy: needs ai disclosure',
|
|
'policy: generated output',
|
|
]);
|
|
const SHERIFF_WAIT_COMMAND = /^\/sheriff\s+wait\s*$/i;
|
|
|
|
function normalizeLabelName(label) {
|
|
return String(label || '').trim().toLowerCase();
|
|
}
|
|
|
|
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 } }
|
|
# This is only an initial seed; hydrateIssueComments() replaces it with
|
|
# the full paginated issue comment history before evaluation.
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
labelTimelineItems: timelineItems(last: 100, itemTypes: [LABELED_EVENT, UNLABELED_EVENT]) {
|
|
nodes {
|
|
__typename
|
|
... on LabeledEvent {
|
|
createdAt
|
|
label { name }
|
|
actor { login }
|
|
}
|
|
... on UnlabeledEvent {
|
|
createdAt
|
|
label { name }
|
|
actor { login }
|
|
}
|
|
}
|
|
}
|
|
draftTimelineItems: timelineItems(last: 100, itemTypes: [CONVERT_TO_DRAFT_EVENT, READY_FOR_REVIEW_EVENT]) {
|
|
nodes {
|
|
__typename
|
|
... on ConvertToDraftEvent {
|
|
createdAt
|
|
actor { login }
|
|
}
|
|
... on ReadyForReviewEvent {
|
|
createdAt
|
|
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).map(normalizeLabelName),
|
|
);
|
|
const trustedMarkerAuthors = loginSet(options.trustedMarkerAuthors || DEFAULT_TRUSTED_MARKER_AUTHORS);
|
|
const autoCloseRegulars = options.autoCloseRegulars === true;
|
|
|
|
const labels = new Set(pr.labels || []);
|
|
const policyBlockers = [...labels].filter(isBlockingPolicyLabel);
|
|
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 = [];
|
|
const addBlocker = (blocker) => blockers.push({ contributorAction: false, ...blocker });
|
|
const addContributorBlocker = (blocker) => blockers.push({ contributorAction: true, ...blocker });
|
|
if (pr.isDraft) {
|
|
addContributorBlocker({ kind: 'draft', at: currentDraftStartedAt(pr) });
|
|
}
|
|
|
|
if (FAILING_STATUS_STATES.has(pr.statusState)) {
|
|
addBlocker({ kind: 'ci', label: 'blocked: ci', at: pr.latestCommitAt || pr.updatedAt });
|
|
}
|
|
|
|
if (pr.mergeable === 'CONFLICTING') {
|
|
const firstSeenAt = latestLabelEventAt(
|
|
pr.labelEvents || [],
|
|
'blocked: merge conflicts',
|
|
'LabeledEvent',
|
|
) || pr.updatedAt || pr.latestCommitAt || pr.createdAt;
|
|
addContributorBlocker({
|
|
kind: 'merge-conflicts',
|
|
label: 'blocked: merge conflicts',
|
|
at: latestDate([firstSeenAt, latestContributorAt]),
|
|
});
|
|
}
|
|
|
|
for (const label of policyBlockers) {
|
|
const firstSeenAt = latestLabelEventAt(
|
|
pr.labelEvents || [],
|
|
label,
|
|
'LabeledEvent',
|
|
) || pr.updatedAt || pr.createdAt;
|
|
const blocker = {
|
|
kind: label,
|
|
at: latestDate([firstSeenAt, latestContributorAt]),
|
|
};
|
|
if (CONTRIBUTOR_POLICY_LABELS.has(label)) addContributorBlocker(blocker);
|
|
else addBlocker(blocker);
|
|
}
|
|
|
|
if (latestBlockingReviewAt && !isAfter(latestContributorAt, latestBlockingReviewAt)) {
|
|
addContributorBlocker({ kind: 'changes-requested', label: 'blocked: review threads', at: latestBlockingReviewAt });
|
|
}
|
|
|
|
if (unresolvedThreadBlockers.length > 0) {
|
|
addContributorBlocker({
|
|
kind: 'review-threads',
|
|
label: 'blocked: review threads',
|
|
at: latestDate(unresolvedThreadBlockers.map((thread) => thread.latestCommentAt)),
|
|
});
|
|
}
|
|
|
|
if (latestMaintainerWaitAt && !isAfter(latestContributorAt, latestMaintainerWaitAt)) {
|
|
addContributorBlocker({ kind: 'sheriff-wait', at: latestMaintainerWaitAt });
|
|
}
|
|
|
|
const waitingLabelAt = latestMaintainerLabelEventAt(
|
|
pr.labelEvents || [],
|
|
'waiting on contributor',
|
|
'LabeledEvent',
|
|
maintainers,
|
|
);
|
|
if (labels.has('waiting on contributor') && waitingLabelAt && !isAfter(latestContributorAt, waitingLabelAt)) {
|
|
addContributorBlocker({ kind: 'manual-waiting', at: waitingLabelAt });
|
|
}
|
|
|
|
const contributorActionBlockerAt = latestDate(blockers
|
|
.filter((blocker) => blocker.contributorAction)
|
|
.map((blocker) => blocker.at || pr.createdAt));
|
|
const contributorActionRequired = blockers.some((blocker) => blocker.contributorAction);
|
|
const waitingDays = contributorActionRequired && contributorActionBlockerAt
|
|
? Math.floor((now.getTime() - contributorActionBlockerAt.getTime()) / DAY_MS)
|
|
: 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'
|
|
&& policyBlockers.length === 0
|
|
&& 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 && waitingDays >= warningDays;
|
|
if (staleEligible) desiredLabels.add('stale');
|
|
|
|
const warningPostedAt = latestMarkerAt(pr.comments, WARNING_MARKER, trustedMarkerAuthors);
|
|
const warningAlreadyPosted = Boolean(warningPostedAt
|
|
&& (!contributorActionBlockerAt || !isAfter(contributorActionBlockerAt, warningPostedAt)));
|
|
const closeAlreadyPosted = hasMarker(pr.comments, CLOSE_MARKER, trustedMarkerAuthors);
|
|
const exemptFromClose = [...labels]
|
|
.some((label) => exemptLabels.has(normalizeLabelName(label)));
|
|
const regularContributor = regularContributors.has(author);
|
|
const shouldWarn = staleEligible && !warningAlreadyPosted;
|
|
const shouldClose = contributorActionRequired
|
|
&& waitingDays >= 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,
|
|
waitingDays,
|
|
contributorActionRequired,
|
|
readyToMerge,
|
|
blockers: blockers.map((blocker) => blocker.kind),
|
|
desiredLabels: [...desiredLabels].sort(),
|
|
labelsToAdd,
|
|
labelsToRemove,
|
|
shouldWarn,
|
|
shouldClose,
|
|
warningComment: shouldWarn ? staleWarningComment(pr, { waitingDays, closeDays, contributorActionBlockerAt, now }) : '',
|
|
closeComment: shouldClose ? staleCloseComment(pr, { waitingDays }) : '',
|
|
};
|
|
}
|
|
|
|
export function normalizePullRequest(node) {
|
|
const latestCommit = node.commits?.nodes?.[0]?.commit || null;
|
|
const timelineNodes = node.timelineItems?.nodes || [];
|
|
const labelTimelineNodes = node.labelTimelineItems?.nodes || timelineNodes;
|
|
const draftTimelineNodes = node.draftTimelineItems?.nodes || timelineNodes;
|
|
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: labelTimelineNodes
|
|
.filter((event) => event.label?.name)
|
|
.map((event) => ({
|
|
type: event.__typename,
|
|
label: event.label.name,
|
|
actorLogin: event.actor?.login || '',
|
|
createdAt: event.createdAt,
|
|
})),
|
|
draftEvents: draftTimelineNodes
|
|
.filter((event) => ['ConvertToDraftEvent', 'ReadyForReviewEvent'].includes(event.__typename))
|
|
.map((event) => ({
|
|
type: event.__typename,
|
|
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 mergeIssueComments(pr, comments = []) {
|
|
pr.comments = comments.map(normalizeComment);
|
|
return pr;
|
|
}
|
|
|
|
export function staleWarningComment(pr, {
|
|
waitingDays,
|
|
closeDays,
|
|
contributorActionBlockerAt,
|
|
now = new Date(),
|
|
}) {
|
|
const scheduledCloseAt = addDays(toDate(contributorActionBlockerAt || now), 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 waiting for contributor action for ${waitingDays} days. Please address the outstanding review feedback, draft state, merge conflict, policy requirement, or explicit maintainer wait request. PRs that are still waiting on contributor action after ${closeDays} days 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, { waitingDays }) {
|
|
return [
|
|
CLOSE_MARKER,
|
|
`Closing this because it has been waiting on contributor action for ${waitingDays} days.`,
|
|
'',
|
|
'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);
|
|
hydrateIssueComments(repo, prs);
|
|
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 currentDraftStartedAt(pr) {
|
|
const latestTransition = [...(pr.draftEvents || [])]
|
|
.filter((event) => ['ConvertToDraftEvent', 'ReadyForReviewEvent'].includes(event.type))
|
|
.sort((a, b) => toDate(b.createdAt) - toDate(a.createdAt))[0];
|
|
return latestTransition?.createdAt || pr.createdAt;
|
|
}
|
|
|
|
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 hydrateIssueComments(repo, prs) {
|
|
for (const pr of prs) {
|
|
mergeIssueComments(pr, fetchIssueComments(repo, pr.number));
|
|
}
|
|
}
|
|
|
|
function fetchIssueComments(repo, number) {
|
|
const pages = runGhJson([
|
|
'api',
|
|
'--paginate',
|
|
'--slurp',
|
|
`repos/${repo}/issues/${number}/comments?per_page=100`,
|
|
]);
|
|
return Array.isArray(pages) ? pages.flat() : [];
|
|
}
|
|
|
|
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.authorLogin || comment.author?.login || comment.user?.login || '',
|
|
createdAt: comment.createdAt || comment.created_at,
|
|
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, trustedAuthors = null) {
|
|
return comments.some((comment) => isTrustedMarkerComment(comment, marker, trustedAuthors));
|
|
}
|
|
|
|
function latestMarkerAt(comments = [], marker, trustedAuthors = null) {
|
|
return latestDate(comments
|
|
.filter((comment) => isTrustedMarkerComment(comment, marker, trustedAuthors))
|
|
.map((comment) => comment.createdAt));
|
|
}
|
|
|
|
function isTrustedMarkerComment(comment, marker, trustedAuthors) {
|
|
if (typeof comment?.body !== 'string' || !comment.body.includes(marker)) return false;
|
|
if (!trustedAuthors) return true;
|
|
return trustedAuthors.has(normalizeLogin(comment.authorLogin || comment.author?.login));
|
|
}
|
|
|
|
function latestLabelEventAt(events, label, type) {
|
|
return latestDate(events
|
|
.filter((event) => event.type === type && event.label === label)
|
|
.map((event) => event.createdAt));
|
|
}
|
|
|
|
function latestMaintainerLabelEventAt(events, label, type, maintainers) {
|
|
return latestDate(events
|
|
.filter((event) => event.type === type && event.label === label)
|
|
.filter((event) => maintainers.has(normalizeLogin(event.actorLogin)))
|
|
.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 isBlockingPolicyLabel(label) {
|
|
return String(label || '').startsWith('policy:') && label !== 'policy: approved';
|
|
}
|
|
|
|
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);
|
|
});
|
|
}
|