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:
Bryan Morgan
2026-01-19 09:23:08 -05:00
committed by GitHub
parent d87a3acdef
commit d079b7a216
2 changed files with 402 additions and 0 deletions

View 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);

View 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);
});