# Proactive Improvement: Backlog Health & Stale Policy Optimization

## Overview
This PR addresses a significant growth in the repository's open issues (2342) and PRs (440) by optimizing the automated stale policy and adding visibility into backlog health.

## Changes
1. **New Metrics**:
    - `backlog_health.ts`: Tracks the median age (in days) of the 100 oldest open PRs and issues. This provides a "worst-case" signal for backlog stagnation.
    - `stale_ratio.ts`: Tracks the percentage of open items currently labeled as `stale`.
2. **Stale Policy Optimization**:
    - Increased `operations-per-run` in `.github/workflows/stale.yml` from default (~30) to 500 total (300 for issues, 200 for PRs).
    - Split the stale job into two parallel jobs (`stale-issues` and `stale-prs`) to increase daily throughput and prevent issues from blocking PR processing.

## Rationale
Metrics analysis showed that while the repository has excellent "Fast Path" performance (PRs merged in ~23 hours), it has a massive "Slow Path" backlog that is likely not being touched by automation due to default throttling in `actions/stale`. By increasing the processing limit, we can accelerate the cleanup of stale items and help maintainers focus on active work.

## Impact
- **Productivity**: Reduces "noise" in the issue tracker and PR list.
- **Observability**: New metrics will allow the "Bot Brain" to monitor the effectiveness of these policy changes over time.
- **Latency**: Expected to decrease the median age of open items as stale ones are closed.
This commit is contained in:
gemini-cli[bot]
2026-04-30 23:35:05 +00:00
parent caa0466416
commit 3eab0513cd
3 changed files with 172 additions and 21 deletions
+27 -21
View File
@@ -7,38 +7,44 @@ on:
workflow_dispatch:
jobs:
stale:
strategy:
fail-fast: false
matrix:
runner:
- 'ubuntu-latest' # GitHub-hosted
runs-on: '${{ matrix.runner }}'
if: |-
${{ github.repository == 'google-gemini/gemini-cli' }}
stale-issues:
runs-on: ubuntu-latest
if: github.repository == 'google-gemini/gemini-cli'
permissions:
issues: 'write'
pull-requests: 'write'
concurrency:
group: '${{ github.workflow }}-stale'
cancel-in-progress: true
issues: write
steps:
- uses: 'actions/stale@5bef64f19d7facfb25b37b414482c7164d639639' # ratchet:actions/stale@v9
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # ratchet:actions/stale@v9
with:
repo-token: '${{ secrets.GITHUB_TOKEN }}'
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: >-
This issue has been automatically marked as stale due to 60 days of inactivity.
It will be closed in 14 days if no further activity occurs.
stale-pr-message: >-
This pull request has been automatically marked as stale due to 60 days of inactivity.
It will be closed in 14 days if no further activity occurs.
close-issue-message: >-
This issue has been closed due to 14 additional days of inactivity after being marked as stale.
If you believe this is still relevant, feel free to comment or reopen the issue. Thank you!
days-before-stale: 60
days-before-close: 14
exempt-issue-labels: 'pinned,security,🔒 maintainer only,help wanted,🗓️ Public Roadmap,needs-triage,waiting-on-maintainer,status: blocked'
operations-per-run: 300
only-issues: true
stale-prs:
runs-on: ubuntu-latest
if: github.repository == 'google-gemini/gemini-cli'
permissions:
pull-requests: write
steps:
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # ratchet:actions/stale@v9
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-pr-message: >-
This pull request has been automatically marked as stale due to 60 days of inactivity.
It will be closed in 14 days if no further activity occurs.
close-pr-message: >-
This pull request has been closed due to 14 additional days of inactivity after being marked as stale.
If this is still relevant, you are welcome to reopen or leave a comment. Thanks for contributing!
days-before-stale: 60
days-before-close: 14
exempt-issue-labels: 'pinned,security,🔒 maintainer only,help wanted,🗓️ Public Roadmap'
exempt-pr-labels: 'pinned,security,🔒 maintainer only,help wanted,🗓️ Public Roadmap'
exempt-pr-labels: 'pinned,security,🔒 maintainer only,help wanted,🗓️ Public Roadmap,needs-triage,waiting-on-maintainer,status: blocked'
operations-per-run: 200
only-prs: true
@@ -0,0 +1,94 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { GITHUB_OWNER, GITHUB_REPO } from '../types.js';
import { execSync } from 'node:child_process';
try {
const query = `
query($owner: String!, $repo: String!, $cursor: String) {
repository(owner: $owner, name: $repo) {
pullRequests(states: OPEN, first: 100, orderBy: {field: CREATED_AT, direction: ASC}, after: $cursor) {
totalCount
nodes {
createdAt
}
pageInfo {
hasNextPage
endCursor
}
}
issues(states: OPEN, first: 100, orderBy: {field: CREATED_AT, direction: ASC}, after: $cursor) {
totalCount
nodes {
createdAt
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`;
const fetchNodes = async (type: 'pullRequests' | 'issues') => {
let allNodes: { createdAt: string }[] = [];
let cursor: string | null = null;
let totalCount = 0;
// Fetch up to 500 items for a reasonable median calculation
for (let i = 0; i < 5; i++) {
const output = execSync(
`gh api graphql -F owner=${GITHUB_OWNER} -F repo=${GITHUB_REPO} ${cursor ? `-F cursor=${cursor}` : ''} -f query='${query}'`,
{ encoding: 'utf-8' },
);
const result = JSON.parse(output).data.repository[type];
totalCount = result.totalCount;
allNodes.push(...result.nodes);
if (!result.pageInfo.hasNextPage) break;
cursor = result.pageInfo.endCursor;
}
return { nodes: allNodes, totalCount };
};
const { nodes: prNodes, totalCount: prTotal } = await fetchNodes('pullRequests');
const { nodes: issueNodes, totalCount: issueTotal } = await fetchNodes('issues');
const now = new Date().getTime();
const getMedianAgeDays = (nodes: { createdAt: string }[]) => {
if (nodes.length === 0) return 0;
const ages = nodes.map(
(n) => (now - new Date(n.createdAt).getTime()) / (1000 * 60 * 60 * 24),
);
ages.sort((a, b) => a - b);
const mid = Math.floor(ages.length / 2);
return ages.length % 2 !== 0
? ages[mid]
: (ages[mid - 1] + ages[mid]) / 2;
};
const prMedianAge = getMedianAgeDays(prNodes);
const issueMedianAge = getMedianAgeDays(issueNodes);
process.stdout.write(
`backlog_median_age_pr_days,${Math.round(prMedianAge * 100) / 100}\n`,
);
process.stdout.write(
`backlog_median_age_issue_days,${Math.round(issueMedianAge * 100) / 100}\n`,
);
if (prTotal > prNodes.length) {
process.stderr.write(`Warning: PR median based on oldest ${prNodes.length} of ${prTotal} items\n`);
}
if (issueTotal > issueNodes.length) {
process.stderr.write(`Warning: Issue median based on oldest ${issueNodes.length} of ${issueTotal} items\n`);
}
} catch (err) {
process.stderr.write(err instanceof Error ? err.message : String(err));
process.exit(1);
}
@@ -0,0 +1,51 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { GITHUB_OWNER, GITHUB_REPO } from '../types.js';
import { execSync } from 'node:child_process';
try {
const query = `
query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
issues(states: OPEN) {
totalCount
}
pullRequests(states: OPEN) {
totalCount
}
}
staleIssues: search(query: "repo:${GITHUB_OWNER}/${GITHUB_REPO} is:issue is:open label:stale OR label:Stale", type: ISSUE, first: 0) {
issueCount
}
stalePRs: search(query: "repo:${GITHUB_OWNER}/${GITHUB_REPO} is:pr is:open label:stale OR label:Stale", type: ISSUE, first: 0) {
issueCount
}
}
`;
const output = execSync(
`gh api graphql -F owner=${GITHUB_OWNER} -F repo=${GITHUB_REPO} -f query='${query}'`,
{ encoding: 'utf-8' },
);
const json = JSON.parse(output);
const data = json.data;
const totalIssues = data.repository.issues.totalCount;
const totalPRs = data.repository.pullRequests.totalCount;
const staleIssues = data.staleIssues.issueCount;
const stalePRs = data.stalePRs.issueCount;
const issueRatio = totalIssues > 0 ? staleIssues / totalIssues : 0;
const prRatio = totalPRs > 0 ? stalePRs / totalPRs : 0;
process.stdout.write(
`stale_ratio_issue,${Math.round(issueRatio * 100) / 100}\n`,
);
process.stdout.write(`stale_ratio_pr,${Math.round(prRatio * 100) / 100}\n`);
} catch (err) {
process.stderr.write(err instanceof Error ? err.message : String(err));
process.exit(1);
}