Skip to content

Commit 7721c98

Browse files
Actions | Introduce auto-assign-pr workflow (#4275)
1 parent 3488b36 commit 7721c98

2 files changed

Lines changed: 171 additions & 0 deletions

File tree

.github/scripts/auto-assign-pr.js

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Auto-assign PR load balancer.
2+
//
3+
// Selects up to 2 assignees for a qualifying PR from a configurable pool,
4+
// balancing by current open-PR assignment count. Invoked by the
5+
// `auto-assign-pr.yml` workflow via `actions/github-script`.
6+
module.exports = async ({ github, context, core }) => {
7+
const owner = context.repo.owner;
8+
const repo = context.repo.repo;
9+
const prNumber = context.issue.number;
10+
const author = context.payload.pull_request.user.login;
11+
const normalizeLogin = login => login.toLowerCase();
12+
const parseCsvLogins = value => (value ?? '')
13+
.split(',')
14+
.map(entry => entry.trim())
15+
.filter(entry => entry.length > 0);
16+
17+
// Fallback pool keeps behavior unchanged when no repo variable is configured.
18+
const defaultPool = ['cheenamalhotra', 'paulmedynski', 'priyankatiwari08', 'benrr101', 'mdaigle', 'apoorvdeshmukh'];
19+
const configuredPool = parseCsvLogins(process.env.PR_REVIEWER_POOL);
20+
const rawPool = configuredPool.length > 0 ? configuredPool : defaultPool;
21+
const seenPoolUsers = new Set();
22+
const pool = [];
23+
for (const user of rawPool) {
24+
const normalized = normalizeLogin(user);
25+
if (!seenPoolUsers.has(normalized)) {
26+
seenPoolUsers.add(normalized);
27+
pool.push(user);
28+
}
29+
}
30+
31+
let latestPr;
32+
try {
33+
const response = await github.rest.pulls.get({
34+
owner,
35+
repo,
36+
pull_number: prNumber
37+
});
38+
latestPr = response.data;
39+
} catch (error) {
40+
throw new Error(`Failed to fetch latest PR details: ${error.message}`);
41+
}
42+
43+
if (latestPr.state !== 'open' || latestPr.draft || !latestPr.milestone) {
44+
console.log('PR is no longer assignment-eligible (not open, draft, or missing milestone).');
45+
return;
46+
}
47+
48+
const currentAssignees = (latestPr.assignees ?? []).map(a => a.login);
49+
50+
console.log(`PR Author: ${author}`);
51+
console.log(`Event Name: ${context.eventName}; Is Fork PR: ${context.payload.pull_request.head.repo.fork === true}`);
52+
console.log(`Current Assignees: ${currentAssignees.join(', ')}`);
53+
54+
if (currentAssignees.length >= 2) {
55+
console.log('PR already has 2 or more assignees. No action needed.');
56+
return;
57+
}
58+
59+
const neededAssigneesCount = 2 - currentAssignees.length;
60+
61+
const candidates = pool.filter(user =>
62+
normalizeLogin(user) !== normalizeLogin(author) &&
63+
!currentAssignees.some(a => normalizeLogin(a) === normalizeLogin(user))
64+
);
65+
66+
if (candidates.length === 0) {
67+
console.log('No valid candidates left in the pool.');
68+
return;
69+
}
70+
71+
const workloads = {};
72+
const canonicalCandidateByNormalized = {};
73+
candidates.forEach(user => {
74+
const normalized = normalizeLogin(user);
75+
workloads[normalized] = 0;
76+
canonicalCandidateByNormalized[normalized] = user;
77+
});
78+
79+
try {
80+
// Rank candidates by current assignment count across all open PRs.
81+
const iterator = github.paginate.iterator(github.rest.pulls.list, {
82+
owner,
83+
repo,
84+
state: 'open',
85+
per_page: 100
86+
});
87+
88+
for await (const response of iterator) {
89+
for (const pr of response.data) {
90+
if (pr.draft) continue;
91+
if (pr.assignees) {
92+
for (const assignee of pr.assignees) {
93+
const login = normalizeLogin(assignee.login);
94+
if (workloads[login] !== undefined) {
95+
workloads[login]++;
96+
}
97+
}
98+
}
99+
}
100+
}
101+
} catch (error) {
102+
throw new Error(`Failed to fetch open PRs for auto-assignment: ${error.message}`);
103+
}
104+
105+
const workloadArray = candidates.map(user => {
106+
const normalized = normalizeLogin(user);
107+
return { user: canonicalCandidateByNormalized[normalized], count: workloads[normalized] };
108+
});
109+
console.log('Current Workloads:', workloadArray);
110+
111+
// Shuffle before sorting so ties are broken fairly instead of favoring pool order.
112+
for (let i = workloadArray.length - 1; i > 0; i--) {
113+
const j = Math.floor(Math.random() * (i + 1));
114+
[workloadArray[i], workloadArray[j]] = [workloadArray[j], workloadArray[i]];
115+
}
116+
117+
workloadArray.sort((a, b) => a.count - b.count);
118+
119+
const selectedAssignees = workloadArray.slice(0, neededAssigneesCount).map(w => w.user);
120+
console.log(`Selected candidates: ${selectedAssignees.join(', ')}`);
121+
122+
if (selectedAssignees.length === 0) {
123+
console.log('No assignees selected. No action needed.');
124+
return;
125+
}
126+
127+
await github.rest.issues.addAssignees({
128+
owner,
129+
repo,
130+
issue_number: prNumber,
131+
assignees: selectedAssignees
132+
});
133+
};
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: Auto Assign PR Load Balancer
2+
3+
on:
4+
pull_request_target:
5+
types: [opened, reopened, ready_for_review, milestoned]
6+
7+
concurrency:
8+
group: auto-assign-pr-${{ github.event.pull_request.number }}
9+
cancel-in-progress: true
10+
11+
jobs:
12+
load-balance-assignees:
13+
# Only run for assignment-eligible PRs in the upstream repository. Use
14+
# pull_request_target so the workflow only runs once per PR event.
15+
# Exclude PRs from forks as GitHub Actions does not have permissions to assign users on PRs from forks.
16+
if: github.repository == 'dotnet/SqlClient' && github.event.pull_request.state == 'open' && github.event.pull_request.draft == false && github.event.pull_request.milestone != null && github.event.pull_request.head.repo.fork == false
17+
runs-on: ubuntu-latest
18+
permissions:
19+
contents: read
20+
issues: write
21+
pull-requests: write
22+
env:
23+
PR_REVIEWER_POOL: ${{ vars.PR_REVIEWER_POOL }}
24+
steps:
25+
- name: Checkout repository
26+
uses: actions/checkout@v4
27+
with:
28+
sparse-checkout: |
29+
.github/scripts/auto-assign-pr.js
30+
sparse-checkout-cone-mode: false
31+
persist-credentials: false
32+
33+
- name: Calculate Workload and Apply
34+
uses: actions/github-script@v9
35+
with:
36+
script: |
37+
const script = require('./.github/scripts/auto-assign-pr.js');
38+
await script({ github, context, core });

0 commit comments

Comments
 (0)