diff --git a/.github/workflows/gemini-scheduled-stale-issue-closer.yml b/.github/workflows/gemini-scheduled-stale-issue-closer.yml index cfbecd6490..a77f6a6965 100644 --- a/.github/workflows/gemini-scheduled-stale-issue-closer.yml +++ b/.github/workflows/gemini-scheduled-stale-issue-closer.yml @@ -53,8 +53,12 @@ jobs: const tenDaysAgo = new Date(); tenDaysAgo.setDate(tenDaysAgo.getDate() - 10); + const sixMonthsAgo = new Date(); + sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6); + core.info(`Cutoff date for creation: ${threeMonthsAgo.toISOString()}`); core.info(`Cutoff date for updates: ${tenDaysAgo.toISOString()}`); + core.info(`Cutoff date for 'help wanted': ${sixMonthsAgo.toISOString()}`); const query = `repo:${context.repo.owner}/${context.repo.repo} is:issue is:open created:<${threeMonthsAgo.toISOString()}`; core.info(`Searching with query: ${query}`); @@ -80,17 +84,23 @@ jobs: continue; } - // Skip if it has a maintainer, help wanted, or Public Roadmap label + // Skip if it has a maintainer or Public Roadmap label const rawLabels = issue.labels.map((l) => l.name); const lowercaseLabels = rawLabels.map((l) => l.toLowerCase()); if ( lowercaseLabels.some((l) => l.includes('maintainer')) || - lowercaseLabels.includes('help wanted') || rawLabels.includes('🗓️ Public Roadmap') ) { continue; } + // Special handling for 'help wanted' + const isHelpWanted = lowercaseLabels.includes('help wanted'); + if (isHelpWanted && createdAt > sixMonthsAgo) { + // Help wanted is protected for 6 months + continue; + } + let isStale = updatedAt < tenDaysAgo; // If apparently active, check if it's only bot activity @@ -122,35 +132,58 @@ jobs: } } + const hasStaleLabel = rawLabels.includes(batchLabel); + if (isStale) { processedCount++; - const message = `Closing stale issue #${issue.number}: "${issue.title}" (${issue.html_url})`; - core.info(message); + if (!hasStaleLabel) { + core.info(`Nudging stale issue #${issue.number}: "${issue.title}"`); + if (!dryRun) { + // Add label + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: [batchLabel] + }); + // Add comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: 'Hello! As part of our effort to keep our backlog manageable and focus on the most active issues, we are tidying up older reports.\n\nIt looks like this issue hasn\'t been active for a while, so we\'ve labeled it as "Stale". If no activity occurs in the next 14 days, it will be closed. However, if this is still relevant, please leave a comment and we will keep it open.\n\nThank you for your contribution!' + }); + } + } else { + core.info(`Closing stale issue #${issue.number}: "${issue.title}"`); + if (!dryRun) { + // Add final comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: 'Closing this issue as it has remained stale since our last nudge. Feel free to reopen if this is still an issue.' + }); + + // Close issue + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + state: 'closed', + state_reason: 'not_planned' + }); + } + } + } else if (hasStaleLabel) { + core.info(`Removing Stale label from issue #${issue.number} as it is no longer stale.`); if (!dryRun) { - // Add label - await github.rest.issues.addLabels({ + await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, - labels: [batchLabel] - }); - - // Add comment - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: 'Hello! As part of our effort to keep our backlog manageable and focus on the most active issues, we are tidying up older reports.\n\nIt looks like this issue hasn\'t been active for a while, so we are closing it for now. However, if you are still experiencing this bug on the latest stable build, please feel free to comment on this issue or create a new one with updated details.\n\nThank you for your contribution!' - }); - - // Close issue - await github.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - state: 'closed', - state_reason: 'not_planned' + name: batchLabel }); } } diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 4a975869f5..c6083857e2 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -40,5 +40,6 @@ jobs: 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 + operations-per-run: 200 exempt-issue-labels: 'pinned,security,🔒 maintainer only,help wanted,🗓️ Public Roadmap' exempt-pr-labels: 'pinned,security,🔒 maintainer only,help wanted,🗓️ Public Roadmap' diff --git a/tools/gemini-cli-bot/metrics/scripts/backlog_age.ts b/tools/gemini-cli-bot/metrics/scripts/backlog_age.ts new file mode 100644 index 0000000000..2160809e3c --- /dev/null +++ b/tools/gemini-cli-bot/metrics/scripts/backlog_age.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execSync } from 'node:child_process'; +import { GITHUB_OWNER, GITHUB_REPO } from '../types.js'; + +try { + const query = ` + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + issues(states: OPEN, first: 100, orderBy: {field: CREATED_AT, direction: ASC}) { + totalCount + nodes { + createdAt + } + } + pullRequests(states: OPEN, first: 100, orderBy: {field: CREATED_AT, direction: ASC}) { + totalCount + nodes { + createdAt + } + } + } + } + `; + const output = execSync( + `gh api graphql -F owner=${GITHUB_OWNER} -F repo=${GITHUB_REPO} -f query='${query}'`, + { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }, + ).trim(); + const parsed = JSON.parse(output); + const data = parsed?.data?.repository; + + if (data?.issues?.totalCount > 100) { + process.stderr.write(`Warning: Backlog has ${data.issues.totalCount} issues, but only the oldest 100 were used for median calculation.\n`); + } + if (data?.pullRequests?.totalCount > 100) { + process.stderr.write(`Warning: Backlog has ${data.pullRequests.totalCount} PRs, but only the oldest 100 were used for median calculation.\n`); + } + + const calculateMedianAgeDays = (nodes: { createdAt: string }[]) => { + if (!nodes || nodes.length === 0) return 0; + const now = Date.now(); + 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 issueAge = calculateMedianAgeDays(data?.issues?.nodes ?? []); + const prAge = calculateMedianAgeDays(data?.pullRequests?.nodes ?? []); + + process.stdout.write(`backlog_age_issue_median_days,${Math.round(issueAge * 100) / 100}\n`); + process.stdout.write(`backlog_age_pr_median_days,${Math.round(prAge * 100) / 100}\n`); +} catch (err) { + process.stderr.write(err instanceof Error ? err.message : String(err)); + process.exit(1); +}