Created
November 17, 2025 14:25
-
-
Save sean-brydon/6934f24415093642b7610603097cc07f to your computer and use it in GitHub Desktop.
weighted approvals
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Weighted approvals | |
| on: | |
| pull_request: | |
| types: [opened, reopened, synchronize, labeled, unlabeled, ready_for_review] | |
| pull_request_review: | |
| types: [submitted, edited, dismissed] | |
| permissions: | |
| pull-requests: read | |
| contents: read | |
| # If you need to read org team membership for private orgs, you may need: | |
| # organizations: read | |
| jobs: | |
| gate: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Evaluate weighted approvals | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| // SETTINGS | |
| // Teams are declared as "org/team-slug" (same format as @org/team mentions) | |
| // Each team listed will have +2 weight per member approval. | |
| const TEAMS_WITH_PLUS_TWO_APPROVALS = [ | |
| 'calcom/foundation', | |
| // 'calcom/security', | |
| // 'another-org/senior-reviewers', | |
| ]; | |
| const REQUIRED_WEIGHTED_SCORE = 2; // threshold to pass (e.g., emulate “+2”) | |
| const EXCLUDE_AUTHOR = true; // set false to allow PR author’s approval to count | |
| // Helper: group teams by org | |
| const teamsByOrg = TEAMS_WITH_PLUS_TWO_APPROVALS.reduce((acc, t) => { | |
| const [org, teamSlug] = t.split('/'); | |
| if (!org || !teamSlug) { | |
| throw new Error(`Invalid team format: ${t}. Use "org/team-slug".`); | |
| } | |
| acc[org] = acc[org] || new Set(); | |
| acc[org].add(teamSlug); | |
| return acc; | |
| }, {}); | |
| // Fetch PR | |
| const pr = context.payload.pull_request || (await github.rest.pulls.get({ | |
| owner, repo, pull_number: context.issue.number | |
| })).data; | |
| const prNumber = pr.number; | |
| // Fetch all reviews and determine latest state per user | |
| const reviews = await github.paginate(github.rest.pulls.listReviews, { | |
| owner, repo, pull_number: prNumber, per_page: 100 | |
| }); | |
| const sorted = [...reviews].sort((a,b) => new Date(a.submitted_at) - new Date(b.submitted_at)); | |
| const latestStateByUser = new Map(); | |
| const userById = new Map(); | |
| for (const r of sorted) { | |
| if (r.user?.type === 'Bot') continue; | |
| latestStateByUser.set(r.user.id, r.state); | |
| userById.set(r.user.id, r.user); | |
| } | |
| // Collect latest APPROVED reviewers | |
| let approvers = []; | |
| for (const [uid, state] of latestStateByUser.entries()) { | |
| if (state === 'APPROVED') { | |
| const u = userById.get(uid); | |
| approvers.push(u); | |
| } | |
| } | |
| // Optionally exclude PR author | |
| if (EXCLUDE_AUTHOR) { | |
| approvers = approvers.filter(u => u.login !== pr.user.login); | |
| } | |
| // Build a cache of team memberships across all configured orgs/teams | |
| // For each org, union all members from the listed team slugs | |
| const plusTwoMembers = new Set(); | |
| for (const [org, teamSlugsSet] of Object.entries(teamsByOrg)) { | |
| for (const teamSlug of teamSlugsSet) { | |
| // Requires org visibility for Actions token; for private orgs you may need a PAT with read:org | |
| const members = await github.paginate(github.rest.teams.listMembersInOrg, { | |
| org, | |
| team_slug: teamSlug, | |
| per_page: 100 | |
| }); | |
| for (const m of members) { | |
| plusTwoMembers.add(`${org}///${m.login.toLowerCase()}`); | |
| } | |
| } | |
| } | |
| // Compute weighted score | |
| let score = 0; | |
| const details = []; | |
| for (const u of approvers) { | |
| // Determine user org context for membership check: | |
| // We can’t know a user's org “ownership” directly, so we check them against all orgs we queried. | |
| // We mark +2 if user login is in any of the org/team membership sets we collected. | |
| const isPlusTwo = [...Object.keys(teamsByOrg)].some(org => plusTwoMembers.has(`${org}///${u.login.toLowerCase()}`)); | |
| score += isPlusTwo ? 2 : 1; | |
| details.push(`${u.login}${isPlusTwo ? ' (x2 team)' : ''}`); | |
| } | |
| const ok = score >= REQUIRED_WEIGHTED_SCORE; | |
| // Summary output | |
| core.summary | |
| .addHeading('Weighted approvals') | |
| .addRaw(`Teams (x2): ${TEAMS_WITH_PLUS_TWO_APPROVALS.join(', ') || 'none'}\n`) | |
| .addRaw(`Approvers: ${details.join(', ') || 'none'}\n`) | |
| .addRaw(`Weighted score: ${score} / ${REQUIRED_WEIGHTED_SCORE}\n`) | |
| .write(); | |
| if (!ok) { | |
| core.setFailed(`Need weighted score ≥ ${REQUIRED_WEIGHTED_SCORE}. Current: ${score}.`); | |
| } else { | |
| core.notice('Weighted approval requirement satisfied.'); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment