Skip to content

docs(overview): enumerate dismissed_repair_count in fields= description + static drift test #2828

docs(overview): enumerate dismissed_repair_count in fields= description + static drift test

docs(overview): enumerate dismissed_repair_count in fields= description + static drift test #2828

Workflow file for this run

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