Repository navigation
chore(.github): add stale PR workflow #1170
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
andrewdennis117
wants to merge
1
commit into
main
Choose a base branch
from
reg-126-stale-pr-workflow
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,196 @@ | ||
| // Labels and closes pull requests that are waiting on their author. | ||
| // | ||
| // A PR is waiting on its author when the latest comment or review from someone | ||
| // other than the author is newer than the author's latest comment, review, or | ||
| // push. Bots are ignored, and an approval does not count as waiting on the | ||
| // author. PRs with no human feedback yet are never labeled. | ||
| // | ||
| // - Waiting on the author for DAYS_BEFORE_STALE days: add the stale label. | ||
| // - Still waiting DAYS_BEFORE_CLOSE days after labeling: close the PR. | ||
| // - The author comments, reviews, or pushes: remove the stale label. | ||
| // | ||
| // PRs are processed oldest first, and at most OPERATIONS_PER_RUN label or | ||
| // close actions are taken per run. Set DRY_RUN=true to only log actions. | ||
|
|
||
| const DAY_MS = 24 * 60 * 60 * 1000; | ||
|
|
||
| const isBot = (user) => | ||
| !user || user.type === "Bot" || user.login.endsWith("[bot]"); | ||
|
|
||
| const toTime = (value) => (value ? Date.parse(value) : 0); | ||
|
|
||
| async function getActivity({ github, owner, repo, pr, staleLabel }) { | ||
| const author = pr.user.login; | ||
| const activity = { | ||
| lastAuthorAt: 0, | ||
| lastOtherAt: 0, | ||
| lastOtherIsApproval: false, | ||
| staleLabeledAt: 0, | ||
| }; | ||
|
|
||
| const record = (user, time, isApproval = false) => { | ||
| const at = toTime(time); | ||
| if (!at || isBot(user)) { | ||
| return; | ||
| } | ||
| if (user.login === author) { | ||
| activity.lastAuthorAt = Math.max(activity.lastAuthorAt, at); | ||
| } else if (at > activity.lastOtherAt) { | ||
| activity.lastOtherAt = at; | ||
| activity.lastOtherIsApproval = isApproval; | ||
| } | ||
| }; | ||
|
|
||
| const timeline = await github.paginate( | ||
| github.rest.issues.listEventsForTimeline, | ||
| { owner, repo, issue_number: pr.number, per_page: 100 }, | ||
| ); | ||
| for (const event of timeline) { | ||
| switch (event.event) { | ||
| case "commented": | ||
| record(event.user ?? event.actor, event.created_at); | ||
| break; | ||
| case "reviewed": { | ||
| const state = (event.state ?? "").toLowerCase(); | ||
| if (state !== "pending") { | ||
| record(event.user, event.submitted_at, state === "approved"); | ||
| } | ||
| break; | ||
| } | ||
| case "line-commented": | ||
| for (const comment of event.comments ?? []) { | ||
| record(comment.user, comment.created_at); | ||
| } | ||
| break; | ||
| case "head_ref_force_pushed": | ||
| if (event.actor?.login === author) { | ||
| record(event.actor, event.created_at); | ||
| } | ||
| break; | ||
| case "labeled": | ||
| if (event.label?.name === staleLabel) { | ||
| activity.staleLabeledAt = Math.max( | ||
| activity.staleLabeledAt, | ||
| toTime(event.created_at), | ||
| ); | ||
| } | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| // Pushes count as author activity. Merge commits from "Update branch" are | ||
| // attributed to whoever clicked it, so they only count for the author. | ||
| const commits = await github.paginate(github.rest.pulls.listCommits, { | ||
| owner, | ||
| repo, | ||
| pull_number: pr.number, | ||
| per_page: 100, | ||
| }); | ||
| for (const commit of commits) { | ||
| if (commit.author?.login === author || commit.committer?.login === author) { | ||
| activity.lastAuthorAt = Math.max( | ||
| activity.lastAuthorAt, | ||
| toTime(commit.commit?.committer?.date), | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| return activity; | ||
| } | ||
|
|
||
| async function ensureLabel({ github, owner, repo, name, dryRun, core }) { | ||
| try { | ||
| await github.rest.issues.getLabel({ owner, repo, name }); | ||
| } catch (error) { | ||
| if (error.status !== 404) { | ||
| throw error; | ||
| } | ||
| const verb = dryRun ? "[dry run] Would create" : "Creating"; | ||
| core.info(`${verb} label "${name}"`); | ||
| if (!dryRun) { | ||
| await github.rest.issues.createLabel({ | ||
| owner, | ||
| repo, | ||
| name, | ||
| color: "ededed", | ||
| description: "Waiting on the author; closes automatically if no reply", | ||
| }); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| module.exports = async ({ github, context, core }) => { | ||
| const { owner, repo } = context.repo; | ||
| const staleLabel = process.env.STALE_LABEL || "stale"; | ||
| const daysBeforeStale = Number(process.env.DAYS_BEFORE_STALE || 7); | ||
| const daysBeforeClose = Number(process.env.DAYS_BEFORE_CLOSE || 3); | ||
| const operationsPerRun = Number(process.env.OPERATIONS_PER_RUN || 60); | ||
| const dryRun = process.env.DRY_RUN === "true"; | ||
| const now = Date.now(); | ||
| let operations = 0; | ||
|
|
||
| const act = async (description, fn) => { | ||
| operations++; | ||
| core.info(`${dryRun ? "[dry run] Would " : ""}${description}`); | ||
| if (!dryRun) { | ||
| await fn(); | ||
| } | ||
| }; | ||
|
|
||
| await ensureLabel({ github, owner, repo, name: staleLabel, dryRun, core }); | ||
|
|
||
| const prs = await github.paginate(github.rest.pulls.list, { | ||
| owner, | ||
| repo, | ||
| state: "open", | ||
| sort: "created", | ||
| direction: "asc", | ||
| per_page: 100, | ||
| }); | ||
| core.info(`Checking ${prs.length} open PRs`); | ||
|
|
||
| for (const pr of prs) { | ||
| if (operations >= operationsPerRun) { | ||
| core.info(`Reached ${operationsPerRun} operations; stopping early`); | ||
| break; | ||
| } | ||
|
|
||
| const activity = await getActivity({ github, owner, repo, pr, staleLabel }); | ||
| const isStale = pr.labels.some((label) => label.name === staleLabel); | ||
| const waitingOnAuthor = | ||
| activity.lastOtherAt > activity.lastAuthorAt && | ||
| !activity.lastOtherIsApproval; | ||
| const issue = { owner, repo, issue_number: pr.number }; | ||
|
|
||
| if (isStale) { | ||
| // If the label event is missing, never close based on unknown timing. | ||
| const labeledAt = activity.staleLabeledAt || now; | ||
| if (!waitingOnAuthor || activity.lastAuthorAt > labeledAt) { | ||
| await act(`remove "${staleLabel}" from #${pr.number}`, () => | ||
| github.rest.issues.removeLabel({ ...issue, name: staleLabel }), | ||
| ); | ||
| } else if (now - labeledAt >= daysBeforeClose * DAY_MS) { | ||
| await act(`close #${pr.number}`, () => | ||
| github.rest.pulls.update({ | ||
| owner, | ||
| repo, | ||
| pull_number: pr.number, | ||
| state: "closed", | ||
| }), | ||
| ); | ||
| } | ||
| continue; | ||
| } | ||
|
|
||
| if ( | ||
| waitingOnAuthor && | ||
| now - activity.lastOtherAt >= daysBeforeStale * DAY_MS | ||
| ) { | ||
| await act(`add "${staleLabel}" to #${pr.number}`, () => | ||
| github.rest.issues.addLabels({ ...issue, labels: [staleLabel] }), | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| core.info(`Done after ${operations} operations`); | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| name: Stale PRs | ||
|
|
||
| on: | ||
| schedule: | ||
| # Every day at midnight UTC. | ||
| - cron: "0 0 * * *" | ||
| workflow_dispatch: | ||
| inputs: | ||
| dry_run: | ||
| description: "Log what would change without labeling or closing PRs" | ||
| type: boolean | ||
| default: false | ||
|
|
||
| permissions: {} | ||
|
|
||
| concurrency: | ||
| group: ${{ github.workflow }} | ||
| cancel-in-progress: false | ||
|
|
||
| jobs: | ||
| stale-prs: | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: read | ||
| # Needed to create the stale label and add or remove it on PRs. | ||
| issues: write | ||
| # Needed to close PRs. | ||
| pull-requests: write | ||
| steps: | ||
| - name: Checkout repository | ||
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | ||
| with: | ||
| persist-credentials: false | ||
|
|
||
| - name: Mark and close PRs waiting on their author | ||
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | ||
| env: | ||
| STALE_LABEL: "stale" | ||
| # PRs become stale quickly due to merge conflicts, and we want to | ||
| # minimize WIP. The clock only starts once a reviewer is waiting on | ||
| # the author, so PRs waiting on a maintainer are never staled. | ||
| DAYS_BEFORE_STALE: "7" | ||
| DAYS_BEFORE_CLOSE: "3" | ||
| # Cap label and close actions per run to stay within the API limit. | ||
| OPERATIONS_PER_RUN: "60" | ||
| DRY_RUN: ${{ inputs.dry_run || false }} | ||
| with: | ||
| script: | | ||
| const run = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/stale-prs.js`); | ||
| await run({ github, context, core }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When an author responds to review by pushing a commit that was created earlier—for example, an old local commit—
commit.commit.committer.dateremains earlier than the review because it is commit metadata, not the time the branch was pushed.lastAuthorAttherefore is not advanced, so the workflow can leave the stale label in place and automatically close an actively updated PR. Track the branch update time rather than the embedded commit timestamp.Useful? React with 👍 / 👎.