mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-25 09:10:59 -07:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7da1a54bd | |||
| 87485c87a4 | |||
| 9109505145 | |||
| 1ab09fb428 | |||
| 8b4c10dd15 | |||
| 5e24d6285e | |||
| 80ffb7dafd | |||
| 9d1ed876cc |
@@ -0,0 +1,48 @@
|
||||
name: Optimizer Brain
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
brain:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Run Optimizer Brain
|
||||
run: |
|
||||
cd tools/optimizer
|
||||
npx tsx index.ts --investigate
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
EXECUTE_ACTIONS: 'true'
|
||||
|
||||
- name: Upload Brain Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: optimizer-brain-results
|
||||
path: |
|
||||
metrics-before.csv
|
||||
metrics-after.csv
|
||||
*-before.csv
|
||||
*-after.csv
|
||||
investigations/INVESTIGATIONS.md
|
||||
lessons-learned.md
|
||||
@@ -0,0 +1,45 @@
|
||||
name: Optimizer Pulse
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/30 * * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
pulse:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Run Optimizer Pulse
|
||||
run: |
|
||||
cd tools/optimizer
|
||||
npx tsx index.ts --pulse
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
EXECUTE_ACTIONS: 'true'
|
||||
|
||||
- name: Upload Metrics Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: optimizer-pulse-results
|
||||
path: |
|
||||
metrics-before.csv
|
||||
metrics-after.csv
|
||||
*-before.csv
|
||||
*-after.csv
|
||||
@@ -0,0 +1,54 @@
|
||||
name: Optimizer1000 Nightly
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
investigate:
|
||||
type: boolean
|
||||
description: 'Investigate metrics'
|
||||
default: false
|
||||
update_processes:
|
||||
type: boolean
|
||||
description: 'Update processes based on learnings'
|
||||
default: false
|
||||
commit:
|
||||
type: boolean
|
||||
description: 'Run processes and commit'
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
optimize:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
- name: Build optimizer
|
||||
run: |
|
||||
cd tools/optimizer
|
||||
npm install
|
||||
- name: Run Optimizer1000
|
||||
env:
|
||||
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
npx tsx tools/optimizer/index.ts \
|
||||
${{ github.event.inputs.investigate == 'true' && '--investigate' || '' }} \
|
||||
${{ github.event.inputs.update_processes == 'true' && '--update-processes' || '' }} \
|
||||
${{ github.event.inputs.commit == 'true' && '--commit' || '' }}
|
||||
- name: Upload artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: optimizer-results
|
||||
path: "*.csv"
|
||||
@@ -0,0 +1,15 @@
|
||||
# Optimizer1000 - Instructions for Gemini CLI
|
||||
|
||||
You are the engine behind the `optimizer1000`. You run in phases, and for each phase, you are given a specific `-AGENT.md` prompt.
|
||||
|
||||
## How to Modify the Tool
|
||||
- **Metrics**: To add a new metric, add a script in `metrics/scripts/` and document it in `metrics/METRICS.md`.
|
||||
- **Investigations**: To add a deep-dive investigation, add a script in `investigations/scripts/` and document it in `investigations/INVESTIGATIONS.md`.
|
||||
- **Processes**: To add an optimization process, add a script in `processes/scripts/` and document it in `processes/PROCESSES.md`.
|
||||
- **Prompts**: You can update your own behavior by modifying the `*-AGENT.md` files in each directory.
|
||||
|
||||
## Safety & Security
|
||||
- Never modify product or tool code outside of `tools/optimizer/` unless the `commit` flag is explicitly enabled.
|
||||
- All repository modifications should be proposed via PRs created with the `gh` CLI.
|
||||
- Changes should prioritize transparency by logging all intended actions to CSV files.
|
||||
- Always check the `metrics-before.csv` to understand the current state before recommending changes.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Critique Agent
|
||||
|
||||
Your task is to analyze the process scripts implemented or updated by the investigation phase to ensure they are technically robust, performant, and correctly execute their logic. You are responsible for applying fixes to the scripts if you detect any issues, while staying within the scope of the original investigation.
|
||||
|
||||
## Critique Requirements
|
||||
|
||||
Review all modified scripts in `processes/scripts/` against the following technical and logical checklist. If any of these items fail, you MUST directly edit the scripts to fix the issue.
|
||||
|
||||
### Technical Robustness
|
||||
1. **Time-Based Logic:** Do your grace periods actually calculate elapsed time (e.g., checking when a label was added or reading the event timeline) rather than just checking if a label exists?
|
||||
2. **Dynamic Data:** Are lists of maintainers, contributors, or teams dynamically fetched (e.g., via the GitHub API, parsing CODEOWNERS, or `gh api`) instead of being hardcoded arrays in the script?
|
||||
3. **Error Handling & Visibility:** Are CLI/API calls (like `gh` commands via `execSync` or `exec`) wrapped in `try/catch` blocks so a single failure on one item doesn't crash the entire loop? Furthermore, are errors logged with sufficient context (rather than just being silently swallowed) to understand why a process stopped early? Are file reads protected with existence checks or `try/catch` blocks?
|
||||
4. **Accurate Simulation & Data Safety:** Does your logic for generating `[concept]-after.csv` actually track and filter out the specific items modified/closed during the simulation, rather than blindly slicing off an arbitrary percentage of the array? When modifying CSV strings, do you parse and mutate the exact column/index safely instead of using brittle global or naive `.replace()` operations (e.g., replacing the first occurrence of "OPEN" in an entire line)?
|
||||
5. **Sequential File Interactions:** When generating simulation output (like `issues-after.csv`), do the scripts account for sequential execution? If multiple scripts operate on the same metric, they MUST read from the `[concept]-after.csv` if it exists, falling back to `[concept]-before.csv` only on the first run, to prevent overwriting prior simulation results.
|
||||
6. **Performance:** Are you avoiding synchronous CLI calls (`execSync`) inside large loops? Are you using asynchronous execution (`exec` or `spawn` with `Promise.all` or concurrency limits) where appropriate?
|
||||
7. **Execution Gate & Dry-Run Logging:** Does the script strictly respect the `EXECUTE_ACTIONS` environment variable? Does it ensure that when `process.env.EXECUTE_ACTIONS !== 'true'`, it only performs a dry-run and executes zero state-changing commands? During a dry run, does the script explicitly and consistently log what it *would* have done for every intended action, ensuring a complete audit trail without requiring actual execution?
|
||||
|
||||
### Logical & Workflow Integrity
|
||||
8. **Actor-Awareness:** Are interventions correctly targeted at the *blocking actor*? Ensure the script does not nudge authors if the bottleneck is waiting on maintainers (e.g., for triage or review).
|
||||
9. **Systemic Solutions:** If the bottleneck is maintainer workload, does the script implement systemic improvements (routing, aggregations) rather than just spamming pings?
|
||||
10. **Terminal Escalation & Anti-Spam:** Do loops have terminal escalation states? If an automated process nudges a user, does it record that state (e.g., via a label) to prevent infinite loops of redundant spam on subsequent runs?
|
||||
11. **Graceful Closures:** Are you ensuring that items are NEVER forcefully closed without providing prior warning (a nudge) and allowing a reasonable grace period for the author to respond?
|
||||
12. **Targeted Mitigation:** Do the script actions tangibly drive the target metric toward the goal (e.g., actually closing or routing, not just passively adding a label)?
|
||||
|
||||
## Implementation Mandate
|
||||
|
||||
If you determine that the scripts suffer from any of the technical flaws listed above:
|
||||
|
||||
1. Identify the specific flaw in the script.
|
||||
2. Apply the technical fixes directly to the appropriate `processes/scripts/*.ts` file.
|
||||
3. Ensure your fixes remain strictly within the scope of the original script's logic and the goals of the prior investigation. Do not invent new workflows; just ensure the existing ones are implemented robustly according to this checklist.
|
||||
|
||||
## Final Verdict & PR Creation
|
||||
|
||||
After applying any necessary fixes, you must evaluate the overall quality and impact of the modified scripts.
|
||||
- If the result is a complete, incremental improvement for quality that avoids annoying behavior, pinging too many users, or degrading the development experience, you must output the exact magic string `[APPROVED]` at the very end of your response.
|
||||
- If the changes are too annoying, spammy, or degrade the developer experience and cannot be easily fixed, you must output the exact magic string `[REJECTED]` at the very end of your response.
|
||||
|
||||
If your verdict is `[APPROVED]` and the environment variable `CREATE_PR=true` is provided, you must submit a PR with these changes using the `gh pr create` command. If your verdict is `[REJECTED]`, do not create a PR under any circumstances.
|
||||
@@ -0,0 +1,208 @@
|
||||
import { Command } from 'commander';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execSync, spawn } from 'node:child_process';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function main() {
|
||||
const program = new Command();
|
||||
program
|
||||
.option('--investigate', 'Run deep investigation (Brain phase)', false)
|
||||
.option('--pulse', 'Run high-frequency reflex actions (Pulse phase)', false)
|
||||
.option('--create-pr', 'Create a PR when updating processes', false)
|
||||
.option('--execute-actions', 'Actually execute state-changing actions', false)
|
||||
.parse(process.argv);
|
||||
|
||||
const options = program.opts();
|
||||
|
||||
console.log('Optimizer1000 starting...');
|
||||
console.log('Options:', options);
|
||||
|
||||
const rootDir = path.resolve(__dirname, '../..');
|
||||
|
||||
// Ensure history directory exists
|
||||
await fs.mkdir(path.join(rootDir, 'history'), { recursive: true });
|
||||
|
||||
// 0. Fetch previous artifacts (Memory)
|
||||
await syncHistory(rootDir);
|
||||
|
||||
const policyPath = options.executeActions ? undefined : path.join(__dirname, 'policies', 'readonly-gh.toml');
|
||||
|
||||
// 1. Initial Metrics (Deterministic)
|
||||
await runMetrics(true, rootDir);
|
||||
|
||||
// 2. Investigation & Update Processes (Agentic - Brain Phase)
|
||||
if (options.investigate) {
|
||||
await runAgentPhase('investigations', {
|
||||
EXECUTE_ACTIONS: String(options.executeActions),
|
||||
}, options, undefined);
|
||||
|
||||
// 3. Critique Phase (Only runs if investigations ran)
|
||||
await runAgentPhase('critique', {
|
||||
CREATE_PR: String(options.createPr),
|
||||
EXECUTE_ACTIONS: String(options.executeActions),
|
||||
}, options, undefined);
|
||||
}
|
||||
|
||||
// 4. Run Processes (Pulse Phase)
|
||||
// In v1, Pulse runs are deterministic script executions.
|
||||
if (options.pulse || options.investigate) {
|
||||
await runProcesses(options.executeActions, rootDir);
|
||||
}
|
||||
|
||||
// 5. Final Metrics (Deterministic)
|
||||
await runMetrics(false, rootDir);
|
||||
|
||||
console.log('\nOptimizer1000 completed.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs repository metrics deterministically by executing scripts in metrics/scripts/
|
||||
*/
|
||||
async function runMetrics(preRun: boolean, rootDir: string) {
|
||||
const phase = preRun ? 'before' : 'after';
|
||||
console.log(`\n--- Phase: metrics (${phase}) ---`);
|
||||
|
||||
const scriptsDir = path.join(__dirname, 'metrics', 'scripts');
|
||||
const scripts = await fs.readdir(scriptsDir);
|
||||
const jsScripts = scripts.filter(s => s.endsWith('.js') || s.endsWith('.ts'));
|
||||
|
||||
const results: any[] = [];
|
||||
|
||||
for (const script of jsScripts) {
|
||||
console.log(`Running metric script: ${script}`);
|
||||
try {
|
||||
const scriptPath = path.join(scriptsDir, script);
|
||||
const command = script.endsWith('.ts') ? `npx tsx ${scriptPath}` : `node ${scriptPath}`;
|
||||
const output = execSync(command, { encoding: 'utf-8', cwd: rootDir });
|
||||
|
||||
// Scripts should output JSON objects per line
|
||||
const lines = output.trim().split('\n');
|
||||
for (const line of lines) {
|
||||
try {
|
||||
results.push(JSON.parse(line));
|
||||
} catch {
|
||||
// Fallback for non-JSON output
|
||||
if (line) console.log(`[Script Output]: ${line}`);
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`Error running ${script}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
const outputFile = path.join(rootDir, `metrics-${phase}.csv`);
|
||||
const csvContent = jsonToCsv(results);
|
||||
await fs.writeFile(outputFile, csvContent);
|
||||
console.log(`Metrics saved to ${outputFile}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs optimization processes deterministically
|
||||
*/
|
||||
async function runProcesses(execute: boolean, rootDir: string) {
|
||||
console.log(`\n--- Phase: processes ---`);
|
||||
const scriptsDir = path.join(__dirname, 'processes', 'scripts');
|
||||
|
||||
// We look for a PROCESSES.md to see what's active, but for now we just run all .ts scripts in the dir
|
||||
const scripts = await fs.readdir(scriptsDir);
|
||||
const activeScripts = scripts.filter(s => s.endsWith('.ts') && s !== 'utils.ts');
|
||||
|
||||
for (const script of activeScripts) {
|
||||
console.log(`Running process: ${script}`);
|
||||
try {
|
||||
const scriptPath = path.join(scriptsDir, script);
|
||||
execSync(`npx tsx ${scriptPath}`, {
|
||||
stdio: 'inherit',
|
||||
cwd: rootDir,
|
||||
env: { ...process.env, EXECUTE_ACTIONS: String(execute) }
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error(`Error running process ${script}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an agentic phase using GCLI
|
||||
*/
|
||||
async function runAgentPhase(phaseDir: string, env: Record<string, string>, options: any, policyPath?: string) {
|
||||
console.log(`\n--- Phase: ${phaseDir} (Agentic) ---`);
|
||||
const phasePath = path.join(__dirname, phaseDir);
|
||||
|
||||
const files = await fs.readdir(phasePath);
|
||||
const promptFile = files.find(f => f.endsWith('-AGENT.md'));
|
||||
|
||||
if (!promptFile) {
|
||||
console.warn(`No agent prompt found in ${phaseDir}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const instructionsPath = path.join(phasePath, promptFile);
|
||||
const instructionsContent = await fs.readFile(instructionsPath, 'utf8');
|
||||
|
||||
const envString = Object.entries(env).map(([k, v]) => `${k}=${v}`).join('\n');
|
||||
const userPrompt = `Execution Context:\n${envString}\n\n${instructionsContent}\n\nPlease proceed with the ${phaseDir} tasks.`;
|
||||
|
||||
const rootDir = path.resolve(__dirname, '../..');
|
||||
const cliPath = path.join(rootDir, 'packages', 'cli');
|
||||
const args = ['--prompt', userPrompt, '--yolo', '--model', 'gemini-3-flash-preview'];
|
||||
|
||||
if (policyPath) args.push('--admin-policy', policyPath);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn('node', [cliPath, ...args], {
|
||||
stdio: 'inherit',
|
||||
cwd: rootDir,
|
||||
env: { ...process.env, ...env }
|
||||
});
|
||||
child.on('close', code => code === 0 ? resolve() : reject(new Error(`Exit code ${code}`)));
|
||||
child.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function syncHistory(rootDir: string) {
|
||||
try {
|
||||
console.log('Checking for previous artifacts...');
|
||||
const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim();
|
||||
const runCheck = execSync(`gh run list --branch ${branch} --limit 1 --json databaseId --jq '.[0].databaseId' || true`, { encoding: 'utf-8' }).trim();
|
||||
|
||||
if (runCheck && runCheck !== '') {
|
||||
console.log('Fetching previous artifacts into history/...');
|
||||
execSync(`gh run download --name optimizer-pulse-results --pattern "*.csv" --dir history > /dev/null 2>&1 || true`, {
|
||||
stdio: 'inherit',
|
||||
timeout: 30000,
|
||||
cwd: rootDir
|
||||
});
|
||||
execSync(`gh run download --name optimizer-brain-results --pattern "*.csv" --dir history > /dev/null 2>&1 || true`, {
|
||||
stdio: 'inherit',
|
||||
timeout: 30000,
|
||||
cwd: rootDir
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Artifact sync skipped.');
|
||||
}
|
||||
}
|
||||
|
||||
function jsonToCsv(items: any[]): string {
|
||||
if (items.length === 0) return '';
|
||||
const headers = Array.from(new Set(items.flatMap(item => Object.keys(item))));
|
||||
const csvRows = [headers.join(',')];
|
||||
for (const item of items) {
|
||||
const values = headers.map(header => {
|
||||
const val = item[header] ?? '';
|
||||
const stringVal = String(val);
|
||||
return stringVal.includes(',') ? `"${stringVal.replace(/"/g, '""')}"` : stringVal;
|
||||
});
|
||||
csvRows.push(values.join(','));
|
||||
}
|
||||
return csvRows.join('\n');
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
# Investigations and Process Updater Agent
|
||||
|
||||
Your task is to investigate metrics to understand what is contributing to their current values, and then safely improve the optimization scripts in the repository based on your findings.
|
||||
|
||||
## Phase 1: Investigation
|
||||
The investigation should search deeply to understand the shape of the data and identify any opportunities for improvement.
|
||||
|
||||
1. Analyze `metrics-before.csv` and compare it with any historical metrics in
|
||||
`history/` (e.g., `history/metrics-after.csv` from a previous run).
|
||||
2. Run existing scripts in `investigations/scripts/` to gather more data.
|
||||
3. If necessary, create NEW investigation scripts in `investigations/scripts/`
|
||||
to dig deeper (e.g., check issue labels, age, or assignees).
|
||||
4. **Hypothesis Testing**: For each metric not meeting goals:
|
||||
- **Develop Competing Hypotheses**: Brainstorm multiple potential root causes (e.g., "Latency is due to slow reviews" vs. "Latency is due to slow author responses").
|
||||
- **Gather Evidence**: Use or create scripts to collect data that supports or refutes EACH hypothesis (e.g., check timestamp of last review vs. last commit).
|
||||
- **Select Root Cause**: Identify the hypothesis most strongly supported by the data.
|
||||
- **Prioritize Impact**: Always prioritize making rules for verified hypotheses that have the largest impact. For example: if we find there's 500 PRs, and 30 of those have merge conflicts, reducing merge conflicts is a lot less helpful than some other cause that impacts more than 30 of the 500.
|
||||
5. **Maintainer Workload Assessment**: Before recommending process changes that rely on maintainer action (e.g., triage, review), you MUST actively quantify the maintainers' current capacity. Develop scripts to compare the volume of open, unactioned work (e.g., open issues, 'help wanted' PRs, untriaged items) against the number of active maintainers. If the ratio indicates overload (e.g., thousands of issues for a small team), do not propose solutions that simply generate more pings; instead, prioritize systemic triage or closure processes.
|
||||
6. If you learn something new about the shape of the problem, investigate the new dimension. Repeat as many times as needed to
|
||||
develop a comprehensive understanding of the shape of the problem.
|
||||
7. **Output Actionable Data**: Write specific targets for optimization to CSV files (e.g., `author_stale_prs.csv`). These files MUST contain identifiers and the specific reason (evidence) for targeting.
|
||||
8. Maintain a table of all available investigation scripts in
|
||||
`investigations/INVESTIGATIONS.md`.
|
||||
9. Document your hypotheses, the data gathered for each, and your final conclusion in `investigations/INVESTIGATIONS.md`.
|
||||
|
||||
## Phase 2: Process Update
|
||||
Based on your findings in Phase 1, you must update or create optimization scripts. You are strictly responsible for updating the scripts, NOT for running them against GitHub.
|
||||
|
||||
|
||||
Ensure that any customer communications are polite, respectful, and professional.
|
||||
|
||||
### Repo Policy Priorities
|
||||
Prioritize the following when automating repo policies. They are in order by priority:
|
||||
1. Security and product quality and other release requirements.
|
||||
2. Keeping a manageable and focused workload for core maintainers.
|
||||
3. Working effectively with the external contributor community, maintaining a close collaborative relationship with them, and treating them with respect and thanks.
|
||||
|
||||
### Core Requirements
|
||||
1. **Targeted Mitigation**: Ensure your proposed improvements or new scripts in `processes/scripts/` directly address the *confirmed* root cause from your investigation. The actions taken must tangibly drive the metric toward the goal (e.g., adding a label silently does not nudge a user or reduce the metric; closing, commenting, or explicitly pinging does).
|
||||
2. **Action-Oriented over Passive Reporting**: Processes must actually attempt to solve the bottleneck (e.g., applying labels, routing issues) rather than just generating reports telling humans they are behind. High-confidence automated actions should always be prioritized. Do not write scripts that only generate passive reports.
|
||||
3. **Data-Driven & Iterative**: Scripts can and should use both local data (e.g., `metrics-before.csv`, `investigations/INVESTIGATIONS.md`, and local CSVs you generate) and live API calls. The goal is that processes can learn and refine their actions over multiple runs based on the outputs of the investigations phase, while using live API calls to verify current state or execute actions.
|
||||
4. **Mandatory Simulation**: The scripts you write MUST output a `[concept]-after.csv` (e.g., `issues-after.csv`) in the project root simulating the final state, so that later phases can observe the intended results.
|
||||
5. **Safety & Idempotency**: Ensure any new scripts or updates you write are safe to run multiple times. They must check for existing states before acting.
|
||||
6. **Execution Gate**: All scripts you write MUST respect the `EXECUTE_ACTIONS` environment variable. If `process.env.EXECUTE_ACTIONS !== 'true'`, the script must perform a "dry-run" only (logging what it would do) and MUST NOT execute any state-changing `gh` CLI commands (like commenting, closing issues, labeling, etc.).
|
||||
7. **No Direct Execution**: You MUST NOT run `gh issue close`, `gh issue comment`, `gh issue edit`, `gh pr comment` or any other destructive GitHub CLI commands yourself. You are only allowed to write/update the local `.ts` files in `processes/scripts/`.
|
||||
8. Leave your changes locally. Do NOT create a PR. PR creation will be handled in a later phase.
|
||||
|
||||
### Implementation Best Practices
|
||||
- **Dynamic State**: Never hardcode current dates, times, or environmental states (e.g., `new Date('2026...')`) in the generated scripts. Use dynamic runtime generation (e.g., `new Date()`) so scripts remain robust and valid on future executions.
|
||||
- **Accurate Templating**: Do not use literal placeholder strings (like `"Hi @author"`) in communications. Always retrieve and interpolate the actual dynamic data (e.g., `${pr.author.login}`) from the API responses.
|
||||
- **Efficient Data Fetching**: Prefer server-side filtering (e.g., using `gh issue list --search "..."` or GraphQL queries) to narrow down results rather than fetching hundreds or thousands of items and filtering them locally in memory.
|
||||
- **Data Utilization**: Only request the data fields you need, and ensure you utilize the data you request to make intelligent decisions (e.g., acknowledging `isDraft` status to handle drafts appropriately).
|
||||
|
||||
### Workflow Design Principles
|
||||
When designing and updating processes, you must adhere to the following workflow safety rules to avoid creating a poor user experience:
|
||||
- **Actor-Aware Bottleneck Resolution**: Before acting on a stalled item, verify who the current blocker is. If waiting on an author, a polite nudge or closure grace period may be appropriate. If waiting on a maintainer (e.g., waiting for triage, reviews, or CI fixes), do not nudge the author. Furthermore, when maintainers are the bottleneck, do not just rely on pinging them. Instead, empower yourself to design processes and tools that systematically increase maintainer engagement, visibility, and throughput (e.g., routing mechanisms, aggregated reports, escalations, or new triage boards).
|
||||
- **Terminal Escalation & Anti-Spam**: Avoid infinite loops and redundant spam. If an automated process nudges a user, it must record that state (e.g., via a label) to prevent nudging them again for the same issue on subsequent runs. Furthermore, if an automated process nudges a user multiple times without resolution (e.g., for merge conflicts), the script must define a terminal state (e.g., automatically closing the PR/Issue after a set number of nudges or days).
|
||||
- **Graceful Closures**: Never forcefully close an item without providing prior warning (a nudge) and a reasonable grace period for the author to respond or object.
|
||||
|
||||
## Phase 3: Critique your approach
|
||||
Review the process updates you made and ensure that they are complete, backed by data, and not overly naive. Process scripts and instructions should never implement changes that fail to solve the root problem.
|
||||
|
||||
**Validation Checklist:** You MUST verify your scripts against your findings in `INVESTIGATIONS.md` before finalizing them.
|
||||
- [ ] Did you account for all data points found (e.g., draft PRs, specific labels)?
|
||||
- [ ] Are interventions correctly targeted at the blocking actor (e.g., maintainer vs. author)?
|
||||
- [ ] If waiting on maintainers, are you developing systemic processes to improve engagement and throughput rather than just relying on nudges?
|
||||
- [ ] Do your loops have terminal escalation states?
|
||||
- [ ] Do your closures have grace periods?
|
||||
- [ ] Do your actions align with the Repo Policy Priorities?
|
||||
|
||||
For example: when optimizing for fewer open PRs, nagging the user
|
||||
is not sufficient, if they are unable to complete their PR due to
|
||||
an unreliable CI.
|
||||
|
||||
If you determine that your processes are too naive or do not solve
|
||||
the root problem or otherwise degrade the quality of experience for
|
||||
maintainers or contributors, do the following:
|
||||
|
||||
1) Think through what information you are missing to make an informed process improvement.
|
||||
|
||||
2) Gather that information.
|
||||
|
||||
3) Thinking through the learnings from that information.
|
||||
|
||||
4) Update your INVESTIGATIONS.md and PROCESSES.md and process scripts to better optimize given the new knowledge.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Deep-Dive Investigations
|
||||
|
||||
This file documents ad hoc investigations performed to understand contributing factors to metrics.
|
||||
|
||||
| Investigation | Metric | Script | Findings |
|
||||
|---------------|--------|--------|----------|
|
||||
| Triage Backlog Analysis | open_issues | `analyze_issues.js` | 578/1000 issues are untriaged. 980/1000 are unassigned. |
|
||||
| Community PR Latency | latency_pr_community | `analyze_community_prs.js` | ~50% of sampled community PRs have merge conflicts. ~50% are ready for review but pending. |
|
||||
| Maintainer Workload | workload | `maintainer_workload.csv` | 13 active maintainers, ~77 issues per maintainer ratio. Only 20 issues are currently assigned. |
|
||||
| PR Conflict Analysis | latency_pr_community | `check_merge_conflicts.js` | 37/80 community PRs have merge conflicts. |
|
||||
|
||||
## Hypotheses and Findings
|
||||
|
||||
### Metric: `open_issues` (Current: 1000)
|
||||
- **Hypothesis 1**: High count is due to a massive triage backlog.
|
||||
- **Evidence**: 578 issues (58%) have `status/need-triage`. 980 issues (98%) are unassigned.
|
||||
- **Conclusion**: Supported. Triage is the primary bottleneck. Most issues are just sitting in the backlog without assignment.
|
||||
- **Hypothesis 2**: High count is due to stale issues.
|
||||
- **Evidence**: (To be gathered) Need to check age of untriaged issues.
|
||||
- **Conclusion**: Pending.
|
||||
|
||||
### Metric: `latency_pr_community_hours` (Current: 75.24)
|
||||
- **Hypothesis 1**: Latency is due to author-side merge conflicts.
|
||||
- **Evidence**: Previous sample showed 37/80 community PRs have conflicts.
|
||||
- **Conclusion**: Strongly Supported.
|
||||
- **Hypothesis 2**: Latency is due to waiting for maintainer review.
|
||||
- **Evidence**: Previous sample showed 34/80 PRs are SUCCESS CI + Mergeable but in `REVIEW_REQUIRED`.
|
||||
- **Conclusion**: Supported.
|
||||
- **Hypothesis 3**: Latency is due to CI failures.
|
||||
- **Evidence**: Previous sample showed only 2/80 had FAILURE status.
|
||||
- **Conclusion**: Refuted for the majority.
|
||||
|
||||
## Final Conclusions and Implemented Solutions
|
||||
|
||||
### Root Cause 1: Manual Triage Overload
|
||||
- **Finding**: 58% of issues were untriaged and 98% were unassigned, with 13 maintainers each potentially responsible for 77+ issues.
|
||||
- **Solution**: Implemented `triage_router.ts` with workload-aware assignment. It automatically categorizes issues (bug/feature) and assigns them to the maintainer with the lowest current workload. It also identifies low-quality reports and requests more info.
|
||||
- **Impact**: 94 issues were triaged/assigned in a single run.
|
||||
|
||||
### Root Cause 2: Communication Gap for Conflicts
|
||||
- **Finding**: 37% of community PRs had merge conflicts, many persisting for weeks.
|
||||
- **Solution**: Enhanced `pr_nudge.ts` to nudge authors about conflicts immediately. Added a terminal state in `stale_manager.ts` to automatically close PRs with conflicts after 14 days of inactivity.
|
||||
- **Impact**: 53 PRs nudged or targeted for closure.
|
||||
|
||||
### Root Cause 3: Stale Debt
|
||||
- **Finding**: Hundreds of issues were inactive for > 30 days. `status/needs-info` issues were never closed.
|
||||
- **Solution**: Updated `stale_manager.ts` to handle `status/needs-info` (stale after 7 days) and increased the scan limit.
|
||||
- **Impact**: 40 stale items identified and targeted for labeling/closure.
|
||||
|
||||
### Root Cause 4: Review Bottleneck
|
||||
- **Finding**: 34 community PRs were ready for review but unassigned.
|
||||
- **Solution**: Updated `pr_nudge.ts` to automatically assign a reviewer based on workload for any ready-to-review community PR.
|
||||
- **Impact**: Ensures every ready PR has a clear owner.
|
||||
@@ -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();
|
||||
@@ -0,0 +1,13 @@
|
||||
# Repository Metrics
|
||||
|
||||
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) |
|
||||
| domain_expertise | Tracks if reviewers in the maintainers group have domain expertise based on git blame of changed files and their neighbors | `metrics/scripts/domain_expertise.js` | Higher is better |
|
||||
@@ -0,0 +1,124 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const repoRoot = path.resolve(__dirname, '../../../../');
|
||||
|
||||
try {
|
||||
// 1. Fetch recent PR numbers and reviews from GitHub (so we have reviewer names/logins)
|
||||
const query = `
|
||||
query($owner: String!, $repo: String!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequests(last: 100, states: MERGED) {
|
||||
nodes {
|
||||
number
|
||||
reviews(first: 20) {
|
||||
nodes {
|
||||
authorAssociation
|
||||
author { login, ... on User { name } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const output = execSync(`gh api graphql -F owner=google-gemini -F repo=gemini-cli -f query='${query}'`, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
const data = JSON.parse(output).data.repository;
|
||||
|
||||
// 2. Map PR numbers to local commits using git log
|
||||
const logOutput = execSync('git log -n 5000 --format="%H|%s"', { cwd: repoRoot, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
const prCommits = new Map();
|
||||
for (const line of logOutput.split('\n')) {
|
||||
if (!line) continue;
|
||||
const [hash, subject] = line.split('|');
|
||||
const match = subject.match(/\(#(\d+)\)$/);
|
||||
if (match) {
|
||||
prCommits.set(parseInt(match[1], 10), hash);
|
||||
}
|
||||
}
|
||||
|
||||
let totalMaintainerReviews = 0;
|
||||
let maintainerReviewsWithExpertise = 0;
|
||||
|
||||
for (const pr of data.pullRequests.nodes) {
|
||||
if (!pr.reviews?.nodes || pr.reviews.nodes.length === 0) continue;
|
||||
|
||||
const commitHash = prCommits.get(pr.number);
|
||||
if (!commitHash) continue; // Skip if we don't have the commit locally
|
||||
|
||||
// 3. Get exact files changed using local git diff-tree, bypassing GraphQL limits
|
||||
const diffTreeOutput = execSync(`git diff-tree --no-commit-id --name-only -r ${commitHash}`, { cwd: repoRoot, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
const files = diffTreeOutput.split('\n').filter(Boolean);
|
||||
if (files.length === 0) continue;
|
||||
|
||||
// Cache git log authors per path to avoid redundant child_process calls
|
||||
const authorCache = new Map();
|
||||
const getAuthors = (targetPath) => {
|
||||
if (authorCache.has(targetPath)) return authorCache.get(targetPath);
|
||||
try {
|
||||
const authors = execSync(`git log --format="%an|%ae" -- "${targetPath}"`, { cwd: repoRoot, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).toLowerCase();
|
||||
authorCache.set(targetPath, authors);
|
||||
return authors;
|
||||
} catch (e) {
|
||||
authorCache.set(targetPath, "");
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const reviewersOnPR = new Map();
|
||||
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;
|
||||
reviewersOnPR.set(login, review.author);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [login, authorInfo] of reviewersOnPR.entries()) {
|
||||
totalMaintainerReviews++;
|
||||
let hasExpertise = false;
|
||||
const name = authorInfo.name ? authorInfo.name.toLowerCase() : "";
|
||||
|
||||
for (const file of files) {
|
||||
// Precise check: immediate file
|
||||
let authorsStr = getAuthors(file);
|
||||
if (authorsStr.includes(login) || (name && authorsStr.includes(name))) {
|
||||
hasExpertise = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Fallback: file's directory
|
||||
const dir = path.dirname(file);
|
||||
authorsStr = getAuthors(dir);
|
||||
if (authorsStr.includes(login) || (name && authorsStr.includes(name))) {
|
||||
hasExpertise = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasExpertise) {
|
||||
maintainerReviewsWithExpertise++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ratio = totalMaintainerReviews > 0 ? maintainerReviewsWithExpertise / totalMaintainerReviews : 0;
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
process.stdout.write(JSON.stringify({
|
||||
metric: 'domain_expertise',
|
||||
value: Math.round(ratio * 100) / 100,
|
||||
timestamp,
|
||||
details: {
|
||||
totalMaintainerReviews,
|
||||
maintainerReviewsWithExpertise
|
||||
}
|
||||
}) + '\n');
|
||||
|
||||
} catch (err) {
|
||||
process.stderr.write(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -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,14 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
try {
|
||||
const output = execSync('gh issue list --state open --limit 1000 --json number', { encoding: 'utf-8' });
|
||||
const issues = JSON.parse(output);
|
||||
process.stdout.write(JSON.stringify({
|
||||
metric: 'open_issues',
|
||||
value: issues.length,
|
||||
timestamp: new Date().toISOString()
|
||||
}));
|
||||
} catch (err) {
|
||||
process.stderr.write(err.message);
|
||||
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);
|
||||
}
|
||||
Generated
+631
@@ -0,0 +1,631 @@
|
||||
{
|
||||
"name": "optimizer1000",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "optimizer1000",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-sdk": "file:../../packages/sdk",
|
||||
"commander": "^12.0.0",
|
||||
"csv-writer": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.12.7",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
},
|
||||
"../../packages/sdk": {
|
||||
"name": "@google/gemini-cli-sdk",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
"zod": "^3.23.8",
|
||||
"zod-to-json-schema": "^3.23.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
|
||||
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
|
||||
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
|
||||
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
|
||||
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@google/gemini-cli-sdk": {
|
||||
"resolved": "../../packages/sdk",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.39",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
||||
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "12.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
||||
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/csv-writer": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/csv-writer/-/csv-writer-1.6.0.tgz",
|
||||
"integrity": "sha512-NOx7YDFWEsM/fTRAJjRpPp8t+MKRVvniAg9wQlUKx20MFrPs73WLJhFf5iteqrxNYnsy924K3Iroh3yNHeYd2g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
||||
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.7",
|
||||
"@esbuild/android-arm": "0.27.7",
|
||||
"@esbuild/android-arm64": "0.27.7",
|
||||
"@esbuild/android-x64": "0.27.7",
|
||||
"@esbuild/darwin-arm64": "0.27.7",
|
||||
"@esbuild/darwin-x64": "0.27.7",
|
||||
"@esbuild/freebsd-arm64": "0.27.7",
|
||||
"@esbuild/freebsd-x64": "0.27.7",
|
||||
"@esbuild/linux-arm": "0.27.7",
|
||||
"@esbuild/linux-arm64": "0.27.7",
|
||||
"@esbuild/linux-ia32": "0.27.7",
|
||||
"@esbuild/linux-loong64": "0.27.7",
|
||||
"@esbuild/linux-mips64el": "0.27.7",
|
||||
"@esbuild/linux-ppc64": "0.27.7",
|
||||
"@esbuild/linux-riscv64": "0.27.7",
|
||||
"@esbuild/linux-s390x": "0.27.7",
|
||||
"@esbuild/linux-x64": "0.27.7",
|
||||
"@esbuild/netbsd-arm64": "0.27.7",
|
||||
"@esbuild/netbsd-x64": "0.27.7",
|
||||
"@esbuild/openbsd-arm64": "0.27.7",
|
||||
"@esbuild/openbsd-x64": "0.27.7",
|
||||
"@esbuild/openharmony-arm64": "0.27.7",
|
||||
"@esbuild/sunos-x64": "0.27.7",
|
||||
"@esbuild/win32-arm64": "0.27.7",
|
||||
"@esbuild/win32-ia32": "0.27.7",
|
||||
"@esbuild/win32-x64": "0.27.7"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.14.0",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
|
||||
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve-pkg-maps": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "optimizer1000",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "tsx index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": "^12.0.0",
|
||||
"csv-writer": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.4.5",
|
||||
"@types/node": "^20.12.7",
|
||||
"@google/gemini-cli-core": "file:../../packages/core"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
# Optimizer Read-Only Policy
|
||||
# This policy prevents the agent from performing destructive actions on GitHub.
|
||||
|
||||
[[rule]]
|
||||
toolName = "run_shell_command"
|
||||
commandRegex = "^gh [a-z0-9-]+ (create|close|comment|delete|develop|edit|lock|pin|reopen|transfer|unlock|unpin|checkout|merge|ready|revert|review|update-branch|copy|field-create|field-delete|item-add|item-archive|item-create|item-delete|item-edit|link|mark-template|unlink|set)\\b"
|
||||
decision = "deny"
|
||||
priority = 200
|
||||
denyMessage = "State-changing GitHub commands are prohibited in dry-run mode. Use --execute-actions to enable them."
|
||||
description = "Deny state-changing commands for issues, PRs, projects, and repos."
|
||||
|
||||
[[rule]]
|
||||
toolName = "run_shell_command"
|
||||
commandRegex = "^gh api.*(?:-X|--method)\\s*(?:POST|PUT|PATCH|DELETE)"
|
||||
decision = "deny"
|
||||
priority = 200
|
||||
denyMessage = "State-changing GitHub API commands are prohibited in dry-run mode."
|
||||
description = "Deny state-changing gh api calls."
|
||||
|
||||
[[rule]]
|
||||
toolName = "run_shell_command"
|
||||
commandRegex = "^gh co\\b"
|
||||
decision = "deny"
|
||||
priority = 200
|
||||
denyMessage = "State-changing GitHub commands are prohibited in dry-run mode."
|
||||
description = "Deny gh co alias."
|
||||
|
||||
[[rule]]
|
||||
toolName = "run_shell_command"
|
||||
commandPrefix = "gh "
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
description = "Allow all other read-only GitHub commands for data gathering."
|
||||
@@ -0,0 +1,10 @@
|
||||
# Processes Runner Agent
|
||||
|
||||
Your task is to run the existing optimization processes to improve repository metrics based on investigations and current state. You are strictly a runner, you MUST NOT modify or update the scripts themselves.
|
||||
|
||||
1. Read `processes/PROCESSES.md` to identify all active processes and their corresponding scripts in `processes/scripts/`.
|
||||
2. Run each documented script (e.g., using `npx tsx <script-path>`). The scripts will automatically read `metrics-before.csv` and `investigations/INVESTIGATIONS.md` if needed.
|
||||
3. The scripts are already programmed to respect the `EXECUTE_ACTIONS` environment variable. Do not override this variable.
|
||||
4. **Professional Communication & Empathy**: The scripts have been written to maintain a helpful and collaborative tone, clarifying before closing issues.
|
||||
5. **Mandatory Simulation**: The scripts will generate `[concept]-after.csv` (e.g., `issues-after.csv`) in the project root simulating the final state.
|
||||
6. If any tool fails (e.g., policy denial), report the error.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Optimization Processes
|
||||
|
||||
This file documents the automated processes implemented to drive repository metrics toward their goals.
|
||||
|
||||
| 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,166 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { updateSimulationCsv, execGh, getRepoInfo, getMaintainers, getMaintainerWorkload } 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 {
|
||||
const MAINTAINERS = await getMaintainers();
|
||||
const WORKLOAD = await getMaintainerWorkload();
|
||||
|
||||
// 1. Fetch community PRs
|
||||
const query = `
|
||||
query($owner: String!, $repo: String!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequests(first: 500, states: OPEN, orderBy: {field: UPDATED_AT, direction: ASC}) {
|
||||
nodes {
|
||||
number
|
||||
author { login }
|
||||
authorAssociation
|
||||
createdAt
|
||||
updatedAt
|
||||
isDraft
|
||||
reviewDecision
|
||||
mergeable
|
||||
assignees(first: 1) { nodes { login } }
|
||||
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', maxBuffer: 50 * 1024 * 1024 });
|
||||
const data = JSON.parse(output).data.repository;
|
||||
const prs = data.pullRequests.nodes;
|
||||
|
||||
const actions = [];
|
||||
const now = new Date();
|
||||
|
||||
// Sort maintainers by workload (ascending)
|
||||
const sortedMaintainers = MAINTAINERS
|
||||
.filter(m => m !== 'TOTAL_MAINTAINERS')
|
||||
.sort((a, b) => (WORKLOAD[a] || 0) - (WORKLOAD[b] || 0));
|
||||
|
||||
let mIndex = 0;
|
||||
|
||||
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 daysSinceUpdate = (now.getTime() - updatedAt.getTime()) / (1000 * 60 * 60 * 24);
|
||||
const hoursSinceUpdate = daysSinceUpdate * 24;
|
||||
|
||||
const hasNudgeLabel = pr.labels.nodes.some(l => l.name === 'status/nudge');
|
||||
const hasConflictLabel = pr.labels.nodes.some(l => l.name === 'status/merge-conflict');
|
||||
const hasAssignee = pr.assignees.nodes.length > 0;
|
||||
|
||||
// 1. Terminal State: Close stale conflicts
|
||||
if (isConflicting && hasConflictLabel && daysSinceUpdate > 14) {
|
||||
actions.push({
|
||||
number: pr.number,
|
||||
type: 'close-conflict',
|
||||
comment: `Hi @${pr.author?.login || 'author'}! This PR has had merge conflicts for over 14 days. We are closing it to keep the queue manageable. Please feel free to reopen it once you have resolved the conflicts and synchronized with the main branch.`
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. 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;
|
||||
}
|
||||
|
||||
// 3. Maintainer Action for Ready PRs
|
||||
if (isCiSuccess && isMergeable && pr.reviewDecision === 'REVIEW_REQUIRED') {
|
||||
if (!hasAssignee) {
|
||||
// Assign a maintainer based on workload
|
||||
const assignee = sortedMaintainers[mIndex % sortedMaintainers.length];
|
||||
mIndex++;
|
||||
WORKLOAD[assignee] = (WORKLOAD[assignee] || 0) + 1;
|
||||
|
||||
actions.push({
|
||||
number: pr.number,
|
||||
type: 'assign-reviewer',
|
||||
assignee,
|
||||
comment: `Hi @${assignee}! This community PR by @${pr.author?.login || 'author'} is ready for review (Mergeable + CI Success). Assigning to you based on current workload.`
|
||||
});
|
||||
} else if (hoursSinceUpdate > 48 && !hasNudgeLabel) {
|
||||
// Nudge existing assignee if inactive for 48 hours
|
||||
actions.push({
|
||||
number: pr.number,
|
||||
type: 'maintainer-nudge',
|
||||
comment: `Hi @${pr.assignees.nodes[0].login}! This PR is ready and has been inactive for over 48 hours. Could you please take a look?`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Execute actions
|
||||
const simulationUpdates = new Map<string, Record<string, string>>();
|
||||
|
||||
for (const action of actions) {
|
||||
try {
|
||||
if (action.type === 'author-nudge-conflict') {
|
||||
await 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 === 'close-conflict') {
|
||||
await execGh(`pr close ${action.number}`, EXECUTE_ACTIONS);
|
||||
simulationUpdates.set(action.number.toString(), { state: 'CLOSED' });
|
||||
} else if (action.type === 'assign-reviewer') {
|
||||
await execGh(`pr edit ${action.number} --add-assignee "${action.assignee}" --add-label "status/nudge"`, EXECUTE_ACTIONS);
|
||||
simulationUpdates.set(action.number.toString(), { assignee: action.assignee, labels: 'status/nudge' });
|
||||
} else if (action.type === 'maintainer-nudge') {
|
||||
await execGh(`pr edit ${action.number} --add-label "status/nudge"`, EXECUTE_ACTIONS);
|
||||
simulationUpdates.set(action.number.toString(), { labels: 'status/nudge' });
|
||||
}
|
||||
|
||||
if (action.comment) {
|
||||
await 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/actions.`);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error in PR Nudge:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* @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
|
||||
const issueQuery = `
|
||||
query($owner: String!, $repo: String!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
issues(first: 1000, states: OPEN, orderBy: {field: UPDATED_AT, direction: ASC}) {
|
||||
nodes {
|
||||
number
|
||||
authorAssociation
|
||||
updatedAt
|
||||
labels(first: 20) {
|
||||
nodes { name }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
let issueOutput;
|
||||
try {
|
||||
issueOutput = execSync(`gh api graphql -F owner=${owner} -F repo=${repo} -f query='${issueQuery}'`, { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024 });
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch issues from GitHub:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2. Fetch open PRs
|
||||
const prQuery = `
|
||||
query($owner: String!, $repo: String!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequests(first: 500, 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 prOutput;
|
||||
try {
|
||||
prOutput = execSync(`gh api graphql -F owner=${owner} -F repo=${repo} -f query='${prQuery}'`, { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024 });
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch PRs from GitHub:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const issueData = JSON.parse(issueOutput).data.repository;
|
||||
const prData = JSON.parse(prOutput).data.repository;
|
||||
const items = [...issueData.issues.nodes.map(i => ({...i, type: 'issue'})), ...prData.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 needsInfo = item.labels.nodes.some(l => l.name === 'status/needs-info');
|
||||
const isCommunity = !['MEMBER', 'OWNER', 'COLLABORATOR'].includes(item.authorAssociation);
|
||||
|
||||
if (isMaintainerOnly) continue; // Maintainer issues have their own lifecycle
|
||||
|
||||
// Special handling for needs-info: mark stale faster
|
||||
if (needsInfo && !isStale && daysSinceUpdate > 7) {
|
||||
actions.push({
|
||||
number: item.number,
|
||||
target: item.type,
|
||||
type: 'label',
|
||||
label: 'Stale',
|
||||
comment: `Hi! This ${item.type} was marked as 'status/needs-info' but has had no activity for 7 days. We are labeling it as 'Stale'. It will be closed in 14 days if no further activity occurs. Thank you!`
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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') {
|
||||
await execGh(`${cmdPrefix} edit ${action.number} --add-label "${action.label}"`, EXECUTE_ACTIONS);
|
||||
simulationMap.set(action.number.toString(), { labels: action.label });
|
||||
}
|
||||
if (action.comment) {
|
||||
await execGh(`${cmdPrefix} comment ${action.number} --body "${action.comment}"`, EXECUTE_ACTIONS);
|
||||
}
|
||||
if (action.type === 'close') {
|
||||
await 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,138 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { getMaintainers, execGh, getRepoInfo, updateSimulationCsv, getMaintainerWorkload } 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();
|
||||
const WORKLOAD = await getMaintainerWorkload();
|
||||
console.log(`Fetched ${MAINTAINERS.length} maintainers and current workloads.`);
|
||||
|
||||
// 1. Fetch untriaged issues (Increase limit to process the backlog)
|
||||
const query = `
|
||||
query($owner: String!, $repo: String!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
issues(first: 1000, states: OPEN, labels: ["status/need-triage"], orderBy: {field: CREATED_AT, direction: ASC}) {
|
||||
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 = [];
|
||||
|
||||
// Sort maintainers by workload (ascending)
|
||||
const sortedMaintainers = MAINTAINERS
|
||||
.filter(m => m !== 'TOTAL_MAINTAINERS') // safeguard
|
||||
.sort((a, b) => (WORKLOAD[a] || 0) - (WORKLOAD[b] || 0));
|
||||
|
||||
let mIndex = 0;
|
||||
|
||||
for (const issue of issues) {
|
||||
if (issue.assignees.nodes.length > 0) continue;
|
||||
|
||||
const body = issue.body || '';
|
||||
const title = issue.title.toLowerCase();
|
||||
|
||||
// Better categorization
|
||||
const labelsToAdd: string[] = [];
|
||||
if (title.includes('bug') || body.toLowerCase().includes('expected behavior')) {
|
||||
labelsToAdd.push('type/bug');
|
||||
} else if (title.includes('feature') || title.includes('enhancement') || body.toLowerCase().includes('proposed change')) {
|
||||
labelsToAdd.push('type/feature');
|
||||
}
|
||||
|
||||
// Low quality check
|
||||
if (body.length < 50 || title.length < 10 || !body.includes('###')) {
|
||||
actions.push({
|
||||
number: issue.number,
|
||||
type: 'needs-info',
|
||||
labelsToAdd: ['status/needs-info'],
|
||||
labelsToRemove: ['status/need-triage'],
|
||||
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;
|
||||
}
|
||||
|
||||
// Assign to the maintainer with the lowest workload
|
||||
const assignee = sortedMaintainers[mIndex % sortedMaintainers.length];
|
||||
mIndex++;
|
||||
// Increment local workload tracker to keep distribution even during this run
|
||||
WORKLOAD[assignee] = (WORKLOAD[assignee] || 0) + 1;
|
||||
|
||||
actions.push({
|
||||
number: issue.number,
|
||||
type: 'assign',
|
||||
assignee,
|
||||
labelsToAdd: [...labelsToAdd, 'status/manual-triage'],
|
||||
labelsToRemove: ['status/need-triage'],
|
||||
comment: `Automated Triage: Assigning to @${assignee} based on current workload. Please categorize and set priority.`
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Execute actions
|
||||
const simulationUpdates = new Map<string, Record<string, string>>();
|
||||
|
||||
for (const action of actions) {
|
||||
try {
|
||||
const addLabels = action.labelsToAdd?.map(l => `"${l}"`).join(',') || '';
|
||||
const removeLabels = action.labelsToRemove?.map(l => `"${l}"`).join(',') || '';
|
||||
|
||||
let editCmd = `issue edit ${action.number}`;
|
||||
if (addLabels) editCmd += ` --add-label ${addLabels}`;
|
||||
if (removeLabels) editCmd += ` --remove-label ${removeLabels}`;
|
||||
if (action.assignee) editCmd += ` --add-assignee "${action.assignee}"`;
|
||||
|
||||
await execGh(editCmd, EXECUTE_ACTIONS);
|
||||
simulationUpdates.set(action.number.toString(), {
|
||||
labels: action.labelsToAdd?.join(', ') || '',
|
||||
assignee: action.assignee || ''
|
||||
});
|
||||
|
||||
if (action.comment) {
|
||||
await 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,195 @@
|
||||
/**
|
||||
* @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) {
|
||||
const cleanLine = line.split('#')[0].trim();
|
||||
if (!cleanLine) continue;
|
||||
|
||||
// Match @user or @org/team
|
||||
const matches = cleanLine.match(/@[\w-]+\/[\w-]+|@[\w-]+/g);
|
||||
if (matches) {
|
||||
for (const match of matches) {
|
||||
if (match.includes('/')) {
|
||||
// For teams, we should ideally expand them via gh api,
|
||||
// but for simulation/simple use, we'll just log it.
|
||||
console.log(`[INFO] Found team ownership: ${match}. Skipping team expansion for now.`);
|
||||
continue;
|
||||
}
|
||||
maintainers.add(match.replace('@', ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (maintainers.size === 0) {
|
||||
console.warn('No maintainers found in CODEOWNERS, using fallbacks.');
|
||||
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) {
|
||||
if (colName === 'labels') {
|
||||
const existingLabels = columns[colIndex].replace(/"/g, '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
const newLabels = newValue.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const combined = Array.from(new Set([...existingLabels, ...newLabels]));
|
||||
columns[colIndex] = `"${combined.join(', ')}"`;
|
||||
} else {
|
||||
columns[colIndex] = newValue.includes(',') ? `"${newValue}"` : newValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
newLines.push(columns.join(','));
|
||||
}
|
||||
|
||||
await fs.writeFile(filePath, newLines.join('\n'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads maintainer workload from maintainer_workload.csv.
|
||||
*/
|
||||
export async function getMaintainerWorkload(): Promise<Record<string, number>> {
|
||||
try {
|
||||
const filePath = path.join(process.cwd(), 'maintainer_workload.csv');
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
const workload: Record<string, number> = {};
|
||||
|
||||
const lines = content.split('\n');
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line || line.startsWith('TOTAL') || line.startsWith('RATIO')) continue;
|
||||
|
||||
const columns = line.split(',');
|
||||
const maintainer = columns[0];
|
||||
const assignedIssues = parseInt(columns[1] || '0', 10);
|
||||
const assignedPrs = parseInt(columns[2] || '0', 10);
|
||||
workload[maintainer] = assignedIssues + assignedPrs;
|
||||
}
|
||||
return workload;
|
||||
} catch (err) {
|
||||
console.warn('maintainer_workload.csv not found or unreadable, returning empty workload.');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a gh command with logging and dry-run support.
|
||||
*/
|
||||
export async function execGh(command: string, execute: boolean) {
|
||||
if (!execute) {
|
||||
console.log(`[DRY RUN] Would execute: gh ${command}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Executing: gh ${command}`);
|
||||
try {
|
||||
// Small delay to be nicer to the API and avoid race conditions if run concurrently
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
execSync(`gh ${command}`, { stdio: 'inherit' });
|
||||
} catch (err) {
|
||||
console.error(`Failed to execute gh ${command}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
query($owner: String!, $repo: String!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequests(last: 100, states: MERGED) {
|
||||
nodes {
|
||||
number
|
||||
reviews(first: 20) {
|
||||
nodes {
|
||||
authorAssociation
|
||||
author { login, ... on User { name } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user