Script updates.

This commit is contained in:
Christian Gunderman
2026-04-23 07:57:37 -07:00
parent 9109505145
commit 87485c87a4
8 changed files with 367 additions and 93 deletions
+55 -14
View File
@@ -5,7 +5,7 @@
*/
import { execSync } from 'node:child_process';
import { updateSimulationCsv, execGh, getRepoInfo } from './utils.js';
import { updateSimulationCsv, execGh, getRepoInfo, getMaintainers, getMaintainerWorkload } from './utils.js';
const EXECUTE_ACTIONS = process.env.EXECUTE_ACTIONS === 'true';
@@ -14,11 +14,14 @@ async function run() {
console.log(`PR Nudge starting for ${owner}/${repo}... (EXECUTE_ACTIONS=${EXECUTE_ACTIONS})`);
try {
// 1. Fetch community PRs that pass CI but need review
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: 50, states: OPEN) {
pullRequests(first: 500, states: OPEN, orderBy: {field: UPDATED_AT, direction: ASC}) {
nodes {
number
author { login }
@@ -28,6 +31,7 @@ async function run() {
isDraft
reviewDecision
mergeable
assignees(first: 1) { nodes { login } }
commits(last: 1) {
nodes {
commit {
@@ -45,13 +49,20 @@ async function run() {
}
}
`;
const output = execSync(`gh api graphql -F owner=${owner} -F repo=${repo} -f query='${query}'`, { encoding: 'utf-8' });
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;
@@ -62,12 +73,24 @@ async function run() {
const isConflicting = pr.mergeable === 'CONFLICTING';
const updatedAt = new Date(pr.updatedAt);
const hoursSinceUpdate = (now.getTime() - updatedAt.getTime()) / (1000 * 60 * 60);
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. Author Nudge for Conflicts
// 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,
@@ -77,14 +100,26 @@ async function run() {
continue;
}
// 2. Maintainer Nudge for Ready PRs
// 3. Maintainer Action for Ready PRs
if (isCiSuccess && isMergeable && pr.reviewDecision === 'REVIEW_REQUIRED') {
// Nudge maintainers if ready and no activity for 48 hours
if (hoursSinceUpdate > 48 && !hasNudgeLabel) {
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 maintainers! This community PR by @${pr.author?.login || 'author'} has passed all CI checks and is mergeable. It has been open and inactive for over 48 hours. Could someone please take a look? @google-gemini/gemini-cli-maintainers`
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?`
});
}
}
@@ -96,15 +131,21 @@ async function run() {
for (const action of actions) {
try {
if (action.type === 'author-nudge-conflict') {
execGh(`pr edit ${action.number} --add-label "status/merge-conflict"`, EXECUTE_ACTIONS);
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') {
execGh(`pr edit ${action.number} --add-label "status/nudge"`, EXECUTE_ACTIONS);
await execGh(`pr edit ${action.number} --add-label "status/nudge"`, EXECUTE_ACTIONS);
simulationUpdates.set(action.number.toString(), { labels: 'status/nudge' });
}
if (action.comment) {
execGh(`pr comment ${action.number} --body "${action.comment}"`, EXECUTE_ACTIONS);
await execGh(`pr comment ${action.number} --body "${action.comment}"`, EXECUTE_ACTIONS);
}
} catch (err) {
console.error(`Failed to process PR #${action.number}:`, err);
@@ -114,7 +155,7 @@ async function run() {
// 3. Update simulation
await updateSimulationCsv('prs-after.csv', simulationUpdates);
console.log(`Processed ${actions.length} PR nudges.`);
console.log(`Processed ${actions.length} PR nudges/actions.`);
} catch (err) {
console.error('Error in PR Nudge:', err);
@@ -14,11 +14,11 @@ async function run() {
console.log(`Stale Manager starting for ${owner}/${repo}... (EXECUTE_ACTIONS=${EXECUTE_ACTIONS})`);
try {
// 1. Fetch open issues/PRs that might be stale
const query = `
// 1. Fetch open issues
const issueQuery = `
query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
issues(first: 200, states: OPEN, orderBy: {field: UPDATED_AT, direction: ASC}) {
issues(first: 1000, states: OPEN, orderBy: {field: UPDATED_AT, direction: ASC}) {
nodes {
number
authorAssociation
@@ -28,7 +28,22 @@ async function run() {
}
}
}
pullRequests(first: 200, states: OPEN, orderBy: {field: UPDATED_AT, direction: ASC}) {
}
}
`;
let issueOutput;
try {
issueOutput = execSync(`gh api graphql -F owner=${owner} -F repo=${repo} -f query='${issueQuery}'`, { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024 });
} catch (err) {
console.error('Failed to fetch issues from GitHub:', err);
process.exit(1);
}
// 2. Fetch open PRs
const prQuery = `
query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
pullRequests(first: 500, states: OPEN, orderBy: {field: UPDATED_AT, direction: ASC}) {
nodes {
number
authorAssociation
@@ -52,16 +67,17 @@ async function run() {
}
}
`;
let output;
let prOutput;
try {
output = execSync(`gh api graphql -F owner=${owner} -F repo=${repo} -f query='${query}'`, { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
prOutput = execSync(`gh api graphql -F owner=${owner} -F repo=${repo} -f query='${prQuery}'`, { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024 });
} catch (err) {
console.error('Failed to fetch issues/PRs from GitHub:', err);
console.error('Failed to fetch PRs from GitHub:', err);
process.exit(1);
}
const data = JSON.parse(output).data.repository;
const items = [...data.issues.nodes.map(i => ({...i, type: 'issue'})), ...data.pullRequests.nodes.map(p => ({...p, type: 'pr'}))];
const issueData = JSON.parse(issueOutput).data.repository;
const prData = JSON.parse(prOutput).data.repository;
const items = [...issueData.issues.nodes.map(i => ({...i, type: 'issue'})), ...prData.pullRequests.nodes.map(p => ({...p, type: 'pr'}))];
const now = new Date();
const actions = [];
@@ -71,10 +87,23 @@ async function run() {
const daysSinceUpdate = (now.getTime() - updatedAt.getTime()) / (1000 * 60 * 60 * 24);
const isMaintainerOnly = item.labels.nodes.some(l => l.name === '🔒 maintainer only');
const isStale = item.labels.nodes.some(l => l.name === 'Stale');
const needsInfo = item.labels.nodes.some(l => l.name === 'status/needs-info');
const isCommunity = !['MEMBER', 'OWNER', 'COLLABORATOR'].includes(item.authorAssociation);
if (isMaintainerOnly) continue; // Maintainer issues have their own lifecycle
// Special handling for needs-info: mark stale faster
if (needsInfo && !isStale && daysSinceUpdate > 7) {
actions.push({
number: item.number,
target: item.type,
type: 'label',
label: 'Stale',
comment: `Hi! This ${item.type} was marked as 'status/needs-info' but has had no activity for 7 days. We are labeling it as 'Stale'. It will be closed in 14 days if no further activity occurs. Thank you!`
});
continue;
}
// Safeguard: Don't mark as stale if it's a PR ready for review (Maintainer bottleneck)
if (item.type === 'pr') {
const ciState = item.commits?.nodes[0]?.commit?.statusCheckRollup?.state;
@@ -124,14 +153,14 @@ async function run() {
const simulationMap = action.target === 'pr' ? prSimulationUpdates : issueSimulationUpdates;
if (action.type === 'label') {
execGh(`${cmdPrefix} edit ${action.number} --add-label "Stale"`, EXECUTE_ACTIONS);
simulationMap.set(action.number.toString(), { labels: 'Stale' });
await execGh(`${cmdPrefix} edit ${action.number} --add-label "${action.label}"`, EXECUTE_ACTIONS);
simulationMap.set(action.number.toString(), { labels: action.label });
}
if (action.comment) {
execGh(`${cmdPrefix} comment ${action.number} --body "${action.comment}"`, EXECUTE_ACTIONS);
await execGh(`${cmdPrefix} comment ${action.number} --body "${action.comment}"`, EXECUTE_ACTIONS);
}
if (action.type === 'close') {
execGh(`${cmdPrefix} close ${action.number}`, EXECUTE_ACTIONS);
await execGh(`${cmdPrefix} close ${action.number}`, EXECUTE_ACTIONS);
simulationMap.set(action.number.toString(), { state: 'CLOSED' });
}
} catch (err) {
@@ -5,7 +5,7 @@
*/
import { execSync } from 'node:child_process';
import { getMaintainers, execGh, getRepoInfo, updateSimulationCsv } from './utils.js';
import { getMaintainers, execGh, getRepoInfo, updateSimulationCsv, getMaintainerWorkload } from './utils.js';
const EXECUTE_ACTIONS = process.env.EXECUTE_ACTIONS === 'true';
@@ -15,13 +15,14 @@ async function run() {
try {
const MAINTAINERS = await getMaintainers();
console.log(`Fetched ${MAINTAINERS.length} maintainers.`);
const WORKLOAD = await getMaintainerWorkload();
console.log(`Fetched ${MAINTAINERS.length} maintainers and current workloads.`);
// 1. Fetch untriaged issues
// 1. Fetch untriaged issues (Increase limit to process the backlog)
const query = `
query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
issues(first: 100, states: OPEN, labels: ["status/need-triage"]) {
issues(first: 1000, states: OPEN, labels: ["status/need-triage"], orderBy: {field: CREATED_AT, direction: ASC}) {
nodes {
number
title
@@ -46,43 +47,53 @@ async function run() {
const issues = data.issues.nodes;
const actions = [];
let maintainerIndex = Math.floor(Math.random() * MAINTAINERS.length);
// Sort maintainers by workload (ascending)
const sortedMaintainers = MAINTAINERS
.filter(m => m !== 'TOTAL_MAINTAINERS') // safeguard
.sort((a, b) => (WORKLOAD[a] || 0) - (WORKLOAD[b] || 0));
let mIndex = 0;
for (const issue of issues) {
if (issue.assignees.nodes.length > 0) continue;
const body = issue.body || '';
const title = issue.title || '';
const title = issue.title.toLowerCase();
// Better categorization
const labelsToAdd: string[] = [];
if (title.includes('bug') || body.toLowerCase().includes('expected behavior')) {
labelsToAdd.push('type/bug');
} else if (title.includes('feature') || title.includes('enhancement') || body.toLowerCase().includes('proposed change')) {
labelsToAdd.push('type/feature');
}
// Low quality check
if (body.length < 50 || title.length < 10 || !body.includes('###')) {
actions.push({
number: issue.number,
type: 'needs-info',
labelsToAdd: ['status/needs-info'],
labelsToRemove: ['status/need-triage'],
comment: `Hi @${issue.author?.login || 'author'}! Thank you for the report. This issue seems to be missing some critical information or doesn't follow the template. Could you please provide more details? Labeling as 'status/needs-info' for now.`
});
continue;
}
// Potential duplicate check (very naive but better than nothing)
if (title.toLowerCase().includes('duplicate') || title.toLowerCase().includes('same as #')) {
actions.push({
number: issue.number,
type: 'possible-duplicate',
comment: `Hi @${issue.author?.login || 'author'}! This issue might be a duplicate of another existing issue. Labeling as 'status/possible-duplicate' for maintainer review.`
});
continue;
}
// Assign to a maintainer (round-robin)
const assignee = MAINTAINERS[maintainerIndex % MAINTAINERS.length];
maintainerIndex++;
// Assign to the maintainer with the lowest workload
const assignee = sortedMaintainers[mIndex % sortedMaintainers.length];
mIndex++;
// Increment local workload tracker to keep distribution even during this run
WORKLOAD[assignee] = (WORKLOAD[assignee] || 0) + 1;
actions.push({
number: issue.number,
type: 'assign',
assignee,
comment: `Automated Triage: Assigning to @${assignee} for initial review. Please categorize and set priority.`
labelsToAdd: [...labelsToAdd, 'status/manual-triage'],
labelsToRemove: ['status/need-triage'],
comment: `Automated Triage: Assigning to @${assignee} based on current workload. Please categorize and set priority.`
});
}
@@ -91,19 +102,22 @@ async function run() {
for (const action of actions) {
try {
if (action.type === 'needs-info') {
execGh(`issue edit ${action.number} --add-label "status/needs-info" --remove-label "status/need-triage"`, EXECUTE_ACTIONS);
simulationUpdates.set(action.number.toString(), { labels: 'status/needs-info' });
} else if (action.type === 'possible-duplicate') {
execGh(`issue edit ${action.number} --add-label "status/possible-duplicate" --remove-label "status/need-triage"`, EXECUTE_ACTIONS);
simulationUpdates.set(action.number.toString(), { labels: 'status/possible-duplicate' });
} else if (action.type === 'assign') {
execGh(`issue edit ${action.number} --add-assignee "${action.assignee}" --remove-label "status/need-triage" --add-label "status/manual-triage"`, EXECUTE_ACTIONS);
simulationUpdates.set(action.number.toString(), { labels: 'status/manual-triage' });
}
const addLabels = action.labelsToAdd?.map(l => `"${l}"`).join(',') || '';
const removeLabels = action.labelsToRemove?.map(l => `"${l}"`).join(',') || '';
let editCmd = `issue edit ${action.number}`;
if (addLabels) editCmd += ` --add-label ${addLabels}`;
if (removeLabels) editCmd += ` --remove-label ${removeLabels}`;
if (action.assignee) editCmd += ` --add-assignee "${action.assignee}"`;
await execGh(editCmd, EXECUTE_ACTIONS);
simulationUpdates.set(action.number.toString(), {
labels: action.labelsToAdd?.join(', ') || '',
assignee: action.assignee || ''
});
if (action.comment) {
execGh(`issue comment ${action.number} --body "${action.comment}"`, EXECUTE_ACTIONS);
await execGh(`issue comment ${action.number} --body "${action.comment}"`, EXECUTE_ACTIONS);
}
} catch (err) {
console.error(`Failed to process issue #${action.number}:`, err);
+47 -6
View File
@@ -36,14 +36,17 @@ export async function getMaintainers(): Promise<string[]> {
const lines = content.split('\n');
for (const line of lines) {
if (line.startsWith('#') || !line.trim()) continue;
const cleanLine = line.split('#')[0].trim();
if (!cleanLine) continue;
// Match @user or @org/team
const matches = line.match(/@[\w-]+\/[\w-]+|@[\w-]+/g);
const matches = cleanLine.match(/@[\w-]+\/[\w-]+|@[\w-]+/g);
if (matches) {
for (const match of matches) {
if (match.includes('/')) {
// For team mentions, we might want to expand them,
// but for now let's just skip them or handle them if needed.
// For teams, we should ideally expand them via gh api,
// but for simulation/simple use, we'll just log it.
console.log(`[INFO] Found team ownership: ${match}. Skipping team expansion for now.`);
continue;
}
maintainers.add(match.replace('@', ''));
@@ -52,6 +55,7 @@ export async function getMaintainers(): Promise<string[]> {
}
if (maintainers.size === 0) {
console.warn('No maintainers found in CODEOWNERS, using fallbacks.');
return ['gundermanc', 'jackwotherspoon', 'DavidAPierce'];
}
@@ -126,7 +130,14 @@ export async function updateSimulationCsv(
for (const [colName, newValue] of Object.entries(update)) {
const colIndex = header.indexOf(colName);
if (colIndex !== -1) {
columns[colIndex] = newValue;
if (colName === 'labels') {
const existingLabels = columns[colIndex].replace(/"/g, '').split(',').map(s => s.trim()).filter(Boolean);
const newLabels = newValue.split(',').map(s => s.trim()).filter(Boolean);
const combined = Array.from(new Set([...existingLabels, ...newLabels]));
columns[colIndex] = `"${combined.join(', ')}"`;
} else {
columns[colIndex] = newValue.includes(',') ? `"${newValue}"` : newValue;
}
}
}
}
@@ -136,10 +147,37 @@ export async function updateSimulationCsv(
await fs.writeFile(filePath, newLines.join('\n'));
}
/**
* Reads maintainer workload from maintainer_workload.csv.
*/
export async function getMaintainerWorkload(): Promise<Record<string, number>> {
try {
const filePath = path.join(process.cwd(), 'maintainer_workload.csv');
const content = await fs.readFile(filePath, 'utf8');
const workload: Record<string, number> = {};
const lines = content.split('\n');
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line || line.startsWith('TOTAL') || line.startsWith('RATIO')) continue;
const columns = line.split(',');
const maintainer = columns[0];
const assignedIssues = parseInt(columns[1] || '0', 10);
const assignedPrs = parseInt(columns[2] || '0', 10);
workload[maintainer] = assignedIssues + assignedPrs;
}
return workload;
} catch (err) {
console.warn('maintainer_workload.csv not found or unreadable, returning empty workload.');
return {};
}
}
/**
* Executes a gh command with logging and dry-run support.
*/
export function execGh(command: string, execute: boolean) {
export async function execGh(command: string, execute: boolean) {
if (!execute) {
console.log(`[DRY RUN] Would execute: gh ${command}`);
return;
@@ -147,8 +185,11 @@ export function execGh(command: string, execute: boolean) {
console.log(`Executing: gh ${command}`);
try {
// Small delay to be nicer to the API and avoid race conditions if run concurrently
await new Promise(resolve => setTimeout(resolve, 100));
execSync(`gh ${command}`, { stdio: 'inherit' });
} catch (err) {
console.error(`Failed to execute gh ${command}:`, err);
}
}