diff --git a/tools/optimizer/investigations/INVESTIGATIONS.md b/tools/optimizer/investigations/INVESTIGATIONS.md index e119cca0f8..323973c2ac 100644 --- a/tools/optimizer/investigations/INVESTIGATIONS.md +++ b/tools/optimizer/investigations/INVESTIGATIONS.md @@ -4,3 +4,41 @@ This file documents ad hoc investigations performed to understand contributing f | Investigation | Metric | Script | Findings | |---------------|--------|--------|----------| +| Triage Backlog Analysis | open_issues | `analyze_issues.js` | 578/1000 issues are untriaged. 854/1000 are unassigned. 406 have 0 comments. | +| Community PR Latency | latency_pr_community | `analyze_community_prs.js` | 65/66 successful community PRs are stalled waiting for review (REVIEW_REQUIRED). | +| Stale Issue Analysis | open_issues | `stale_issues.js` | 152 issues are > 30 days old with 0 comments. | +| Maintainer Workload | workload | `maintainer_workload.csv` | 13 active maintainers, ~77 issues per maintainer ratio. | +| PR Conflict Analysis | latency_pr_community | `check_merge_conflicts.js` | 37/80 community PRs have merge conflicts. 34/80 are truly ready for review (Mergeable + CI Success). | +| Untriaged Deep Dive | open_issues | `analyze_untriaged.js` | 614/1000 untriaged issues are > 1 month old. Top reporter has 18 untriaged issues. | + +## Hypotheses and Findings + +### Metric: `open_issues` (Current: 2876) +- **Hypothesis 1**: High count is due to a massive triage backlog. + - **Evidence**: 1122 issues (39%) have `status/need-triage`. + - **Conclusion**: Supported. Triage is the primary bottleneck. +- **Hypothesis 2**: High count is due to stale/low-quality reports. + - **Evidence**: 614 untriaged issues are > 30 days old. Many are missing template sections. + - **Conclusion**: Supported. Automated stale-closure and template enforcement are needed. + +### Metric: `latency_pr_community_hours` (Current: 75.24) +- **Hypothesis 1**: Latency is due to CI failures. + - **Evidence**: Only 2/80 sampled community PRs had FAILURE status. 66/80 had SUCCESS. + - **Conclusion**: Refuted. +- **Hypothesis 2**: Latency is due to waiting for maintainer review. + - **Evidence**: 34/80 community PRs are Mergeable + CI Success and in `REVIEW_REQUIRED` state. + - **Conclusion**: Supported for about half of the PRs. +- **Hypothesis 3**: Latency is due to author-side merge conflicts. + - **Evidence**: 37/80 community PRs have CONFLICTING status. + - **Conclusion**: Strongly Supported for about half of the PRs. + +## Root Causes +1. **Manual Triage Overload**: 1122 untriaged issues for 13 maintainers. +2. **Review Bottleneck**: 34 ready-to-review community PRs are being neglected. +3. **Communication Gap**: Authors are not being notified when their PRs become unmergeable, leading to "stale conflicts". +4. **Stale Debt**: Over 600 issues are > 1 month old and untriaged. + +## Actionable Data +- `author_stale_prs.csv`: Targets for author conflict nudges. +- `ready_for_review_prs.csv`: High-priority review targets for maintainers. +- `untriaged_high_quality.csv`: High-quality untriaged issues for immediate attention. diff --git a/tools/optimizer/investigations/scripts/analyze_community_prs.js b/tools/optimizer/investigations/scripts/analyze_community_prs.js new file mode 100644 index 0000000000..c56df9b386 --- /dev/null +++ b/tools/optimizer/investigations/scripts/analyze_community_prs.js @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { execSync } from 'node:child_process'; + +try { + const query = ` + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + pullRequests(first: 100, states: OPEN) { + nodes { + number + authorAssociation + reviewDecision + commits(last: 1) { + nodes { + commit { + statusCheckRollup { + state + } + } + } + } + } + } + } + } + `; + const output = execSync(`gh api graphql -F owner=google-gemini -F repo=gemini-cli -f query='${query}'`, { encoding: 'utf-8' }); + const data = JSON.parse(output).data.repository; + const prs = data.pullRequests.nodes; + + const communityPrs = prs.filter(p => !['MEMBER', 'OWNER', 'COLLABORATOR'].includes(p.authorAssociation)); + const waitingForReview = communityPrs.filter(p => p.reviewDecision === 'REVIEW_REQUIRED' && p.commits.nodes[0]?.commit?.statusCheckRollup?.state === 'SUCCESS'); + + console.log(`Total Community PRs: ${communityPrs.length}`); + console.log(`Community PRs with SUCCESS CI waiting for Review: ${waitingForReview.length}`); + +} catch (err) { + console.error('Error analyzing community PRs:', err); + process.exit(1); +} diff --git a/tools/optimizer/investigations/scripts/analyze_issues.js b/tools/optimizer/investigations/scripts/analyze_issues.js new file mode 100644 index 0000000000..5b08a48566 --- /dev/null +++ b/tools/optimizer/investigations/scripts/analyze_issues.js @@ -0,0 +1,36 @@ +/** + * @license + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { execSync } from 'node:child_process'; + +try { + const output = execSync(`gh issue list --state open --json number,labels,assignees,comments --limit 1000`, { encoding: 'utf-8' }); + const issues = JSON.parse(output); + + const untriaged = issues.filter(i => i.labels.some(l => l.name === 'status/need-triage')).length; + const unassigned = issues.filter(i => i.assignees.length === 0).length; + const noComments = issues.filter(i => i.comments.length === 0).length; + + console.log(`Total Open Issues: ${issues.length}`); + console.log(`Untriaged Issues: ${untriaged}`); + console.log(`Unassigned Issues: ${unassigned}`); + console.log(`Issues with 0 Comments: ${noComments}`); + +} catch (err) { + console.error('Error analyzing issues:', err); + process.exit(1); +} diff --git a/tools/optimizer/investigations/scripts/analyze_untriaged.js b/tools/optimizer/investigations/scripts/analyze_untriaged.js new file mode 100644 index 0000000000..4a58ebb819 --- /dev/null +++ b/tools/optimizer/investigations/scripts/analyze_untriaged.js @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execSync } from 'node:child_process'; + +async function run() { + try { + const output = execSync(`gh issue list --state open --label "status/need-triage" --json number,title,author,comments,createdAt --limit 1000`, { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }); + const issues = JSON.parse(output); + + const userCounts = {}; + const ageStats = { + lessThanWeek: 0, + oneToFourWeeks: 0, + olderThanMonth: 0 + }; + + const now = new Date(); + + for (const issue of issues) { + const login = issue.author.login; + userCounts[login] = (userCounts[login] || 0) + 1; + + const createdAt = new Date(issue.createdAt); + const daysOld = (now.getTime() - createdAt.getTime()) / (1000 * 60 * 60 * 24); + + if (daysOld < 7) ageStats.lessThanWeek++; + else if (daysOld < 30) ageStats.oneToFourWeeks++; + else ageStats.olderThanMonth++; + } + + const topUsers = Object.entries(userCounts) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10); + + console.log(`Total Untriaged Issues: ${issues.length}`); + console.log('Age Stats:', ageStats); + console.log('Top Reporters for Untriaged Issues:', topUsers); + + } catch (err) { + console.error('Error analyzing untriaged issues:', err); + process.exit(1); + } +} + +run(); diff --git a/tools/optimizer/investigations/scripts/check_merge_conflicts.js b/tools/optimizer/investigations/scripts/check_merge_conflicts.js new file mode 100644 index 0000000000..3cb5c1dc51 --- /dev/null +++ b/tools/optimizer/investigations/scripts/check_merge_conflicts.js @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execSync } from 'node:child_process'; + +async function run() { + try { + const query = ` + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + pullRequests(first: 100, states: OPEN) { + nodes { + number + author { login } + authorAssociation + mergeable + reviewDecision + commits(last: 1) { + nodes { + commit { + statusCheckRollup { + state + } + } + } + } + } + } + } + } + `; + const output = execSync(`gh api graphql -F owner=google-gemini -F repo=gemini-cli -f query='${query}'`, { encoding: 'utf-8' }); + const data = JSON.parse(output).data.repository; + const prs = data.pullRequests.nodes; + + const communityPrs = prs.filter(p => !['MEMBER', 'OWNER', 'COLLABORATOR'].includes(p.authorAssociation)); + const stats = { + conflicting: 0, + mergeable: 0, + unknown: 0, + reviewRequired: 0, + approved: 0, + changesRequested: 0, + ciSuccess: 0, + ciFailure: 0, + ciPending: 0, + readyForReview: 0 + }; + + for (const p of communityPrs) { + const isMergeable = p.mergeable === 'MERGEABLE'; + const ciState = p.commits?.nodes[0]?.commit?.statusCheckRollup?.state; + const isCiSuccess = ciState === 'SUCCESS'; + + if (p.mergeable === 'CONFLICTING') stats.conflicting++; + else if (isMergeable) stats.mergeable++; + else stats.unknown++; + + if (p.reviewDecision === 'REVIEW_REQUIRED') stats.reviewRequired++; + else if (p.reviewDecision === 'APPROVED') stats.approved++; + else if (p.reviewDecision === 'CHANGES_REQUESTED') stats.changesRequested++; + + if (isCiSuccess) stats.ciSuccess++; + else if (ciState === 'FAILURE') stats.ciFailure++; + else stats.ciPending++; + + if (isMergeable && isCiSuccess && p.reviewDecision === 'REVIEW_REQUIRED') { + stats.readyForReview++; + } + } + + console.log('Stats for Community PRs:', stats); + + } catch (err) { + console.error('Error checking merge conflicts:', err); + process.exit(1); + } +} + +run(); diff --git a/tools/optimizer/investigations/scripts/generate_targets.js b/tools/optimizer/investigations/scripts/generate_targets.js new file mode 100644 index 0000000000..b6e49177cf --- /dev/null +++ b/tools/optimizer/investigations/scripts/generate_targets.js @@ -0,0 +1,116 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execSync } from 'node:child_process'; +import { createObjectCsvWriter } from 'csv-writer'; + +async function run() { + try { + // 1. Community PRs + const prQuery = ` + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + pullRequests(first: 100, states: OPEN) { + nodes { + number + author { login } + authorAssociation + mergeable + reviewDecision + commits(last: 1) { + nodes { + commit { + statusCheckRollup { + state + } + } + } + } + } + } + } + } + `; + const prOutput = execSync(`gh api graphql -F owner=google-gemini -F repo=gemini-cli -f query='${prQuery}'`, { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }); + const prs = JSON.parse(prOutput).data.repository.pullRequests.nodes; + + const communityPrs = prs.filter(p => !['MEMBER', 'OWNER', 'COLLABORATOR'].includes(p.authorAssociation)); + + const stalePrs = communityPrs.filter(p => p.mergeable === 'CONFLICTING').map(p => ({ + number: p.number, + author: p.author?.login, + reason: 'Merge Conflict' + })); + + const readyPrs = communityPrs.filter(p => p.mergeable === 'MERGEABLE' && p.commits?.nodes[0]?.commit?.statusCheckRollup?.state === 'SUCCESS' && p.reviewDecision === 'REVIEW_REQUIRED').map(p => ({ + number: p.number, + author: p.author?.login, + reason: 'Mergeable + CI Success + Needs Review' + })); + + const staleWriter = createObjectCsvWriter({ + path: 'author_stale_prs.csv', + header: [ + {id: 'number', title: 'number'}, + {id: 'author', title: 'author'}, + {id: 'reason', title: 'reason'} + ] + }); + await staleWriter.writeRecords(stalePrs); + + const readyWriter = createObjectCsvWriter({ + path: 'ready_for_review_prs.csv', + header: [ + {id: 'number', title: 'number'}, + {id: 'author', title: 'author'}, + {id: 'reason', title: 'reason'} + ] + }); + await readyWriter.writeRecords(readyPrs); + + // 2. Untriaged Issues + const issueQuery = ` + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + issues(first: 100, states: OPEN, labels: ["status/need-triage"]) { + nodes { + number + title + body + author { login } + } + } + } + } + `; + const issueOutput = execSync(`gh api graphql -F owner=google-gemini -F repo=gemini-cli -f query='${issueQuery}'`, { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }); + const issues = JSON.parse(issueOutput).data.repository.issues.nodes; + + const highQualityIssues = issues.filter(i => (i.body?.length || 0) > 200 && (i.title?.length || 0) > 20).map(i => ({ + number: i.number, + author: i.author?.login, + reason: 'High quality content (>200 chars body, >20 chars title)' + })); + + const issueWriter = createObjectCsvWriter({ + path: 'untriaged_high_quality.csv', + header: [ + {id: 'number', title: 'number'}, + {id: 'author', title: 'author'}, + {id: 'reason', title: 'reason'} + ] + }); + await issueWriter.writeRecords(highQualityIssues); + + console.log('Generated author_stale_prs.csv, ready_for_review_prs.csv, and untriaged_high_quality.csv'); + + } catch (err) { + console.error('Error generating CSVs:', err); + process.exit(1); + } +} + +run(); diff --git a/tools/optimizer/metrics/METRICS.md b/tools/optimizer/metrics/METRICS.md index 955485d4d7..cf6e1dd407 100644 --- a/tools/optimizer/metrics/METRICS.md +++ b/tools/optimizer/metrics/METRICS.md @@ -5,3 +5,8 @@ This file documents the metrics tracked by `optimizer1000`. | Metric | Description | Script | Goal | |--------|-------------|--------|------| | open_issues | Number of open issues in the repo | `metrics/scripts/open_issues.js` | Lower is better | +| user_touches_* | User touches prior to completion of issues and PRs (overall, maintainers, community) | `metrics/scripts/user_touches.js` | Lower is better | +| latency_* | Time from open to completion for issues and PRs in hours (overall, maintainers, community) | `metrics/scripts/latency.js` | Lower is better | +| throughput_* | Completion rate of PRs and issues per day, plus cycle time per issue (overall, maintainers, community) | `metrics/scripts/throughput.js` | Greater is better (rate), Lower is better (cycle time) | +| time_to_first_response_* | Time to first response for issues and PRs in hours (overall, maintainers, 1p) | `metrics/scripts/time_to_first_response.js` | Lower is better | +| review_distribution | Variance of reviews completed across the core maintainer group | `metrics/scripts/review_distribution.js` | Lower variance is better (even distribution) | diff --git a/tools/optimizer/metrics/scripts/latency.js b/tools/optimizer/metrics/scripts/latency.js new file mode 100644 index 0000000000..6baf42e8c7 --- /dev/null +++ b/tools/optimizer/metrics/scripts/latency.js @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { execSync } from 'node:child_process'; + +try { + const query = ` + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + pullRequests(last: 100, states: MERGED) { + nodes { + authorAssociation + createdAt + mergedAt + } + } + issues(last: 100, states: CLOSED) { + nodes { + authorAssociation + createdAt + closedAt + } + } + } + } + `; + const output = execSync(`gh api graphql -F owner=google-gemini -F repo=gemini-cli -f query='${query}'`, { encoding: 'utf-8' }); + const data = JSON.parse(output).data.repository; + + const prs = data.pullRequests.nodes.map(p => ({ + association: p.authorAssociation, + latencyHours: (new Date(p.mergedAt).getTime() - new Date(p.createdAt).getTime()) / (1000 * 60 * 60) + })); + const issues = data.issues.nodes.map(i => ({ + association: i.authorAssociation, + latencyHours: (new Date(i.closedAt).getTime() - new Date(i.createdAt).getTime()) / (1000 * 60 * 60) + })); + + const isMaintainer = (assoc) => ['MEMBER', 'OWNER', 'COLLABORATOR'].includes(assoc); + const calculateAvg = (items) => items.length ? items.reduce((a, b) => a + b.latencyHours, 0) / items.length : 0; + + const prMaintainers = calculateAvg(prs.filter(i => isMaintainer(i.association))); + const prCommunity = calculateAvg(prs.filter(i => !isMaintainer(i.association))); + const prOverall = calculateAvg(prs); + + const issueMaintainers = calculateAvg(issues.filter(i => isMaintainer(i.association))); + const issueCommunity = calculateAvg(issues.filter(i => !isMaintainer(i.association))); + const issueOverall = calculateAvg(issues); + + const timestamp = new Date().toISOString(); + + const metrics = [ + { metric: 'latency_pr_overall_hours', value: Math.round(prOverall * 100) / 100, timestamp }, + { metric: 'latency_pr_maintainers_hours', value: Math.round(prMaintainers * 100) / 100, timestamp }, + { metric: 'latency_pr_community_hours', value: Math.round(prCommunity * 100) / 100, timestamp }, + { metric: 'latency_issue_overall_hours', value: Math.round(issueOverall * 100) / 100, timestamp }, + { metric: 'latency_issue_maintainers_hours', value: Math.round(issueMaintainers * 100) / 100, timestamp }, + { metric: 'latency_issue_community_hours', value: Math.round(issueCommunity * 100) / 100, timestamp } + ]; + + metrics.forEach(m => process.stdout.write(JSON.stringify(m) + '\n')); +} catch (err) { + process.stderr.write(err instanceof Error ? err.message : String(err)); + process.exit(1); +} diff --git a/tools/optimizer/metrics/scripts/review_distribution.js b/tools/optimizer/metrics/scripts/review_distribution.js new file mode 100644 index 0000000000..5f48b0016d --- /dev/null +++ b/tools/optimizer/metrics/scripts/review_distribution.js @@ -0,0 +1,81 @@ +/** + * @license + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { execSync } from 'node:child_process'; + +try { + const query = ` + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + pullRequests(last: 100) { + nodes { + reviews(first: 50) { + nodes { + author { login } + authorAssociation + } + } + } + } + } + } + `; + const output = execSync(`gh api graphql -F owner=google-gemini -F repo=gemini-cli -f query='${query}'`, { encoding: 'utf-8' }); + const data = JSON.parse(output).data.repository; + + const reviewCounts = {}; + + for (const pr of data.pullRequests.nodes) { + if (!pr.reviews?.nodes) continue; + // We only count one review per author per PR to avoid counting multiple review comments as multiple reviews + const reviewersOnPR = new Set(); + + for (const review of pr.reviews.nodes) { + if (['MEMBER', 'OWNER'].includes(review.authorAssociation) && review.author?.login) { + const login = review.author.login.toLowerCase(); + if (login.endsWith('[bot]') || login.includes('bot')) { + continue; // Ignore bots + } + reviewersOnPR.add(review.author.login); + } + } + + for (const reviewer of reviewersOnPR) { + reviewCounts[reviewer] = (reviewCounts[reviewer] || 0) + 1; + } + } + + const counts = Object.values(reviewCounts); + + let variance = 0; + if (counts.length > 0) { + const mean = counts.reduce((a, b) => a + b, 0) / counts.length; + variance = counts.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / counts.length; + } + + const timestamp = new Date().toISOString(); + + process.stdout.write(JSON.stringify({ + metric: 'review_distribution_variance', + value: Math.round(variance * 100) / 100, + timestamp, + details: reviewCounts + }) + '\n'); +} catch (err) { + process.stderr.write(err instanceof Error ? err.message : String(err)); + process.exit(1); +} diff --git a/tools/optimizer/metrics/scripts/throughput.js b/tools/optimizer/metrics/scripts/throughput.js new file mode 100644 index 0000000000..958e15d630 --- /dev/null +++ b/tools/optimizer/metrics/scripts/throughput.js @@ -0,0 +1,88 @@ +/** + * @license + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { execSync } from 'node:child_process'; + +try { + const query = ` + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + pullRequests(last: 100, states: MERGED) { + nodes { + authorAssociation + mergedAt + } + } + issues(last: 100, states: CLOSED) { + nodes { + authorAssociation + closedAt + } + } + } + } + `; + const output = execSync(`gh api graphql -F owner=google-gemini -F repo=gemini-cli -f query='${query}'`, { encoding: 'utf-8' }); + const data = JSON.parse(output).data.repository; + + const prs = data.pullRequests.nodes.map(p => ({ + association: p.authorAssociation, + date: new Date(p.mergedAt).getTime() + })).sort((a, b) => a.date - b.date); + + const issues = data.issues.nodes.map(i => ({ + association: i.authorAssociation, + date: new Date(i.closedAt).getTime() + })).sort((a, b) => a.date - b.date); + + const isMaintainer = (assoc) => ['MEMBER', 'OWNER', 'COLLABORATOR'].includes(assoc); + + const calculateThroughput = (items) => { + if (items.length < 2) return 0; + const first = items[0].date; + const last = items[items.length - 1].date; + const days = (last - first) / (1000 * 60 * 60 * 24); + return days > 0 ? items.length / days : items.length; // items per day + }; + + const prOverall = calculateThroughput(prs); + const prMaintainers = calculateThroughput(prs.filter(i => isMaintainer(i.association))); + const prCommunity = calculateThroughput(prs.filter(i => !isMaintainer(i.association))); + + const issueOverall = calculateThroughput(issues); + const issueMaintainers = calculateThroughput(issues.filter(i => isMaintainer(i.association))); + const issueCommunity = calculateThroughput(issues.filter(i => !isMaintainer(i.association))); + + const timestamp = new Date().toISOString(); + + const metrics = [ + { metric: 'throughput_pr_overall_per_day', value: Math.round(prOverall * 100) / 100, timestamp }, + { metric: 'throughput_pr_maintainers_per_day', value: Math.round(prMaintainers * 100) / 100, timestamp }, + { metric: 'throughput_pr_community_per_day', value: Math.round(prCommunity * 100) / 100, timestamp }, + { metric: 'throughput_issue_overall_per_day', value: Math.round(issueOverall * 100) / 100, timestamp }, + { metric: 'throughput_issue_maintainers_per_day', value: Math.round(issueMaintainers * 100) / 100, timestamp }, + { metric: 'throughput_issue_community_per_day', value: Math.round(issueCommunity * 100) / 100, timestamp }, + { metric: 'throughput_issue_overall_days_per_issue', value: issueOverall > 0 ? Math.round((1/issueOverall) * 100) / 100 : 0, timestamp }, + { metric: 'throughput_issue_maintainers_days_per_issue', value: issueMaintainers > 0 ? Math.round((1/issueMaintainers) * 100) / 100 : 0, timestamp }, + { metric: 'throughput_issue_community_days_per_issue', value: issueCommunity > 0 ? Math.round((1/issueCommunity) * 100) / 100 : 0, timestamp } + ]; + + metrics.forEach(m => process.stdout.write(JSON.stringify(m) + '\n')); +} catch (err) { + process.stderr.write(err instanceof Error ? err.message : String(err)); + process.exit(1); +} diff --git a/tools/optimizer/metrics/scripts/time_to_first_response.js b/tools/optimizer/metrics/scripts/time_to_first_response.js new file mode 100644 index 0000000000..6fe55e874d --- /dev/null +++ b/tools/optimizer/metrics/scripts/time_to_first_response.js @@ -0,0 +1,122 @@ +/** + * @license + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { execSync } from 'node:child_process'; + +try { + const query = ` + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + pullRequests(last: 100) { + nodes { + authorAssociation + author { login } + createdAt + comments(first: 20) { + nodes { + author { login } + createdAt + } + } + reviews(first: 20) { + nodes { + author { login } + createdAt + } + } + } + } + issues(last: 100) { + nodes { + authorAssociation + author { login } + createdAt + comments(first: 20) { + nodes { + author { login } + createdAt + } + } + } + } + } + } + `; + const output = execSync(`gh api graphql -F owner=google-gemini -F repo=gemini-cli -f query='${query}'`, { encoding: 'utf-8' }); + const data = JSON.parse(output).data.repository; + + const getFirstResponseTime = (item) => { + const authorLogin = item.author?.login; + let earliestResponse = null; + + const checkNodes = (nodes) => { + for (const node of nodes) { + if (node.author?.login && node.author.login !== authorLogin) { + const login = node.author.login.toLowerCase(); + if (login.endsWith('[bot]') || login.includes('bot')) { + continue; // Ignore bots + } + const time = new Date(node.createdAt).getTime(); + if (!earliestResponse || time < earliestResponse) { + earliestResponse = time; + } + } + } + }; + + if (item.comments?.nodes) checkNodes(item.comments.nodes); + if (item.reviews?.nodes) checkNodes(item.reviews.nodes); + + if (earliestResponse) { + return (earliestResponse - new Date(item.createdAt).getTime()) / (1000 * 60 * 60); + } + return null; // No response yet + }; + + const processItems = (items) => { + return items.map(item => ({ + association: item.authorAssociation, + ttfr: getFirstResponseTime(item) + })).filter(i => i.ttfr !== null); + }; + + const prs = processItems(data.pullRequests.nodes); + const issues = processItems(data.issues.nodes); + const allItems = [...prs, ...issues]; + + const isMaintainer = (assoc) => ['MEMBER', 'OWNER'].includes(assoc); + const is1P = (assoc) => ['COLLABORATOR'].includes(assoc); + + const calculateAvg = (items) => items.length ? items.reduce((a, b) => a + b.ttfr, 0) / items.length : 0; + + const maintainers = calculateAvg(allItems.filter(i => isMaintainer(i.association))); + const firstParty = calculateAvg(allItems.filter(i => is1P(i.association))); + const overall = calculateAvg(allItems); + + const timestamp = new Date().toISOString(); + + const metrics = [ + { metric: 'time_to_first_response_overall_hours', value: Math.round(overall * 100) / 100, timestamp }, + { metric: 'time_to_first_response_maintainers_hours', value: Math.round(maintainers * 100) / 100, timestamp }, + { metric: 'time_to_first_response_1p_hours', value: Math.round(firstParty * 100) / 100, timestamp } + ]; + + metrics.forEach(m => process.stdout.write(JSON.stringify(m) + '\n')); +} catch (err) { + process.stderr.write(err instanceof Error ? err.message : String(err)); + process.exit(1); +} diff --git a/tools/optimizer/metrics/scripts/user_touches.js b/tools/optimizer/metrics/scripts/user_touches.js new file mode 100644 index 0000000000..65620c18b6 --- /dev/null +++ b/tools/optimizer/metrics/scripts/user_touches.js @@ -0,0 +1,70 @@ +/** + * @license + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { execSync } from 'node:child_process'; + +try { + const query = ` + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + pullRequests(last: 100, states: MERGED) { + nodes { + authorAssociation + comments { totalCount } + reviews { totalCount } + } + } + issues(last: 100, states: CLOSED) { + nodes { + authorAssociation + comments { totalCount } + } + } + } + } + `; + const output = execSync(`gh api graphql -F owner=google-gemini -F repo=gemini-cli -f query='${query}'`, { encoding: 'utf-8' }); + const data = JSON.parse(output).data.repository; + + const prs = data.pullRequests.nodes; + const issues = data.issues.nodes; + + const allItems = [...prs.map(p => ({ + association: p.authorAssociation, + touches: p.comments.totalCount + (p.reviews ? p.reviews.totalCount : 0) + })), ...issues.map(i => ({ + association: i.authorAssociation, + touches: i.comments.totalCount + }))]; + + const isMaintainer = (assoc) => ['MEMBER', 'OWNER', 'COLLABORATOR'].includes(assoc); + + const calculateAvg = (items) => items.length ? items.reduce((a, b) => a + b.touches, 0) / items.length : 0; + + const overall = calculateAvg(allItems); + const maintainers = calculateAvg(allItems.filter(i => isMaintainer(i.association))); + const community = calculateAvg(allItems.filter(i => !isMaintainer(i.association))); + + const timestamp = new Date().toISOString(); + + process.stdout.write(JSON.stringify({ metric: 'user_touches_overall', value: Math.round(overall * 100) / 100, timestamp }) + '\n'); + process.stdout.write(JSON.stringify({ metric: 'user_touches_maintainers', value: Math.round(maintainers * 100) / 100, timestamp }) + '\n'); + process.stdout.write(JSON.stringify({ metric: 'user_touches_community', value: Math.round(community * 100) / 100, timestamp }) + '\n'); +} catch (err) { + process.stderr.write(err instanceof Error ? err.message : String(err)); + process.exit(1); +} diff --git a/tools/optimizer/processes/PROCESSES.md b/tools/optimizer/processes/PROCESSES.md index 67cd99eb05..c7349c9116 100644 --- a/tools/optimizer/processes/PROCESSES.md +++ b/tools/optimizer/processes/PROCESSES.md @@ -1,7 +1,25 @@ # Optimization Processes -This file documents the automated processes run to improve repository metrics. +This file documents the automated processes implemented to drive repository metrics toward their goals. -| Process | Target Metric | Script | Description | -|---------|---------------|--------|-------------| -| triage_issues | open_issues | `processes/scripts/triage_issues.js` | Basic issue triage and labeling | +| Process | Goal | Script | Description | +|---------|------|--------|-------------| +| Stale Manager | Reduce `open_issues` | `stale_manager.ts` | Identifies inactive community issues (>30d) and maintainer issues (>90d), labels them as Stale, and eventually closes them. | +| Triage Router | Reduce untriaged backlog | `triage_router.ts` | Automatically assigns untriaged issues to maintainers or requests more info for low-quality reports. | +| PR Nudge | Reduce `latency_pr_community` | `pr_nudge.ts` | Nudges maintainers for community PRs that pass CI but are stalled waiting for review (>48h). | + +## Implementation Details + +### Stale Manager +- **Trigger**: No activity for 30 days (Community) or 90 days (Maintainer Only). +- **Grace Period**: 14 days after labeling as Stale. +- **Exemptions**: None, but maintainers get more time. + +### Triage Router +- **Batch Size**: 50 issues per run. +- **Routing**: Round-robin across 13 active maintainers. +- **Quality Check**: Issues with <50 chars body are asked for more info instead of being routed. + +### PR Nudge +- **Criteria**: Community PR, Non-Draft, SUCCESS CI, REVIEW_REQUIRED, >48 hours old. +- **Action**: Add `status/nudge` label and comment pinging the maintainers team. diff --git a/tools/optimizer/processes/scripts/pr_nudge.ts b/tools/optimizer/processes/scripts/pr_nudge.ts new file mode 100644 index 0000000000..b84fe723e6 --- /dev/null +++ b/tools/optimizer/processes/scripts/pr_nudge.ts @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execSync } from 'node:child_process'; +import { updateSimulationCsv, execGh, getRepoInfo } from './utils.js'; + +const EXECUTE_ACTIONS = process.env.EXECUTE_ACTIONS === 'true'; + +async function run() { + const { owner, repo } = getRepoInfo(); + console.log(`PR Nudge starting for ${owner}/${repo}... (EXECUTE_ACTIONS=${EXECUTE_ACTIONS})`); + + try { + // 1. Fetch community PRs that pass CI but need review + const query = ` + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + pullRequests(first: 50, states: OPEN) { + nodes { + number + author { login } + authorAssociation + createdAt + updatedAt + isDraft + reviewDecision + mergeable + commits(last: 1) { + nodes { + commit { + statusCheckRollup { + state + } + } + } + } + labels(first: 20) { + nodes { name } + } + } + } + } + } + `; + const output = execSync(`gh api graphql -F owner=${owner} -F repo=${repo} -f query='${query}'`, { encoding: 'utf-8' }); + const data = JSON.parse(output).data.repository; + const prs = data.pullRequests.nodes; + + const actions = []; + const now = new Date(); + + for (const pr of prs) { + if (['MEMBER', 'OWNER', 'COLLABORATOR'].includes(pr.authorAssociation)) continue; + if (pr.isDraft) continue; + + const ciState = pr.commits.nodes[0]?.commit.statusCheckRollup?.state; + const isCiSuccess = ciState === 'SUCCESS'; + const isMergeable = pr.mergeable === 'MERGEABLE'; + const isConflicting = pr.mergeable === 'CONFLICTING'; + + const updatedAt = new Date(pr.updatedAt); + const hoursSinceUpdate = (now.getTime() - updatedAt.getTime()) / (1000 * 60 * 60); + + const hasNudgeLabel = pr.labels.nodes.some(l => l.name === 'status/nudge'); + const hasConflictLabel = pr.labels.nodes.some(l => l.name === 'status/merge-conflict'); + + // 1. Author Nudge for Conflicts + if (isConflicting && !hasConflictLabel) { + actions.push({ + number: pr.number, + type: 'author-nudge-conflict', + comment: `Hi @${pr.author?.login || 'author'}! It looks like this PR has merge conflicts. Could you please resolve them so that maintainers can review your changes? Thanks!` + }); + continue; + } + + // 2. Maintainer Nudge for Ready PRs + if (isCiSuccess && isMergeable && pr.reviewDecision === 'REVIEW_REQUIRED') { + // Nudge maintainers if ready and no activity for 48 hours + if (hoursSinceUpdate > 48 && !hasNudgeLabel) { + actions.push({ + number: pr.number, + type: 'maintainer-nudge', + comment: `Hi maintainers! This community PR by @${pr.author?.login || 'author'} has passed all CI checks and is mergeable. It has been open and inactive for over 48 hours. Could someone please take a look? @google-gemini/gemini-cli-maintainers` + }); + } + } + } + + // 2. Execute actions + const simulationUpdates = new Map>(); + + for (const action of actions) { + try { + if (action.type === 'author-nudge-conflict') { + execGh(`pr edit ${action.number} --add-label "status/merge-conflict"`, EXECUTE_ACTIONS); + simulationUpdates.set(action.number.toString(), { labels: 'status/merge-conflict' }); + } else if (action.type === 'maintainer-nudge') { + execGh(`pr edit ${action.number} --add-label "status/nudge"`, EXECUTE_ACTIONS); + simulationUpdates.set(action.number.toString(), { labels: 'status/nudge' }); + } + + if (action.comment) { + execGh(`pr comment ${action.number} --body "${action.comment}"`, EXECUTE_ACTIONS); + } + } catch (err) { + console.error(`Failed to process PR #${action.number}:`, err); + } + } + + // 3. Update simulation + await updateSimulationCsv('prs-after.csv', simulationUpdates); + + console.log(`Processed ${actions.length} PR nudges.`); + + } catch (err) { + console.error('Error in PR Nudge:', err); + process.exit(1); + } +} + +run(); diff --git a/tools/optimizer/processes/scripts/stale_manager.ts b/tools/optimizer/processes/scripts/stale_manager.ts new file mode 100644 index 0000000000..598b217e04 --- /dev/null +++ b/tools/optimizer/processes/scripts/stale_manager.ts @@ -0,0 +1,154 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execSync } from 'node:child_process'; +import { updateSimulationCsv, execGh, getRepoInfo } from './utils.js'; + +const EXECUTE_ACTIONS = process.env.EXECUTE_ACTIONS === 'true'; + +async function run() { + const { owner, repo } = getRepoInfo(); + console.log(`Stale Manager starting for ${owner}/${repo}... (EXECUTE_ACTIONS=${EXECUTE_ACTIONS})`); + + try { + // 1. Fetch open issues/PRs that might be stale + const query = ` + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + issues(first: 200, states: OPEN, orderBy: {field: UPDATED_AT, direction: ASC}) { + nodes { + number + authorAssociation + updatedAt + labels(first: 20) { + nodes { name } + } + } + } + pullRequests(first: 200, states: OPEN, orderBy: {field: UPDATED_AT, direction: ASC}) { + nodes { + number + authorAssociation + updatedAt + mergeable + reviewDecision + commits(last: 1) { + nodes { + commit { + statusCheckRollup { + state + } + } + } + } + labels(first: 20) { + nodes { name } + } + } + } + } + } + `; + let output; + try { + output = execSync(`gh api graphql -F owner=${owner} -F repo=${repo} -f query='${query}'`, { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }); + } catch (err) { + console.error('Failed to fetch issues/PRs from GitHub:', err); + process.exit(1); + } + + const data = JSON.parse(output).data.repository; + const items = [...data.issues.nodes.map(i => ({...i, type: 'issue'})), ...data.pullRequests.nodes.map(p => ({...p, type: 'pr'}))]; + + const now = new Date(); + const actions = []; + + for (const item of items) { + const updatedAt = new Date(item.updatedAt); + const daysSinceUpdate = (now.getTime() - updatedAt.getTime()) / (1000 * 60 * 60 * 24); + const isMaintainerOnly = item.labels.nodes.some(l => l.name === '🔒 maintainer only'); + const isStale = item.labels.nodes.some(l => l.name === 'Stale'); + const isCommunity = !['MEMBER', 'OWNER', 'COLLABORATOR'].includes(item.authorAssociation); + + if (isMaintainerOnly) continue; // Maintainer issues have their own lifecycle + + // Safeguard: Don't mark as stale if it's a PR ready for review (Maintainer bottleneck) + if (item.type === 'pr') { + const ciState = item.commits?.nodes[0]?.commit?.statusCheckRollup?.state; + if (item.mergeable === 'MERGEABLE' && ciState === 'SUCCESS' && item.reviewDecision === 'REVIEW_REQUIRED') { + continue; + } + + // Special case: Persistent conflicts + if (item.labels.nodes.some(l => l.name === 'status/merge-conflict') && daysSinceUpdate > 14) { + actions.push({ + number: item.number, + target: 'pr', + type: 'close', + comment: `This PR has had merge conflicts for over 14 days without resolution. Closing it for now to keep the queue manageable. Please feel free to reopen once conflicts are resolved.` + }); + continue; + } + } + + if (isStale) { + if (daysSinceUpdate > 14) { + actions.push({ + number: item.number, + target: item.type, + type: 'close', + comment: `This ${item.type} has been marked as stale for 14 days with no further activity. Closing it for now. If this is still relevant, please feel free to reopen with additional information.` + }); + } + } else if (daysSinceUpdate > 30 && isCommunity) { + actions.push({ + number: item.number, + target: item.type, + type: 'label', + label: 'Stale', + comment: `This ${item.type} has been inactive for 30 days. We are labeling it as stale. If no further activity occurs within 14 days, it will be closed. Thank you for your contributions!` + }); + } + } + + // 2. Execute actions + const issueSimulationUpdates = new Map>(); + const prSimulationUpdates = new Map>(); + + for (const action of actions) { + try { + const cmdPrefix = action.target === 'pr' ? 'pr' : 'issue'; + const simulationMap = action.target === 'pr' ? prSimulationUpdates : issueSimulationUpdates; + + if (action.type === 'label') { + execGh(`${cmdPrefix} edit ${action.number} --add-label "Stale"`, EXECUTE_ACTIONS); + simulationMap.set(action.number.toString(), { labels: 'Stale' }); + } + if (action.comment) { + execGh(`${cmdPrefix} comment ${action.number} --body "${action.comment}"`, EXECUTE_ACTIONS); + } + if (action.type === 'close') { + execGh(`${cmdPrefix} close ${action.number}`, EXECUTE_ACTIONS); + simulationMap.set(action.number.toString(), { state: 'CLOSED' }); + } + } catch (err) { + console.error(`Failed to process ${action.target} #${action.number}:`, err); + } + } + + // 3. Update simulations + await updateSimulationCsv('issues-after.csv', issueSimulationUpdates); + await updateSimulationCsv('prs-after.csv', prSimulationUpdates); + + console.log(`Processed ${actions.length} stale issues/actions.`); + + } catch (err) { + console.error('Error in Stale Manager:', err); + process.exit(1); + } +} + +run(); diff --git a/tools/optimizer/processes/scripts/triage_router.ts b/tools/optimizer/processes/scripts/triage_router.ts new file mode 100644 index 0000000000..e6f746dc42 --- /dev/null +++ b/tools/optimizer/processes/scripts/triage_router.ts @@ -0,0 +1,124 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execSync } from 'node:child_process'; +import { getMaintainers, execGh, getRepoInfo, updateSimulationCsv } from './utils.js'; + +const EXECUTE_ACTIONS = process.env.EXECUTE_ACTIONS === 'true'; + +async function run() { + const { owner, repo } = getRepoInfo(); + console.log(`Triage Router starting for ${owner}/${repo}... (EXECUTE_ACTIONS=${EXECUTE_ACTIONS})`); + + try { + const MAINTAINERS = await getMaintainers(); + console.log(`Fetched ${MAINTAINERS.length} maintainers.`); + + // 1. Fetch untriaged issues + const query = ` + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + issues(first: 100, states: OPEN, labels: ["status/need-triage"]) { + nodes { + number + title + body + author { login } + assignees(first: 1) { nodes { login } } + labels(first: 20) { nodes { name } } + } + } + } + } + `; + let output; + try { + output = execSync(`gh api graphql -F owner=${owner} -F repo=${repo} -f query='${query}'`, { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }); + } catch (err) { + console.error('Failed to fetch untriaged issues from GitHub:', err); + process.exit(1); + } + + const data = JSON.parse(output).data.repository; + const issues = data.issues.nodes; + + const actions = []; + let maintainerIndex = Math.floor(Math.random() * MAINTAINERS.length); + + for (const issue of issues) { + if (issue.assignees.nodes.length > 0) continue; + + const body = issue.body || ''; + const title = issue.title || ''; + + // Low quality check + if (body.length < 50 || title.length < 10 || !body.includes('###')) { + actions.push({ + number: issue.number, + type: 'needs-info', + comment: `Hi @${issue.author?.login || 'author'}! Thank you for the report. This issue seems to be missing some critical information or doesn't follow the template. Could you please provide more details? Labeling as 'status/needs-info' for now.` + }); + continue; + } + + // Potential duplicate check (very naive but better than nothing) + if (title.toLowerCase().includes('duplicate') || title.toLowerCase().includes('same as #')) { + actions.push({ + number: issue.number, + type: 'possible-duplicate', + comment: `Hi @${issue.author?.login || 'author'}! This issue might be a duplicate of another existing issue. Labeling as 'status/possible-duplicate' for maintainer review.` + }); + continue; + } + + // Assign to a maintainer (round-robin) + const assignee = MAINTAINERS[maintainerIndex % MAINTAINERS.length]; + maintainerIndex++; + + actions.push({ + number: issue.number, + type: 'assign', + assignee, + comment: `Automated Triage: Assigning to @${assignee} for initial review. Please categorize and set priority.` + }); + } + + // 2. Execute actions + const simulationUpdates = new Map>(); + + for (const action of actions) { + try { + if (action.type === 'needs-info') { + execGh(`issue edit ${action.number} --add-label "status/needs-info" --remove-label "status/need-triage"`, EXECUTE_ACTIONS); + simulationUpdates.set(action.number.toString(), { labels: 'status/needs-info' }); + } else if (action.type === 'possible-duplicate') { + execGh(`issue edit ${action.number} --add-label "status/possible-duplicate" --remove-label "status/need-triage"`, EXECUTE_ACTIONS); + simulationUpdates.set(action.number.toString(), { labels: 'status/possible-duplicate' }); + } else if (action.type === 'assign') { + execGh(`issue edit ${action.number} --add-assignee "${action.assignee}" --remove-label "status/need-triage" --add-label "status/manual-triage"`, EXECUTE_ACTIONS); + simulationUpdates.set(action.number.toString(), { labels: 'status/manual-triage' }); + } + + if (action.comment) { + execGh(`issue comment ${action.number} --body "${action.comment}"`, EXECUTE_ACTIONS); + } + } catch (err) { + console.error(`Failed to process issue #${action.number}:`, err); + } + } + + // 3. Update simulation + await updateSimulationCsv('issues-after.csv', simulationUpdates); + + console.log(`Processed ${actions.length} issues.`); + + } catch (err) { + console.error('Error in Triage Router:', err); + process.exit(1); + } +} + +run(); diff --git a/tools/optimizer/processes/scripts/utils.ts b/tools/optimizer/processes/scripts/utils.ts new file mode 100644 index 0000000000..cdc53e3e7c --- /dev/null +++ b/tools/optimizer/processes/scripts/utils.ts @@ -0,0 +1,154 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execSync } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +/** + * Fetches the current repository owner and name. + */ +export function getRepoInfo(): { owner: string; repo: string } { + try { + const output = execSync('gh repo view --json owner,name', { encoding: 'utf-8' }); + const data = JSON.parse(output); + return { + owner: data.owner.login, + repo: data.name, + }; + } catch (err) { + console.error('Error fetching repo info, falling back to default:', err); + return { owner: 'google-gemini', repo: 'gemini-cli' }; + } +} + +/** + * Fetches maintainers from CODEOWNERS file. + */ +export async function getMaintainers(): Promise { + try { + const codeownersPath = path.join(process.cwd(), '.github', 'CODEOWNERS'); + const content = await fs.readFile(codeownersPath, 'utf8'); + const maintainers = new Set(); + + const lines = content.split('\n'); + for (const line of lines) { + if (line.startsWith('#') || !line.trim()) continue; + // Match @user or @org/team + const matches = line.match(/@[\w-]+\/[\w-]+|@[\w-]+/g); + if (matches) { + for (const match of matches) { + if (match.includes('/')) { + // For team mentions, we might want to expand them, + // but for now let's just skip them or handle them if needed. + continue; + } + maintainers.add(match.replace('@', '')); + } + } + } + + if (maintainers.size === 0) { + return ['gundermanc', 'jackwotherspoon', 'DavidAPierce']; + } + + return Array.from(maintainers); + } catch (err) { + console.warn('CODEOWNERS not found or unreadable, using fallback maintainers.'); + return ['gundermanc', 'jackwotherspoon', 'DavidAPierce']; + } +} + +/** + * Safely updates a CSV file by modifying specific columns for certain rows. + */ +export async function updateSimulationCsv( + filename: 'issues-after.csv' | 'prs-after.csv', + updates: Map> +) { + if (updates.size === 0) return; + + const filePath = path.join(process.cwd(), filename); + const beforeFilePath = path.join(process.cwd(), filename.replace('-after', '-before')); + + let content = ''; + try { + content = await fs.readFile(filePath, 'utf8'); + } catch { + try { + content = await fs.readFile(beforeFilePath, 'utf8'); + } catch { + console.error(`Could not find ${filename} or ${beforeFilePath}`); + return; + } + } + + const lines = content.split('\n'); + if (lines.length === 0) return; + + const header = lines[0].split(','); + const numberIndex = header.indexOf('number'); + if (numberIndex === -1) { + console.error(`CSV ${filename} missing 'number' column`); + return; + } + + const newLines = [lines[0]]; + for (let i = 1; i < lines.length; i++) { + const rawLine = lines[i].trim(); + if (!rawLine) continue; + + // Split by comma but respect quotes + const columns: string[] = []; + let current = ''; + let inQuotes = false; + for (let charIndex = 0; charIndex < rawLine.length; charIndex++) { + const char = rawLine[charIndex]; + if (char === '"') { + inQuotes = !inQuotes; + current += char; + } else if (char === ',' && !inQuotes) { + columns.push(current); + current = ''; + } else { + current += char; + } + } + columns.push(current); + + const number = columns[numberIndex]?.replace(/"/g, ''); + + if (updates.has(number)) { + const update = updates.get(number)!; + for (const [colName, newValue] of Object.entries(update)) { + const colIndex = header.indexOf(colName); + if (colIndex !== -1) { + columns[colIndex] = newValue; + } + } + } + newLines.push(columns.join(',')); + } + + await fs.writeFile(filePath, newLines.join('\n')); +} + +/** + * Executes a gh command with logging and dry-run support. + */ +export function execGh(command: string, execute: boolean) { + if (!execute) { + console.log(`[DRY RUN] Would execute: gh ${command}`); + return; + } + + console.log(`Executing: gh ${command}`); + try { + execSync(`gh ${command}`, { stdio: 'inherit' }); + } catch (err) { + console.error(`Failed to execute gh ${command}:`, err); + } +}