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).` }); } }