mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-02-01 14:44:29 +00:00
chore(scripts): add duplicate issue closer script and fix lint errors (#16997)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
151
scripts/close_duplicate_issues.js
Normal file
151
scripts/close_duplicate_issues.js
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import yargs from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
import prompts from 'prompts';
|
||||
|
||||
if (!process.env.GITHUB_TOKEN) {
|
||||
console.error('Error: GITHUB_TOKEN environment variable is required.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const argv = yargs(hideBin(process.argv))
|
||||
.option('query', {
|
||||
alias: 'q',
|
||||
type: 'string',
|
||||
description:
|
||||
'Search query to find duplicate issues (e.g. "function response parts")',
|
||||
demandOption: true,
|
||||
})
|
||||
.option('canonical', {
|
||||
alias: 'c',
|
||||
type: 'number',
|
||||
description: 'The canonical issue number to duplicate others to',
|
||||
demandOption: true,
|
||||
})
|
||||
.option('pr', {
|
||||
type: 'string',
|
||||
description:
|
||||
'Optional Pull Request URL or ID to mention in the closing comment',
|
||||
})
|
||||
.option('owner', {
|
||||
type: 'string',
|
||||
default: 'google-gemini',
|
||||
description: 'Repository owner',
|
||||
})
|
||||
.option('repo', {
|
||||
type: 'string',
|
||||
default: 'gemini-cli',
|
||||
description: 'Repository name',
|
||||
})
|
||||
.option('dry-run', {
|
||||
alias: 'd',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Run without making actual changes (read-only mode)',
|
||||
})
|
||||
.option('auto', {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Automatically close all duplicates without prompting (batch mode)',
|
||||
})
|
||||
.help()
|
||||
.parse();
|
||||
|
||||
const octokit = new Octokit({
|
||||
auth: process.env.GITHUB_TOKEN,
|
||||
});
|
||||
|
||||
const { query, canonical, pr, owner, repo, dryRun, auto } = argv;
|
||||
|
||||
// Construct the full search query ensuring it targets the specific repo and open issues
|
||||
const fullSearchQuery = `repo:${owner}/${repo} is:issue is:open ${query}`;
|
||||
|
||||
async function run() {
|
||||
console.log(`Searching for issues matching: ${fullSearchQuery}`);
|
||||
if (dryRun) {
|
||||
console.log('--- DRY RUN MODE: No changes will be made ---');
|
||||
}
|
||||
|
||||
try {
|
||||
const issues = await octokit.paginate(
|
||||
octokit.rest.search.issuesAndPullRequests,
|
||||
{
|
||||
q: fullSearchQuery,
|
||||
},
|
||||
);
|
||||
|
||||
console.log(`Found ${issues.length} issues.`);
|
||||
|
||||
for (const issue of issues) {
|
||||
if (issue.number === canonical) {
|
||||
console.log(`Skipping canonical issue #${issue.number}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Processing issue #${issue.number}: ${issue.title} (by @${issue.user?.login})`,
|
||||
);
|
||||
|
||||
if (!auto && !dryRun) {
|
||||
const response = await prompts({
|
||||
type: 'confirm',
|
||||
name: 'value',
|
||||
message: `Close issue #${issue.number} "${issue.title}" created by @${issue.user?.login}?`,
|
||||
initial: true,
|
||||
});
|
||||
|
||||
if (!response.value) {
|
||||
console.log(`Skipping issue #${issue.number}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let commentBody = `Closing this issue as a duplicate of #${canonical}.`;
|
||||
if (pr) {
|
||||
commentBody += ` Please note that this issue should be resolved by PR ${pr}.`;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!dryRun) {
|
||||
// Add comment
|
||||
await octokit.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
body: commentBody,
|
||||
});
|
||||
console.log(` Added comment.`);
|
||||
|
||||
// Close issue
|
||||
await octokit.rest.issues.update({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
state: 'closed',
|
||||
state_reason: 'duplicate',
|
||||
});
|
||||
console.log(` Closed issue.`);
|
||||
} else {
|
||||
console.log(` [DRY RUN] Would add comment: "${commentBody}"`);
|
||||
console.log(` [DRY RUN] Would close issue #${issue.number}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
` Failed to process issue #${issue.number}:`,
|
||||
error.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error searching for issues:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
251
scripts/sync_project_dry_run.js
Normal file
251
scripts/sync_project_dry_run.js
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
const PROJECT_ID = 36;
|
||||
const ORG = 'google-gemini';
|
||||
const REPO = 'google-gemini/gemini-cli';
|
||||
const MAINTAINERS_REPO = 'google-gemini/maintainers-gemini-cli';
|
||||
|
||||
// Parent issues to recursively traverse
|
||||
const PARENT_ISSUES = [15374, 15456, 15324];
|
||||
|
||||
// Labels to Exclude
|
||||
const EXCLUDED_LABELS = [
|
||||
'help wanted',
|
||||
'status/need-triage',
|
||||
'status/need-info',
|
||||
'area/unknown',
|
||||
];
|
||||
|
||||
// Labels that force inclusion (override exclusions)
|
||||
const FORCE_INCLUDE_LABELS = ['🔒 maintainer only'];
|
||||
|
||||
function runCommand(command) {
|
||||
try {
|
||||
return execSync(command, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getIssues(repo) {
|
||||
console.log(`Fetching open issues from ${repo}...`);
|
||||
const json = runCommand(
|
||||
`gh issue list --repo ${repo} --state open --limit 3000 --json number,title,url,labels`,
|
||||
);
|
||||
if (!json) {
|
||||
return [];
|
||||
}
|
||||
return JSON.parse(json);
|
||||
}
|
||||
|
||||
function getIssueBody(repo, number) {
|
||||
const json = runCommand(
|
||||
`gh issue view ${number} --repo ${repo} --json body,title,url,number`,
|
||||
);
|
||||
if (!json) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(json);
|
||||
}
|
||||
|
||||
function getProjectItems() {
|
||||
console.log(`Fetching items from Project ${PROJECT_ID}...`);
|
||||
const json = runCommand(
|
||||
`gh project item-list ${PROJECT_ID} --owner ${ORG} --format json --limit 3000`,
|
||||
);
|
||||
if (!json) {
|
||||
return [];
|
||||
}
|
||||
return JSON.parse(json).items;
|
||||
}
|
||||
|
||||
function shouldInclude(issue) {
|
||||
const labels = issue.labels.map((l) => l.name);
|
||||
|
||||
// Check Force Include first
|
||||
if (labels.some((l) => FORCE_INCLUDE_LABELS.includes(l))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check Exclude
|
||||
if (labels.some((l) => EXCLUDED_LABELS.includes(l))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Recursive function to find children
|
||||
const visitedParents = new Set();
|
||||
async function findChildren(repo, number, depth = 0) {
|
||||
const key = `${repo}/${number}`;
|
||||
if (visitedParents.has(key) || depth > 3) {
|
||||
return []; // Avoid cycles and too deep
|
||||
}
|
||||
visitedParents.add(key);
|
||||
|
||||
process.stdout.write('.'); // progress indicator
|
||||
const issue = getIssueBody(repo, number);
|
||||
if (!issue) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const children = [];
|
||||
const body = issue.body || '';
|
||||
|
||||
// Regex to find #1234 (local repo) and https://github.com/.../issues/1234 (cross repo)
|
||||
// 1. Local references: #1234
|
||||
const localMatches = [
|
||||
...body.matchAll(/(?<!issue\s)(?<!issues\/)(?<!pull\/)(?<!#)#(\d+)/g),
|
||||
];
|
||||
for (const match of localMatches) {
|
||||
children.push({ repo, number: parseInt(match[1]) });
|
||||
}
|
||||
|
||||
// 2. Full URL references
|
||||
const urlMatches = [
|
||||
...body.matchAll(/https:\/\/github\.com\/([^/]+\/[^/]+)\/issues\/(\d+)/g),
|
||||
];
|
||||
for (const match of urlMatches) {
|
||||
children.push({ repo: match[1], number: parseInt(match[2]) });
|
||||
}
|
||||
|
||||
// Recursively find children of these children
|
||||
const allDescendants = [];
|
||||
for (const child of children) {
|
||||
// Only recurse if it's one of our interesting repos
|
||||
if (child.repo !== REPO && child.repo !== MAINTAINERS_REPO) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fetch details
|
||||
const childDetails = getIssueBody(child.repo, child.number);
|
||||
if (childDetails) {
|
||||
allDescendants.push({ ...childDetails, repo: child.repo });
|
||||
|
||||
// Recurse
|
||||
const grandChildren = await findChildren(
|
||||
child.repo,
|
||||
child.number,
|
||||
depth + 1,
|
||||
);
|
||||
allDescendants.push(...grandChildren);
|
||||
}
|
||||
}
|
||||
|
||||
return allDescendants;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const issues = getIssues(REPO);
|
||||
const currentItems = getProjectItems();
|
||||
|
||||
console.log(`
|
||||
Total Open Gemini Issues: ${issues.length}`);
|
||||
console.log(`Total Current Project Items: ${currentItems.length}`);
|
||||
|
||||
const currentUrlMap = new Set(currentItems.map((i) => i.content.url));
|
||||
const toAddMap = new Map();
|
||||
const allowedUrls = new Set(); // URLs that are safe to stay/be added
|
||||
|
||||
// 1. Label Logic
|
||||
for (const issue of issues) {
|
||||
if (shouldInclude(issue)) {
|
||||
allowedUrls.add(issue.url);
|
||||
if (!currentUrlMap.has(issue.url)) {
|
||||
toAddMap.set(issue.url, issue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Hierarchy Logic
|
||||
console.log('\n--- SCANNING HIERARCHY ---');
|
||||
console.log(`Fetching recursive children of: ${PARENT_ISSUES.join(', ')}`);
|
||||
for (const parentId of PARENT_ISSUES) {
|
||||
const descendants = await findChildren(REPO, parentId);
|
||||
for (const item of descendants) {
|
||||
if (item.repo === REPO || item.repo === MAINTAINERS_REPO) {
|
||||
allowedUrls.add(item.url); // Mark as allowed
|
||||
if (!currentUrlMap.has(item.url) && !toAddMap.has(item.url)) {
|
||||
toAddMap.set(item.url, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('\nScanning complete.');
|
||||
|
||||
// 3. Removal Logic
|
||||
const toRemove = [];
|
||||
for (const item of currentItems) {
|
||||
// Protect Maintainers Repo
|
||||
if (
|
||||
item.content.repository === MAINTAINERS_REPO ||
|
||||
(item.content.url && item.content.url.includes('maintainers-gemini-cli'))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If not allowed by Labels OR Hierarchy, remove
|
||||
if (!allowedUrls.has(item.content.url)) {
|
||||
toRemove.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
const toAdd = Array.from(toAddMap.values());
|
||||
|
||||
console.log('\n--- ANALYSIS ---');
|
||||
console.log(`Items to ADD: ${toAdd.length}`);
|
||||
console.log(`Items to REMOVE: ${toRemove.length}`);
|
||||
|
||||
if (toAdd.length > 0) {
|
||||
console.log('\n--- EXAMPLES TO ADD ---');
|
||||
toAdd
|
||||
.slice(0, 5)
|
||||
.forEach((i) => console.log(`[+] #${i.number} ${i.title}`));
|
||||
}
|
||||
|
||||
if (toRemove.length > 0) {
|
||||
console.log('\n--- EXAMPLES TO REMOVE ---');
|
||||
toRemove
|
||||
.slice(0, 5)
|
||||
.forEach((i) => console.log(`[-] ${i.content.title} (${i.status})`));
|
||||
}
|
||||
|
||||
if (process.argv.includes('--execute')) {
|
||||
console.log('\n--- EXECUTING CHANGES ---');
|
||||
|
||||
for (const issue of toAdd) {
|
||||
process.stdout.write(`Adding ${issue.url}... `);
|
||||
const res = runCommand(
|
||||
`gh project item-add ${PROJECT_ID} --owner ${ORG} --url "${issue.url}" --format json`,
|
||||
);
|
||||
process.stdout.write(res ? 'OK\n' : 'FAILED\n');
|
||||
}
|
||||
|
||||
for (const item of toRemove) {
|
||||
process.stdout.write(`Removing ${item.id}... `);
|
||||
const res = runCommand(
|
||||
`gh project item-delete ${PROJECT_ID} --owner ${ORG} --id ${item.id}`,
|
||||
);
|
||||
process.stdout.write(res ? 'OK\n' : 'FAILED\n');
|
||||
}
|
||||
console.log('Done.');
|
||||
} else {
|
||||
console.log('\nRun with --execute to apply.');
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user