Skip to content
156 changes: 156 additions & 0 deletions .github/workflows/auto-assign-pr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
name: Auto Assign PR Load Balancer

on:
pull_request_target:
types: [opened, reopened, ready_for_review, milestoned]

concurrency:
group: auto-assign-pr-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
load-balance-assignees:
# Only run for assignment-eligible PRs in the upstream repository. Use
# pull_request_target for both same-repo and fork PRs so the workflow
# only runs once per PR event.
if: github.repository == 'dotnet/SqlClient' && github.event.pull_request.draft == false && github.event.pull_request.milestone != null
Comment thread
cheenamalhotra marked this conversation as resolved.
runs-on: ubuntu-latest
permissions:
issues: write
Comment thread
cheenamalhotra marked this conversation as resolved.
pull-requests: read
env:
AUTO_ASSIGN_PR_POOL: ${{ vars.AUTO_ASSIGN_PR_POOL }}
AUTO_ASSIGN_PR_SKIP: ${{ vars.AUTO_ASSIGN_PR_SKIP }}

steps:
- name: Calculate Workload and Apply
uses: actions/github-script@v7
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const prNumber = context.issue.number;
const author = context.payload.pull_request.user.login;
const normalizeLogin = login => login.toLowerCase();
const parseCsvLogins = value => (value ?? '')
.split(',')
.map(entry => entry.trim())
.filter(entry => entry.length > 0);

// Fallback pool keeps behavior unchanged when no repo variable is configured.
const defaultPool = ['cheenamalhotra', 'paulmedynski', 'priyankatiwari08', 'benrr101', 'mdaigle', 'apoorvdeshmukh'];
const configuredPool = parseCsvLogins(process.env.AUTO_ASSIGN_PR_POOL);
const rawPool = configuredPool.length > 0 ? configuredPool : defaultPool;
const skipUsers = new Set(parseCsvLogins(process.env.AUTO_ASSIGN_PR_SKIP).map(normalizeLogin));
const seenPoolUsers = new Set();
const pool = [];
Comment thread
cheenamalhotra marked this conversation as resolved.
for (const user of rawPool) {
const lower = normalizeLogin(user);
if (!seenPoolUsers.has(lower)) {
seenPoolUsers.add(lower);
pool.push(user);
}
}

let latestPr;
try {
const response = await github.rest.pulls.get({
owner,
repo,
pull_number: prNumber
});
latestPr = response.data;
} catch (error) {
throw new Error(`Failed to fetch latest PR details: ${error.message}`);
}

if (latestPr.draft || !latestPr.milestone) {
console.log('PR is no longer assignment-eligible (draft or missing milestone).');
return;
}

const currentAssignees = (latestPr.assignees ?? []).map(a => a.login);

console.log(`PR Author: ${author}`);
Comment thread
cheenamalhotra marked this conversation as resolved.
console.log(`Event Name: ${context.eventName}; Is Fork PR: ${context.payload.pull_request.head.repo.fork === true}`);
console.log(`Current Assignees: ${currentAssignees.join(', ')}`);

if (currentAssignees.length >= 2) {
console.log('PR already has 2 or more assignees. No action needed.');
return;
}

Comment thread
cheenamalhotra marked this conversation as resolved.
const neededAssigneesCount = 2 - currentAssignees.length;

const candidates = pool.filter(user =>
normalizeLogin(user) !== normalizeLogin(author) &&
!skipUsers.has(normalizeLogin(user)) &&
!currentAssignees.some(a => normalizeLogin(a) === normalizeLogin(user))
);

Comment thread
cheenamalhotra marked this conversation as resolved.
if (candidates.length === 0) {
console.log('No valid candidates left in the pool.');
return;
}

const workloads = {};
const canonicalCandidateByLower = {};
candidates.forEach(user => {
const lower = normalizeLogin(user);
workloads[lower] = 0;
canonicalCandidateByLower[lower] = user;
});

try {
// Rank candidates by current assignment count across all open PRs.
const iterator = github.paginate.iterator(github.rest.pulls.list, {
owner,
repo,
state: 'open',
per_page: 100
});

for await (const response of iterator) {
for (const pr of response.data) {
if (pr.assignees) {
for (const assignee of pr.assignees) {
const login = normalizeLogin(assignee.login);
if (workloads[login] !== undefined) {
workloads[login]++;
}
}
}
}
}
} catch (error) {
throw new Error(`Failed to fetch open PRs for auto-assignment: ${error.message}`);
}

const workloadArray = candidates.map(user => {
const lower = normalizeLogin(user);
return { user: canonicalCandidateByLower[lower], count: workloads[lower] };
});
console.log('Current Workloads:', workloadArray);

// Shuffle before sorting so ties are broken fairly instead of favoring pool order.
for (let i = workloadArray.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[workloadArray[i], workloadArray[j]] = [workloadArray[j], workloadArray[i]];
}

workloadArray.sort((a, b) => a.count - b.count);

const selectedAssignees = workloadArray.slice(0, neededAssigneesCount).map(w => w.user);
console.log(`Selected candidates: ${selectedAssignees.join(', ')}`);

if (selectedAssignees.length === 0) {
console.log('No assignees selected. No action needed.');
return;
}

await github.rest.issues.addAssignees({
owner,
repo,
issue_number: prNumber,
assignees: selectedAssignees
});
Loading