From f403ffbfceabfafcf0d6cc19b36e94d7dd02d5bb Mon Sep 17 00:00:00 2001 From: Marc Chan Date: Mon, 18 May 2026 16:45:37 +0800 Subject: [PATCH] ci: add PR-author and stale-issue inactivity workflows (#2055) * ci: add PR-author and stale-issue inactivity workflows Adds two queue-management automations: - pr-author-inactivity: reminds PR authors after 72h of inactivity following human reviewer/maintainer feedback (issue comments, non-approval reviews, or inline review comments) and closes after 120h. Author response is detected via issue comments, inline review replies, or commit/force-push events. Bot-authored reviews are intentionally excluded so authors are not pressured by automated nits alone. - stale-issues: marks issues stale after 30 days of inactivity and closes after a further 7 days. Exempts good first issue, help wanted, and security labels. A pre-step also auto-applies 'exempt-from-stale' to issues opened by org members/owners/ collaborators, since actions/stale only supports label-based exemptions. PR processing is disabled (handled by the workflow above). * fix: limit PR inactivity feedback to trusted reviewers Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode) * fix: count author PR reviews as inactivity responses Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode) --- .github/workflows/pr-author-inactivity.yml | 242 +++++++++++++++++++++ .github/workflows/stale-issues.yml | 104 +++++++++ 2 files changed, 346 insertions(+) create mode 100644 .github/workflows/pr-author-inactivity.yml create mode 100644 .github/workflows/stale-issues.yml diff --git a/.github/workflows/pr-author-inactivity.yml b/.github/workflows/pr-author-inactivity.yml new file mode 100644 index 000000000..41d7177b5 --- /dev/null +++ b/.github/workflows/pr-author-inactivity.yml @@ -0,0 +1,242 @@ +name: PR author inactivity + +# Remind PR authors when reviewer / maintainer feedback has gone unanswered +# for more than 3 days, and close after more than 5 days. +# +# Inactivity heuristic: +# - We look for the latest trusted non-author feedback on an open PR via: +# 1) issue comments, +# 2) non-approval reviews, or +# 3) inline review comments. +# - We then check whether the PR author responded after that feedback via: +# 1) an issue comment, +# 2) a submitted pull-request review, +# 3) an inline review comment reply, or +# 4) a commit / force-push timeline event. +# - If not, we remind once after 72h and close after 120h. +# +# 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: {} + +permissions: + issues: write + pull-requests: write + +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 = ''; + const REMINDER_MS = 72 * 60 * 60 * 1000; + const CLOSE_MS = 120 * 60 * 60 * 1000; + + function ts(value) { + return value ? new Date(value).getTime() : 0; + } + + function isBot(login) { + return Boolean(login && login.endsWith('[bot]')); + } + + const TRUSTED_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); + + function isTrustedReviewer(actor) { + return Boolean(actor?.user?.login) && TRUSTED_ASSOCIATIONS.has(actor.author_association); + } + + 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', + }); + + const now = Date.now(); + const summary = []; + + for (const pr of pulls) { + const author = pr.user?.login; + if (!author || pr.draft || isBot(author)) { + continue; + } + + const [comments, reviews, reviewComments, timeline] = 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, + }), + ]); + + let latestFeedbackAt = 0; + + for (const comment of comments) { + const login = comment.user?.login; + if (login === author || isBot(login) || !isTrustedReviewer(comment)) { + continue; + } + latestFeedbackAt = Math.max(latestFeedbackAt, ts(comment.created_at)); + } + + for (const review of reviews) { + const login = review.user?.login; + const state = review.state?.toUpperCase(); + if (login === author || isBot(login) || !isTrustedReviewer(review)) { + continue; + } + if (state === 'APPROVED' || state === 'DISMISSED' || state === 'PENDING') { + continue; + } + latestFeedbackAt = Math.max(latestFeedbackAt, ts(review.submitted_at || review.created_at)); + } + + for (const reviewComment of reviewComments) { + const login = reviewComment.user?.login; + if (login === author || isBot(login) || !isTrustedReviewer(reviewComment)) { + continue; + } + latestFeedbackAt = Math.max(latestFeedbackAt, ts(reviewComment.created_at)); + } + + if (!latestFeedbackAt) { + continue; + } + + let latestAuthorResponseAt = 0; + + for (const comment of comments) { + if (comment.user?.login !== author) { + continue; + } + latestAuthorResponseAt = Math.max(latestAuthorResponseAt, ts(comment.created_at)); + } + + for (const review of reviews) { + 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) { + if (reviewComment.user?.login !== author) { + continue; + } + latestAuthorResponseAt = Math.max(latestAuthorResponseAt, ts(reviewComment.created_at)); + } + + for (const event of timeline) { + if (event.actor?.login !== author) { + continue; + } + if (!['committed', 'head_ref_force_pushed'].includes(event.event)) { + continue; + } + latestAuthorResponseAt = Math.max(latestAuthorResponseAt, ts(event.created_at)); + } + + if (latestAuthorResponseAt > latestFeedbackAt) { + continue; + } + + const reminderSent = comments.some((comment) => { + return ts(comment.created_at) > latestFeedbackAt && comment.body?.includes(REMINDER_MARKER); + }); + + const inactiveFor = now - latestFeedbackAt; + + if (inactiveFor >= CLOSE_MS) { + 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 has been waiting on an author response for more than 5 days after reviewer or maintainer feedback.', + '', + '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(`#${pr.number}: closed after ${Math.floor(inactiveFor / 3600000)}h of author inactivity`); + continue; + } + + if (inactiveFor >= REMINDER_MS && !reminderSent) { + 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 has been waiting on an author response for more than 3 days after reviewer or maintainer feedback.`, + '', + 'When you have a chance, please reply here or push an update. To keep the queue manageable, PRs with no author activity for more than 5 days after feedback may be closed automatically, but they can be reopened when work resumes.', + ].join('\n'), + }); + + summary.push(`#${pr.number}: reminded after ${Math.floor(inactiveFor / 3600000)}h of author inactivity`); + } + } + + if (summary.length === 0) { + core.info('No inactive PRs needed action.'); + await core.summary.addRaw('No inactive PRs needed action.').write(); + return; + } + + for (const line of summary) { + core.info(line); + } + + await core.summary + .addHeading('PR author inactivity actions') + .addList(summary) + .write(); diff --git a/.github/workflows/stale-issues.yml b/.github/workflows/stale-issues.yml new file mode 100644 index 000000000..75b3fef02 --- /dev/null +++ b/.github/workflows/stale-issues.yml @@ -0,0 +1,104 @@ +name: Stale issues + +# Mark issues as stale after 30 days of inactivity, and close them after a +# further 7 days if no activity resumes. +# +# Exemption policy: +# - Label-based exemptions: issues carrying any of the labels in +# EXEMPT_LABELS are never marked stale (security, contributor-invite, +# priority, awaiting product direction, etc.). +# - Author-based exemption: issues opened by org members, owners, or +# collaborators are also exempt. Since actions/stale only supports +# label-based exemptions, a pre-step labels qualifying issues with +# `exempt-from-stale` so the stale step honors the same rule. +# +# PRs are handled separately by pr-author-inactivity.yml; this workflow +# disables PR processing by setting the PR thresholds to -1. + +on: + schedule: + - cron: '30 1 * * *' + workflow_dispatch: {} + +permissions: + issues: write + pull-requests: read + +concurrency: + group: stale-issues + cancel-in-progress: false + +jobs: + stale: + if: github.repository == 'nexu-io/open-design' + runs-on: ubuntu-latest + steps: + - name: Exempt org-member issues from staling + uses: actions/github-script@v7 + with: + script: | + const EXEMPT_LABEL = 'exempt-from-stale'; + const EXEMPT_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); + + // Ensure the exempt label exists so the stale step can match it. + try { + await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: EXEMPT_LABEL, + }); + } catch (err) { + if (err.status !== 404) throw err; + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: EXEMPT_LABEL, + color: 'cccccc', + description: 'Issue is exempt from automatic stale handling (set by stale-issues workflow).', + }); + } + + const issues = await github.paginate(github.rest.issues.listForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100, + }); + + let labeled = 0; + + for (const issue of issues) { + if (issue.pull_request) continue; + if (!EXEMPT_ASSOCIATIONS.has(issue.author_association)) continue; + if (issue.labels.some((l) => (typeof l === 'string' ? l : l.name) === EXEMPT_LABEL)) continue; + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: [EXEMPT_LABEL], + }); + labeled += 1; + } + + core.info(`Applied ${EXEMPT_LABEL} to ${labeled} org-member issue(s).`); + + - name: Mark and close stale issues + uses: actions/stale@v9 + with: + days-before-issue-stale: 30 + days-before-issue-close: 7 + # Disable PR processing; PRs are handled by pr-author-inactivity.yml. + days-before-pr-stale: -1 + days-before-pr-close: -1 + stale-issue-label: 'stale' + exempt-issue-labels: 'good first issue,help wanted,security,exempt-from-stale' + stale-issue-message: | + This issue has been inactive for 30 days and is being marked as stale. If it is still relevant, please leave a comment or push an update — otherwise it will be closed in 7 days. + + This is only a queue-management step, not a rejection of the report. Closed issues can be reopened when work resumes. + close-issue-message: | + Closing this issue for now because it has been inactive for more than 37 days. If it is still relevant, please leave a comment and reopen — context is preserved and we can pick it back up. + remove-issue-stale-when-updated: true + operations-per-run: 100 + ascending: true