Download .github/scripts/sync-maintainer-labels.cjs from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 11.1 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/.github/scripts/sync-maintainer-labels.cjs
- Command line
-
hf download hf://SaylorTwift/gemini-cli/.github/scripts/sync-maintainer-labels.cjs
-
curl -L -o sync-maintainer-labels.cjs https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/.github/scripts/sync-maintainer-labels.cjs
11.1 kB
| /** | |
| * @license | |
| * Copyright 2026 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| const { Octokit } = require('@octokit/rest'); | |
| /** | |
| * Sync Maintainer Labels (Recursive with strict parent-child relationship detection) | |
| * - Uses Native Sub-issues. | |
| * - Uses Markdown Task Lists (- [ ] #123). | |
| * - Filters for OPEN issues only. | |
| * - Skips DUPLICATES. | |
| * - Skips Pull Requests. | |
| * - ONLY labels issues in the PUBLIC (gemini-cli) repo. | |
| */ | |
| 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, | |
| }); | |
| /** | |
| * Extracts child issue references from markdown Task Lists ONLY. | |
| * e.g. - [ ] #123 or - [x] google-gemini/gemini-cli#123 | |
| */ | |
| 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) }); | |
| } | |
| }; | |
| // 1. Full URLs in task lists | |
| 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]); | |
| } | |
| // 2. Cross-repo refs in task lists: owner/repo#123 | |
| 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]); | |
| } | |
| // 3. Short refs in task lists: #123 | |
| const shortRefRegex = /-\s+\[[ x]\].*#(\d+)\b/g; | |
| while ((match = shortRefRegex.exec(text)) !== null) { | |
| add(contextOwner, contextRepo, match[1]); | |
| } | |
| return Array.from(childIssues.values()); | |
| } | |
| /** | |
| * Fetches issue data via GraphQL with full pagination for sub-issues, comments, and labels. | |
| */ | |
| 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), | |
| }; | |
| // Paginate subIssues if there are more than 100 | |
| 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); | |
| } | |
| // Paginate labels if there are more than 100 (unlikely but for completeness) | |
| 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); | |
| } | |
| // Note: Comments are handled via Task Lists in body + first 100 comments. | |
| // If an issue has > 100 comments with task lists, we'd need to paginate those too. | |
| // Given the 1,100+ issue discovery count, 100 comments is usually sufficient, | |
| // but we can add it for absolute completeness. | |
| // (Skipping for now to avoid excessive API churn unless clearly needed). | |
| return issue; | |
| } catch (error) { | |
| if (error.errors && error.errors.some((e) => e.type === 'NOT_FOUND')) { | |
| return null; | |
| } | |
| throw error; | |
| } | |
| } | |
| /** | |
| * Helper to paginate any GraphQL connection. | |
| */ | |
| 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; | |
| } | |
| /** | |
| * Validates if an issue should be processed (Open, not a duplicate, not a PR) | |
| */ | |
| 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; | |
| } | |
| // ONLY add to labeling list if it's in the PUBLIC repository | |
| if (current.repo === PUBLIC_REPO) { | |
| // Don't label the roots themselves | |
| 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(); | |
| // 1. Process Native Sub-issues | |
| 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, | |
| }); | |
| } | |
| } | |
| // 2. Process Markdown Task Lists in Body and Comments | |
| 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); | |
| } | |
| // Queue children (regardless of which repo they are in, for recursion) | |
| 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 { | |
| // Data is already available from the discovery phase | |
| 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], | |
| }); | |
| } | |
| } | |
| // Remove status/need-triage from maintainer-only issues since they | |
| // don't need community triage. We always attempt removal rather than | |
| // checking the (potentially stale) label snapshot, because the | |
| // issue-opened-labeler workflow runs concurrently and may add the | |
| // label after our snapshot was taken. | |
| 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) { | |
| // 404 means the label wasn't present — that's fine. | |
| 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); | |
| }); | |