Files
gemini-cli/.github/workflows/gemini-scheduled-stale-issue-closer.yml
T
gemini-cli[bot] e9efec3a5b # PR: Fix Lint, Stale Logic & Policy Conflict
This PR addresses critical technical and logical flaws identified in the previous bot run.

### Changes:
- **Fixed Lint Failures**: Resolved unused variable errors in `tools/gemini-cli-bot/metrics/scripts/backlog_age.ts`.
- **Robust Stale Logic**: Refactored `.github/workflows/gemini-scheduled-stale-issue-closer.yml` to:
    - Implement a proper **2-phase logic** (Nudge then Close).
    - Add **automatic 'stale' label removal** when human activity is detected.
    - Implement **robust human activity detection** checking comments, issue events (e.g. description updates), and creation date.
    - Added a **180-day threshold** for `help wanted` issues to reduce backlog bloat while respecting community-friendly labels.
- **Consolidated Stale Policy**: Disabled issue processing in `.github/workflows/stale.yml` and increased its throughput for PRs to 300 operations per run. Centralizing issue management in the custom scheduled closer eliminates contradictory behaviors and policy fragmentation.

### Expected Impact:
- **Backlog Reduction**: Safely targets a massive issue backlog (2300+) by allowing `help wanted` issues to eventually go stale after 6 months of inactivity.
- **Improved Accuracy**: Prevents incorrect closures by accurately detecting human engagement even without comments.
- **Maintainer Confidence**: Ensures issues are only closed after a mandatory nudge period and that engagement is properly rewarded by resetting the stale state.
2026-05-01 04:09:13 +00:00

209 lines
8.8 KiB
YAML

name: '🔒 Gemini Scheduled Stale Issue Closer'
on:
schedule:
- cron: '0 0 * * 0' # Every Sunday at midnight UTC
workflow_dispatch:
inputs:
dry_run:
description: 'Run in dry-run mode (no changes applied)'
required: false
default: false
type: 'boolean'
concurrency:
group: '${{ github.workflow }}'
cancel-in-progress: true
defaults:
run:
shell: 'bash'
jobs:
close-stale-issues:
if: "github.repository == 'google-gemini/gemini-cli'"
runs-on: 'ubuntu-latest'
permissions:
issues: 'write'
steps:
- name: 'Generate GitHub App Token'
id: 'generate_token'
uses: 'actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349' # ratchet:actions/create-github-app-token@v2
with:
app-id: '${{ secrets.APP_ID }}'
private-key: '${{ secrets.PRIVATE_KEY }}'
permission-issues: 'write'
- name: 'Process Stale Issues'
uses: 'actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b' # ratchet:actions/github-script@v7
env:
DRY_RUN: '${{ inputs.dry_run }}'
with:
github-token: '${{ steps.generate_token.outputs.token }}'
script: |
const dryRun = process.env.DRY_RUN === 'true';
if (dryRun) {
core.info('DRY RUN MODE ENABLED: No changes will be applied.');
}
const STALE_LABEL = 'stale';
const SIXTY_DAYS_MS = 60 * 24 * 60 * 60 * 1000;
const ONE_EIGHTY_DAYS_MS = 180 * 24 * 60 * 60 * 1000;
const GRACE_PERIOD_MS = 14 * 24 * 60 * 60 * 1000;
const now = Date.now();
const sixtyDaysAgo = new Date(now - SIXTY_DAYS_MS);
const hundredEightyDaysAgo = new Date(now - ONE_EIGHTY_DAYS_MS);
const graceThreshold = new Date(now - GRACE_PERIOD_MS);
core.info(`Cutoff for standard issues: ${sixtyDaysAgo.toISOString()}`);
core.info(`Cutoff for 'help wanted' issues: ${hundredEightyDaysAgo.toISOString()}`);
core.info(`Grace period threshold: ${graceThreshold.toISOString()}`);
// 1. Un-stale or Close issues ALREADY marked as stale
const staleIssues = await github.paginate(github.rest.issues.listForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
labels: STALE_LABEL,
state: 'open',
per_page: 100
});
core.info(`Checking ${staleIssues.length} issues already marked as '${STALE_LABEL}'.`);
for (const issue of staleIssues) {
const events = await github.paginate(github.rest.issues.listEvents, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number
});
const staleEvent = events.reverse().find(e => e.event === 'labeled' && e.label?.name === STALE_LABEL);
if (!staleEvent) {
core.warning(`Issue #${issue.number} has '${STALE_LABEL}' label but no labeling event found. Skipping.`);
continue;
}
const staleDate = new Date(staleEvent.created_at);
// Check for human activity AFTER staleDate
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
since: staleDate.toISOString()
});
const hasHumanComment = comments.data.some(c => c.user.type !== 'Bot');
const hasHumanEvent = events.some(e =>
e.actor.type !== 'Bot' &&
new Date(e.created_at) > staleDate &&
e.event !== 'labeled'
);
if (hasHumanComment || hasHumanEvent) {
core.info(`Human activity detected on #${issue.number} since it was marked stale. Removing label.`);
if (!dryRun) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
name: STALE_LABEL
});
}
continue;
}
// Phase 2: Close if grace period exceeded
if (staleDate < graceThreshold) {
core.info(`Closing #${issue.number} (marked stale on ${staleDate.toISOString()}).`);
if (!dryRun) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: '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!'
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: 'closed',
state_reason: 'not_planned'
});
}
}
}
// 2. Mark NEW issues as stale
// Note: We use search to efficiently find candidates.
const query = `repo:${context.repo.owner}/${context.repo.repo} is:issue is:open -label:${STALE_LABEL} updated:<${sixtyDaysAgo.toISOString()}`;
const searchResponse = await github.rest.search.issuesAndPullRequests({
q: query,
sort: 'updated',
order: 'asc',
per_page: 100
});
const candidates = searchResponse.data.items;
core.info(`Found ${candidates.length} candidates to potentially mark as stale.`);
let markedCount = 0;
for (const issue of candidates) {
if (markedCount >= 50) break; // Safety limit per run
const labels = issue.labels.map(l => l.name.toLowerCase());
// Standard exemptions
if (labels.some(l => l.includes('maintainer') || l.includes('security') || l.includes('pinned') || l.includes('roadmap'))) {
continue;
}
const isHelpWanted = labels.includes('help wanted');
const threshold = isHelpWanted ? hundredEightyDaysAgo : sixtyDaysAgo;
// Robust activity check
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
per_page: 100
});
const lastHumanComment = comments.data.reverse().find(c => c.user.type !== 'Bot');
const lastHumanCommentDate = lastHumanComment ? new Date(lastHumanComment.created_at) : new Date(0);
const events = await github.rest.issues.listEvents({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
per_page: 100
});
const lastHumanEvent = events.data.reverse().find(e => e.actor.type !== 'Bot');
const lastHumanEventDate = lastHumanEvent ? new Date(lastHumanEvent.created_at) : new Date(0);
const creationDate = issue.user.type !== 'Bot' ? new Date(issue.created_at) : new Date(0);
const lastActivity = Math.max(lastHumanCommentDate.getTime(), lastHumanEventDate.getTime(), creationDate.getTime());
if (lastActivity > threshold.getTime()) {
continue;
}
core.info(`Marking #${issue.number} as stale (last activity: ${new Date(lastActivity).toISOString()}).`);
markedCount++;
if (!dryRun) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: [STALE_LABEL]
});
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: `This issue has been automatically marked as stale due to ${isHelpWanted ? '180' : '60'} days of inactivity. It will be closed in 14 days if no further activity occurs. Thank you for your contributions!`
});
}
}
core.info(`\nSummary: Marked ${markedCount} new issues as stale.`);