|
| 1 | +// Auto-close of issues/PRs that didn't get updated within the grace period. |
| 2 | +// |
| 3 | +// Runs on a daily schedule from .github/workflows/auto-close-unaddressed.yml. |
| 4 | +// Enumerates every open item that still carries the `state-needs-updates` |
| 5 | +// label; for each, reads the timestamp of the "labeled" event and closes |
| 6 | +// the item if the grace period has elapsed. The `do-not-auto-close` label |
| 7 | +// exempts a specific submission. |
| 8 | +// |
| 9 | +// This deliberately replaces actions/stale so that the warning posted by |
| 10 | +// triage.mjs (which lists the specific violations) is not duplicated by an |
| 11 | +// additional stale-marker comment. |
| 12 | + |
| 13 | +import { gracePeriodDays, labelDefs, labels, messages } from './triage-config.mjs'; |
| 14 | + |
| 15 | +const dayMs = 24 * 60 * 60 * 1000; |
| 16 | + |
| 17 | +export default async function closeUnaddressed({ github, context, core, dryRun = false }) { |
| 18 | + const { owner, repo } = context.repo; |
| 19 | + const graceMs = gracePeriodDays * dayMs; |
| 20 | + const now = Date.now(); |
| 21 | + |
| 22 | + const items = await github.paginate(github.rest.issues.listForRepo, { |
| 23 | + owner, |
| 24 | + repo, |
| 25 | + state: 'open', |
| 26 | + labels: labels.needsUpdates, |
| 27 | + per_page: 100 |
| 28 | + }); |
| 29 | + |
| 30 | + core.info(`Found ${items.length} candidate(s) with \`${labels.needsUpdates}\`.`); |
| 31 | + |
| 32 | + let closed = 0; |
| 33 | + let skipped = 0; |
| 34 | + |
| 35 | + for (const item of items) { |
| 36 | + const decision = await decide(github, owner, repo, item, graceMs, now); |
| 37 | + core.info(`#${item.number}: ${decision.reason}`); |
| 38 | + if (decision.action !== 'close') { |
| 39 | + skipped++; |
| 40 | + continue; |
| 41 | + } |
| 42 | + if (dryRun) { |
| 43 | + continue; |
| 44 | + } |
| 45 | + await closeItem(github, owner, repo, item); |
| 46 | + closed++; |
| 47 | + } |
| 48 | + |
| 49 | + core.summary |
| 50 | + .addHeading('Auto-close unaddressed') |
| 51 | + .addRaw(`\nCandidates: ${items.length}, closed: ${closed}, skipped: ${skipped}${dryRun ? ' (dry-run)' : ''}\n`) |
| 52 | + .write(); |
| 53 | +} |
| 54 | + |
| 55 | +async function decide(github, owner, repo, item, graceMs, now) { |
| 56 | + if (item.labels.some(l => l.name === labels.bypassAutoClose)) { |
| 57 | + return { action: 'skip', reason: `bypassed via \`${labels.bypassAutoClose}\`` }; |
| 58 | + } |
| 59 | + |
| 60 | + const labeledAt = await findLabeledAt(github, owner, repo, item.number); |
| 61 | + if (labeledAt === null) { |
| 62 | + return { action: 'skip', reason: 'no matching "labeled" event found' }; |
| 63 | + } |
| 64 | + |
| 65 | + const elapsedMs = now - labeledAt; |
| 66 | + if (elapsedMs < graceMs) { |
| 67 | + const remainingDays = Math.ceil((graceMs - elapsedMs) / dayMs); |
| 68 | + return { action: 'skip', reason: `${remainingDays}d remaining in grace period` }; |
| 69 | + } |
| 70 | + |
| 71 | + return { action: 'close', reason: `${Math.floor(elapsedMs / dayMs)}d elapsed since labeling` }; |
| 72 | +} |
| 73 | + |
| 74 | +async function findLabeledAt(github, owner, repo, issueNumber) { |
| 75 | + let latest = null; |
| 76 | + for await (const { data } of github.paginate.iterator(github.rest.issues.listEvents, { |
| 77 | + owner, |
| 78 | + repo, |
| 79 | + issue_number: issueNumber, |
| 80 | + per_page: 100 |
| 81 | + })) { |
| 82 | + for (const event of data) { |
| 83 | + if (event.event === 'labeled' && event.label?.name === labels.needsUpdates) { |
| 84 | + const at = new Date(event.created_at).getTime(); |
| 85 | + if (latest === null || at > latest) { |
| 86 | + latest = at; |
| 87 | + } |
| 88 | + } |
| 89 | + } |
| 90 | + } |
| 91 | + return latest; |
| 92 | +} |
| 93 | + |
| 94 | +async function closeItem(github, owner, repo, item) { |
| 95 | + const isPr = !!item.pull_request; |
| 96 | + const body = isPr ? messages.closePr : messages.closeIssue; |
| 97 | + |
| 98 | + await github.rest.issues.createComment({ |
| 99 | + owner, |
| 100 | + repo, |
| 101 | + issue_number: item.number, |
| 102 | + body |
| 103 | + }); |
| 104 | + await addLabelWithCreate(github, owner, repo, item.number, labels.rulesNotFollowed); |
| 105 | + |
| 106 | + // `state_reason` is meaningful for issues (shown as "closed as not planned") |
| 107 | + // but has no display effect for PRs — omit it there to keep the call clean. |
| 108 | + const updateParams = { |
| 109 | + owner, |
| 110 | + repo, |
| 111 | + issue_number: item.number, |
| 112 | + state: 'closed' |
| 113 | + }; |
| 114 | + if (!isPr) { |
| 115 | + updateParams.state_reason = 'not_planned'; |
| 116 | + } |
| 117 | + await github.rest.issues.update(updateParams); |
| 118 | +} |
| 119 | + |
| 120 | +async function addLabelWithCreate(github, owner, repo, issueNumber, name) { |
| 121 | + try { |
| 122 | + await github.rest.issues.addLabels({ |
| 123 | + owner, |
| 124 | + repo, |
| 125 | + issue_number: issueNumber, |
| 126 | + labels: [name] |
| 127 | + }); |
| 128 | + } catch (e) { |
| 129 | + if (e.status !== 422) { |
| 130 | + throw e; |
| 131 | + } |
| 132 | + const def = labelDefs[name]; |
| 133 | + if (!def) { |
| 134 | + throw new Error(`No labelDefs entry for ${name}`); |
| 135 | + } |
| 136 | + try { |
| 137 | + await github.rest.issues.createLabel({ owner, repo, name, ...def }); |
| 138 | + } catch (createErr) { |
| 139 | + if (createErr.status !== 422) { |
| 140 | + throw createErr; |
| 141 | + } |
| 142 | + } |
| 143 | + await github.rest.issues.addLabels({ |
| 144 | + owner, |
| 145 | + repo, |
| 146 | + issue_number: issueNumber, |
| 147 | + labels: [name] |
| 148 | + }); |
| 149 | + } |
| 150 | +} |
0 commit comments