| |
| |
| |
| |
| |
|
|
| const { Octokit } = require('@octokit/rest'); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const REPO_OWNER = 'google-gemini'; |
| const PUBLIC_REPO = 'gemini-cli'; |
| const PRIVATE_REPO = 'maintainers-gemini-cli'; |
| const ALLOWED_REPOS = [PUBLIC_REPO, PRIVATE_REPO]; |
|
|
| const ROOT_ISSUES = [ |
| { owner: REPO_OWNER, repo: PUBLIC_REPO, number: 15374 }, |
| { owner: REPO_OWNER, repo: PUBLIC_REPO, number: 15456 }, |
| { owner: REPO_OWNER, repo: PUBLIC_REPO, number: 15324 }, |
| ]; |
|
|
| const TARGET_LABEL = '🔒 maintainer only'; |
| const isDryRun = |
| process.argv.includes('--dry-run') || process.env.DRY_RUN === 'true'; |
|
|
| const octokit = new Octokit({ |
| auth: process.env.GITHUB_TOKEN, |
| }); |
|
|
| |
| |
| |
| |
| function extractTaskListLinks(text, contextOwner, contextRepo) { |
| if (!text) return []; |
| const childIssues = new Map(); |
|
|
| const add = (owner, repo, number) => { |
| if (ALLOWED_REPOS.includes(repo)) { |
| const key = `${owner}/${repo}#${number}`; |
| childIssues.set(key, { owner, repo, number: parseInt(number, 10) }); |
| } |
| }; |
|
|
| |
| const urlRegex = |
| /-\s+\[[ x]\].*https:\/\/github\.com\/([a-zA-Z0-9._-]+)\/([a-zA-Z0-9._-]+)\/issues\/(\d+)\b/g; |
| let match; |
| while ((match = urlRegex.exec(text)) !== null) { |
| add(match[1], match[2], match[3]); |
| } |
|
|
| |
| const crossRepoRegex = |
| /-\s+\[[ x]\].*([a-zA-Z0-9._-]+)\/([a-zA-Z0-9._-]+)#(\d+)\b/g; |
| while ((match = crossRepoRegex.exec(text)) !== null) { |
| add(match[1], match[2], match[3]); |
| } |
|
|
| |
| const shortRefRegex = /-\s+\[[ x]\].*#(\d+)\b/g; |
| while ((match = shortRefRegex.exec(text)) !== null) { |
| add(contextOwner, contextRepo, match[1]); |
| } |
|
|
| return Array.from(childIssues.values()); |
| } |
|
|
| |
| |
| |
| async function fetchIssueData(owner, repo, number) { |
| const query = ` |
| query($owner:String!, $repo:String!, $number:Int!) { |
| repository(owner:$owner, name:$repo) { |
| issue(number:$number) { |
| state |
| title |
| body |
| labels(first: 100) { |
| nodes { name } |
| pageInfo { hasNextPage endCursor } |
| } |
| subIssues(first: 100) { |
| nodes { |
| number |
| repository { |
| name |
| owner { login } |
| } |
| } |
| pageInfo { hasNextPage endCursor } |
| } |
| comments(first: 100) { |
| nodes { |
| body |
| } |
| } |
| } |
| } |
| } |
| `; |
|
|
| try { |
| const response = await octokit.graphql(query, { owner, repo, number }); |
| const data = response.repository.issue; |
| if (!data) return null; |
|
|
| const issue = { |
| state: data.state, |
| title: data.title, |
| body: data.body || '', |
| labels: data.labels.nodes.map((n) => n.name), |
| subIssues: [...data.subIssues.nodes], |
| comments: data.comments.nodes.map((n) => n.body), |
| }; |
|
|
| |
| if (data.subIssues.pageInfo.hasNextPage) { |
| const moreSubIssues = await paginateConnection( |
| owner, |
| repo, |
| number, |
| 'subIssues', |
| 'number repository { name owner { login } }', |
| data.subIssues.pageInfo.endCursor, |
| ); |
| issue.subIssues.push(...moreSubIssues); |
| } |
|
|
| |
| if (data.labels.pageInfo.hasNextPage) { |
| const moreLabels = await paginateConnection( |
| owner, |
| repo, |
| number, |
| 'labels', |
| 'name', |
| data.labels.pageInfo.endCursor, |
| (n) => n.name, |
| ); |
| issue.labels.push(...moreLabels); |
| } |
|
|
| |
| |
| |
| |
| |
|
|
| return issue; |
| } catch (error) { |
| if (error.errors && error.errors.some((e) => e.type === 'NOT_FOUND')) { |
| return null; |
| } |
| throw error; |
| } |
| } |
|
|
| |
| |
| |
| async function paginateConnection( |
| owner, |
| repo, |
| number, |
| connectionName, |
| nodeFields, |
| initialCursor, |
| transformNode = (n) => n, |
| ) { |
| let additionalNodes = []; |
| let hasNext = true; |
| let cursor = initialCursor; |
|
|
| while (hasNext) { |
| const query = ` |
| query($owner:String!, $repo:String!, $number:Int!, $cursor:String) { |
| repository(owner:$owner, name:$repo) { |
| issue(number:$number) { |
| ${connectionName}(first: 100, after: $cursor) { |
| nodes { ${nodeFields} } |
| pageInfo { hasNextPage endCursor } |
| } |
| } |
| } |
| } |
| `; |
| const response = await octokit.graphql(query, { |
| owner, |
| repo, |
| number, |
| cursor, |
| }); |
| const connection = response.repository.issue[connectionName]; |
| additionalNodes.push(...connection.nodes.map(transformNode)); |
| hasNext = connection.pageInfo.hasNextPage; |
| cursor = connection.pageInfo.endCursor; |
| } |
| return additionalNodes; |
| } |
|
|
| |
| |
| |
| function shouldProcess(issueData) { |
| if (!issueData) return false; |
|
|
| if (issueData.state !== 'OPEN') return false; |
|
|
| const labels = issueData.labels.map((l) => l.toLowerCase()); |
| if (labels.includes('duplicate') || labels.includes('kind/duplicate')) { |
| return false; |
| } |
|
|
| return true; |
| } |
|
|
| async function getAllDescendants(roots) { |
| const allDescendants = new Map(); |
| const visited = new Set(); |
| const queue = [...roots]; |
|
|
| for (const root of roots) { |
| visited.add(`${root.owner}/${root.repo}#${root.number}`); |
| } |
|
|
| console.log(`Starting discovery from ${roots.length} roots...`); |
|
|
| while (queue.length > 0) { |
| const current = queue.shift(); |
| const currentKey = `${current.owner}/${current.repo}#${current.number}`; |
|
|
| try { |
| const issueData = await fetchIssueData( |
| current.owner, |
| current.repo, |
| current.number, |
| ); |
|
|
| if (!shouldProcess(issueData)) { |
| continue; |
| } |
|
|
| |
| if (current.repo === PUBLIC_REPO) { |
| |
| if ( |
| !ROOT_ISSUES.some( |
| (r) => r.number === current.number && r.repo === current.repo, |
| ) |
| ) { |
| allDescendants.set(currentKey, { |
| ...current, |
| title: issueData.title, |
| labels: issueData.labels, |
| }); |
| } |
| } |
|
|
| const children = new Map(); |
|
|
| |
| if (issueData.subIssues) { |
| for (const node of issueData.subIssues) { |
| const childOwner = node.repository.owner.login; |
| const childRepo = node.repository.name; |
| const childNumber = node.number; |
| const key = `${childOwner}/${childRepo}#${childNumber}`; |
| children.set(key, { |
| owner: childOwner, |
| repo: childRepo, |
| number: childNumber, |
| }); |
| } |
| } |
|
|
| |
| let combinedText = issueData.body || ''; |
| if (issueData.comments) { |
| for (const commentBody of issueData.comments) { |
| combinedText += '\n' + (commentBody || ''); |
| } |
| } |
|
|
| const taskListLinks = extractTaskListLinks( |
| combinedText, |
| current.owner, |
| current.repo, |
| ); |
| for (const link of taskListLinks) { |
| const key = `${link.owner}/${link.repo}#${link.number}`; |
| children.set(key, link); |
| } |
|
|
| |
| for (const [key, child] of children) { |
| if (!visited.has(key)) { |
| visited.add(key); |
| queue.push(child); |
| } |
| } |
| } catch (error) { |
| console.error(`Error processing ${currentKey}: ${error.message}`); |
| } |
| } |
|
|
| return Array.from(allDescendants.values()); |
| } |
|
|
| async function run() { |
| if (isDryRun) { |
| console.log('=== DRY RUN MODE: No labels will be applied ==='); |
| } |
|
|
| const descendants = await getAllDescendants(ROOT_ISSUES); |
| console.log( |
| `\nFound ${descendants.length} total unique open descendant issues in ${PUBLIC_REPO}.`, |
| ); |
|
|
| for (const issueInfo of descendants) { |
| const issueKey = `${issueInfo.owner}/${issueInfo.repo}#${issueInfo.number}`; |
| try { |
| |
| const hasLabel = issueInfo.labels.some((l) => l === TARGET_LABEL); |
|
|
| if (!hasLabel) { |
| if (isDryRun) { |
| console.log( |
| `[DRY RUN] Would label ${issueKey}: "${issueInfo.title}"`, |
| ); |
| } else { |
| console.log(`Labeling ${issueKey}: "${issueInfo.title}"...`); |
| await octokit.rest.issues.addLabels({ |
| owner: issueInfo.owner, |
| repo: issueInfo.repo, |
| issue_number: issueInfo.number, |
| labels: [TARGET_LABEL], |
| }); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| if (isDryRun) { |
| console.log( |
| `[DRY RUN] Would remove status/need-triage from ${issueKey}`, |
| ); |
| } else { |
| try { |
| await octokit.rest.issues.removeLabel({ |
| owner: issueInfo.owner, |
| repo: issueInfo.repo, |
| issue_number: issueInfo.number, |
| name: 'status/need-triage', |
| }); |
| console.log(`Removed status/need-triage from ${issueKey}`); |
| } catch (removeError) { |
| |
| if (removeError.status === 404) { |
| console.log( |
| `status/need-triage not present on ${issueKey}, skipping.`, |
| ); |
| } else { |
| throw removeError; |
| } |
| } |
| } |
| } catch (error) { |
| console.error(`Error processing label for ${issueKey}: ${error.message}`); |
| } |
| } |
| } |
|
|
| run().catch((error) => { |
| console.error(error); |
| process.exit(1); |
| }); |
|
|