177 lines
4.9 KiB
JavaScript
177 lines
4.9 KiB
JavaScript
const STATS_ISSUE_NUMBER = 19428;
|
|
const MEMBERS = [
|
|
"B-Step62",
|
|
"daniellok-db",
|
|
"harupy",
|
|
"kriscon-db",
|
|
"PattaraS",
|
|
"serena-ruan",
|
|
"TomeHirata",
|
|
"WeichenXu123",
|
|
"aaronteo-db",
|
|
];
|
|
|
|
async function loadStats(github, owner, repo) {
|
|
try {
|
|
const issue = await github.rest.issues.get({
|
|
owner,
|
|
repo,
|
|
issue_number: STATS_ISSUE_NUMBER,
|
|
});
|
|
const match = issue.data.body.match(/```json\n([\s\S]*?)\n```/);
|
|
if (match) {
|
|
return JSON.parse(match[1]);
|
|
}
|
|
} catch (err) {
|
|
console.warn(`Warning: Failed to load stats from issue: ${err.message}`);
|
|
}
|
|
return { reviewCounts: {} };
|
|
}
|
|
|
|
async function saveStats(github, owner, repo, stats) {
|
|
const body = `This issue tracks reviewer assignment counts for fair distribution.
|
|
|
|
\`\`\`json
|
|
${JSON.stringify(stats, null, 2)}
|
|
\`\`\`
|
|
`;
|
|
|
|
await github.rest.issues.update({
|
|
owner,
|
|
repo,
|
|
issue_number: STATS_ISSUE_NUMBER,
|
|
body,
|
|
});
|
|
}
|
|
|
|
function shuffle(array) {
|
|
const shuffled = [...array];
|
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
|
}
|
|
return shuffled;
|
|
}
|
|
|
|
/**
|
|
* Select reviewers with the lowest review counts, with random shuffling within each count tier.
|
|
*
|
|
* @example
|
|
* // Counts: {A: 0, B: 0, C: 1}, need 2
|
|
* // → Group 0: [A, B] → shuffle → [B, A] → select both → [B, A]
|
|
*
|
|
* @example
|
|
* // Counts: {A: 0, B: 1, C: 1}, need 2
|
|
* // → Group 0: [A] → select A
|
|
* // → Group 1: [B, C] → shuffle → [C, B] → select C → [A, C]
|
|
*
|
|
* @example
|
|
* // Counts: {A: 2, B: 2, C: 2}, need 2
|
|
* // → Group 2: [A, B, C] → shuffle → [C, A, B] → select C, A → [C, A]
|
|
*/
|
|
function selectReviewers(eligibleReviewers, stats, count = 2) {
|
|
if (eligibleReviewers.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const reviewCounts = stats.reviewCounts || {};
|
|
|
|
// Group by review count
|
|
const groups = {};
|
|
for (const reviewer of eligibleReviewers) {
|
|
const c = reviewCounts[reviewer] || 0;
|
|
if (!groups[c]) groups[c] = [];
|
|
groups[c].push(reviewer);
|
|
}
|
|
|
|
// Process groups from lowest count, shuffle each, and select
|
|
const sortedCounts = Object.keys(groups)
|
|
.map(Number)
|
|
.sort((a, b) => a - b);
|
|
const result = [];
|
|
for (const c of sortedCounts) {
|
|
const shuffled = shuffle(groups[c]);
|
|
for (const reviewer of shuffled) {
|
|
result.push(reviewer);
|
|
if (result.length >= count) {
|
|
return result;
|
|
}
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function updateStats(stats, selectedReviewers) {
|
|
const reviewCounts = stats.reviewCounts || {};
|
|
for (const reviewer of selectedReviewers) {
|
|
reviewCounts[reviewer] = (reviewCounts[reviewer] || 0) + 1;
|
|
}
|
|
stats.reviewCounts = Object.fromEntries(
|
|
Object.keys(reviewCounts)
|
|
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base", numeric: true }))
|
|
.map((k) => [k, reviewCounts[k]])
|
|
);
|
|
return stats;
|
|
}
|
|
|
|
async function getCopilotInitiator(github, owner, repo, pull_number) {
|
|
const timeline = await github.rest.issues.listEventsForTimeline({
|
|
owner,
|
|
repo,
|
|
issue_number: pull_number,
|
|
});
|
|
return timeline.data.find((e) => e.event === "copilot_work_started")?.actor?.login;
|
|
}
|
|
|
|
module.exports = async ({ github, context }) => {
|
|
const { owner, repo } = context.repo;
|
|
const pull_number = context.payload.pull_request.number;
|
|
const author = context.payload.pull_request.user.login;
|
|
|
|
const copilotInitiator = await getCopilotInitiator(github, owner, repo, pull_number);
|
|
|
|
// Get existing reviews
|
|
const reviews = await github.rest.pulls.listReviews({
|
|
owner,
|
|
repo,
|
|
pull_number,
|
|
});
|
|
|
|
const approved = reviews.data.filter((r) => r.state === "APPROVED").map((r) => r.user.login);
|
|
const requested = context.payload.pull_request.requested_reviewers.map((r) => r.login);
|
|
|
|
const eligibleReviewers = MEMBERS.filter(
|
|
(m) => !approved.includes(m) && !requested.includes(m) && m !== author && m !== copilotInitiator
|
|
);
|
|
|
|
// Load stats, select reviewers, and update stats
|
|
const stats = await loadStats(github, owner, repo);
|
|
const selectedReviewers = selectReviewers(eligibleReviewers, stats);
|
|
|
|
if (selectedReviewers.length > 0) {
|
|
try {
|
|
await github.rest.pulls.requestReviewers({
|
|
owner,
|
|
repo,
|
|
pull_number,
|
|
reviewers: selectedReviewers,
|
|
});
|
|
console.log(`Assigned reviewers: ${selectedReviewers.join(", ")}`);
|
|
console.log(`Review counts before: ${JSON.stringify(stats.reviewCounts || {})}`);
|
|
|
|
const updatedStats = updateStats(stats, selectedReviewers);
|
|
await saveStats(github, owner, repo, updatedStats);
|
|
|
|
console.log(`Review counts after: ${JSON.stringify(updatedStats.reviewCounts)}`);
|
|
console.log(
|
|
`Saved stats to https://github.com/${owner}/${repo}/issues/${STATS_ISSUE_NUMBER}`
|
|
);
|
|
} catch (error) {
|
|
console.error("Failed to assign reviewers:", error);
|
|
}
|
|
} else {
|
|
console.log("No eligible reviewers available");
|
|
}
|
|
};
|