Stale PR Management #72
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"); | |
| // ------------------------------------------------------- | |
| // NEW: Auto-label PRs with no activity in the last 14 days | |
| // ------------------------------------------------------- | |
| if (!hasStale && !hasKeepOpen) { | |
| // Find the most recent commit date | |
| 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); | |
| // Find the most recent non-bot comment date | |
| const { data: comments } = await github.rest.issues.listComments({ | |
| owner, | |
| repo, | |
| issue_number: pr.number, | |
| per_page: 100 | |
| }); | |
| 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).` | |
| }); | |
| } | |
| } |