name: Review State # Event-driven interpreter that maintains four mutually-exclusive review/* # labels on open PRs so maintainers can see review status at a glance. # Pure actions/github-script: no checkout, no secrets, no agent. on: pull_request_target: types: [opened, reopened, synchronize, ready_for_review] pull_request_review: types: [submitted, dismissed] schedule: # Backstop sweep so state stays correct even if an event was missed. - cron: "15 */6 * * *" workflow_dispatch: permissions: contents: read jobs: review-state: name: Update review state labels runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: read pull-requests: write concurrency: group: review-state-${{ github.event.pull_request.number || 'sweep' }} cancel-in-progress: false steps: - name: Apply review state labels uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const owner = context.repo.owner; const repo = context.repo.repo; // The four mutually-exclusive review states. const stateLabels = { 'review/needs-review': { color: 'fbca04', description: 'No maintainer or bot review yet', }, 'review/awaiting-author': { color: 'c5def5', description: 'Reviewed; waiting on the author to respond', }, 'review/needs-rereview': { color: 'd93f0b', description: 'New commits since the last review', }, 'review/approved': { color: '0e8a16', description: 'Approved; no new commits since', }, }; const stateNames = Object.keys(stateLabels); // --- Ensure the four labels exist (mirrors pr-sweep.yml) --- const existingLabels = new Set(); for await (const response of github.paginate.iterator( github.rest.issues.listLabelsForRepo, { owner, repo, per_page: 100 } )) { for (const label of response.data) { existingLabels.add(label.name); } } for (const [name, meta] of Object.entries(stateLabels)) { if (!existingLabels.has(name)) { await github.rest.issues.createLabel({ owner, repo, name, color: meta.color, description: meta.description, }); } } // Remove a label, ignoring 404 if it's already gone (mirrors pr-sweep.yml). async function safeRemoveLabel(prNumber, name) { try { await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name, }); } catch (e) { if (e.status !== 404) throw e; } } // --- Determine the set of PRs to process --- let prs; if ( context.eventName === 'pull_request_target' || context.eventName === 'pull_request_review' ) { prs = [context.payload.pull_request]; } else { // schedule / workflow_dispatch: sweep every open PR. prs = await github.paginate(github.rest.pulls.list, { owner, repo, state: 'open', per_page: 100, }); } core.info(`Processing ${prs.length} PR(s) for review state`); for (const pr of prs) { if (!pr) continue; try { const currentLabels = new Set((pr.labels || []).map(l => l.name)); // Drafts get no review state: strip any review/* labels and move on. if (pr.draft === true) { for (const name of stateNames) { if (currentLabels.has(name)) { await safeRemoveLabel(pr.number, name); } } core.info(`#${pr.number}: draft, cleared review state`); continue; } // Skip bot-authored PRs entirely (no labeling). if (pr.user && pr.user.type === 'Bot') { core.info(`#${pr.number}: bot author, skipping`); continue; } // --- Qualifying reviews --- const reviews = await github.paginate(github.rest.pulls.listReviews, { owner, repo, pull_number: pr.number, per_page: 100, }); const qualifying = reviews.filter(review => review.user && review.user.login !== pr.user.login && ( review.user.type === 'Bot' || ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(review.author_association) ) && ['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED'].includes(review.state) ); let lastReview = null; for (const review of qualifying) { if ( !lastReview || new Date(review.submitted_at) > new Date(lastReview.submitted_at) ) { lastReview = review; } } // --- Last non-merge commit --- // Only NON-merge commits count as new work. Merge commits (parents > 1) // are "Update branch" / `git merge main`, so syncing a branch with main // does not flip a reviewed PR back to needs-rereview. const commits = await github.paginate(github.rest.pulls.listCommits, { owner, repo, pull_number: pr.number, per_page: 100, }); let lastCommitAt; for (const commit of commits) { if (commit.parents && commit.parents.length > 1) continue; const committed = commit.commit?.committer?.date; if (!committed) continue; if (!lastCommitAt || new Date(committed) > new Date(lastCommitAt)) { lastCommitAt = committed; } } // --- Decide the state --- // lastReview may be a COMMENTED review; approvals should still win unless a later // decision review (APPROVED/CHANGES_REQUESTED) supersedes them. let lastDecisionReview = null; for (const review of qualifying) { if (review.state === 'COMMENTED') continue; if ( !lastDecisionReview || new Date(review.submitted_at) > new Date(lastDecisionReview.submitted_at) ) { lastDecisionReview = review; } } let desired; if (!lastReview) { desired = 'review/needs-review'; } else if ( lastCommitAt && new Date(lastCommitAt) > new Date(lastReview.submitted_at) ) { desired = 'review/needs-rereview'; } else if (lastDecisionReview?.state === 'APPROVED') { desired = 'review/approved'; } else { desired = 'review/awaiting-author'; } // --- Apply: add the chosen label, remove the other three --- if (!currentLabels.has(desired)) { await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: [desired], }); } for (const name of stateNames) { if (name !== desired && currentLabels.has(name)) { await safeRemoveLabel(pr.number, name); } } core.info(`#${pr.number}: ${desired}`); } catch (e) { core.warning(`#${pr.number}: failed to update review state: ${e.message}`); } }