gemini-cli/scripts/sync_project_dry_run.js

252 lines
6.5 KiB
JavaScript

/**
* @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);
});