diff --git a/.github/scripts/gemini-lifecycle-manager.cjs b/.github/scripts/gemini-lifecycle-manager.cjs index 6a32beeb53..35e6b2a336 100644 --- a/.github/scripts/gemini-lifecycle-manager.cjs +++ b/.github/scripts/gemini-lifecycle-manager.cjs @@ -26,7 +26,7 @@ module.exports = async ({ github, context, core }) => { '🗓️ Public Roadmap', ]; - const STALE_DAYS = 60; + const STALE_DAYS = 30; const CLOSE_DAYS = 14; const NO_RESPONSE_DAYS = 14; @@ -64,30 +64,66 @@ module.exports = async ({ github, context, core }) => { } } - // 1. Handle No-Response (status/need-information) - // Removal: Check issues updated in the last 48h that have the label - const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000); - await processItems( - `repo:${owner}/${repo} is:open label:"${NEED_INFO_LABEL}" updated:>${twoDaysAgo.toISOString()}`, - async (item) => { + /** + * Helper to get the timestamp when a specific label was added to an item. + */ + async function getLabelAddedDate(issueNumber, labelName) { + try { + const events = await github.paginate( + github.rest.issues.listEventsForTimeline, + { + owner, + repo, + issue_number: issueNumber, + per_page: 100, + }, + ); + const labelEvent = events + .filter((e) => e.event === 'labeled' && e.label?.name === labelName) + .pop(); // Get the most recent application of the label + return labelEvent ? new Date(labelEvent.created_at) : null; + } catch (err) { + core.warning( + `Failed to fetch timeline for #${issueNumber}: ${err.message}`, + ); + return null; + } + } + + /** + * Helper to check if there is a non-maintainer comment after a certain date. + */ + async function hasContributorResponse(issueNumber, sinceDate) { + try { const { data: comments } = await github.rest.issues.listComments({ owner, repo, - issue_number: item.number, - sort: 'created', - direction: 'desc', - per_page: 5, + issue_number: issueNumber, + since: sinceDate.toISOString(), }); + return comments.some( + (c) => + !['OWNER', 'MEMBER', 'COLLABORATOR'].includes( + c.author_association, + ) && c.user?.type !== 'Bot', + ); + } catch (err) { + core.warning( + `Failed to fetch comments for #${issueNumber}: ${err.message}`, + ); + return false; + } + } - // Check if the last comment is from a non-maintainer - const lastComment = comments[0]; - if ( - lastComment && - !['OWNER', 'MEMBER', 'COLLABORATOR'].includes( - lastComment.author_association, - ) && - lastComment.user?.type !== 'Bot' - ) { + // 1. Handle No-Response (status/need-information) + // Removal: Check issues with the label + await processItems( + `repo:${owner}/${repo} is:open label:"${NEED_INFO_LABEL}"`, + async (item) => { + const labelAddedAt = await getLabelAddedDate(item.number, NEED_INFO_LABEL); + if (!labelAddedAt) return; + + if (await hasContributorResponse(item.number, labelAddedAt)) { core.info( `Removing ${NEED_INFO_LABEL} from #${item.number} due to contributor response.`, ); @@ -101,35 +137,30 @@ module.exports = async ({ github, context, core }) => { }) .catch(() => {}); } + } else if (labelAddedAt < noResponseThreshold) { + // Closure: Check if grace period passed + core.info( + `Closing #${item.number} due to no response for ${NO_RESPONSE_DAYS} days.`, + ); + if (!dryRun) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: item.number, + body: `This item was marked as needing more information and has not received a response in ${NO_RESPONSE_DAYS} days. Closing it for now. If you still face this problem, feel free to reopen with more details. Thank you!`, + }); + await github.rest.issues.update({ + owner, + repo, + issue_number: item.number, + state: 'closed', + }); + } } }, ); - // Closure: Check issues with the label that haven't been updated in 14 days - await processItems( - `repo:${owner}/${repo} is:open label:"${NEED_INFO_LABEL}" updated:<${noResponseThreshold.toISOString()}`, - async (item) => { - core.info( - `Closing #${item.number} due to no response for ${NO_RESPONSE_DAYS} days.`, - ); - if (!dryRun) { - await github.rest.issues.createComment({ - owner, - repo, - issue_number: item.number, - body: `This item was marked as needing more information and has not received a response in ${NO_RESPONSE_DAYS} days. Closing it for now. If you still face this problem, feel free to reopen with more details. Thank you!`, - }); - await github.rest.issues.update({ - owner, - repo, - issue_number: item.number, - state: 'closed', - }); - } - }, - ); - - // 2. Handle Stale Mark (60 days inactivity, no stale label) + // 2. Handle Stale Mark (30 days inactivity, no stale label) const exemptQuery = EXEMPT_LABELS.map((l) => `-label:"${l}"`).join(' '); await processItems( `repo:${owner}/${repo} is:open -label:"${STALE_LABEL}" ${exemptQuery} updated:<${staleThreshold.toISOString()}`, @@ -154,22 +185,27 @@ module.exports = async ({ github, context, core }) => { // 3. Handle Stale Close (14 days with stale label) await processItems( - `repo:${owner}/${repo} is:open label:"${STALE_LABEL}" ${exemptQuery} updated:<${closeThreshold.toISOString()}`, + `repo:${owner}/${repo} is:open label:"${STALE_LABEL}" ${exemptQuery}`, async (item) => { - core.info(`Closing stale item #${item.number}.`); - if (!dryRun) { - await github.rest.issues.createComment({ - owner, - repo, - issue_number: item.number, - body: `This item has been closed due to ${CLOSE_DAYS} additional days of inactivity after being marked as stale. If you believe this is still relevant, feel free to comment or reopen. Thank you!`, - }); - await github.rest.issues.update({ - owner, - repo, - issue_number: item.number, - state: 'closed', - }); + const staleAddedAt = await getLabelAddedDate(item.number, STALE_LABEL); + if (!staleAddedAt) return; + + if (staleAddedAt < closeThreshold) { + core.info(`Closing stale item #${item.number}.`); + if (!dryRun) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: item.number, + body: `This item has been closed due to ${CLOSE_DAYS} additional days of inactivity after being marked as stale. If you believe this is still relevant, feel free to comment or reopen. Thank you!`, + }); + await github.rest.issues.update({ + owner, + repo, + issue_number: item.number, + state: 'closed', + }); + } } }, ); diff --git a/.github/workflows/chained_e2e.yml b/.github/workflows/chained_e2e.yml index 4a5de8bf7c..5e208e7e20 100644 --- a/.github/workflows/chained_e2e.yml +++ b/.github/workflows/chained_e2e.yml @@ -184,7 +184,7 @@ jobs: needs: - 'merge_queue_skipper' - 'parse_run_context' - runs-on: 'macos-latest-large' + runs-on: 'macos-latest' if: | github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') steps: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ef8bdb58d..28f682c30e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -231,7 +231,7 @@ jobs: test_mac: name: 'Test (Mac) - ${{ matrix.node-version }}, ${{ matrix.shard }}' - runs-on: 'macos-latest-large' + runs-on: 'macos-latest' needs: - 'merge_queue_skipper' if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'" @@ -244,8 +244,6 @@ jobs: matrix: node-version: - '20.x' - - '22.x' - - '24.x' shard: - 'cli' - 'others' diff --git a/.github/workflows/gemini-cli-bot-pulse.yml b/.github/workflows/gemini-cli-bot-pulse.yml index b929444837..ddc272d677 100644 --- a/.github/workflows/gemini-cli-bot-pulse.yml +++ b/.github/workflows/gemini-cli-bot-pulse.yml @@ -15,15 +15,35 @@ permissions: pull-requests: 'write' jobs: - pulse: - name: 'Pulse (Reflex Layer)' + precheck: + name: 'Pre-check' runs-on: 'ubuntu-latest' if: "github.repository == 'google-gemini/gemini-cli'" + outputs: + has_scripts: '${{ steps.check.outputs.has_scripts }}' steps: - name: 'Checkout' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 with: - fetch-depth: 0 + fetch-depth: 1 # Shallow checkout for check + - id: 'check' + run: | + if [ -d "tools/gemini-cli-bot/reflexes/scripts" ] && [ "$(ls -A tools/gemini-cli-bot/reflexes/scripts/*.ts 2>/dev/null)" ]; then + echo "has_scripts=true" >> "$GITHUB_OUTPUT" + else + echo "has_scripts=false" >> "$GITHUB_OUTPUT" + fi + + pulse: + name: 'Pulse (Reflex Layer)' + needs: 'precheck' + if: "needs.precheck.outputs.has_scripts == 'true'" + runs-on: 'ubuntu-latest' + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + with: + fetch-depth: 1 # Pulse doesn't need full history unless a script specifically asks for it. - name: 'Setup Node.js' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 @@ -38,11 +58,8 @@ jobs: env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' run: | - if [ -d "tools/gemini-cli-bot/reflexes/scripts" ] && [ "$(ls -A tools/gemini-cli-bot/reflexes/scripts)" ]; then - for script in tools/gemini-cli-bot/reflexes/scripts/*.ts; do - echo "Running reflex script: $script" - npx tsx "$script" - done - else - echo "No reflex scripts found." - fi + shopt -s nullglob + for script in tools/gemini-cli-bot/reflexes/scripts/*.ts; do + echo "Running reflex script: $script" + npx tsx "$script" + done diff --git a/.github/workflows/gemini-scheduled-issue-triage.yml b/.github/workflows/gemini-scheduled-issue-triage.yml index f66724cd20..c9062384cd 100644 --- a/.github/workflows/gemini-scheduled-issue-triage.yml +++ b/.github/workflows/gemini-scheduled-issue-triage.yml @@ -63,15 +63,15 @@ jobs: echo '🔍 Finding issues missing area labels...' NO_AREA_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \ - --search 'is:open is:issue -label:status/bot-triaged -label:area/core -label:area/agent -label:area/enterprise -label:area/non-interactive -label:area/security -label:area/platform -label:area/extensions -label:area/documentation -label:area/unknown' --limit 100 --json number,title,body)" + --search 'is:open is:issue -label:status/bot-triaged -label:area/core -label:area/agent -label:area/enterprise -label:area/non-interactive -label:area/security -label:area/platform -label:area/extensions -label:area/documentation -label:area/unknown' --limit 200 --json number,title,body)" echo '🔍 Finding issues missing kind labels...' NO_KIND_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \ - --search 'is:open is:issue -label:status/bot-triaged -label:kind/bug -label:kind/enhancement -label:kind/customer-issue -label:kind/question' --limit 100 --json number,title,body)" + --search 'is:open is:issue -label:status/bot-triaged -label:kind/bug -label:kind/enhancement -label:kind/customer-issue -label:kind/question' --limit 200 --json number,title,body)" echo '🏷️ Finding issues missing priority labels...' NO_PRIORITY_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \ - --search 'is:open is:issue -label:status/bot-triaged -label:priority/p0 -label:priority/p1 -label:priority/p2 -label:priority/p3 -label:priority/unknown' --limit 100 --json number,title,body)" + --search 'is:open is:issue -label:status/bot-triaged -label:priority/p0 -label:priority/p1 -label:priority/p2 -label:priority/p3 -label:priority/unknown' --limit 200 --json number,title,body)" echo '🔄 Merging and deduplicating issues...' ISSUES="$(echo "${NO_AREA_ISSUES}" "${NO_KIND_ISSUES}" "${NO_PRIORITY_ISSUES}" | jq -c -s 'add | unique_by(.number)')" @@ -228,14 +228,22 @@ jobs: github-token: '${{ steps.generate_token.outputs.token }}' script: |- const rawLabels = process.env.LABELS_OUTPUT; - core.info(`Raw labels JSON: ${rawLabels}`); + core.info(`Raw labels output: ${rawLabels}`); let parsedLabels; try { - const jsonMatch = rawLabels.match(/```json\s*([\s\S]*?)\s*```/); - if (!jsonMatch || !jsonMatch[1]) { - throw new Error("Could not find a ```json ... ``` block in the output."); + // Strategy 1: Look for JSON block + const jsonMatch = rawLabels.match(/```json\s*([\s\S]*?)\s*```/i) || rawLabels.match(/```\s*([\s\S]*?)\s*```/); + let jsonString = jsonMatch ? jsonMatch[1].trim() : rawLabels.trim(); + + // Strategy 2: Remove any non-JSON prefix/suffix if backticks weren't found + if (!jsonMatch) { + const firstBracket = jsonString.indexOf('['); + const lastBracket = jsonString.lastIndexOf(']'); + if (firstBracket !== -1 && lastBracket !== -1) { + jsonString = jsonString.substring(firstBracket, lastBracket + 1); + } } - const jsonString = jsonMatch[1].trim(); + parsedLabels = JSON.parse(jsonString); core.info(`Parsed labels JSON: ${JSON.stringify(parsedLabels)}`); } catch (err) {