From 854f811be0391d7a7d8bc1a15e372eb5318bde7f Mon Sep 17 00:00:00 2001 From: Coco Sheng Date: Thu, 21 May 2026 14:42:59 -0400 Subject: [PATCH] perf: optimize issue triage and lifecycle management (#27346) --- .github/scripts/apply-issue-labels.cjs | 262 +++++++++--------- .../gemini-scheduled-issue-triage.yml | 50 ++-- 2 files changed, 155 insertions(+), 157 deletions(-) diff --git a/.github/scripts/apply-issue-labels.cjs b/.github/scripts/apply-issue-labels.cjs index 017309ec41..d0a82ab1f0 100644 --- a/.github/scripts/apply-issue-labels.cjs +++ b/.github/scripts/apply-issue-labels.cjs @@ -5,86 +5,122 @@ */ module.exports = async ({ github, context, core }) => { - const rawLabels = process.env.LABELS_OUTPUT; - core.info(`Raw labels JSON: ${rawLabels}`); - let parsedLabels; - try { - // First, try to parse the raw output as JSON. - parsedLabels = JSON.parse(rawLabels); - } catch (jsonError) { - // If that fails, check for a markdown code block. - core.warning( - `Direct JSON parsing failed: ${jsonError.message}. Trying to extract from a markdown block.`, - ); - const jsonMatch = rawLabels.match(/```json\s*([\s\S]*?)\s*```/); - if (jsonMatch && jsonMatch[1]) { - try { - parsedLabels = JSON.parse(jsonMatch[1].trim()); - } catch (markdownError) { - core.setFailed( - `Failed to parse JSON even after extracting from markdown block: ${markdownError.message}\nRaw output: ${rawLabels}`, - ); - return; + const extractJson = (raw) => { + if (!raw || raw === '[]' || raw === '') return []; + try { + // First, try to parse the raw output as JSON. + return JSON.parse(raw); + } catch { + // If that fails, check for a markdown code block. + core.info( + 'Direct JSON parsing failed. Trying to extract from a markdown block.', + ); + const jsonMatch = raw.match(/```json\s*([\s\S]*?)\s*```/); + if (jsonMatch && jsonMatch[1]) { + try { + return JSON.parse(jsonMatch[1].trim()); + } catch (markdownError) { + core.warning( + `Failed to parse extracted JSON from markdown block: ${markdownError.message}`, + ); + } } - } else { - // If no markdown block, try to find a raw JSON array in the output. - // The CLI may include debug/log lines (e.g. telemetry init, YOLO mode) - // before the actual JSON response. - const jsonArrayMatch = rawLabels.match( + + // Try to find a raw JSON array in the output. + const jsonArrayMatch = raw.match( /\[\s*\{\s*"issue_number"[\s\S]*\}\s*\]/, ); if (jsonArrayMatch) { try { - parsedLabels = JSON.parse(jsonArrayMatch[0]); - } catch (extractError) { - // It's possible the regex matched from a `[STARTUP]` log all the way to the end - // of the JSON array. We need to be more aggressive and find the FIRST `[ { "issue_number"` - core.warning( - `Strict array match failed: ${extractError.message}. Attempting to clean leading noisy brackets.`, - ); - const fallbackMatch = rawLabels.match( - /(\[\s*\{\s*"issue_number"[\s\S]*)/, - ); + return JSON.parse(jsonArrayMatch[0]); + } catch { + const fallbackMatch = raw.match(/(\[\s*\{\s*"issue_number"[\s\S]*)/); if (fallbackMatch) { try { - // We might have grabbed trailing noise too, so we find the last closing bracket const cleaned = fallbackMatch[0].substring( 0, fallbackMatch[0].lastIndexOf(']') + 1, ); - parsedLabels = JSON.parse(cleaned); + return JSON.parse(cleaned); } catch (fallbackError) { - core.setFailed( - `Found JSON-like content but failed to parse: ${fallbackError.message}\nRaw output: ${rawLabels}`, + core.warning( + `Failed to parse extracted JSON using fallback regex: ${fallbackError.message}`, ); - return; } - } else { - core.setFailed( - `Found JSON-like content but failed to parse: ${extractError.message}\nRaw output: ${rawLabels}`, - ); - return; } } - } else { - core.setFailed( - `Output is not valid JSON and does not contain extractable JSON.\nRaw output: ${rawLabels}`, - ); - return; } } - } - core.info(`Parsed labels JSON: ${JSON.stringify(parsedLabels)}`); + core.warning('No valid JSON could be extracted from input.'); + return []; + }; - for (const entry of parsedLabels) { - const issueNumber = entry.issue_number; - if (!issueNumber) { - core.info( - `Skipping entry with no issue number: ${JSON.stringify(entry)}`, - ); - continue; + // Collect all outputs from environment variables + // Prioritize EFFORT results over STANDARD results by processing Effort FIRST + // so that its labels appear first in the merged arrays (and thus win in mutually exclusive logic) + const effortRaw = process.env.LABELS_OUTPUT_EFFORT; + const standardRaw = process.env.LABELS_OUTPUT_STANDARD; + const genericRaw = process.env.LABELS_OUTPUT; + + const resultsByIssue = new Map(); + + const processResults = (results, _sourceName) => { + for (const entry of results) { + const issueNumber = entry.issue_number; + if (!issueNumber) continue; + + if (!resultsByIssue.has(issueNumber)) { + resultsByIssue.set(issueNumber, { + issue_number: issueNumber, + labels_to_add: [...(entry.labels_to_add || [])], + labels_to_remove: [...(entry.labels_to_remove || [])], + explanation: entry.explanation || '', + effort_analysis: entry.effort_analysis || '', + }); + } else { + const existing = resultsByIssue.get(issueNumber); + // Combine labels + existing.labels_to_add = [ + ...new Set([ + ...existing.labels_to_add, + ...(entry.labels_to_add || []), + ]), + ]; + existing.labels_to_remove = [ + ...new Set([ + ...existing.labels_to_remove, + ...(entry.labels_to_remove || []), + ]), + ]; + + // Combine explanations (if different) + if ( + entry.explanation && + !existing.explanation.includes(entry.explanation) + ) { + existing.explanation = existing.explanation + ? `${existing.explanation}\n\n${entry.explanation}` + : entry.explanation; + } + + // Take effort analysis if present + if (entry.effort_analysis && !existing.effort_analysis) { + existing.effort_analysis = entry.effort_analysis; + } + } } + }; + // Order matters: Effort first so its labels win in conflict resolution + processResults(extractJson(effortRaw), 'EFFORT'); + processResults(extractJson(standardRaw), 'STANDARD'); + processResults(extractJson(genericRaw), 'GENERIC'); + + const finalResults = Array.from(resultsByIssue.values()); + core.info(`Aggregated triage results for ${finalResults.length} issues.`); + + for (const entry of finalResults) { + const issueNumber = entry.issue_number; let labelsToAdd = entry.labels_to_add || []; let labelsToRemove = entry.labels_to_remove || []; let existingLabels = []; @@ -131,73 +167,56 @@ module.exports = async ({ github, context, core }) => { labelsToAdd.includes('status/manual-triage') || existingLabels.includes('status/manual-triage') ) { - // If the AI flagged it for manual triage, remove bot-triaged if it exists labelsToRemove.push('status/bot-triaged'); - // Ensure we don't accidentally try to add bot-triaged if the AI returned it labelsToAdd = labelsToAdd.filter((l) => l !== 'status/bot-triaged'); } else { - // Standard successful bot triage labelsToAdd.push('status/bot-triaged'); } - // Deduplicate arrays - labelsToAdd = [...new Set(labelsToAdd)]; - labelsToRemove = [...new Set(labelsToRemove)]; + // Resolve internal conflicts (e.g., adding P1 and P2) + // We already resolved these by putting Effort first in the combined list - // Fetch existing labels to auto-resolve conflicts - const hasNewArea = labelsToAdd.some((l) => l.startsWith('area/')); - if (hasNewArea) { - const existingAreas = existingLabels.filter((l) => l.startsWith('area/')); - labelsToRemove.push(...existingAreas); - } - - const hasNewPriority = labelsToAdd.some((l) => l.startsWith('priority/')); - if (hasNewPriority) { - const existingPriorities = existingLabels.filter((l) => - l.startsWith('priority/'), + // Resolve external conflicts with existing labels + if (labelsToAdd.some((l) => l.startsWith('area/'))) { + labelsToRemove.push( + ...existingLabels.filter((l) => l.startsWith('area/')), ); - labelsToRemove.push(...existingPriorities); } - - const hasNewKind = labelsToAdd.some((l) => l.startsWith('kind/')); - if (hasNewKind) { - const existingKinds = existingLabels.filter((l) => l.startsWith('kind/')); - labelsToRemove.push(...existingKinds); - } - - // Enforce mutually exclusive area labels - const areaLabelsToAdd = labelsToAdd.filter((l) => l.startsWith('area/')); - if (areaLabelsToAdd.length > 1) { - core.warning( - `Issue #${issueNumber} has multiple area labels to add: ${areaLabelsToAdd.join(', ')}. Keeping only the first one.`, + if (labelsToAdd.some((l) => l.startsWith('priority/'))) { + labelsToRemove.push( + ...existingLabels.filter((l) => l.startsWith('priority/')), ); - const firstArea = areaLabelsToAdd[0]; - labelsToAdd = labelsToAdd.filter( - (l) => !l.startsWith('area/') || l === firstArea, + } + if (labelsToAdd.some((l) => l.startsWith('kind/'))) { + labelsToRemove.push( + ...existingLabels.filter((l) => l.startsWith('kind/')), ); } - // Enforce mutually exclusive priority labels - const priorityLabelsToAdd = labelsToAdd.filter((l) => - l.startsWith('priority/'), - ); - if (priorityLabelsToAdd.length > 1) { - core.warning( - `Issue #${issueNumber} has multiple priority labels to add: ${priorityLabelsToAdd.join(', ')}. Keeping only the first one.`, - ); - const firstPriority = priorityLabelsToAdd[0]; - labelsToAdd = labelsToAdd.filter( - (l) => !l.startsWith('priority/') || l === firstPriority, - ); + // Enforce mutual exclusivity in the TO-ADD list (Architect wins) + const exclusivePrefixes = ['area/', 'priority/', 'kind/']; + for (const prefix of exclusivePrefixes) { + const filtered = labelsToAdd.filter((l) => l.startsWith(prefix)); + if (filtered.length > 1) { + const winner = filtered[0]; // First one wins + core.info( + `Issue #${issueNumber} has multiple ${prefix} labels suggested. Keeping "${winner}" and discarding others.`, + ); + labelsToAdd = labelsToAdd.filter( + (l) => !l.startsWith(prefix) || l === winner, + ); + } } - // Re-deduplicate and filter out labels we are trying to add, - // and filter out labels that are already present or absent to avoid unnecessary API calls + // Final deduplication and cleanup labelsToRemove = [...new Set(labelsToRemove)].filter( (l) => !labelsToAdd.includes(l) && existingLabels.includes(l), ); - labelsToAdd = labelsToAdd.filter((l) => !existingLabels.includes(l)); + labelsToAdd = [...new Set(labelsToAdd)].filter( + (l) => !existingLabels.includes(l), + ); + // Batch label operations if (labelsToAdd.length > 0) { await github.rest.issues.addLabels({ owner: context.repo.owner, @@ -205,10 +224,8 @@ module.exports = async ({ github, context, core }) => { issue_number: issueNumber, labels: labelsToAdd, }); - - const explanation = entry.explanation ? ` - ${entry.explanation}` : ''; core.info( - `Successfully added labels for #${issueNumber}: ${labelsToAdd.join(', ')}${explanation}`, + `Successfully added labels for #${issueNumber}: ${labelsToAdd.join(', ')}`, ); } @@ -222,11 +239,10 @@ module.exports = async ({ github, context, core }) => { name: label, }); } catch (e) { - if (e.status !== 404) { + if (e.status !== 404) core.warning( `Failed to remove label ${label} from #${issueNumber}: ${e.message}`, ); - } } } core.info( @@ -234,10 +250,7 @@ module.exports = async ({ github, context, core }) => { ); } - // Restrictive Commenting Policy: - // - Silence standard triage (Area/Kind/Priority) to avoid spam. - // - Only comment if status/need-information is added (to explain what is missing). - // - Only comment if effort_analysis is present (deep technical dive). + // Post comment if needed const needsInfoAdded = labelsToAdd.includes('status/need-information') && !existingLabels.includes('status/need-information'); @@ -245,9 +258,7 @@ module.exports = async ({ github, context, core }) => { if (needsInfoAdded || hasEffortAnalysis) { let commentBody = ''; - if (needsInfoAdded && entry.explanation) { - commentBody += entry.explanation; - } + if (needsInfoAdded && entry.explanation) commentBody += entry.explanation; if (hasEffortAnalysis) { if (commentBody) commentBody += '\n\n'; commentBody += `**Effort Analysis:**\n${entry.effort_analysis}`; @@ -260,19 +271,8 @@ module.exports = async ({ github, context, core }) => { issue_number: issueNumber, body: commentBody, }); - core.info( - `Posted required comment (need-info or effort) for #${issueNumber}`, - ); + core.info(`Posted required comment for #${issueNumber}`); } } - - if ( - (!entry.labels_to_add || entry.labels_to_add.length === 0) && - (!entry.labels_to_remove || entry.labels_to_remove.length === 0) - ) { - core.info( - `No labels to add or remove for #${issueNumber}, leaving as is`, - ); - } } }; diff --git a/.github/workflows/gemini-scheduled-issue-triage.yml b/.github/workflows/gemini-scheduled-issue-triage.yml index 55ea54de37..262dd5ca48 100644 --- a/.github/workflows/gemini-scheduled-issue-triage.yml +++ b/.github/workflows/gemini-scheduled-issue-triage.yml @@ -29,6 +29,18 @@ jobs: with: persist-credentials: false + - name: 'Install Utilities' + run: | + sudo apt-get update + sudo apt-get install -y ripgrep + + - name: 'Get Current Version' + id: 'get_version' + run: | + VERSION=$(jq -r .version package.json | cut -d'-' -f1) + echo "version=${VERSION}" >> "${GITHUB_OUTPUT}" + echo "🚀 Current CLI Version: ${VERSION}" + - name: 'Generate GitHub App Token' id: 'generate_token' uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 @@ -163,6 +175,7 @@ jobs: GITHUB_TOKEN: '' # Do not pass any auth token here since this runs on untrusted inputs REPOSITORY: '${{ github.repository }}' AVAILABLE_LABELS: '${{ steps.get_labels.outputs.available_labels }}' + CLI_VERSION: '${{ steps.get_version.outputs.version }}' GEMINI_CLI_TRUST_WORKSPACE: 'true' GEMINI_EXP: 'gemini_exp.json' GEMINI_STRICT_TELEMETRY_LIMITS: 'true' @@ -222,9 +235,9 @@ jobs: ] ``` If an issue cannot be classified, do not include it in the output array. - 9. For each issue, carefully check if the CLI version is present. It is usually found under the "### Client information" header, as a bullet point (e.g., "• CLI Version: 0.33.1"), or in the output of the `/about` command. - - If the version is provided but is more than 6 minor versions older than the most recent release, apply the status/need-information label and leave a comment politely asking the user to verify if the issue persists in the latest version. - 10. If the issue does not have sufficient information, recommend the status/need-information label and leave a comment politely requesting the missing details. For example, if repro steps are missing, ask for them; if the CLI version is completely missing, ask for the version information in the explanation section below. Do not ask for version info if it is already in the issue body. + 9. For each issue, carefully check if the CLI version is present. It is usually found under the "### Client information" header, as a bullet point (e.g., "• CLI Version: 0.33.1", "* **CLI Version:** 0.42.0"), or in the output of the `/about` command. + - **Only for issues classified as kind/bug:** If the version is provided but is more than 6 minor versions older than the most recent release (current version is ${{ steps.get_version.outputs.version }}), apply the status/need-information label and leave a comment politely asking the user to verify if the issue persists in the latest version. + 10. **Only for issues classified as kind/bug:** If the issue does not have sufficient information, recommend the status/need-information label and leave a comment politely requesting the missing details. For example, if repro steps are missing, ask for them; if the CLI version is completely missing, ask for the version information in the explanation section below. Do not ask for version info if it is already in the issue body. (Check both bullet points and bold text). For features and enhancements, the CLI version is NOT required. 11. If you think an issue is a Priority/P0, you MUST apply the priority/p1 label AND the status/manual-triage label, and include a note in your explanation that it likely requires P0 escalation. 12. If the issue is highly ambiguous, completely lacks a description, or you are torn between two lower priorities (like P2 vs P3), you MUST retain the existing priority label if one is already present. Do not toggle the priority if you do not have enough information to make a definitive change. 13. If you are uncertain about a category, use the area/unknown, kind/question, or priority/unknown labels as appropriate. If you are extremely uncertain, apply the status/manual-triage label. @@ -284,6 +297,7 @@ jobs: GITHUB_TOKEN: '' # Do not pass any auth token here since this runs on untrusted inputs REPOSITORY: '${{ github.repository }}' AVAILABLE_LABELS: '${{ steps.get_labels.outputs.available_labels }}' + CLI_VERSION: '${{ steps.get_version.outputs.version }}' GEMINI_CLI_TRUST_WORKSPACE: 'true' GEMINI_EXP: 'gemini_exp.json' GEMINI_STRICT_TELEMETRY_LIMITS: 'true' @@ -298,7 +312,7 @@ jobs: use_gemini_code_assist: '${{ vars.GOOGLE_GENAI_USE_GCA }}' settings: |- { - "maxSessionTurns": 25, + "maxSessionTurns": 30, "coreTools": [ "run_shell_command(echo)", "grep_search", @@ -392,30 +406,14 @@ jobs: - This product is designed to use different models eg.. using pro, downgrading to flash etc. - When users report that they dont expect the model to change those would be categorized as feature requests. - - name: 'Apply Standard Labels to Issues' + - name: 'Apply Triaged Labels' if: |- - ${{ steps.gemini_standard_issue_analysis.outcome == 'success' && - steps.gemini_standard_issue_analysis.outputs.summary != '[]' && - steps.gemini_standard_issue_analysis.outputs.summary != '' }} + always() && + ( (steps.gemini_standard_issue_analysis.outcome == 'success' && steps.gemini_standard_issue_analysis.outputs.summary != '[]' && steps.gemini_standard_issue_analysis.outputs.summary != '') || + (steps.gemini_effort_issue_analysis.outcome == 'success' && steps.gemini_effort_issue_analysis.outputs.summary != '[]' && steps.gemini_effort_issue_analysis.outputs.summary != '') ) env: - REPOSITORY: '${{ github.repository }}' - LABELS_OUTPUT: '${{ steps.gemini_standard_issue_analysis.outputs.summary }}' - SUPPRESS_COMMENT: 'true' - uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' - with: - github-token: '${{ steps.generate_token.outputs.token }}' - script: |- - const applyLabels = require('./.github/scripts/apply-issue-labels.cjs'); - await applyLabels({ github, context, core }); - - - name: 'Apply Effort Labels to Issues' - if: |- - ${{ steps.gemini_effort_issue_analysis.outcome == 'success' && - steps.gemini_effort_issue_analysis.outputs.summary != '[]' && - steps.gemini_effort_issue_analysis.outputs.summary != '' }} - env: - REPOSITORY: '${{ github.repository }}' - LABELS_OUTPUT: '${{ steps.gemini_effort_issue_analysis.outputs.summary }}' + LABELS_OUTPUT_STANDARD: '${{ steps.gemini_standard_issue_analysis.outputs.summary }}' + LABELS_OUTPUT_EFFORT: '${{ steps.gemini_effort_issue_analysis.outputs.summary }}' uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' with: github-token: '${{ steps.generate_token.outputs.token }}'