diff --git a/.github/workflows/stale-blocked.yml b/.github/workflows/stale-blocked.yml index 11a47cd..e1f337e 100644 --- a/.github/workflows/stale-blocked.yml +++ b/.github/workflows/stale-blocked.yml @@ -1,4 +1,35 @@ +# +# This workflow aims to reduce the number of PRs that fail basic +# requirements by automatically flagging and closing those +# that remain unaddressed for an extended period. Unlike the stale +# action, this works based on the label application date since the +# blocking labels are added manually. It ignores push events and updates +# intentionally as blocking labels are supposed to be manually +# removed on review once the PR passes basic requirements and neither +# push events nor updates signal that the PR passes the basic +# requirements again. +# +# This workflow processes open PRs that - +# +# 1. Aren't drafts or exempted through EXEMPT_LABEL label, AND +# 2. Have one or more of LABEL_BLOCKED, LABEL_CHECKS, or LABEL_STALE +# +# It tracks how long blocking labels have been applied, then - +# +# 1. If the PR no longer has any blocking labels (LABEL_BLOCKED or +# LABEL_CHECKS), the LABEL_STALE label is removed. +# 2. If a PR is blocked (LABEL_BLOCKED or LABEL_CHECKS labels) for at +# least COMMENT_AFTER_DAYS days, a warning comment is posted notifying +# that it will be closed in CLOSE_AFTER_COMMENT_DAYS more days and +# the LABEL_STALE label is applied. +# 3. If a PR is both stale (LABEL_STALE label) and still blocked +# (LABEL_BLOCKED or LABEL_CHECKS labels), it is closed after +# CLOSE_AFTER_COMMENT_DAYS days from when the LABEL_STALE label was +# applied. +# + name: "Close stale blocked PRs" + on: schedule: - cron: "0 0 * * 1" @@ -8,10 +39,12 @@ on: description: "Dry run" type: boolean default: false + jobs: close-blocked: permissions: pull-requests: write + issues: write runs-on: ubuntu-latest steps: - name: Close stale blocked PRs @@ -19,154 +52,225 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd env: DRY_RUN: ${{ inputs.dry_run || 'false' }} - DAYS_THRESHOLD: "14" + COMMENT_AFTER_DAYS: "14" + CLOSE_AFTER_COMMENT_DAYS: "7" LABEL_BLOCKED: "blocked" LABEL_CHECKS: "pr-check-blocked" - COMMENT_BLOCKED: |- - This pull request has been marked as blocked for {days} days - and is not ready for inclusion. It is being automatically closed. - COMMENT_CHECKS: |- - This pull request is failing checks for {days} days - and is not ready for inclusion. It is being automatically closed. - COMMENT_BOTH: |- - This pull request has been marked as blocked and is failing - checks for {days} days. It is not ready for inclusion. It is being - automatically closed. EXEMPT_LABEL: "leave-open" - CLOSE_LABEL: "Stale" + LABEL_STALE: "stale-blocked" + COMMENT: |- + + This pull request has been marked as blocked for {days} days + and is not ready for inclusion. It will be closed in {grace_days} + days unless the issues are fixed and the {label} label is removed. with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | - const labelBlocked = process.env.LABEL_BLOCKED || "blocked"; - const labelChecks = process.env.LABEL_CHECKS || "pr-check-blocked"; - const labelStale = process.env.LABEL_STALE || "Stale"; + const LABEL_BLOCKED = process.env.LABEL_BLOCKED || "blocked"; + const LABEL_CHECKS = process.env.LABEL_CHECKS || "pr-check-blocked"; + const LABEL_STALE = process.env.LABEL_STALE || "stale-blocked"; + const EXEMPT_LABEL = process.env.EXEMPT_LABEL || "leave-open"; - const daysThreshold = parseInt(process.env.DAYS_THRESHOLD || "14", 10); - const exemptLabel = process.env.EXEMPT_LABEL || "leave-open"; - const dryRun = process.env.DRY_RUN === "true"; + const COMMENT_AFTER_DAYS = parseInt(process.env.COMMENT_AFTER_DAYS || "14", 10); + const CLOSE_AFTER_DAYS = parseInt( + process.env.CLOSE_AFTER_COMMENT_DAYS || "7", + 10 + ); - if (daysThreshold < 2) throw new Error(`DAYS_THRESHOLD must be >= 2, got ${daysThreshold}`); + const DRY_RUN = process.env.DRY_RUN === "true"; + const COMMENT_TEMPLATE = process.env.COMMENT?.trim(); + const STALE_MARKER = ""; + const MS_PER_DAY = 1000 * 60 * 60 * 24; - const now = new Date(); + if (!COMMENT_TEMPLATE) throw new Error("COMMENT env var is required"); - const commentTemplates = { - blocked: process.env.COMMENT_BLOCKED, - checks: process.env.COMMENT_CHECKS, - both: process.env.COMMENT_BOTH, - }; + const { owner, repo } = context.repo; + const now = Date.now(); - for (const [key, val] of Object.entries(commentTemplates)) { - if (!val?.trim()) throw new Error(`Comment template "${key}" is empty or missing`); + function act(description, fn) { + if (DRY_RUN) { + console.log(`[DRY RUN] ${description}`); + return Promise.resolve(); + } + console.log(description); + return fn(); + } + + function latestUnremovedLabelDate(events, label) { + let applied = null; + for (const e of events) { + if (e.event === "labeled" && e.label?.name === label) + applied = new Date(e.created_at).getTime(); + if (e.event === "unlabeled" && e.label?.name === label) applied = null; + } + return applied; + } + + function daysSince(ms) { + return (now - ms) / MS_PER_DAY; + } + + try { + const rate = await github.rest.rateLimit.get(); + console.log(`Rate limit remaining: ${rate.data.rate.remaining}`); + } catch (e) { + console.log("Could not fetch rate limit"); } const prs = await github.paginate(github.rest.pulls.list, { - owner: context.repo.owner, - repo: context.repo.repo, + owner, + repo, state: "open", - per_page: 100 + per_page: 100, }); - const blockedCount = prs.filter(pr => - pr.labels.some(l => l.name === labelBlocked) - ).length; - - let globalThreshold = daysThreshold; - - if (blockedCount > 10) { - globalThreshold = Math.floor(daysThreshold / 2); - } - for (const pr of prs) { - const prLabels = pr.labels.map(l => l.name); - const hasBlocked = prLabels.includes(labelBlocked); - const hasChecks = prLabels.includes(labelChecks); - - if (!hasBlocked && !hasChecks) continue; - if (pr.draft) continue; - if (prLabels.includes(exemptLabel)) continue; - - const effectiveThreshold = hasChecks - ? Math.floor(globalThreshold / 2) - : globalThreshold; - - const commentTemplate = hasBlocked && hasChecks - ? commentTemplates.both - : hasChecks - ? commentTemplates.checks - : commentTemplates.blocked; - try { - const events = await github.paginate( - github.rest.issues.listEventsForTimeline, - { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number - } - ); - - - const relevantLabels = []; - if (hasBlocked) relevantLabels.push(labelBlocked); - if (hasChecks) relevantLabels.push(labelChecks); - - const labelEvents = events - .filter(e => - e.event === "labeled" && - relevantLabels.includes(e.label?.name) - ) - .map(e => new Date(e.created_at)); - - if (labelEvents.length === 0) continue; - - const appliedDate = new Date( - Math.min(...labelEvents.map(d => d.getTime())) - ); - - const diffDays = (now - appliedDate) / (1000 * 60 * 60 * 24); - - if (diffDays >= effectiveThreshold) { - const days = Math.floor(diffDays); - - let reason; - if (hasBlocked && hasChecks) { - reason = "marked as blocked and failing checks"; - } else if (hasChecks) { - reason = "failing checks"; - } else { - reason = "marked as blocked"; - } - - const msg = `Closing PR ${pr.html_url} because it has been ${reason} for ${days} days`; - if (dryRun) { - console.log(`[DRY RUN] ${msg}`); - } else { - console.log(msg); - try { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - body: commentTemplate.replace("{days}", Math.floor(diffDays)) - }); - } catch (err) { - console.warn(`Failed to comment on PR ${pr.html_url}: ${err.message}`); - } - await github.rest.pulls.update({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pr.number, - state: "closed" - }); - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - labels: [labelStale] - }); - } - } + await processPR(pr); } catch (err) { - console.error(`Error processing PR ${pr.html_url}: ${err.message}`); + console.error( + `Error processing PR #${pr.number} (${pr.html_url}): ${err.message}` + ); + } + } + + async function processPR(pr) { + const labels = new Set(pr.labels.map((l) => l.name)); + const has = (label) => labels.has(label); + + const isBlocked = has(LABEL_BLOCKED); + const isChecks = has(LABEL_CHECKS); + const isStale = has(LABEL_STALE); + + if (!isBlocked && !isChecks && !isStale) return; + if (pr.draft) return; + if (has(EXEMPT_LABEL)) return; + + let events; + try { + events = await github.paginate(github.rest.issues.listEventsForTimeline, { + owner, + repo, + issue_number: pr.number, + }); + } catch (e) { + console.warn(`Failed to fetch timeline for #${pr.number}: ${e.message}`); + return; + } + + if (isStale && !isBlocked && !isChecks) { + const { data: fresh } = await github.rest.pulls.get({ + owner, + repo, + pull_number: pr.number, + }); + const freshLabels = new Set(fresh.labels.map((l) => l.name)); + if (freshLabels.has(LABEL_BLOCKED) || freshLabels.has(LABEL_CHECKS)) return; + + await act( + `Removing stale label from #${pr.number} (no longer blocked)`, + () => + github.rest.issues.removeLabel({ + owner, + repo, + issue_number: pr.number, + name: LABEL_STALE, + }) + ); + return; + } + + if (isStale && (isBlocked || isChecks)) { + const staleApplied = latestUnremovedLabelDate(events, LABEL_STALE); + if (staleApplied === null) return; + + const diff = daysSince(staleApplied); + console.log(`[INFO] #${pr.number}: stale for ${Math.floor(diff)} days`); + if (diff < CLOSE_AFTER_DAYS) return; + + const { data: fresh } = await github.rest.pulls.get({ + owner, + repo, + pull_number: pr.number, + }); + if (!fresh.labels.some((l) => l.name === LABEL_STALE)) return; + + await act( + `Closing PR #${pr.number} — stale and blocked for ${Math.floor( + diff + )} days`, + () => + github.rest.pulls.update({ + owner, + repo, + pull_number: pr.number, + state: "closed", + }) + ); + return; + } + + const blockAppliedDates = [ + isBlocked ? latestUnremovedLabelDate(events, LABEL_BLOCKED) : null, + isChecks ? latestUnremovedLabelDate(events, LABEL_CHECKS) : null, + ].filter(Number.isFinite); + + if (blockAppliedDates.length === 0) return; + + const appliedDate = Math.min(...blockAppliedDates); + const diffDays = daysSince(appliedDate); + + console.log(`[INFO] #${pr.number}: blocked for ${Math.floor(diffDays)} days`); + if (diffDays < COMMENT_AFTER_DAYS) return; + + const labelText = [ + isBlocked ? LABEL_BLOCKED : null, + isChecks ? LABEL_CHECKS : null, + ] + .filter(Boolean) + .join(" / "); + + const body = COMMENT_TEMPLATE.replaceAll("{days}", Math.floor(diffDays)) + .replaceAll("{grace_days}", CLOSE_AFTER_DAYS) + .replaceAll("{label}", labelText); + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: pr.number, + }); + const alreadyCommented = comments.some((c) => c.body?.includes(STALE_MARKER)); + + if (!alreadyCommented) { + await act( + `Commenting on PR #${pr.number} (blocked ${Math.floor( + diffDays + )} days) and marking stale`, + async () => { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body, + }); + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: pr.number, + labels: [LABEL_STALE], + }); + } + ); + } else if (!isStale) { + await act( + `Re-applying stale label to PR #${pr.number} (comment already present)`, + () => + github.rest.issues.addLabels({ + owner, + repo, + issue_number: pr.number, + labels: [LABEL_STALE], + }) + ); } }