docs(overview): enumerate dismissed_repair_count in fields= description + static drift test #2828
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: '🤖 Issue Triage' | |
| on: | |
| issues: | |
| types: [opened] | |
| issue_comment: | |
| types: [created] | |
| workflow_dispatch: | |
| inputs: | |
| issue_number: | |
| description: 'Issue number to triage' | |
| required: true | |
| type: number | |
| concurrency: | |
| group: 'triage-${{ github.event.issue.number || inputs.issue_number }}' | |
| cancel-in-progress: true | |
| permissions: | |
| contents: read | |
| issues: write | |
| pull-requests: read | |
| jobs: | |
| should_run: | |
| runs-on: ubuntu-latest | |
| outputs: | |
| should_run: ${{ steps.check.outputs.should_run }} | |
| skip_preanalysis: ${{ steps.check.outputs.skip_preanalysis }} | |
| steps: | |
| - name: Check if workflow should run | |
| id: check | |
| uses: actions/github-script@v9 | |
| with: | |
| script: | | |
| let issue; | |
| let comment; | |
| let issueNumber; | |
| // Handle manual workflow dispatch | |
| if (context.eventName === 'workflow_dispatch') { | |
| issueNumber = context.payload.inputs.issue_number; | |
| issue = await github.rest.issues.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issueNumber | |
| }).then(r => r.data); | |
| core.info(`Manual trigger for issue #${issueNumber}`); | |
| } else { | |
| issue = context.payload.issue; | |
| issueNumber = issue.number; | |
| comment = context.payload.comment; | |
| } | |
| // Don't run on pull requests | |
| if (issue.pull_request) { | |
| core.info('Skipping: This is a pull request'); | |
| core.setOutput('should_run', 'false'); | |
| return; | |
| } | |
| // Don't run on issues created by julienld (maintainer) - except manual dispatch | |
| if (issue.user.login === 'julienld' && context.eventName !== 'workflow_dispatch') { | |
| core.info('Skipping: Issue created by maintainer (julienld)'); | |
| core.setOutput('should_run', 'false'); | |
| return; | |
| } | |
| // Check if already triaged (has triaged label) | |
| const hasTriagedLabel = issue.labels.some(l => l.name === 'triaged'); | |
| if (hasTriagedLabel && context.eventName !== 'workflow_dispatch') { | |
| core.info('Skipping: Already triaged (has triaged label)'); | |
| core.setOutput('should_run', 'false'); | |
| return; | |
| } | |
| // Circuit breaker: if a previous triage attempt posted the | |
| // generic "temporarily unavailable" notice, the failure path | |
| // applied `triage-failed`. Stop auto-retriggering on every | |
| // subsequent comment — without this gate, large issues that | |
| // exceed maxSessionTurns or otherwise consistently fail end | |
| // up with a stack of identical "temporarily unavailable" | |
| // notices, one per author comment. Maintainers can use | |
| // workflow_dispatch to retry manually once the underlying | |
| // cause is fixed; the success path also removes this label. | |
| const hasTriageFailedLabel = issue.labels.some(l => l.name === 'triage-failed'); | |
| if (hasTriageFailedLabel && context.eventName !== 'workflow_dispatch') { | |
| core.info('Skipping: Previous triage failed (has triage-failed label). Use workflow_dispatch to retry.'); | |
| core.setOutput('should_run', 'false'); | |
| return; | |
| } | |
| // Don't run if bot commented. Broad `type === 'Bot'` (not just | |
| // `github-actions[bot]`) is intentional here — we want to ignore | |
| // retriggers from any bot, including Dependabot/Renovate update | |
| // comments. The orphan-cleanup logic uses the narrower | |
| // `login === 'github-actions[bot]'` check because *that* code | |
| // deletes comments and must avoid hijacking other bots' work. | |
| if (comment && comment.user.type === 'Bot') { | |
| core.info('Skipping: Comment is from a bot'); | |
| core.setOutput('should_run', 'false'); | |
| return; | |
| } | |
| // Get all comments NOW to avoid race condition | |
| const allComments = await github.rest.issues.listComments({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issueNumber | |
| }); | |
| // Count non-bot comments from issue author | |
| const authorComments = allComments.data.filter(c => | |
| c.user.login === issue.user.login && | |
| c.user.type !== 'Bot' | |
| ); | |
| const hasAuthorComments = authorComments.length > 0; | |
| // Skip pre-analysis if author has already commented (provided more info) | |
| core.setOutput('skip_preanalysis', hasAuthorComments ? 'true' : 'false'); | |
| // For manual dispatch, always run | |
| if (context.eventName === 'workflow_dispatch') { | |
| core.info(`Manual trigger: Running (skip_preanalysis: ${hasAuthorComments})`); | |
| core.setOutput('should_run', 'true'); | |
| return; | |
| } | |
| // For new issues, always run | |
| if (context.eventName === 'issues') { | |
| core.info('Running: New issue created (will run pre-analysis)'); | |
| core.setOutput('should_run', 'true'); | |
| return; | |
| } | |
| // For comments, only run if from issue author | |
| if (comment && comment.user.login === issue.user.login) { | |
| core.info('Running: Comment from issue author (will skip pre-analysis)'); | |
| core.setOutput('should_run', 'true'); | |
| return; | |
| } | |
| core.info('Skipping: Comment not from issue author'); | |
| core.setOutput('should_run', 'false'); | |
| triage: | |
| runs-on: ubuntu-latest | |
| needs: should_run | |
| if: needs.should_run.outputs.should_run == 'true' | |
| timeout-minutes: 12 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| - name: Get issue number | |
| id: issue_number | |
| uses: actions/github-script@v9 | |
| with: | |
| script: | | |
| const issueNumber = context.payload.inputs?.issue_number || context.issue.number; | |
| core.setOutput('number', issueNumber); | |
| return issueNumber; | |
| - name: Post initial "working" comment | |
| id: initial_comment | |
| uses: actions/github-script@v9 | |
| with: | |
| script: | | |
| const issueNumber = ${{ steps.issue_number.outputs.number }}; | |
| const PLACEHOLDER_PREFIX = '🤖 **Issue Triage Bot is analyzing this issue...**'; | |
| const PLACEHOLDER_BODY = `${PLACEHOLDER_PREFIX}\n\nThis may take a minute. I\'m:\n- Fetching the full issue thread\n- Searching for related issues\n- Researching relevant code and documentation\n\nI\'ll update this comment when complete.`; | |
| // Match strictly on `github-actions[bot]` (not just user.type === 'Bot') | |
| // so a Dependabot/Renovate/etc. comment that happens to share the | |
| // prefix can never be hijacked. | |
| const isOurOrphan = (c) => | |
| c.user && c.user.login === 'github-actions[bot]' && | |
| c.body && c.body.startsWith(PLACEHOLDER_PREFIX); | |
| // Reuse / clean up orphan placeholders from prior cancelled runs. | |
| // The `Update comment` step is gated on `!cancelled()`, so a run | |
| // that gets cancelled (concurrency cancel-in-progress, job | |
| // timeout) never touches its own placeholder again — without | |
| // this cleanup, the placeholder stays as orphan text on the | |
| // issue forever and every retriggering comment piles up a new one. | |
| let existing; | |
| try { | |
| existing = await github.rest.issues.listComments({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issueNumber, | |
| per_page: 100, | |
| }); | |
| } catch (err) { | |
| // Bail rather than create a duplicate. If we can't see existing | |
| // comments we can't tell whether an orphan is already there; | |
| // creating a new placeholder anyway would leave the issue with | |
| // two visible "🤖 analyzing..." comments and no way to clean | |
| // them up. Failing the step is the lesser evil — Update comment | |
| // skips (no comment_id), no user-facing artifact, red Actions | |
| // run for the maintainer. | |
| core.setFailed(`listComments failed during orphan check (${err.message}); refusing to create a duplicate placeholder.`); | |
| return; | |
| } | |
| const orphans = existing.data.filter(isOurOrphan); | |
| let commentId; | |
| if (orphans.length > 0) { | |
| const mostRecent = orphans[orphans.length - 1]; | |
| commentId = mostRecent.id; | |
| core.info(`Reusing orphaned placeholder ${commentId} from a prior cancelled run; cleaning up ${orphans.length - 1} older orphan(s).`); | |
| for (const old of orphans.slice(0, -1)) { | |
| try { | |
| await github.rest.issues.deleteComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: old.id, | |
| }); | |
| } catch (err) { | |
| core.error(`Failed to delete older orphan placeholder ${old.id}: ${err.message}`); | |
| } | |
| } | |
| } else { | |
| const created = await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issueNumber, | |
| body: PLACEHOLDER_BODY, | |
| }); | |
| commentId = created.data.id; | |
| } | |
| // Defensive re-list AFTER our action: if a concurrent run lost | |
| // the cancel race and was mid-createComment when we listed | |
| // earlier, its orphan landed after our snapshot. Find and delete | |
| // any other matching placeholders so the issue is left with | |
| // exactly one (ours). | |
| try { | |
| const recheck = await github.rest.issues.listComments({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issueNumber, | |
| per_page: 100, | |
| }); | |
| const stragglers = recheck.data.filter(c => isOurOrphan(c) && c.id !== commentId); | |
| for (const stale of stragglers) { | |
| try { | |
| await github.rest.issues.deleteComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: stale.id, | |
| }); | |
| core.info(`Cleaned up straggler placeholder ${stale.id} from a concurrent run.`); | |
| } catch (err) { | |
| core.error(`Failed to delete straggler placeholder ${stale.id}: ${err.message}`); | |
| } | |
| } | |
| } catch (err) { | |
| core.error(`Defensive re-list for straggler cleanup failed: ${err.message}`); | |
| } | |
| core.setOutput('comment_id', commentId); | |
| return commentId; | |
| - name: Fetch full issue thread | |
| id: issue_data | |
| uses: actions/github-script@v9 | |
| with: | |
| script: | | |
| const issueNumber = ${{ steps.issue_number.outputs.number }}; | |
| const issue = await github.rest.issues.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issueNumber | |
| }); | |
| const comments = await github.rest.issues.listComments({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issueNumber | |
| }); | |
| const issueTitle = issue.data.title; | |
| const issueBody = issue.data.body || ''; | |
| const labels = issue.data.labels.map(l => l.name); | |
| // Detect issue type | |
| let issueType = 'unknown'; | |
| if (labels.includes('runtime-bug') || issueBody.includes('### 🐛 Runtime Bug')) { | |
| issueType = 'runtime-bug'; | |
| } else if (labels.includes('startup-bug') || issueBody.includes('### 🚀 Startup/Installation Bug')) { | |
| issueType = 'startup-bug'; | |
| } else if (labels.includes('agent-behavior') || issueBody.includes('### 🤖 AI Agent Behavior')) { | |
| issueType = 'agent-behavior'; | |
| } | |
| // Format author comments for prompt | |
| const issueAuthor = issue.data.user.login; | |
| const authorComments = comments.data | |
| .filter(c => c.user.login === issueAuthor && c.user.type !== 'Bot') | |
| .map((c, idx) => `**Comment ${idx + 1}:**\n${c.body}`) | |
| .join('\n\n---\n\n'); | |
| core.setOutput('issue_title', issueTitle); | |
| core.setOutput('issue_body', issueBody); | |
| core.setOutput('issue_author', issueAuthor); | |
| core.setOutput('issue_labels', labels.join(',')); | |
| core.setOutput('issue_type', issueType); | |
| core.setOutput('comment_count', comments.data.length); | |
| core.setOutput('author_comments', authorComments || 'No additional comments from author'); | |
| - name: Build completeness check prompt | |
| id: build_prompt | |
| if: needs.should_run.outputs.skip_preanalysis != 'true' | |
| uses: actions/github-script@v9 | |
| env: | |
| ISSUE_TYPE: ${{ steps.issue_data.outputs.issue_type }} | |
| ISSUE_TITLE: ${{ steps.issue_data.outputs.issue_title }} | |
| ISSUE_BODY: ${{ steps.issue_data.outputs.issue_body }} | |
| with: | |
| script: | | |
| const issueType = process.env.ISSUE_TYPE; | |
| const issueTitle = process.env.ISSUE_TITLE; | |
| const issueBody = process.env.ISSUE_BODY; | |
| let requirements = ''; | |
| if (issueType === 'runtime-bug') { | |
| requirements = `**For Runtime Bugs**: | |
| 1. Exact error message with stack trace (copy-pasted, not just "doesn't work") | |
| 2. ha-mcp version number (from \`ha-mcp --version\`) | |
| 3. Installation method (uvx/pip/docker/addon/git) | |
| 4. What tool or action was being used when the error occurred | |
| 5. Home Assistant version (if relevant)`; | |
| } else if (issueType === 'startup-bug') { | |
| requirements = `**For Startup/Installation Bugs**: | |
| 1. Exact error message or connection failure details | |
| 2. ha-mcp version number | |
| 3. Installation method (uvx/pip/docker/addon) | |
| 4. Client application (Claude Desktop/Code/Continue/etc) | |
| 5. Operating system | |
| 6. Config file content (sanitized, no tokens)`; | |
| } else if (issueType === 'agent-behavior') { | |
| requirements = `**For Agent Behavior Issues**: | |
| 1. Which tool exhibited the unexpected behavior | |
| 2. What input was provided to the tool | |
| 3. What output was received (include ha_report_issue output if available) | |
| 4. What output was expected instead | |
| 5. Context about what the agent was trying to accomplish`; | |
| } else { | |
| requirements = `**General Requirements** (issue type unknown): | |
| 1. Exact error message or clear description of the problem | |
| 2. ha-mcp version number | |
| 3. Installation method (uvx/pip/docker/addon) | |
| 4. What was being done when the issue occurred`; | |
| } | |
| const prompt = `You are doing a QUICK evaluation of a GitHub issue. Do NOT search files, do NOT read code, do NOT use any tools. | |
| Base your evaluation ONLY on the issue text provided below. | |
| **Project**: Home Assistant MCP Server (ha-mcp) | |
| - MCP server with 92+ tools for AI assistants to control Home Assistant | |
| - Supports: Claude Desktop, Claude Code, Cursor, Gemini CLI, Continue, etc. | |
| - Installation: uvx, pip, docker, Home Assistant add-on | |
| - Users can run \`ha_report_issue\` tool to auto-collect diagnostic info | |
| **Issue Title**: "${issueTitle}" | |
| **Issue Type**: ${issueType} | |
| **Issue Body**: "${issueBody}" | |
| **Common Issue Types**: | |
| - Runtime bugs: Tool errors, API failures | |
| - Startup issues: Connection failures, Docker problems | |
| - Agent behavior: Tools returning unexpected results | |
| - Support questions: "How do I...", "Can I..." | |
| **Question**: Is there enough information to provide helpful diagnostic guidance? | |
| ${requirements} | |
| **Decision Criteria**: | |
| - If this is a support/docs question (not a bug): Output "COMPLETE" (we can still provide guidance) | |
| - If 70%+ of required items present: Output "COMPLETE" | |
| - If there's a clear, specific error message (even if other details missing): Output "COMPLETE" | |
| - If issue provides diagnostic value (stack trace, logs, specific symptoms): Output "COMPLETE" | |
| - If the issue is too vague: Output a helpful missing info message | |
| **Your response**: | |
| - If complete enough: Output exactly "COMPLETE" (nothing else) | |
| - If missing info: Output a bulleted list of what's missing with instructions: | |
| * **[Missing item]** - [How to get it] | |
| Be concise. Focus only on what's actually missing. | |
| **CRITICAL**: Do NOT use tools. Do NOT search files. Evaluate based ONLY on the issue text above.`; | |
| core.setOutput('prompt', prompt); | |
| - name: Evaluate completeness and generate response | |
| id: evaluate | |
| if: needs.should_run.outputs.skip_preanalysis != 'true' | |
| uses: google-github-actions/run-gemini-cli@f77273f4c914e4bf38440cf36a0369cb64a37489 # v0.1.22 | |
| continue-on-error: true | |
| env: | |
| GITHUB_TOKEN: '' | |
| GEMINI_CLI_TRUST_WORKSPACE: 'true' | |
| with: | |
| gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' | |
| # Preview alias — review when gemini-3-flash GAs (or when a | |
| # gemini-3.1-flash-preview lands, mirroring the 3-pro → 3.1-pro move). | |
| gemini_model: 'gemini-3-flash-preview' | |
| settings: | | |
| { | |
| "model": { | |
| "maxSessionTurns": 2, | |
| "temperature": 0.3 | |
| }, | |
| "tools": { | |
| "core": [] | |
| } | |
| } | |
| prompt: '${{ steps.build_prompt.outputs.prompt }}' | |
| - name: Full analysis (if COMPLETE or author commented) | |
| id: gemini_analysis | |
| if: contains(steps.evaluate.outputs.summary, 'COMPLETE') || needs.should_run.outputs.skip_preanalysis == 'true' | |
| uses: google-github-actions/run-gemini-cli@f77273f4c914e4bf38440cf36a0369cb64a37489 # v0.1.22 | |
| continue-on-error: true | |
| env: | |
| GITHUB_TOKEN: '' | |
| GEMINI_CLI_TRUST_WORKSPACE: 'true' | |
| ISSUE_NUMBER: '${{ steps.issue_number.outputs.number }}' | |
| ISSUE_TITLE: '${{ steps.issue_data.outputs.issue_title }}' | |
| ISSUE_BODY: '${{ steps.issue_data.outputs.issue_body }}' | |
| ISSUE_AUTHOR: '${{ steps.issue_data.outputs.issue_author }}' | |
| ISSUE_LABELS: '${{ steps.issue_data.outputs.issue_labels }}' | |
| COMMENT_COUNT: '${{ steps.issue_data.outputs.comment_count }}' | |
| REPO_OWNER: '${{ github.repository_owner }}' | |
| REPO_NAME: '${{ github.event.repository.name }}' | |
| with: | |
| gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' | |
| gemini_model: 'gemini-3-flash-preview' | |
| settings: | | |
| { | |
| "model": { | |
| "maxSessionTurns": 50 | |
| }, | |
| "tools": { | |
| "core": [ | |
| "read_file", | |
| "ls", | |
| "grep", | |
| "glob", | |
| "run_shell_command(cat)", | |
| "run_shell_command(ls)", | |
| "run_shell_command(grep)", | |
| "run_shell_command(find)", | |
| "run_shell_command(head)", | |
| "run_shell_command(tail)", | |
| "run_shell_command(git log)", | |
| "run_shell_command(git show)", | |
| "run_shell_command(gh issue view)", | |
| "run_shell_command(gh issue list)", | |
| "google_web_search" | |
| ] | |
| } | |
| } | |
| prompt: | | |
| You are a triage bot for the **ha-mcp** project (Home Assistant MCP Server). | |
| **Project Context:** | |
| - ha-mcp is an MCP (Model Context Protocol) server that connects AI assistants to Home Assistant | |
| - It provides 92+ tools for controlling smart home devices, automations, and scenes | |
| - Installation methods: uvx, pip, Docker, or Home Assistant add-on | |
| - Codebase structure: src/ha_mcp/ (tools, client, server code) | |
| - Common issues: connection timeouts, authentication errors, tool failures | |
| **CRITICAL: You are triaging a GitHub issue** | |
| - Users may provide **inaccurate or wrong information** (wrong version, wrong file paths, misunderstood errors) | |
| - **Do NOT take user claims for granted** - verify by searching the actual codebase | |
| - If a user says "this worked in version X but broke in Y", check git log to verify | |
| - Trust the **main branch code** and **recent git history** over user reports | |
| - If something seems fixed in main, check recent commits with git log | |
| **Your Task:** | |
| Analyze this GitHub issue and provide helpful triage guidance. | |
| **IMPORTANT**: You are read-only. You CANNOT: | |
| - Modify files or create code changes | |
| - Create pull requests | |
| - Commit changes | |
| You CAN: | |
| - Read files and search the codebase | |
| - Identify root causes and suggest fixes (provide fixes as diffs in Technical Analysis section) | |
| - Guide users on what to do next | |
| **Issue #${{ steps.issue_number.outputs.number }}**: ${{ steps.issue_data.outputs.issue_title }} | |
| **Description:** | |
| ${{ steps.issue_data.outputs.issue_body }} | |
| **Author:** ${{ steps.issue_data.outputs.issue_author }} | |
| **Comments from author:** | |
| ${{ steps.issue_data.outputs.author_comments }} | |
| **Investigation Steps:** | |
| 1. **Search for similar issues**: Use `gh issue list` with labels/search to find related problems | |
| 2. **Find relevant code**: Use `grep -r` to search for error messages, function names, or relevant keywords | |
| 3. **Read actual files**: Use `cat` on files you find - DO NOT make up file paths | |
| 4. **Check git history**: Use `git log` to see recent changes, especially if user claims regression | |
| 5. **Find files**: Use `find` to locate specific files or patterns | |
| 6. **Web search**: Use `google_web_search` only if you need to look up error messages or external documentation | |
| **Shell Commands Available:** | |
| - `gh issue list --search "query"` - Find similar issues in this repo | |
| - `gh issue view <number>` - Check specific issue details | |
| - **NOTE**: You can ONLY use `gh issue list` and `gh issue view`. You CANNOT use `gh issue edit`, `gh issue comment`, `gh issue close`, or any other gh commands. | |
| - `grep -r "pattern" src/ --include="*.py"` - Search codebase for error messages, function names | |
| - `find src/ -name "*.py" -type f` - Find Python files | |
| - `cat <path>` - Read file content (only paths found via grep/find) | |
| - `head -n 50 <path>` - Read first 50 lines of a file | |
| - `tail -n 50 <path>` - Read last 50 lines of a file | |
| - `git log --oneline -20` - See recent commits | |
| - `git log --grep="pattern" --oneline` - Search commit messages | |
| - `git show <commit>:<file>` - Show file content at specific commit | |
| - `google_web_search <query>` - Search web for error messages or external docs | |
| **Critical Rules:** | |
| - **TIME BUDGET**: You have ~8 minutes. Aim for ~10 tool calls; if you genuinely need more (long issue, multiple files), the harness allows up to 50 turns total — use them sparingly. If you can't find something after 2 searches, move on. Focus on the single most likely root cause. | |
| - **YOU ARE READ-ONLY**: You cannot modify files, create PRs, or make code changes | |
| - If you identify a fix, provide it as a diff in the Technical Analysis section | |
| - ONLY reference file paths you actually found with `grep` or `find` | |
| - ONLY reference issue numbers you found with `gh issue` | |
| - If you can't find relevant code, say so - don't make up paths | |
| - **If a tool/function doesn't exist**: Clearly state "This tool/feature does not exist in ha-mcp" and suggest it's a feature request | |
| - **Verify user claims**: If user says "this broke in version X", check git log to verify | |
| - **Check main branch first**: If user reports a bug, verify it still exists in current code | |
| - Be specific: use actual error messages and function names from the issue | |
| - Focus on actionable steps the user can take | |
| - Be efficient: if you can't find something after 2-3 searches, move on | |
| - Prioritize the most likely root cause - don't investigate every possibility | |
| - Use shell commands properly: `grep -r`, `find`, `cat`, `head`, `tail`, `git log` | |
| **RESPONSE FORMAT:** | |
| Your response MUST follow this EXACT format. Copy this structure precisely: | |
| ## 🔍 Analysis | |
| [2-3 paragraphs explaining what's likely happening - write for the user, not too technical] | |
| **IMPORTANT**: If the reported tool/feature doesn't exist in the codebase after searching: | |
| - Start with: "The `[tool_name]` tool does not exist in ha-mcp." | |
| - Suggest similar existing tools if applicable | |
| - Recommend filing a feature request | |
| **IMPORTANT**: If you are applying the `duplicate` label, the Analysis section MUST include a sentence like: | |
| "This appears to be a duplicate of #123." — use the actual issue number you found with `gh issue list`. | |
| ## 🛠️ Suggested Steps | |
| 1. [First concrete action - be specific, include exact commands if relevant] | |
| 2. [Second step] | |
| 3. [Third step] | |
| <details> | |
| <summary>📊 Technical Analysis (for maintainers)</summary> | |
| ### Root Cause | |
| [Deep technical explanation - file paths, code references, API details] | |
| ### Affected Code | |
| [ONLY include files you actually found with `grep -r` or `find`. If you didn't investigate code, write "Not investigated" or omit this section] | |
| - `actual/path/from/grep.py:123` - [what this code does] | |
| - `another/real/file.py:456` - [explanation] | |
| ### Proposed Fix | |
| [If you identified a specific fix, provide it here as a diff. If no fix identified, omit this section] | |
| ```diff | |
| --- a/path/to/file.py | |
| +++ b/path/to/file.py | |
| @@ -10,7 +10,7 @@ | |
| def function(): | |
| - old_code = "broken" | |
| + new_code = "fixed" | |
| return result | |
| ``` | |
| ### Related Issues/PRs | |
| [ONLY include issues you found with `gh issue list` or `gh issue view`. If none found, write "No similar issues found" or omit this section] | |
| - #123 - [actual similar issue from gh issue] | |
| - #456 - [actual related PR from gh issue] | |
| </details> | |
| --- | |
| *🤖 Automated analysis by Issue Bot* | |
| **LABEL CLASSIFICATION (CRITICAL):** | |
| As the very last line of your response, output a hidden HTML comment with labels to apply. | |
| Format: `<!-- TRIAGE_LABELS: label1,label2 -->` | |
| Choose ONLY from these labels (can combine multiple): | |
| - `bug` — user is reporting broken/incorrect behavior (error, crash, wrong output) | |
| - `enhancement` — user is requesting a new feature or improvement that doesn't exist yet | |
| - `question` — user is asking how to do something; no clear bug or feature request | |
| - `documentation` — issue is about missing, wrong, or unclear documentation | |
| - `duplicate` — you found a matching open issue via `gh issue list` | |
| - `invalid` — issue is off-topic, spam, unrelated to ha-mcp, or not actionable | |
| Rules: | |
| - Always include exactly one primary label: `bug`, `enhancement`, `question`, or `invalid` | |
| - Add `documentation` in addition to the primary label if docs are clearly part of the problem | |
| - Add `duplicate` in addition to the primary label if you found a matching issue with `gh issue list` | |
| - Do NOT apply `duplicate` alone — always pair it with `bug` or `enhancement` | |
| - The comment MUST be the very last line, after `*🤖 Automated analysis by Issue Bot*` | |
| **HTML FORMAT RULES (CRITICAL):** | |
| - The `<details>` tag MUST be on its own line | |
| - The `<summary>` tag MUST be on the NEXT line after `<details>` | |
| - There MUST be a blank line after `</summary>` | |
| - Content goes after the blank line | |
| - There MUST be a blank line before `</details>` | |
| - The `</details>` tag MUST be on its own line | |
| - DO NOT use markdown code fences (```) around the details section | |
| - DO NOT use <br> tags | |
| **Example of correct HTML:** | |
| <details> | |
| <summary>Title here</summary> | |
| Content here with blank line above and below | |
| </details> | |
| # Skip on cancellation. concurrency.cancel-in-progress means every | |
| # superseded run (user posts a follow-up comment that retriggers triage) | |
| # would otherwise land here and overwrite the in-flight placeholder with | |
| # a "bot unavailable" notice — that's noise, not a failure. The | |
| # replacement run will post the real analysis. | |
| # | |
| # Trade-off: GitHub Actions also surfaces job timeout (timeout-minutes: | |
| # 12 above) as `cancelled()`, so a real timeout exits the run green | |
| # with the placeholder still on the issue. The next user comment will | |
| # retrigger triage and the orphan-cleanup in `Post initial` will pick | |
| # up the stale placeholder. Acceptable: the user-visible artifact | |
| # eventually gets repaired and timeouts are rare relative to | |
| # superseded runs (which are common per issue). | |
| - name: Update comment and labels | |
| if: ${{ !cancelled() && steps.initial_comment.outputs.comment_id }} | |
| uses: actions/github-script@v9 | |
| env: | |
| EVALUATE_RESULT: ${{ steps.evaluate.outputs.summary }} | |
| EVALUATE_OUTCOME: ${{ steps.evaluate.outcome }} | |
| GEMINI_SUMMARY: ${{ steps.gemini_analysis.outputs.summary }} | |
| GEMINI_OUTCOME: ${{ steps.gemini_analysis.outcome }} | |
| COMMENT_ID: ${{ steps.initial_comment.outputs.comment_id }} | |
| with: | |
| script: | | |
| const commentId = process.env.COMMENT_ID; | |
| const evaluateResult = process.env.EVALUATE_RESULT || ''; | |
| const evaluateOutcome = process.env.EVALUATE_OUTCOME || ''; | |
| const geminiSummary = process.env.GEMINI_SUMMARY || ''; | |
| const geminiOutcome = process.env.GEMINI_OUTCOME || ''; | |
| const GENERIC_FAILURE_BODY = `⚠️ **Triage bot temporarily unavailable.**\n\nThe automated triage hit an internal error. Your issue will be triaged manually — no action needed on your part.\n\n*🤖 Automated triage by Issue Bot*`; | |
| let analysisBody; | |
| let shouldAddTriagedLabel = false; | |
| // Set on every path that posts GENERIC_FAILURE_BODY. Drives the | |
| // `triage-failed` circuit-breaker label so the next comment from | |
| // the issue author doesn't auto-retrigger another doomed run. | |
| let shouldAddFailedLabel = false; | |
| let extraLabels = []; | |
| // CLI step failures must NEVER expose raw stderr to issue authors. | |
| // Match anything that isn't `success` or `skipped` — that includes | |
| // step-level `failure`, step-level `cancelled` (e.g. step-timeout, | |
| // rare), and any future outcome value the action may emit. We | |
| // explicitly do NOT include `skipped`: the evaluate step is | |
| // skipped via `skip_preanalysis`, and the analysis step is | |
| // skipped on the missing-info path; both are normal flow, not | |
| // failures. Job-level cancellation is filtered by the step's | |
| // `if: !cancelled()` guard above and never reaches this script. | |
| const stepHadNonSuccess = ( | |
| (evaluateOutcome && evaluateOutcome !== 'success' && evaluateOutcome !== 'skipped') || | |
| (geminiOutcome && geminiOutcome !== 'success' && geminiOutcome !== 'skipped') | |
| ); | |
| if (stepHadNonSuccess) { | |
| core.error(`Gemini triage step did not succeed (evaluate=${evaluateOutcome}, analysis=${geminiOutcome}); posting generic notice to issue.`); | |
| analysisBody = GENERIC_FAILURE_BODY; | |
| shouldAddFailedLabel = true; | |
| } else if (geminiSummary) { | |
| const labelMatch = geminiSummary.match(/<!--\s*TRIAGE_LABELS:\s*([\w,\s-]+?)\s*-->/); | |
| if (labelMatch) { | |
| const validLabels = ['bug', 'enhancement', 'question', 'documentation', 'duplicate', 'invalid']; | |
| const parsed = labelMatch[1].split(',').map(l => l.trim().toLowerCase()); | |
| extraLabels = parsed.filter(l => validLabels.includes(l)); | |
| const rejected = parsed.filter(l => !validLabels.includes(l)); | |
| if (rejected.length > 0) { | |
| core.warning(`Ignoring unrecognized labels from LLM: ${rejected.join(', ')}`); | |
| } | |
| } else { | |
| core.warning('LLM did not emit a TRIAGE_LABELS comment; issue will only get the "triaged" label.'); | |
| } | |
| analysisBody = geminiSummary.replace(/\n?<!--\s*TRIAGE_LABELS:.*?-->\n?/g, '').trimEnd(); | |
| shouldAddTriagedLabel = true; | |
| } else if (evaluateResult && evaluateResult.trim() !== 'COMPLETE') { | |
| // Missing-info path. The evaluate step said the issue isn't | |
| // ready for full analysis (its output isn't the literal | |
| // "COMPLETE" sentinel), so the output is a question for the | |
| // user. Trust the prose — but guard against two ways it can | |
| // go wrong: | |
| // | |
| // 1. The model refused or errored ("I cannot help with that", | |
| // "Error: rate limit"). Posting that under "Information | |
| // Needed" puts words in the bot's mouth that contradict | |
| // the prose. Divert to the generic notice instead. | |
| // 2. The model monologued (>4000 chars). Posting that on | |
| // every issue is a comment bomb. Divert. | |
| // | |
| // Anything else — bullet list, numbered list, prose | |
| // question — gets posted verbatim. Shape mismatch (no list | |
| // markers) is logged as a warning so prompt drift is visible | |
| // in run logs, but doesn't gate the post; a coherent prose | |
| // question is just as useful to the user as a bullet list. | |
| // | |
| // The 4000-char cap is a "looks like an LLM monologue, not a | |
| // missing-info checklist" heuristic; unrelated to GitHub's | |
| // 65536-char comment limit. Strict trim()==='COMPLETE' (not | |
| // includes) so "INCOMPLETE" / "MARKED_COMPLETE" / etc. don't | |
| // collide with the sentinel if a future prompt rewrite drifts. | |
| const trimmed = evaluateResult.trim(); | |
| // Refusal patterns. The first-person verbs are anchored to the | |
| // start AND paired with a help-action object so that legitimate | |
| // missing-info prose like "I cannot find the version in the | |
| // issue body" or "I cannot determine which addon is affected" | |
| // doesn't false-positive — those are info requests, not | |
| // refusals. Genuine Gemini refusals always pair the verb with | |
| // help/assist/comply/etc. and lead the response. | |
| const REFUSAL_PATTERNS = [ | |
| // Common refusal lead-ins | |
| /^(unfortunately|sorry|i'?m sorry|i apologize|my apologies|error:)/i, | |
| /^as an? (ai|language model|assistant|llm)\b/i, | |
| // First-person refusal verb + help-action pairing | |
| /^i (cannot|can'?t|won'?t)\s+(help|assist|comply|do that|do this|fulfill|answer that|answer this|provide that|provide this|provide assistance|continue|process this)/i, | |
| /^i am (unable|not able)\s+to\s+(help|assist|comply|do that|do this|fulfill|answer that|answer this|continue|process)/i, | |
| /^i'?m (unable|not able)\s+to\s+(help|assist|comply|do that|do this|fulfill|answer that|answer this|continue|process)/i, | |
| /^we (cannot|can'?t|are unable)\s+(help|assist|comply|fulfill)/i, | |
| /^i refuse to\b/i, | |
| // Additional Gemini refusal lead-ins flagged in PR #1122 review. | |
| // "I am afraid..." / "I'm afraid..." paired with a refusal verb | |
| // (cannot/can't/won't) covers the polite-decline phrasing without | |
| // false-positiving on standalone "I'm afraid the version isn't | |
| // mentioned" info-request prose. | |
| /^i('?m| am) afraid (i )?(cannot|can'?t|won'?t)/i, | |
| // Bare "Unable to help..." (no leading "I'm") — Gemini sometimes | |
| // drops the pronoun entirely. Narrow help-action set so prose | |
| // like "Unable to determine the version" doesn't false-positive. | |
| /^unable to (help|assist|comply)/i, | |
| // Policy / guideline refusals — narrow noun-phrases that don't | |
| // realistically appear in non-refusal prose. Earlier revisions | |
| // included bare `\bcannot assist\b` and `\bi refuse\b` here; | |
| // both are removed because they false-positive on legitimate | |
| // missing-info phrasing like "The user can run X; without it, | |
| // I refuse to guess". `cannot assist` as a refusal lead-in is | |
| // already covered by the anchored verb-pairing pattern above | |
| // (`^i cannot ... (assist|...)`), and `i refuse to` is covered | |
| // by its own anchored pattern. | |
| /\b(safety policy|content policy|won'?t help)\b/i, | |
| /\bviolates? (my|our|the) (guidelines|policies|terms)\b/i, | |
| ]; | |
| const looksLikeRefusal = REFUSAL_PATTERNS.some(re => re.test(trimmed)); | |
| const tooLong = trimmed.length > 4000; | |
| const matchesMissingInfoShape = trimmed.split('\n').some(line => { | |
| const l = line.trimStart(); | |
| return l.startsWith('* ') || l.startsWith('- ') || l.startsWith('• ') || | |
| l.startsWith('**') || /^\d+\.\s/.test(l); | |
| }); | |
| if (looksLikeRefusal || tooLong) { | |
| core.error(`evaluate output diverted to generic notice (looksLikeRefusal=${looksLikeRefusal}, length=${trimmed.length}); raw output: ${trimmed.slice(0, 200)}`); | |
| analysisBody = GENERIC_FAILURE_BODY; | |
| shouldAddFailedLabel = true; | |
| } else { | |
| if (!matchesMissingInfoShape) { | |
| core.warning(`evaluate output didn't match expected bullet/numbered/bold list shape (length=${trimmed.length}); posting LLM prose verbatim.`); | |
| } | |
| analysisBody = `## ℹ️ Information Needed\n\n`; | |
| analysisBody += trimmed + '\n\n'; | |
| analysisBody += `---\n\n`; | |
| analysisBody += `💡 **Please reply to this issue** with the requested information.\n\n`; | |
| analysisBody += `*🤖 Automated triage by Issue Bot*`; | |
| } | |
| } else { | |
| core.error(`Triage produced no usable output (evaluateOutcome=${evaluateOutcome}, geminiOutcome=${geminiOutcome}); posting generic notice.`); | |
| analysisBody = GENERIC_FAILURE_BODY; | |
| shouldAddFailedLabel = true; | |
| } | |
| try { | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: commentId, | |
| body: analysisBody | |
| }); | |
| } catch (err) { | |
| // Failed to update the placeholder. Surface it loudly — the | |
| // user is staring at "🤖 analyzing..." and we owe them at | |
| // least a red Actions run so a maintainer notices. | |
| core.setFailed(`updateComment failed for placeholder ${commentId}: ${err.message}`); | |
| return; | |
| } | |
| const issueNumber = ${{ steps.issue_number.outputs.number }}; | |
| if (shouldAddTriagedLabel) { | |
| const labelsToAdd = ['triaged', ...extraLabels]; | |
| core.info(`Adding labels: ${labelsToAdd.join(', ')}`); | |
| try { | |
| await github.rest.issues.addLabels({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issueNumber, | |
| labels: labelsToAdd | |
| }); | |
| } catch (err) { | |
| // If `triaged` doesn't get applied, the should_run gate at | |
| // the top of this workflow will re-trigger triage on every | |
| // future comment, piling up placeholders. Fail loudly so a | |
| // maintainer adds the label by hand. | |
| core.setFailed(`addLabels failed for issue #${issueNumber} (${labelsToAdd.join(',')}): ${err.message}. Issue will re-trigger triage on next comment until 'triaged' is applied manually.`); | |
| } | |
| // Successful triage clears any prior `triage-failed` circuit-breaker | |
| // so the issue's label state reflects the final outcome (this matters | |
| // when a maintainer recovers a stuck issue via workflow_dispatch). | |
| // 404 = label wasn't there to begin with; that's fine and not an error. | |
| // For other failures (5xx, network, auth) we only warn rather than | |
| // setFailed: the should_run gate at the top of this workflow checks | |
| // `triaged` BEFORE `triage-failed`, so a stale `triage-failed` label | |
| // alongside `triaged` doesn't actually block re-triage — the issue's | |
| // label set looks inconsistent until a maintainer cleans it up, but | |
| // no behavior breaks. | |
| try { | |
| await github.rest.issues.removeLabel({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issueNumber, | |
| name: 'triage-failed' | |
| }); | |
| core.info(`Removed stale 'triage-failed' label from issue #${issueNumber}`); | |
| } catch (err) { | |
| if (err.status !== 404) { | |
| core.warning(`Failed to remove 'triage-failed' label from issue #${issueNumber}: ${err.message}`); | |
| } | |
| } | |
| } | |
| if (shouldAddFailedLabel) { | |
| // Circuit breaker: applying `triage-failed` blocks the | |
| // should_run gate from auto-retriggering on every subsequent | |
| // author comment. Without it, a consistently-failing issue | |
| // (e.g. one that exceeds maxSessionTurns) accumulates one | |
| // "temporarily unavailable" notice per comment. Maintainers | |
| // can clear the label or use workflow_dispatch to retry. | |
| core.info(`Adding 'triage-failed' label to issue #${issueNumber} to break retrigger loop`); | |
| try { | |
| await github.rest.issues.addLabels({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issueNumber, | |
| labels: ['triage-failed'] | |
| }); | |
| } catch (err) { | |
| // Warn instead of setFailed — the run is already going red | |
| // either via the trailing "Fail job if Gemini steps failed" | |
| // step (when a CLI step had a non-success outcome) or via | |
| // the explicit setFailed below (when divert paths fired | |
| // without any step failure). The label itself is a circuit | |
| // breaker not a correctness gate, so a missed addLabels | |
| // here only reverts to pre-#1131 behavior; the warning | |
| // surfaces in the Actions log so a maintainer can apply | |
| // the label by hand if needed. | |
| core.warning(`addLabels failed for 'triage-failed' on issue #${issueNumber}: ${err.message}. Triage may retrigger on next comment until label is applied manually.`); | |
| } | |
| // Divert paths (refusal/too-long/no-output at lines 767/783) | |
| // set `shouldAddFailedLabel = true` even though both Gemini | |
| // steps returned `success`. The trailing "Fail job if Gemini | |
| // steps failed" step won't fire in those cases, so without | |
| // this `setFailed` the run exits green even though the user | |
| // got a "temporarily unavailable" notice — exactly the silent | |
| // failure pattern this PR is meant to eliminate. Skip when a | |
| // step already had a non-success outcome to avoid stacking | |
| // redundant failure messages. | |
| if (!stepHadNonSuccess) { | |
| core.setFailed(`Triage diverted to generic notice with no Gemini step failure (evaluate=${evaluateOutcome}, analysis=${geminiOutcome}). See "Update comment and labels" log above for the divert reason (refusal pattern, too-long output, or no usable output).`); | |
| } | |
| } | |
| # Surface Gemini step failures as a red X. Fires on any non-success/ | |
| # non-skipped outcome (`failure`, step-level `cancelled` from a step | |
| # timeout, or any future outcome value). Job-level cancellation from | |
| # concurrency `cancel-in-progress` is filtered by the `!cancelled()` | |
| # guard so superseded runs don't paint red. Divert paths (refusal/ | |
| # too-long/no-output) where both steps returned `success` are surfaced | |
| # by `core.setFailed` inside the "Update comment and labels" script, | |
| # not here. | |
| - name: Fail job if Gemini steps failed | |
| if: ${{ !cancelled() && ((steps.evaluate.outcome != 'success' && steps.evaluate.outcome != 'skipped') || (steps.gemini_analysis.outcome != 'success' && steps.gemini_analysis.outcome != 'skipped')) }} | |
| run: | | |
| echo "::error::Gemini triage step did not succeed (evaluate=${{ steps.evaluate.outcome }}, analysis=${{ steps.gemini_analysis.outcome }}). The issue received a generic 'temporarily unavailable' notice; check the step logs above for the underlying CLI error." | |
| exit 1 |