mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-14 22:02:59 -07:00
e9efec3a5b
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.
209 lines
8.8 KiB
YAML
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.`);
|