Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 196 additions & 0 deletions .github/scripts/stale-prs.js
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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use the actual push time for author activity

When an author responds to review by pushing a commit that was created earlier—for example, an old local commit—commit.commit.committer.date remains earlier than the review because it is commit metadata, not the time the branch was pushed. lastAuthorAt therefore 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 👍 / 👎.

);
}
}

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`);
};
50 changes: 50 additions & 0 deletions .github/workflows/stale.yaml
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 });
Loading