mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-15 06:12:50 -07:00
Agent generated files.
This commit is contained in:
@@ -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,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);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user