From d079b7a216f86f489c51174fc29c5909a10201c0 Mon Sep 17 00:00:00 2001 From: Bryan Morgan Date: Mon, 19 Jan 2026 09:23:08 -0500 Subject: [PATCH] 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> --- scripts/close_duplicate_issues.js | 151 ++++++++++++++++++ scripts/sync_project_dry_run.js | 251 ++++++++++++++++++++++++++++++ 2 files changed, 402 insertions(+) create mode 100644 scripts/close_duplicate_issues.js create mode 100644 scripts/sync_project_dry_run.js diff --git a/scripts/close_duplicate_issues.js b/scripts/close_duplicate_issues.js new file mode 100644 index 0000000000..087ec59b4c --- /dev/null +++ b/scripts/close_duplicate_issues.js @@ -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); diff --git a/scripts/sync_project_dry_run.js b/scripts/sync_project_dry_run.js new file mode 100644 index 0000000000..47afd2e755 --- /dev/null +++ b/scripts/sync_project_dry_run.js @@ -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(/(? 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); +});