diff --git a/.github/workflows/auto-assign-pr.yml b/.github/workflows/auto-assign-pr.yml new file mode 100644 index 0000000000..8cb4c5e8a7 --- /dev/null +++ b/.github/workflows/auto-assign-pr.yml @@ -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 + runs-on: ubuntu-latest + permissions: + issues: write + 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 = []; + 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}`); + 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; + } + + 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)) + ); + + 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 + });