Stale PR Management #231
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
| name: Stale PR Management | |
| on: | |
| schedule: | |
| - cron: "0 0 * * *" | |
| workflow_dispatch: | |
| pull_request_target: | |
| types: | |
| - labeled | |
| jobs: | |
| stale-prs: | |
| runs-on: self-hosted | |
| permissions: | |
| pull-requests: write | |
| issues: write | |
| contents: read | |
| steps: | |
| - name: Handle stale PRs | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const now = new Date(); | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| // --- When stale label is added, comment immediately --- | |
| if (context.eventName === "pull_request_target" && context.payload.action === "labeled") { | |
| const label = context.payload.label?.name; | |
| if (label === "stale") { | |
| const author = context.payload.pull_request.user.login; | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number: context.payload.pull_request.number, | |
| body: `@${author} This PR has been marked as stale. It will be closed if no new commits are added in 7 days.` | |
| }); | |
| } | |
| return; | |
| } | |
| // --- Scheduled run: fetch all open PRs --- | |
| const { data: prs } = await github.rest.pulls.list({ | |
| owner, | |
| repo, | |
| state: "open", | |
| per_page: 100 | |
| }); | |
| for (const pr of prs) { | |
| const labels = pr.labels.map(l => l.name); | |
| const hasStale = labels.includes("stale"); | |
| const hasKeepOpen = labels.includes("keep-open"); | |
| // ------------------------------------------------------- | |
| // Auto-label PRs with no activity in the last 14 days, | |
| // but ONLY if a maintainer has engaged with the PR at least once. | |
| // ------------------------------------------------------- | |
| if (!hasStale && !hasKeepOpen) { | |
| const author = pr.user.login; | |
| // Fetch reviews and issue comments to determine maintainer engagement. | |
| const { data: reviews } = await github.rest.pulls.listReviews({ | |
| owner, | |
| repo, | |
| pull_number: pr.number, | |
| per_page: 100 | |
| }); | |
| const { data: comments } = await github.rest.issues.listComments({ | |
| owner, | |
| repo, | |
| issue_number: pr.number, | |
| per_page: 100 | |
| }); | |
| // A "maintainer touch" is any review or comment from a non-bot account | |
| // that isn't the PR author. | |
| const hasNonAuthorReview = reviews.some( | |
| r => r.user?.type !== "Bot" && r.user?.login !== author | |
| ); | |
| const hasNonAuthorComment = comments.some( | |
| c => c.user?.type !== "Bot" && c.user?.login !== author | |
| ); | |
| if (!hasNonAuthorReview && !hasNonAuthorComment) { | |
| // No one but the author (and bots) has touched this PR. | |
| // Don't penalize the contributor with a stale label — skip it. | |
| continue; | |
| } | |
| // --- Activity check (unchanged logic) --- | |
| const { data: commits } = await github.rest.pulls.listCommits({ | |
| owner, | |
| repo, | |
| pull_number: pr.number | |
| }); | |
| const lastCommitDate = commits.length > 0 | |
| ? new Date(commits[commits.length - 1].commit.author.date) | |
| : new Date(pr.created_at); | |
| const humanComments = comments.filter(c => c.user?.type !== "Bot"); | |
| const lastCommentDate = humanComments.length > 0 | |
| ? new Date(humanComments[humanComments.length - 1].created_at) | |
| : null; | |
| // Most recent activity across commits and comments | |
| const lastActivityDate = lastCommentDate && lastCommentDate > lastCommitDate | |
| ? lastCommentDate | |
| : lastCommitDate; | |
| const daysSinceActivity = (now - lastActivityDate) / (1000 * 60 * 60 * 24); | |
| if (daysSinceActivity > 14) { | |
| await github.rest.issues.addLabels({ | |
| owner, | |
| repo, | |
| issue_number: pr.number, | |
| labels: ["stale"] | |
| }); | |
| // The pull_request_target labeled event will fire the comment automatically. | |
| // Skip further processing for this PR in this run. | |
| continue; | |
| } | |
| // Not stale, nothing else to do for this PR. | |
| continue; | |
| } | |
| // ------------------------------------------------------- | |
| // EXISTING: Manage already-stale PRs | |
| // ------------------------------------------------------- | |
| if (!hasStale) continue; // has keep-open but not stale — skip | |
| // Get timeline events to find when stale label was added | |
| const { data: events } = await github.rest.issues.listEvents({ | |
| owner, | |
| repo, | |
| issue_number: pr.number, | |
| per_page: 100 | |
| }); | |
| // Find the most recent time the stale label was added | |
| const staleLabelEvents = events | |
| .filter(e => e.event === "labeled" && e.label?.name === "stale") | |
| .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); | |
| if (staleLabelEvents.length === 0) continue; | |
| const staleLabelDate = new Date(staleLabelEvents[0].created_at); | |
| const daysSinceStale = (now - staleLabelDate) / (1000 * 60 * 60 * 24); | |
| // Check for new commits since stale label was added | |
| const { data: commits } = await github.rest.pulls.listCommits({ | |
| owner, | |
| repo, | |
| pull_number: pr.number | |
| }); | |
| const lastCommitDate = new Date(commits[commits.length - 1].commit.author.date); | |
| const author = pr.user.login; | |
| // If there are new commits after the stale label, remove it | |
| if (lastCommitDate > staleLabelDate) { | |
| await github.rest.issues.removeLabel({ | |
| owner, | |
| repo, | |
| issue_number: pr.number, | |
| name: "stale" | |
| }); | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number: pr.number, | |
| body: `@${author} Recent activity detected. Removing stale label.` | |
| }); | |
| } | |
| // If 7 days have passed since stale label, close the PR | |
| else if (daysSinceStale > 7) { | |
| await github.rest.pulls.update({ | |
| owner, | |
| repo, | |
| pull_number: pr.number, | |
| state: "closed" | |
| }); | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number: pr.number, | |
| body: `@${author} Closing stale PR due to inactivity (no commits for 7 days after stale label).` | |
| }); | |
| } | |
| } |