Agent generated files.

This commit is contained in:
Christian Gunderman
2026-04-22 17:28:53 -07:00
parent 1ab09fb428
commit 9109505145
17 changed files with 1402 additions and 4 deletions
@@ -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.
@@ -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);
}
@@ -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);
}
@@ -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();
@@ -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();
@@ -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();
+5
View File
@@ -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) |
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
+22 -4
View File
@@ -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.
@@ -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<string, Record<string, string>>();
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();
@@ -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<string, Record<string, string>>();
const prSimulationUpdates = new Map<string, Record<string, string>>();
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();
@@ -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<string, Record<string, string>>();
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();
+154
View File
@@ -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<string[]> {
try {
const codeownersPath = path.join(process.cwd(), '.github', 'CODEOWNERS');
const content = await fs.readFile(codeownersPath, 'utf8');
const maintainers = new Set<string>();
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<string, Record<string, string>>
) {
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);
}
}