Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 189 additions & 0 deletions .github/workflows/assign-reviewers.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
name: Auto Assign Reviewers

on:
pull_request:
types: [opened, ready_for_review]

permissions:
pull-requests: write
contents: read

jobs:
assign-reviewers:
runs-on: ubuntu-latest
if: github.event.pull_request.draft == false
steps:
- name: Assign Reviewers
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GOOGLER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const author = context.payload.pull_request.user.login;

const ALLOWED_BOTS = new Set([
'gcf-owl-bot[bot]',
'gcf-owl-bot',
'release-please[bot]',
'release-please',
'dependabot[bot]',
'dependabot',
'renovate-bot',
'renovate',
'yoshi-code-bot'
]);

let isGoogler = ALLOWED_BOTS.has(author);

if (isGoogler) {
console.log(`${author} is a trusted bot. Treating as Googler.`);
} else {
// 1. Check if the author is a member of the 'googlers' organization
try {
const res = await github.rest.orgs.checkMembershipForUser({
org: 'googlers',
username: author,
});
if (res.status === 204) {
isGoogler = true;
console.log(`${author} is a member of 'googlers' organization.`);
}
} catch (error) {
if (error.status === 404) {
console.log(`${author} is NOT a member of 'googlers' organization.`);
} else {
console.warn(`Could not check membership in 'googlers' organization: Status ${error.status}. Proceeding as non-member.`);
}
}
}

if (!isGoogler) {
console.log("PR not opened by a Googler. Skipping auto-assignment.");
return;
}

// 2. Get list of files modified in the PR
const { data: files } = await github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
});

const PATH_ROUTING = [
{ prefix: 'handwritten/bigquery/', team: 'bigquery-team' },
{ prefix: 'handwritten/cloud-profiler/', team: 'cloud-profiler-team' },
{ prefix: 'handwritten/storage/', team: 'gcs-team' },
{ prefix: 'handwritten/firestore/', team: 'firestore-team' },
{ prefix: 'handwritten/spanner/', team: 'spanner-team' },
{ prefix: 'handwritten/bigquery-storage/', team: 'bigquery-team' },
{ prefix: 'handwritten/pubsub/', team: 'pubsub-team' },
{ prefix: 'handwritten/bigtable/', team: 'bigtable-team' },
{ prefix: 'core/packages/google-auth-library-nodejs/', team: 'aion-team' }
];

const assignedTeams = new Set();
for (const route of PATH_ROUTING) {
if (files.some(file => file.filename.startsWith(route.prefix))) {
assignedTeams.add(route.team);
}
}

if (assignedTeams.size > 0) {
const teamReviewers = Array.from(assignedTeams);
console.log(`PR contains changes matching specific routes. Requesting review from: ${teamReviewers.join(', ')}`);
await github.rest.pulls.requestReviewers({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
team_reviewers: teamReviewers,
});
} else {
// Route to cloud-sdk-nodejs-team members with load balancing
console.log("Requesting review from a member of cloud-sdk-nodejs-team using load balancing.");
try {
const { data: members } = await github.rest.teams.listMembersInOrg({
org: 'googleapis',
team_slug: 'cloud-sdk-nodejs-team',
});

const memberLogins = members
.map(m => m.login)
.filter(login => login !== author);

if (memberLogins.length > 0) {
// Retrieve active open PRs to calculate load
const { data: openPRs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
per_page: 100,
});

const loadMap = {};
for (const member of memberLogins) {
loadMap[member] = 0;
}

for (const pr of openPRs) {
// Count pending review requests
if (pr.requested_reviewers) {
for (const reviewer of pr.requested_reviewers) {
if (loadMap[reviewer.login] !== undefined) {
loadMap[reviewer.login]++;
}
}
}
// Count assignees
if (pr.assignees) {
for (const assignee of pr.assignees) {
if (loadMap[assignee.login] !== undefined) {
loadMap[assignee.login]++;
}
}
}
}

console.log("Current team workload:", loadMap);

// Find members with the minimum load
let minLoad = Infinity;
let selectedReviewers = [];
for (const member of memberLogins) {
const load = loadMap[member];
if (load < minLoad) {
minLoad = load;
selectedReviewers = [member];
} else if (load === minLoad) {
selectedReviewers.push(member);
}
}

const leastLoadedReviewer = selectedReviewers[Math.floor(Math.random() * selectedReviewers.length)];
console.log(`Selected reviewer with least load (load: ${minLoad}): ${leastLoadedReviewer}`);

await github.rest.pulls.requestReviewers({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
reviewers: [leastLoadedReviewer],
team_reviewers: ['cloud-sdk-nodejs-team'],
});
} else {
console.log("No other members found in cloud-sdk-nodejs-team. Requesting team review only.");
await github.rest.pulls.requestReviewers({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
team_reviewers: ['cloud-sdk-nodejs-team'],
});
}
} catch (err) {
console.error("Failed to fetch team members or assign reviewers:", err);
// Fallback to just requesting the team review
await github.rest.pulls.requestReviewers({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
team_reviewers: ['cloud-sdk-nodejs-team'],
});
}
}
Loading