mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-27 20:22:58 -07:00
Agent generated files.
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { updateSimulationCsv, execGh, getRepoInfo } 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 {
|
||||
// 1. Fetch community PRs that pass CI but need review
|
||||
const query = `
|
||||
query($owner: String!, $repo: String!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequests(first: 50, states: OPEN) {
|
||||
nodes {
|
||||
number
|
||||
author { login }
|
||||
authorAssociation
|
||||
createdAt
|
||||
updatedAt
|
||||
isDraft
|
||||
reviewDecision
|
||||
mergeable
|
||||
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' });
|
||||
const data = JSON.parse(output).data.repository;
|
||||
const prs = data.pullRequests.nodes;
|
||||
|
||||
const actions = [];
|
||||
const now = new Date();
|
||||
|
||||
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 hoursSinceUpdate = (now.getTime() - updatedAt.getTime()) / (1000 * 60 * 60);
|
||||
|
||||
const hasNudgeLabel = pr.labels.nodes.some(l => l.name === 'status/nudge');
|
||||
const hasConflictLabel = pr.labels.nodes.some(l => l.name === 'status/merge-conflict');
|
||||
|
||||
// 1. 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;
|
||||
}
|
||||
|
||||
// 2. Maintainer Nudge for Ready PRs
|
||||
if (isCiSuccess && isMergeable && pr.reviewDecision === 'REVIEW_REQUIRED') {
|
||||
// Nudge maintainers if ready and no activity for 48 hours
|
||||
if (hoursSinceUpdate > 48 && !hasNudgeLabel) {
|
||||
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`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Execute actions
|
||||
const simulationUpdates = new Map<string, Record<string, string>>();
|
||||
|
||||
for (const action of actions) {
|
||||
try {
|
||||
if (action.type === 'author-nudge-conflict') {
|
||||
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 === 'maintainer-nudge') {
|
||||
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);
|
||||
}
|
||||
} 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.`);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error in PR Nudge:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { updateSimulationCsv, execGh, getRepoInfo } from './utils.js';
|
||||
|
||||
const EXECUTE_ACTIONS = process.env.EXECUTE_ACTIONS === 'true';
|
||||
|
||||
async function run() {
|
||||
const { owner, repo } = getRepoInfo();
|
||||
console.log(`Stale Manager starting for ${owner}/${repo}... (EXECUTE_ACTIONS=${EXECUTE_ACTIONS})`);
|
||||
|
||||
try {
|
||||
// 1. Fetch open issues/PRs that might be stale
|
||||
const query = `
|
||||
query($owner: String!, $repo: String!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
issues(first: 200, states: OPEN, orderBy: {field: UPDATED_AT, direction: ASC}) {
|
||||
nodes {
|
||||
number
|
||||
authorAssociation
|
||||
updatedAt
|
||||
labels(first: 20) {
|
||||
nodes { name }
|
||||
}
|
||||
}
|
||||
}
|
||||
pullRequests(first: 200, states: OPEN, orderBy: {field: UPDATED_AT, direction: ASC}) {
|
||||
nodes {
|
||||
number
|
||||
authorAssociation
|
||||
updatedAt
|
||||
mergeable
|
||||
reviewDecision
|
||||
commits(last: 1) {
|
||||
nodes {
|
||||
commit {
|
||||
statusCheckRollup {
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
labels(first: 20) {
|
||||
nodes { name }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
let output;
|
||||
try {
|
||||
output = execSync(`gh api graphql -F owner=${owner} -F repo=${repo} -f query='${query}'`, { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch issues/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 now = new Date();
|
||||
const actions = [];
|
||||
|
||||
for (const item of items) {
|
||||
const updatedAt = new Date(item.updatedAt);
|
||||
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 isCommunity = !['MEMBER', 'OWNER', 'COLLABORATOR'].includes(item.authorAssociation);
|
||||
|
||||
if (isMaintainerOnly) continue; // Maintainer issues have their own lifecycle
|
||||
|
||||
// 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;
|
||||
if (item.mergeable === 'MERGEABLE' && ciState === 'SUCCESS' && item.reviewDecision === 'REVIEW_REQUIRED') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Special case: Persistent conflicts
|
||||
if (item.labels.nodes.some(l => l.name === 'status/merge-conflict') && daysSinceUpdate > 14) {
|
||||
actions.push({
|
||||
number: item.number,
|
||||
target: 'pr',
|
||||
type: 'close',
|
||||
comment: `This PR has had merge conflicts for over 14 days without resolution. Closing it for now to keep the queue manageable. Please feel free to reopen once conflicts are resolved.`
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (isStale) {
|
||||
if (daysSinceUpdate > 14) {
|
||||
actions.push({
|
||||
number: item.number,
|
||||
target: item.type,
|
||||
type: 'close',
|
||||
comment: `This ${item.type} has been marked as stale for 14 days with no further activity. Closing it for now. If this is still relevant, please feel free to reopen with additional information.`
|
||||
});
|
||||
}
|
||||
} else if (daysSinceUpdate > 30 && isCommunity) {
|
||||
actions.push({
|
||||
number: item.number,
|
||||
target: item.type,
|
||||
type: 'label',
|
||||
label: 'Stale',
|
||||
comment: `This ${item.type} has been inactive for 30 days. We are labeling it as stale. If no further activity occurs within 14 days, it will be closed. Thank you for your contributions!`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Execute actions
|
||||
const issueSimulationUpdates = new Map<string, Record<string, string>>();
|
||||
const prSimulationUpdates = new Map<string, Record<string, string>>();
|
||||
|
||||
for (const action of actions) {
|
||||
try {
|
||||
const cmdPrefix = action.target === 'pr' ? 'pr' : 'issue';
|
||||
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' });
|
||||
}
|
||||
if (action.comment) {
|
||||
execGh(`${cmdPrefix} comment ${action.number} --body "${action.comment}"`, EXECUTE_ACTIONS);
|
||||
}
|
||||
if (action.type === 'close') {
|
||||
execGh(`${cmdPrefix} close ${action.number}`, EXECUTE_ACTIONS);
|
||||
simulationMap.set(action.number.toString(), { state: 'CLOSED' });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to process ${action.target} #${action.number}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Update simulations
|
||||
await updateSimulationCsv('issues-after.csv', issueSimulationUpdates);
|
||||
await updateSimulationCsv('prs-after.csv', prSimulationUpdates);
|
||||
|
||||
console.log(`Processed ${actions.length} stale issues/actions.`);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error in Stale Manager:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { getMaintainers, execGh, getRepoInfo, updateSimulationCsv } from './utils.js';
|
||||
|
||||
const EXECUTE_ACTIONS = process.env.EXECUTE_ACTIONS === 'true';
|
||||
|
||||
async function run() {
|
||||
const { owner, repo } = getRepoInfo();
|
||||
console.log(`Triage Router starting for ${owner}/${repo}... (EXECUTE_ACTIONS=${EXECUTE_ACTIONS})`);
|
||||
|
||||
try {
|
||||
const MAINTAINERS = await getMaintainers();
|
||||
console.log(`Fetched ${MAINTAINERS.length} maintainers.`);
|
||||
|
||||
// 1. Fetch untriaged issues
|
||||
const query = `
|
||||
query($owner: String!, $repo: String!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
issues(first: 100, states: OPEN, labels: ["status/need-triage"]) {
|
||||
nodes {
|
||||
number
|
||||
title
|
||||
body
|
||||
author { login }
|
||||
assignees(first: 1) { nodes { login } }
|
||||
labels(first: 20) { nodes { name } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
let output;
|
||||
try {
|
||||
output = execSync(`gh api graphql -F owner=${owner} -F repo=${repo} -f query='${query}'`, { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch untriaged issues from GitHub:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const data = JSON.parse(output).data.repository;
|
||||
const issues = data.issues.nodes;
|
||||
|
||||
const actions = [];
|
||||
let maintainerIndex = Math.floor(Math.random() * MAINTAINERS.length);
|
||||
|
||||
for (const issue of issues) {
|
||||
if (issue.assignees.nodes.length > 0) continue;
|
||||
|
||||
const body = issue.body || '';
|
||||
const title = issue.title || '';
|
||||
|
||||
// Low quality check
|
||||
if (body.length < 50 || title.length < 10 || !body.includes('###')) {
|
||||
actions.push({
|
||||
number: issue.number,
|
||||
type: 'needs-info',
|
||||
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++;
|
||||
|
||||
actions.push({
|
||||
number: issue.number,
|
||||
type: 'assign',
|
||||
assignee,
|
||||
comment: `Automated Triage: Assigning to @${assignee} for initial review. Please categorize and set priority.`
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Execute actions
|
||||
const simulationUpdates = new Map<string, Record<string, string>>();
|
||||
|
||||
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' });
|
||||
}
|
||||
|
||||
if (action.comment) {
|
||||
execGh(`issue comment ${action.number} --body "${action.comment}"`, EXECUTE_ACTIONS);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to process issue #${action.number}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Update simulation
|
||||
await updateSimulationCsv('issues-after.csv', simulationUpdates);
|
||||
|
||||
console.log(`Processed ${actions.length} issues.`);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error in Triage Router:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Fetches the current repository owner and name.
|
||||
*/
|
||||
export function getRepoInfo(): { owner: string; repo: string } {
|
||||
try {
|
||||
const output = execSync('gh repo view --json owner,name', { encoding: 'utf-8' });
|
||||
const data = JSON.parse(output);
|
||||
return {
|
||||
owner: data.owner.login,
|
||||
repo: data.name,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Error fetching repo info, falling back to default:', err);
|
||||
return { owner: 'google-gemini', repo: 'gemini-cli' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches maintainers from CODEOWNERS file.
|
||||
*/
|
||||
export async function getMaintainers(): Promise<string[]> {
|
||||
try {
|
||||
const codeownersPath = path.join(process.cwd(), '.github', 'CODEOWNERS');
|
||||
const content = await fs.readFile(codeownersPath, 'utf8');
|
||||
const maintainers = new Set<string>();
|
||||
|
||||
const lines = content.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('#') || !line.trim()) continue;
|
||||
// Match @user or @org/team
|
||||
const matches = line.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.
|
||||
continue;
|
||||
}
|
||||
maintainers.add(match.replace('@', ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (maintainers.size === 0) {
|
||||
return ['gundermanc', 'jackwotherspoon', 'DavidAPierce'];
|
||||
}
|
||||
|
||||
return Array.from(maintainers);
|
||||
} catch (err) {
|
||||
console.warn('CODEOWNERS not found or unreadable, using fallback maintainers.');
|
||||
return ['gundermanc', 'jackwotherspoon', 'DavidAPierce'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely updates a CSV file by modifying specific columns for certain rows.
|
||||
*/
|
||||
export async function updateSimulationCsv(
|
||||
filename: 'issues-after.csv' | 'prs-after.csv',
|
||||
updates: Map<string, Record<string, string>>
|
||||
) {
|
||||
if (updates.size === 0) return;
|
||||
|
||||
const filePath = path.join(process.cwd(), filename);
|
||||
const beforeFilePath = path.join(process.cwd(), filename.replace('-after', '-before'));
|
||||
|
||||
let content = '';
|
||||
try {
|
||||
content = await fs.readFile(filePath, 'utf8');
|
||||
} catch {
|
||||
try {
|
||||
content = await fs.readFile(beforeFilePath, 'utf8');
|
||||
} catch {
|
||||
console.error(`Could not find ${filename} or ${beforeFilePath}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const lines = content.split('\n');
|
||||
if (lines.length === 0) return;
|
||||
|
||||
const header = lines[0].split(',');
|
||||
const numberIndex = header.indexOf('number');
|
||||
if (numberIndex === -1) {
|
||||
console.error(`CSV ${filename} missing 'number' column`);
|
||||
return;
|
||||
}
|
||||
|
||||
const newLines = [lines[0]];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const rawLine = lines[i].trim();
|
||||
if (!rawLine) continue;
|
||||
|
||||
// Split by comma but respect quotes
|
||||
const columns: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
for (let charIndex = 0; charIndex < rawLine.length; charIndex++) {
|
||||
const char = rawLine[charIndex];
|
||||
if (char === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
current += char;
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
columns.push(current);
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
columns.push(current);
|
||||
|
||||
const number = columns[numberIndex]?.replace(/"/g, '');
|
||||
|
||||
if (updates.has(number)) {
|
||||
const update = updates.get(number)!;
|
||||
for (const [colName, newValue] of Object.entries(update)) {
|
||||
const colIndex = header.indexOf(colName);
|
||||
if (colIndex !== -1) {
|
||||
columns[colIndex] = newValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
newLines.push(columns.join(','));
|
||||
}
|
||||
|
||||
await fs.writeFile(filePath, newLines.join('\n'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a gh command with logging and dry-run support.
|
||||
*/
|
||||
export function execGh(command: string, execute: boolean) {
|
||||
if (!execute) {
|
||||
console.log(`[DRY RUN] Would execute: gh ${command}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Executing: gh ${command}`);
|
||||
try {
|
||||
execSync(`gh ${command}`, { stdio: 'inherit' });
|
||||
} catch (err) {
|
||||
console.error(`Failed to execute gh ${command}:`, err);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user