ci(3314): add schedule fallback to dismiss-unauthorized-pr-approvals (#3315)

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) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-09 12:04:07 -04:00
committed by GitHub
parent 370033e907
commit 15006936b2

View File

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