mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-02-01 22:48:03 +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