ci: add workflow to dismiss unauthorized PR approvals
This commit is contained in:
165
.github/workflows/dismiss-unauthorized-pr-approvals.yml
vendored
Normal file
165
.github/workflows/dismiss-unauthorized-pr-approvals.yml
vendored
Normal file
@@ -0,0 +1,165 @@
|
||||
name: Dismiss Unauthorized PR Approvals
|
||||
|
||||
on:
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
blocked_reviewer:
|
||||
description: Reviewer login to retroactively sweep
|
||||
required: false
|
||||
default: ari4ka
|
||||
schedule:
|
||||
- cron: '17 4 * * *'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: dismiss-unauthorized-pr-approvals
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
dismiss-unauthorized-approval:
|
||||
if: github.event_name != 'pull_request_review' || github.event.review.state == 'approved'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Dismiss blocked/non-collaborator approvals
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
// Any login here is always blocked, even if they are a collaborator.
|
||||
const blockedReviewers = new Set(['ari4ka']);
|
||||
|
||||
// Any collaborator role is allowed unless explicitly blocked above.
|
||||
const allowedRoles = new Set(['read', 'triage', 'write', 'maintain', 'admin']);
|
||||
|
||||
function norm(v) {
|
||||
return (v || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
async function resolveRole(login) {
|
||||
try {
|
||||
const resp = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner,
|
||||
repo,
|
||||
username: login,
|
||||
});
|
||||
return 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';
|
||||
}
|
||||
}
|
||||
|
||||
function shouldDismissReviewer(login, roleName) {
|
||||
const reviewer = norm(login);
|
||||
const isBlocked = blockedReviewers.has(reviewer);
|
||||
const isAllowedRole = allowedRoles.has(roleName);
|
||||
|
||||
const reasons = [];
|
||||
if (isBlocked) {
|
||||
reasons.push(`reviewer is blocked (${reviewer})`);
|
||||
}
|
||||
if (!isAllowedRole) {
|
||||
reasons.push(`reviewer is not a collaborator or higher (detected role: ${roleName})`);
|
||||
}
|
||||
|
||||
return {
|
||||
dismiss: isBlocked || !isAllowedRole,
|
||||
message: `Auto-dismissed approval: ${reasons.join('; ')}.`,
|
||||
};
|
||||
}
|
||||
|
||||
async function dismissReview(pull_number, review_id, reviewer, message) {
|
||||
try {
|
||||
await github.rest.pulls.dismissReview({
|
||||
owner,
|
||||
repo,
|
||||
pull_number,
|
||||
review_id,
|
||||
message,
|
||||
});
|
||||
core.info(`Dismissed approval from ${reviewer} on PR #${pull_number}. Reason: ${message}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error.status === 422) {
|
||||
core.warning(`Could not dismiss review ${review_id} on PR #${pull_number}: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Future approvals: act immediately when review submitted
|
||||
if (context.eventName === 'pull_request_review') {
|
||||
const review = context.payload.review;
|
||||
const reviewer = review.user.login;
|
||||
const roleName = await resolveRole(reviewer);
|
||||
const verdict = shouldDismissReviewer(reviewer, roleName);
|
||||
|
||||
if (!verdict.dismiss) {
|
||||
core.info(`Approval from ${reviewer} kept (role: ${roleName}).`);
|
||||
return;
|
||||
}
|
||||
|
||||
await dismissReview(
|
||||
context.payload.pull_request.number,
|
||||
review.id,
|
||||
reviewer,
|
||||
verdict.message
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Retroactive sweep: dismiss prior approvals on all PRs
|
||||
const blockedReviewer = norm(core.getInput('blocked_reviewer') || 'ari4ka');
|
||||
|
||||
const pulls = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'all',
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
let scannedPrs = 0;
|
||||
let dismissedCount = 0;
|
||||
|
||||
for (const pr of pulls) {
|
||||
scannedPrs += 1;
|
||||
|
||||
const reviews = await github.paginate(github.rest.pulls.listReviews, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
for (const review of reviews) {
|
||||
const reviewer = norm(review.user?.login);
|
||||
const state = norm(review.state);
|
||||
|
||||
if (reviewer !== blockedReviewer) continue;
|
||||
if (state !== 'approved') continue;
|
||||
|
||||
const ok = await dismissReview(
|
||||
pr.number,
|
||||
review.id,
|
||||
reviewer,
|
||||
`Auto-dismissed approval during retroactive sweep: reviewer is blocked (${blockedReviewer}).`
|
||||
);
|
||||
|
||||
if (ok) dismissedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
core.info(
|
||||
`Retroactive sweep complete. Scanned ${scannedPrs} PR(s); dismissed ${dismissedCount} approval(s) from ${blockedReviewer}.`
|
||||
);
|
||||
Reference in New Issue
Block a user