From 15006936b2fe9ae6eae01018fd81679850324c4a Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 9 May 2026 12:04:07 -0400 Subject: [PATCH] ci(3314): add schedule fallback to dismiss-unauthorized-pr-approvals (#3315) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pull_request_review event path is held in `action_required` whenever the triggering_actor is an outside collaborator — exactly the case the workflow exists to handle. The blocked reviewer's own action is what gates the dismissal that would remove them, so the run never executes. Add `schedule: */15 * * * *` and `workflow_dispatch` triggers. Scheduled runs execute as github-actions[bot], bypassing the gate. The script branches on context.eventName: the review event keeps the single-review fast path; schedule/dispatch paginates open PRs and reviews, applying the same role/blocklist check across all APPROVED reviews. resolveRole() now caches per-login lookups so the schedule path doesn't re-query the same reviewer once per PR. Concurrency group falls back to 'scheduled' for non-PR events so polls serialize. Fixes #3314 Co-authored-by: Claude Opus 4.7 (1M context) --- .../dismiss-unauthorized-pr-approvals.yml | 100 ++++++++++++++---- 1 file changed, 81 insertions(+), 19 deletions(-) diff --git a/.github/workflows/dismiss-unauthorized-pr-approvals.yml b/.github/workflows/dismiss-unauthorized-pr-approvals.yml index 439e37e68..cb4b8d0c2 100644 --- a/.github/workflows/dismiss-unauthorized-pr-approvals.yml +++ b/.github/workflows/dismiss-unauthorized-pr-approvals.yml @@ -3,20 +3,29 @@ name: Dismiss Unauthorized PR Approvals on: pull_request_review: types: [submitted] + schedule: + # Fallback poll: pull_request_review runs whose triggering_actor is an + # outside collaborator are held in `action_required` until a maintainer + # approves them — so the event-driven path never fires for the very + # reviewers we want to dismiss. The schedule path runs as + # github-actions[bot] and bypasses that gate. + - cron: '*/15 * * * *' + workflow_dispatch: permissions: contents: read pull-requests: write concurrency: - group: dismiss-unauthorized-pr-approvals-${{ github.event.pull_request.number }} + # Per-PR group for review events; single shared group for schedule/dispatch + # so polls serialize instead of stomping each other. + group: dismiss-unauthorized-pr-approvals-${{ github.event.pull_request.number || 'scheduled' }} cancel-in-progress: false jobs: dismiss-unauthorized-approval: - if: github.event.review.state == 'approved' && github.event.pull_request.state == 'open' runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 10 steps: - name: Dismiss blocked/non-collaborator approvals @@ -25,6 +34,7 @@ jobs: script: | const owner = context.repo.owner; const repo = context.repo.repo; + const eventName = context.eventName; // Any login here is always blocked, even if they are a collaborator. const blockedReviewers = new Set(['ari4ka']); @@ -36,19 +46,28 @@ jobs: return (v || '').trim().toLowerCase(); } + const roleCache = new Map(); async function resolveRole(login) { + const key = norm(login); + if (roleCache.has(key)) return roleCache.get(key); + let role; try { const resp = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username: login, }); - return norm(resp.data.role_name) || 'unknown'; + role = norm(resp.data.role_name) || 'unknown'; } catch (error) { - if (error.status === 404) return 'none'; // not a collaborator - core.warning(`Could not resolve reviewer role for ${login}: ${error.message}`); - return 'unknown'; + if (error.status === 404) { + role = 'none'; // confirmed non-collaborator + } else { + core.warning(`Could not resolve reviewer role for ${login}: ${error.message}`); + role = 'unknown'; + } } + roleCache.set(key, role); + return role; } function shouldDismissReviewer(login, roleName) { @@ -93,19 +112,62 @@ jobs: } } - const review = context.payload.review; - const reviewer = review.user.login; - const roleName = await resolveRole(reviewer); - const verdict = shouldDismissReviewer(reviewer, roleName); + async function processApproval({ pull_number, review_id, reviewer }) { + const roleName = await resolveRole(reviewer); + const verdict = shouldDismissReviewer(reviewer, roleName); + if (!verdict.dismiss) { + core.info(`Approval from ${reviewer} on PR #${pull_number} kept (role: ${roleName}).`); + return; + } + await dismissReview(pull_number, review_id, reviewer, verdict.message); + } - if (!verdict.dismiss) { - core.info(`Approval from ${reviewer} kept (role: ${roleName}).`); + if (eventName === 'pull_request_review') { + const review = context.payload.review; + const pull = context.payload.pull_request; + if (norm(review.state) !== 'approved') { + core.info(`Skipping non-approval review (state=${review.state}).`); + return; + } + if (pull.state !== 'open') { + core.info(`Skipping review on non-open PR #${pull.number}.`); + return; + } + await processApproval({ + pull_number: pull.number, + review_id: review.id, + reviewer: review.user.login, + }); return; } - await dismissReview( - context.payload.pull_request.number, - review.id, - reviewer, - verdict.message - ); + // schedule or workflow_dispatch — scan all open PRs. + core.info(`Scanning open PRs for unauthorized approvals (event=${eventName})...`); + const pulls = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: 'open', + per_page: 100, + }); + core.info(`Found ${pulls.length} open PR(s).`); + + let approvalsScanned = 0; + for (const pull of pulls) { + const reviews = await github.paginate(github.rest.pulls.listReviews, { + owner, + repo, + pull_number: pull.number, + per_page: 100, + }); + for (const review of reviews) { + // Already-dismissed reviews have state DISMISSED, so we won't reprocess them. + if (review.state !== 'APPROVED') continue; + approvalsScanned += 1; + await processApproval({ + pull_number: pull.number, + review_id: review.id, + reviewer: review.user.login, + }); + } + } + core.info(`Scan complete: ${approvalsScanned} approval(s) evaluated across ${pulls.length} open PR(s).`);