PR author inactivity #208
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: PR author inactivity | |
| # Remind PR authors only when the current blocker is clearly author-owned, then | |
| # close after the blocker has gone stale. | |
| # | |
| # Inactivity heuristic: | |
| # - We look for the PR author's latest response via: | |
| # 1) an issue comment, | |
| # 2) a submitted pull-request review, | |
| # 3) an inline review comment reply, or | |
| # 4) a PR commit / force-push timeline event. | |
| # - We then find the first trusted human reviewer / maintainer feedback that | |
| # arrived after that author response via: | |
| # 1) issue comments, | |
| # 2) non-approval reviews, or | |
| # 3) inline review comments. | |
| # - We also track the latest non-bot human activity on the PR. Reopens, | |
| # comments, reviews, inline review comments, labels, assignments, review | |
| # requests, and pushes all reset the inactivity window. | |
| # - PRs waiting on product direction, QA validation, a non-author assignee, | |
| # requested reviewers, looper, or maintainer-assisted stale handling are not | |
| # author-owned and are skipped by this workflow. | |
| # - Clear author-owned blockers include merge conflicts, failed CI, and | |
| # outstanding trusted reviewer / maintainer feedback. | |
| # - If an author-owned blocker remains and the whole PR has no human activity | |
| # for 72h, we remind once. If it has no human activity for 120h, we close it | |
| # for queue management. | |
| # | |
| # Bot feedback note: trusted human reviewers / maintainers are the trigger for | |
| # this workflow. Bot-authored reviews and comments (e.g. CodeRabbit, Codex) are | |
| # intentionally excluded from the feedback signal so authors are not pressured | |
| # by automated nits alone. Trusted human review feedback — whether top-level or | |
| # inline — is what starts the inactivity clock. | |
| on: | |
| schedule: | |
| - cron: '0 */6 * * *' | |
| workflow_dispatch: | |
| inputs: | |
| dry_run: | |
| description: "Log planned actions without commenting or closing PRs" | |
| required: false | |
| default: false | |
| type: boolean | |
| permissions: | |
| checks: read | |
| issues: write | |
| pull-requests: write | |
| statuses: read | |
| concurrency: | |
| group: pr-author-inactivity | |
| cancel-in-progress: false | |
| jobs: | |
| triage: | |
| if: github.repository == 'nexu-io/open-design' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Remind or close inactive PRs | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const REMINDER_MARKER = '<!-- pr-author-inactivity:reminder -->'; | |
| const REMINDER_MS = 72 * 60 * 60 * 1000; | |
| const CLOSE_MS = 120 * 60 * 60 * 1000; | |
| const DRY_RUN = String(context.payload.inputs?.dry_run || 'false').toLowerCase() === 'true'; | |
| const NON_AUTHOR_BLOCKER_LABELS = new Set([ | |
| 'exempt-from-stale', | |
| 'looper:worker-ready', | |
| 'needs-design-review', | |
| 'needs-maintainer-merge', | |
| 'needs-qa', | |
| 'needs-qa-validation', | |
| 'needs-product-direction', | |
| 'needs-validation', | |
| 'ready-to-merge', | |
| 'stale-pr/blocked', | |
| 'stale-pr/maintainer-assisted', | |
| ]); | |
| const FAILED_CHECK_CONCLUSIONS = new Set(['action_required', 'cancelled', 'failure', 'startup_failure', 'timed_out']); | |
| const FAILED_STATUS_STATES = new Set(['error', 'failure']); | |
| const HUMAN_ACTIVITY_EVENTS = new Set([ | |
| 'assigned', | |
| 'committed', | |
| 'head_ref_force_pushed', | |
| 'labeled', | |
| 'ready_for_review', | |
| 'reopened', | |
| 'review_requested', | |
| 'unassigned', | |
| 'unlabeled', | |
| ]); | |
| function ts(value) { | |
| return value ? new Date(value).getTime() : 0; | |
| } | |
| function isBot(login) { | |
| return Boolean(login && login.endsWith('[bot]')); | |
| } | |
| function labelNames(pr) { | |
| return new Set((pr.labels || []).map((label) => label.name).filter(Boolean)); | |
| } | |
| function userLogins(users) { | |
| return (users || []).map((user) => user.login).filter(Boolean); | |
| } | |
| function commitTimestamp(commit) { | |
| return ts(commit.commit?.committer?.date || commit.commit?.author?.date); | |
| } | |
| function commitLogins(commit) { | |
| return [commit.author?.login, commit.committer?.login].filter(Boolean); | |
| } | |
| const TRUSTED_PERMISSIONS = new Set(['admin', 'maintain', 'write', 'triage']); | |
| const trustedReviewerCache = new Map(); | |
| async function isTrustedReviewer(login) { | |
| if (!login || isBot(login)) { | |
| return false; | |
| } | |
| if (trustedReviewerCache.has(login)) { | |
| return trustedReviewerCache.get(login); | |
| } | |
| try { | |
| const response = await github.rest.repos.getCollaboratorPermissionLevel({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| username: login, | |
| }); | |
| const trusted = TRUSTED_PERMISSIONS.has(response.data.permission); | |
| trustedReviewerCache.set(login, trusted); | |
| return trusted; | |
| } catch (error) { | |
| trustedReviewerCache.set(login, false); | |
| return false; | |
| } | |
| } | |
| async function paginate(method, params) { | |
| return github.paginate(method, { per_page: 100, ...params }); | |
| } | |
| const pulls = await paginate(github.rest.pulls.list, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| state: 'open', | |
| sort: 'updated', | |
| direction: 'asc', | |
| }); | |
| function collectFeedbackAt(author, trustedLogins, collection, getLogin, getTimestamp, predicate = () => true) { | |
| return collection | |
| .filter((item) => { | |
| const login = getLogin(item); | |
| if (!login || login === author || isBot(login) || !trustedLogins.has(login)) { | |
| return false; | |
| } | |
| return predicate(item); | |
| }) | |
| .map((item) => getTimestamp(item)) | |
| .filter(Boolean); | |
| } | |
| const now = Date.now(); | |
| const summary = []; | |
| const diagnostics = []; | |
| for (const pr of pulls) { | |
| const author = pr.user?.login; | |
| if (!author || pr.draft || isBot(author)) { | |
| if (DRY_RUN) { | |
| diagnostics.push(`#${pr.number}: skipped (${!author ? 'missing-author' : pr.draft ? 'draft' : 'bot-author'})`); | |
| } | |
| continue; | |
| } | |
| const [comments, reviews, reviewComments, timeline, prCommits, detailedPr, checkRuns, combinedStatus] = await Promise.all([ | |
| paginate(github.rest.issues.listComments, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: pr.number, | |
| }), | |
| paginate(github.rest.pulls.listReviews, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: pr.number, | |
| }), | |
| paginate(github.rest.pulls.listReviewComments, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: pr.number, | |
| }), | |
| paginate(github.rest.issues.listEventsForTimeline, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: pr.number, | |
| }), | |
| paginate(github.rest.pulls.listCommits, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: pr.number, | |
| }), | |
| github.rest.pulls.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: pr.number, | |
| }), | |
| paginate(github.rest.checks.listForRef, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| ref: pr.head.sha, | |
| }).catch(() => []), | |
| github.rest.repos.getCombinedStatusForRef({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| ref: pr.head.sha, | |
| }).catch(() => ({ data: { statuses: [] } })), | |
| ]); | |
| const labels = labelNames(pr); | |
| const nonAuthorLabels = [...labels].filter((label) => NON_AUTHOR_BLOCKER_LABELS.has(label)); | |
| if (nonAuthorLabels.length > 0) { | |
| if (DRY_RUN) { | |
| diagnostics.push(`#${pr.number}: skipped (non-author blocker label: ${nonAuthorLabels.join(', ')})`); | |
| } | |
| continue; | |
| } | |
| const nonAuthorAssignees = userLogins(pr.assignees).filter((login) => login !== author && !isBot(login)); | |
| if (nonAuthorAssignees.length > 0) { | |
| if (DRY_RUN) { | |
| diagnostics.push(`#${pr.number}: skipped (assigned to non-author: ${nonAuthorAssignees.join(', ')})`); | |
| } | |
| continue; | |
| } | |
| const requestedReviewers = userLogins(pr.requested_reviewers).filter((login) => login !== author && !isBot(login)); | |
| const requestedTeams = (pr.requested_teams || []).map((team) => team.name || team.slug).filter(Boolean); | |
| if (requestedReviewers.length > 0 || requestedTeams.length > 0) { | |
| if (DRY_RUN) { | |
| const reviewers = [...requestedReviewers, ...requestedTeams.map((team) => `team:${team}`)].join(', '); | |
| diagnostics.push(`#${pr.number}: skipped (waiting on requested reviewer: ${reviewers})`); | |
| } | |
| continue; | |
| } | |
| const candidateTrustedLogins = new Set(); | |
| for (const comment of comments) { | |
| const login = comment.user?.login; | |
| if (login && login !== author && !isBot(login)) { | |
| candidateTrustedLogins.add(login); | |
| } | |
| } | |
| for (const review of reviews) { | |
| const login = review.user?.login; | |
| if (login && login !== author && !isBot(login)) { | |
| candidateTrustedLogins.add(login); | |
| } | |
| } | |
| for (const reviewComment of reviewComments) { | |
| const login = reviewComment.user?.login; | |
| if (login && login !== author && !isBot(login)) { | |
| candidateTrustedLogins.add(login); | |
| } | |
| } | |
| const trustedLogins = new Set(); | |
| await Promise.all( | |
| [...candidateTrustedLogins].map(async (login) => { | |
| if (await isTrustedReviewer(login)) { | |
| trustedLogins.add(login); | |
| } | |
| }), | |
| ); | |
| let latestAuthorResponseAt = 0; | |
| let latestHumanActivityAt = 0; | |
| function recordHumanActivity(login, timestamp) { | |
| if (!login || isBot(login)) { | |
| return; | |
| } | |
| latestHumanActivityAt = Math.max(latestHumanActivityAt, timestamp); | |
| } | |
| for (const comment of comments) { | |
| recordHumanActivity(comment.user?.login, ts(comment.created_at)); | |
| if (comment.user?.login !== author) { | |
| continue; | |
| } | |
| latestAuthorResponseAt = Math.max(latestAuthorResponseAt, ts(comment.created_at)); | |
| } | |
| for (const review of reviews) { | |
| if (review.state?.toUpperCase() !== 'PENDING') { | |
| recordHumanActivity(review.user?.login, ts(review.submitted_at || review.created_at)); | |
| } | |
| if (review.user?.login !== author) { | |
| continue; | |
| } | |
| if (review.state?.toUpperCase() === 'PENDING') { | |
| continue; | |
| } | |
| latestAuthorResponseAt = Math.max(latestAuthorResponseAt, ts(review.submitted_at || review.created_at)); | |
| } | |
| for (const reviewComment of reviewComments) { | |
| recordHumanActivity(reviewComment.user?.login, ts(reviewComment.created_at)); | |
| if (reviewComment.user?.login !== author) { | |
| continue; | |
| } | |
| latestAuthorResponseAt = Math.max(latestAuthorResponseAt, ts(reviewComment.created_at)); | |
| } | |
| for (const commit of prCommits) { | |
| const commitAt = commitTimestamp(commit); | |
| const logins = commitLogins(commit); | |
| if (logins.length === 0 && commitAt) { | |
| latestHumanActivityAt = Math.max(latestHumanActivityAt, commitAt); | |
| } | |
| for (const login of logins) { | |
| recordHumanActivity(login, commitAt); | |
| if (login === author) { | |
| latestAuthorResponseAt = Math.max(latestAuthorResponseAt, commitAt); | |
| } | |
| } | |
| } | |
| for (const event of timeline) { | |
| const eventAt = ts(event.created_at); | |
| if (HUMAN_ACTIVITY_EVENTS.has(event.event)) { | |
| recordHumanActivity(event.actor?.login, eventAt); | |
| } | |
| if (event.actor?.login === author && event.event === 'head_ref_force_pushed') { | |
| latestAuthorResponseAt = Math.max(latestAuthorResponseAt, eventAt); | |
| } | |
| } | |
| const trustedApprovalAts = collectFeedbackAt( | |
| author, | |
| trustedLogins, | |
| reviews, | |
| (review) => review.user?.login, | |
| (review) => ts(review.submitted_at || review.created_at), | |
| (review) => review.state?.toUpperCase() === 'APPROVED', | |
| ).filter((approvalAt) => approvalAt > latestAuthorResponseAt); | |
| const latestTrustedApprovalAt = trustedApprovalAts.length > 0 ? Math.max(...trustedApprovalAts) : 0; | |
| const feedbackAts = [ | |
| ...collectFeedbackAt(author, trustedLogins, comments, (comment) => comment.user?.login, (comment) => ts(comment.created_at)), | |
| ...collectFeedbackAt( | |
| author, | |
| trustedLogins, | |
| reviews, | |
| (review) => review.user?.login, | |
| (review) => ts(review.submitted_at || review.created_at), | |
| (review) => { | |
| const state = review.state?.toUpperCase(); | |
| return state !== 'APPROVED' && state !== 'DISMISSED' && state !== 'PENDING'; | |
| }, | |
| ), | |
| ...collectFeedbackAt(author, trustedLogins, reviewComments, (reviewComment) => reviewComment.user?.login, (reviewComment) => ts(reviewComment.created_at)), | |
| ].filter((feedbackAt) => feedbackAt > Math.max(latestAuthorResponseAt, latestTrustedApprovalAt)); | |
| const authorActionReasons = []; | |
| const authorActionAts = []; | |
| if (feedbackAts.length > 0) { | |
| authorActionReasons.push('outstanding trusted reviewer or maintainer feedback'); | |
| authorActionAts.push(Math.min(...feedbackAts)); | |
| } | |
| const prDetails = detailedPr.data || {}; | |
| if (prDetails.mergeable === false && prDetails.mergeable_state === 'dirty') { | |
| authorActionReasons.push('merge conflict'); | |
| authorActionAts.push(Math.max(latestAuthorResponseAt, ts(pr.updated_at || pr.created_at))); | |
| } | |
| const failingCheckAts = checkRuns | |
| .filter((checkRun) => FAILED_CHECK_CONCLUSIONS.has(String(checkRun.conclusion || '').toLowerCase())) | |
| .map((checkRun) => ts(checkRun.completed_at || checkRun.started_at)) | |
| .filter(Boolean); | |
| const failingStatusAts = (combinedStatus.data?.statuses || []) | |
| .filter((status) => FAILED_STATUS_STATES.has(String(status.state || '').toLowerCase())) | |
| .map((status) => ts(status.updated_at || status.created_at)) | |
| .filter(Boolean); | |
| const failingSignalAts = [...failingCheckAts, ...failingStatusAts]; | |
| if (failingSignalAts.length > 0) { | |
| authorActionReasons.push('failing CI'); | |
| authorActionAts.push(Math.max(...failingSignalAts)); | |
| } | |
| if (authorActionReasons.length === 0) { | |
| if (DRY_RUN) { | |
| diagnostics.push( | |
| `#${pr.number}: skipped (no current author-owned blocker; latestAuthorResponseAt=${latestAuthorResponseAt ? new Date(latestAuthorResponseAt).toISOString() : 'none'} latestTrustedApprovalAt=${latestTrustedApprovalAt ? new Date(latestTrustedApprovalAt).toISOString() : 'none'})`, | |
| ); | |
| } | |
| continue; | |
| } | |
| const authorActionAt = Math.min(...authorActionAts); | |
| const inactivityStartedAt = Math.max(authorActionAt, latestHumanActivityAt); | |
| const reminderSent = comments.some((comment) => { | |
| return ts(comment.created_at) > inactivityStartedAt && comment.body?.includes(REMINDER_MARKER); | |
| }); | |
| const inactiveFor = now - inactivityStartedAt; | |
| const inactiveHours = Math.floor(inactiveFor / 3600000); | |
| const authorActionAtIso = new Date(authorActionAt).toISOString(); | |
| const latestHumanActivityAtIso = latestHumanActivityAt ? new Date(latestHumanActivityAt).toISOString() : 'none'; | |
| const inactivityStartedAtIso = new Date(inactivityStartedAt).toISOString(); | |
| const reasonText = authorActionReasons.join(', '); | |
| if (inactiveFor >= CLOSE_MS) { | |
| const line = DRY_RUN | |
| ? `#${pr.number}: would close after ${inactiveHours}h of no human activity (${reasonText})` | |
| : `#${pr.number}: closed after ${inactiveHours}h of no human activity (${reasonText})`; | |
| if (DRY_RUN) { | |
| diagnostics.push( | |
| `#${pr.number}: action=would-close reason=${reasonText} latestAuthorResponseAt=${latestAuthorResponseAt ? new Date(latestAuthorResponseAt).toISOString() : 'none'} authorActionAt=${authorActionAtIso} latestHumanActivityAt=${latestHumanActivityAtIso} inactivityStartedAt=${inactivityStartedAtIso} reminderSent=${reminderSent}`, | |
| ); | |
| summary.push(line); | |
| continue; | |
| } | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: pr.number, | |
| body: [ | |
| `Closing this PR for now because it appears to be waiting on author action (${reasonText}) and has had no human activity for more than 5 days.`, | |
| '', | |
| 'This is only a queue-management step, not a rejection of the work. If you would like to continue, please leave a comment or push an update and reopen the PR when ready.', | |
| ].join('\n'), | |
| }); | |
| await github.rest.pulls.update({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: pr.number, | |
| state: 'closed', | |
| }); | |
| summary.push(line); | |
| continue; | |
| } | |
| if (inactiveFor >= REMINDER_MS && !reminderSent) { | |
| const line = DRY_RUN | |
| ? `#${pr.number}: would remind after ${inactiveHours}h of no human activity (${reasonText})` | |
| : `#${pr.number}: reminded after ${inactiveHours}h of no human activity (${reasonText})`; | |
| if (DRY_RUN) { | |
| diagnostics.push( | |
| `#${pr.number}: action=would-remind reason=${reasonText} latestAuthorResponseAt=${latestAuthorResponseAt ? new Date(latestAuthorResponseAt).toISOString() : 'none'} authorActionAt=${authorActionAtIso} latestHumanActivityAt=${latestHumanActivityAtIso} inactivityStartedAt=${inactivityStartedAtIso} reminderSent=${reminderSent}`, | |
| ); | |
| summary.push(line); | |
| continue; | |
| } | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: pr.number, | |
| body: [ | |
| REMINDER_MARKER, | |
| '', | |
| `@${author} friendly reminder: this PR appears to be waiting on author action (${reasonText}) and has had no human activity for more than 3 days.`, | |
| '', | |
| 'When you have a chance, please reply here or push an update. To keep the queue manageable, PRs with no human activity for more than 5 days may be closed automatically, but they can be reopened when work resumes.', | |
| ].join('\n'), | |
| }); | |
| summary.push(line); | |
| continue; | |
| } | |
| if (DRY_RUN) { | |
| diagnostics.push( | |
| `#${pr.number}: skipped (already-reminded-or-below-threshold) reason=${reasonText} latestAuthorResponseAt=${latestAuthorResponseAt ? new Date(latestAuthorResponseAt).toISOString() : 'none'} authorActionAt=${authorActionAtIso} latestHumanActivityAt=${latestHumanActivityAtIso} inactivityStartedAt=${inactivityStartedAtIso} inactiveHours=${inactiveHours} reminderSent=${reminderSent}`, | |
| ); | |
| } | |
| } | |
| if (DRY_RUN) { | |
| for (const line of diagnostics) { | |
| core.info(line); | |
| } | |
| } | |
| if (summary.length === 0) { | |
| const line = DRY_RUN ? 'Dry run: no inactive PRs would need action.' : 'No inactive PRs needed action.'; | |
| core.info(line); | |
| await core.summary.addRaw(line).write(); | |
| return; | |
| } | |
| for (const line of summary) { | |
| core.info(line); | |
| } | |
| await core.summary | |
| .addHeading(DRY_RUN ? 'PR author inactivity dry run' : 'PR author inactivity actions') | |
| .addList(summary) | |
| .write(); |