|
| 1 | +name: PR Review Assignment Bot |
| 2 | + |
| 3 | +on: |
| 4 | + pull_request_target: |
| 5 | + types: [opened, reopened, labeled] |
| 6 | + |
| 7 | + workflow_dispatch: |
| 8 | + inputs: |
| 9 | + dry_run: |
| 10 | + description: 'Run in dry-run mode (no actual assignments)' |
| 11 | + required: false |
| 12 | + default: 'true' |
| 13 | + type: choice |
| 14 | + options: |
| 15 | + - 'true' |
| 16 | + - 'false' |
| 17 | + |
| 18 | + # schedule: |
| 19 | + # - cron: "*/10 * * * *" |
| 20 | + |
| 21 | +permissions: |
| 22 | + pull-requests: write |
| 23 | + issues: write |
| 24 | + contents: read |
| 25 | + |
| 26 | +concurrency: |
| 27 | + group: pr-review-assignment |
| 28 | + cancel-in-progress: false |
| 29 | + |
| 30 | +jobs: |
| 31 | + assign-reviewers: |
| 32 | + runs-on: ubuntu-latest |
| 33 | + steps: |
| 34 | + - name: Assign reviewers to eligible PRs |
| 35 | + uses: actions/github-script@v7 |
| 36 | + with: |
| 37 | + script: | |
| 38 | + // ── Configuration ── |
| 39 | + const REVIEWERS = ["souvikghosh04", "Aniruddh25", "aaronburtle", "anushakolan", "RubenCerna2079"]; |
| 40 | + const REQUIRED_REVIEWERS = 2; |
| 41 | + const STALE_DAYS = 90; |
| 42 | + const DRY_RUN = context.eventName === "workflow_dispatch" |
| 43 | + ? '${{ github.event.inputs.dry_run }}' === 'true' |
| 44 | + : false; |
| 45 | +
|
| 46 | + const { owner, repo } = context.repo; |
| 47 | + const staleCutoff = new Date(); |
| 48 | + staleCutoff.setDate(staleCutoff.getDate() - STALE_DAYS); |
| 49 | +
|
| 50 | + // ── Helper functions ── |
| 51 | +
|
| 52 | + function getPoolAssignees(pr) { |
| 53 | + return (pr.assignees || []) |
| 54 | + .map((a) => a.login) |
| 55 | + .filter((a) => REVIEWERS.includes(a)); |
| 56 | + } |
| 57 | +
|
| 58 | + function isEligible(pr) { |
| 59 | + if (pr.draft) return false; |
| 60 | + if (!pr.labels.some((l) => l.name === "assign-for-review")) return false; |
| 61 | + if (new Date(pr.updated_at) < staleCutoff) return false; |
| 62 | + return true; |
| 63 | + } |
| 64 | +
|
| 65 | + function getWeight(pr) { |
| 66 | + const labels = pr.labels.map((l) => l.name); |
| 67 | + let w = 1; |
| 68 | + if (labels.includes("size-medium")) w = 2; |
| 69 | + else if (labels.includes("size-large")) w = 3; |
| 70 | + if (labels.includes("priority-high")) w += 1; |
| 71 | + return w; |
| 72 | + } |
| 73 | +
|
| 74 | + async function getActiveReviewers(pr) { |
| 75 | + const assigned = getPoolAssignees(pr); |
| 76 | + if (assigned.length === 0) return []; |
| 77 | + const reviews = await github.paginate(github.rest.pulls.listReviews, { |
| 78 | + owner, repo, pull_number: pr.number, per_page: 100, |
| 79 | + }); |
| 80 | + const submitted = new Set( |
| 81 | + reviews.map((r) => r.user.login).filter((r) => REVIEWERS.includes(r)) |
| 82 | + ); |
| 83 | + return assigned.filter((r) => !submitted.has(r)); |
| 84 | + } |
| 85 | +
|
| 86 | + // Assigns reviewers to a single PR, mutating load/activeCount in place. |
| 87 | + async function assignReviewers(pr, load, activeCount) { |
| 88 | + const author = pr.user.login; |
| 89 | + // Exclude the author from assigned count — they can't review their own PR. |
| 90 | + const assigned = getPoolAssignees(pr).filter((a) => a !== author); |
| 91 | + const needed = REQUIRED_REVIEWERS - assigned.length; |
| 92 | + const weight = getWeight(pr); |
| 93 | +
|
| 94 | + if (needed <= 0) { |
| 95 | + core.info(`PR #${pr.number} — already has ${assigned.length} pool assignees, skipping.`); |
| 96 | + return; |
| 97 | + } |
| 98 | +
|
| 99 | + const candidates = REVIEWERS.filter((r) => r !== author && !assigned.includes(r)); |
| 100 | + if (candidates.length === 0) { |
| 101 | + core.info(`PR #${pr.number} — no candidates after filtering.`); |
| 102 | + return; |
| 103 | + } |
| 104 | +
|
| 105 | + // Sort: prefer unblocked → then lowest load → then random tiebreak. |
| 106 | + candidates.sort((a, b) => { |
| 107 | + const aBlocked = activeCount[a] > 0 ? 1 : 0; |
| 108 | + const bBlocked = activeCount[b] > 0 ? 1 : 0; |
| 109 | + if (aBlocked !== bBlocked) return aBlocked - bBlocked; |
| 110 | + if (load[a] !== load[b]) return load[a] - load[b]; |
| 111 | + return Math.random() - 0.5; |
| 112 | + }); |
| 113 | +
|
| 114 | + const selected = candidates.slice(0, needed); |
| 115 | + core.info(`PR #${pr.number} — weight: ${weight}, assigned: [${assigned}], candidates: ${JSON.stringify(candidates.map((c) => `${c}(active:${activeCount[c]},load:${load[c]})`))}, selected: [${selected}]`); |
| 116 | +
|
| 117 | + if (DRY_RUN) { |
| 118 | + core.info(`[DRY RUN] Would assign [${selected}] to PR #${pr.number}`); |
| 119 | + } else { |
| 120 | + await github.rest.issues.addAssignees({ |
| 121 | + owner, repo, issue_number: pr.number, assignees: selected, |
| 122 | + }); |
| 123 | + core.info(`Assigned [${selected}] to PR #${pr.number}`); |
| 124 | +
|
| 125 | + // Remove the label once reviewers are fully assigned so the bot |
| 126 | + // doesn't re-process this PR on subsequent runs. |
| 127 | + const newAssignedCount = assigned.length + selected.length; |
| 128 | + if (newAssignedCount >= REQUIRED_REVIEWERS) { |
| 129 | + await github.rest.issues.removeLabel({ |
| 130 | + owner, repo, issue_number: pr.number, name: "assign-for-review", |
| 131 | + }).catch((e) => core.warning(`Could not remove label from PR #${pr.number}: ${e.message}`)); |
| 132 | + core.info(`Removed 'assign-for-review' label from PR #${pr.number}`); |
| 133 | + } |
| 134 | + } |
| 135 | +
|
| 136 | + for (const r of selected) { |
| 137 | + load[r] += weight; |
| 138 | + activeCount[r] += 1; |
| 139 | + } |
| 140 | + } |
| 141 | +
|
| 142 | + // ── Main logic ── |
| 143 | +
|
| 144 | + // For pull_request_target events, early-exit if the triggering PR isn't eligible. |
| 145 | + if (context.eventName === "pull_request_target") { |
| 146 | + const triggerPR = context.payload.pull_request; |
| 147 | + if (!isEligible(triggerPR)) { |
| 148 | + core.info(`Triggering PR #${triggerPR.number} is not eligible (draft, missing label, or stale). Exiting.`); |
| 149 | + return; |
| 150 | + } |
| 151 | + const assigned = getPoolAssignees(triggerPR); |
| 152 | + const author = triggerPR.user.login; |
| 153 | + if (assigned.filter((a) => a !== author).length >= REQUIRED_REVIEWERS) { |
| 154 | + core.info(`Triggering PR #${triggerPR.number} already has ${assigned.length} pool assignees (excl. author). Exiting.`); |
| 155 | + return; |
| 156 | + } |
| 157 | + core.info(`Triggering PR #${triggerPR.number} needs reviewers. Proceeding with load calculation.`); |
| 158 | + } |
| 159 | +
|
| 160 | + // Fetch all open PRs (sorted by most recently updated, skip stale). |
| 161 | + const allPRs = await github.paginate(github.rest.pulls.list, { |
| 162 | + owner, repo, state: "open", sort: "updated", direction: "desc", per_page: 100, |
| 163 | + }); |
| 164 | + const freshPRs = allPRs.filter((pr) => new Date(pr.updated_at) >= staleCutoff); |
| 165 | + const eligiblePRs = freshPRs.filter(isEligible); |
| 166 | +
|
| 167 | + core.info(`Open PRs: ${allPRs.length}, fresh (≤${STALE_DAYS}d): ${freshPRs.length}, eligible: ${eligiblePRs.length}`); |
| 168 | +
|
| 169 | + // Build load map from active reviews across all eligible PRs. |
| 170 | + const load = {}; |
| 171 | + const activeCount = {}; |
| 172 | + for (const r of REVIEWERS) { load[r] = 0; activeCount[r] = 0; } |
| 173 | +
|
| 174 | + for (const pr of eligiblePRs) { |
| 175 | + const assigned = getPoolAssignees(pr); |
| 176 | + // Only call listReviews if this PR has pool assignees (saves API calls). |
| 177 | + if (assigned.length === 0) continue; |
| 178 | + const active = await getActiveReviewers(pr); |
| 179 | + const weight = getWeight(pr); |
| 180 | + for (const r of active) { |
| 181 | + load[r] += weight; |
| 182 | + activeCount[r] += 1; |
| 183 | + } |
| 184 | + } |
| 185 | + core.info(`Active load: ${JSON.stringify(load)}`); |
| 186 | + core.info(`Active counts: ${JSON.stringify(activeCount)}`); |
| 187 | +
|
| 188 | + // Determine which PRs to process. |
| 189 | + if (context.eventName === "pull_request_target") { |
| 190 | + // Single-PR mode: only assign the triggering PR. |
| 191 | + const triggerPR = context.payload.pull_request; |
| 192 | + // Re-fetch full PR object to get latest assignees. |
| 193 | + const { data: freshPR } = await github.rest.pulls.get({ |
| 194 | + owner, repo, pull_number: triggerPR.number, |
| 195 | + }); |
| 196 | + await assignReviewers(freshPR, load, activeCount); |
| 197 | + } else { |
| 198 | + // Full-scan mode (workflow_dispatch / schedule): process all eligible PRs that need reviewers. |
| 199 | + const needsReviewers = eligiblePRs |
| 200 | + .filter((pr) => { |
| 201 | + const assigned = getPoolAssignees(pr).filter((a) => a !== pr.user.login); |
| 202 | + return assigned.length < REQUIRED_REVIEWERS; |
| 203 | + }) |
| 204 | + .sort((a, b) => getWeight(b) - getWeight(a)); |
| 205 | +
|
| 206 | + core.info(`PRs needing reviewers: ${needsReviewers.length}`); |
| 207 | + for (const pr of needsReviewers) { |
| 208 | + await assignReviewers(pr, load, activeCount); |
| 209 | + } |
| 210 | + } |
| 211 | +
|
| 212 | + core.info("Review assignment complete."); |
0 commit comments