Files
gemini-cli/tools/optimizer/processes/scripts/pr_nudge.ts
T
Christian Gunderman 87485c87a4 Script updates.
2026-04-23 07:57:37 -07:00

167 lines
6.3 KiB
TypeScript

/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { execSync } from 'node:child_process';
import { updateSimulationCsv, execGh, getRepoInfo, getMaintainers, getMaintainerWorkload } from './utils.js';
const EXECUTE_ACTIONS = process.env.EXECUTE_ACTIONS === 'true';
async function run() {
const { owner, repo } = getRepoInfo();
console.log(`PR Nudge starting for ${owner}/${repo}... (EXECUTE_ACTIONS=${EXECUTE_ACTIONS})`);
try {
const MAINTAINERS = await getMaintainers();
const WORKLOAD = await getMaintainerWorkload();
// 1. Fetch community PRs
const query = `
query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
pullRequests(first: 500, states: OPEN, orderBy: {field: UPDATED_AT, direction: ASC}) {
nodes {
number
author { login }
authorAssociation
createdAt
updatedAt
isDraft
reviewDecision
mergeable
assignees(first: 1) { nodes { login } }
commits(last: 1) {
nodes {
commit {
statusCheckRollup {
state
}
}
}
}
labels(first: 20) {
nodes { name }
}
}
}
}
}
`;
const output = execSync(`gh api graphql -F owner=${owner} -F repo=${repo} -f query='${query}'`, { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024 });
const data = JSON.parse(output).data.repository;
const prs = data.pullRequests.nodes;
const actions = [];
const now = new Date();
// Sort maintainers by workload (ascending)
const sortedMaintainers = MAINTAINERS
.filter(m => m !== 'TOTAL_MAINTAINERS')
.sort((a, b) => (WORKLOAD[a] || 0) - (WORKLOAD[b] || 0));
let mIndex = 0;
for (const pr of prs) {
if (['MEMBER', 'OWNER', 'COLLABORATOR'].includes(pr.authorAssociation)) continue;
if (pr.isDraft) continue;
const ciState = pr.commits.nodes[0]?.commit.statusCheckRollup?.state;
const isCiSuccess = ciState === 'SUCCESS';
const isMergeable = pr.mergeable === 'MERGEABLE';
const isConflicting = pr.mergeable === 'CONFLICTING';
const updatedAt = new Date(pr.updatedAt);
const daysSinceUpdate = (now.getTime() - updatedAt.getTime()) / (1000 * 60 * 60 * 24);
const hoursSinceUpdate = daysSinceUpdate * 24;
const hasNudgeLabel = pr.labels.nodes.some(l => l.name === 'status/nudge');
const hasConflictLabel = pr.labels.nodes.some(l => l.name === 'status/merge-conflict');
const hasAssignee = pr.assignees.nodes.length > 0;
// 1. Terminal State: Close stale conflicts
if (isConflicting && hasConflictLabel && daysSinceUpdate > 14) {
actions.push({
number: pr.number,
type: 'close-conflict',
comment: `Hi @${pr.author?.login || 'author'}! This PR has had merge conflicts for over 14 days. We are closing it to keep the queue manageable. Please feel free to reopen it once you have resolved the conflicts and synchronized with the main branch.`
});
continue;
}
// 2. Author Nudge for Conflicts
if (isConflicting && !hasConflictLabel) {
actions.push({
number: pr.number,
type: 'author-nudge-conflict',
comment: `Hi @${pr.author?.login || 'author'}! It looks like this PR has merge conflicts. Could you please resolve them so that maintainers can review your changes? Thanks!`
});
continue;
}
// 3. Maintainer Action for Ready PRs
if (isCiSuccess && isMergeable && pr.reviewDecision === 'REVIEW_REQUIRED') {
if (!hasAssignee) {
// Assign a maintainer based on workload
const assignee = sortedMaintainers[mIndex % sortedMaintainers.length];
mIndex++;
WORKLOAD[assignee] = (WORKLOAD[assignee] || 0) + 1;
actions.push({
number: pr.number,
type: 'assign-reviewer',
assignee,
comment: `Hi @${assignee}! This community PR by @${pr.author?.login || 'author'} is ready for review (Mergeable + CI Success). Assigning to you based on current workload.`
});
} else if (hoursSinceUpdate > 48 && !hasNudgeLabel) {
// Nudge existing assignee if inactive for 48 hours
actions.push({
number: pr.number,
type: 'maintainer-nudge',
comment: `Hi @${pr.assignees.nodes[0].login}! This PR is ready and has been inactive for over 48 hours. Could you please take a look?`
});
}
}
}
// 2. Execute actions
const simulationUpdates = new Map<string, Record<string, string>>();
for (const action of actions) {
try {
if (action.type === 'author-nudge-conflict') {
await execGh(`pr edit ${action.number} --add-label "status/merge-conflict"`, EXECUTE_ACTIONS);
simulationUpdates.set(action.number.toString(), { labels: 'status/merge-conflict' });
} else if (action.type === 'close-conflict') {
await execGh(`pr close ${action.number}`, EXECUTE_ACTIONS);
simulationUpdates.set(action.number.toString(), { state: 'CLOSED' });
} else if (action.type === 'assign-reviewer') {
await execGh(`pr edit ${action.number} --add-assignee "${action.assignee}" --add-label "status/nudge"`, EXECUTE_ACTIONS);
simulationUpdates.set(action.number.toString(), { assignee: action.assignee, labels: 'status/nudge' });
} else if (action.type === 'maintainer-nudge') {
await execGh(`pr edit ${action.number} --add-label "status/nudge"`, EXECUTE_ACTIONS);
simulationUpdates.set(action.number.toString(), { labels: 'status/nudge' });
}
if (action.comment) {
await execGh(`pr comment ${action.number} --body "${action.comment}"`, EXECUTE_ACTIONS);
}
} catch (err) {
console.error(`Failed to process PR #${action.number}:`, err);
}
}
// 3. Update simulation
await updateSimulationCsv('prs-after.csv', simulationUpdates);
console.log(`Processed ${actions.length} PR nudges/actions.`);
} catch (err) {
console.error('Error in PR Nudge:', err);
process.exit(1);
}
}
run();