name: 'šŸ·ļø Issue Triage' on: issues: types: - 'opened' - 'reopened' issue_comment: types: - 'created' workflow_dispatch: inputs: issue_number: description: 'Issue number to triage' required: true type: 'number' concurrency: group: '${{ github.workflow }}-${{ github.event.issue.number || github.event.inputs.issue_number }}' cancel-in-progress: true permissions: contents: 'read' issues: 'write' jobs: triage-issue: if: | github.event_name == 'workflow_dispatch' || github.event_name == 'issues' || ( github.event_name == 'issue_comment' && contains(github.event.comment.body, '/triage') && ( github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'COLLABORATOR' ) ) timeout-minutes: 30 runs-on: self-hosted steps: - name: 'Check if already triaged' id: 'check_labels' if: github.event_name != 'workflow_dispatch' uses: 'actions/github-script@v7' with: script: | const labels = context.payload.issue?.labels?.map(l => l.name) || []; const triageLabels = ['bug', 'enhancement', 'documentation', 'question', 'invalid']; const areaLabels = labels.filter(l => l.startsWith('area/')); const hasTriageLabel = labels.some(l => triageLabels.includes(l)) || areaLabels.length > 0; const isRetriage = context.payload.comment?.body?.includes('/triage'); if (hasTriageLabel && !labels.includes('needs-triage') && !isRetriage) { core.info(`Issue already triaged: ${labels.join(', ')}. Skipping.`); core.setOutput('skip', 'true'); } else { core.setOutput('skip', 'false'); } - name: 'Get issue data' id: 'get_issue' if: steps.check_labels.outputs.skip != 'true' uses: 'actions/github-script@v7' with: script: | const issueNumber = context.payload.inputs?.issue_number || context.issue?.number; core.setOutput('number', issueNumber); - name: 'Run Opencode Triage' id: 'opencode_triage' if: steps.check_labels.outputs.skip != 'true' run: | echo "Running Opencode Triage on issue ${{ steps.get_issue.outputs.number }}..." # Move to the repository cd /home/admin/coding/opencode-antigravity-auth # Run CLI with the agent config OUTPUT=$(opencode run --agent triage-bot "Triage issue ${{ steps.get_issue.outputs.number }}") echo "Opencode Output: $OUTPUT" echo "result<> $GITHUB_OUTPUT echo "$OUTPUT" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT - name: 'Apply Labels and Respond' if: steps.check_labels.outputs.skip != 'true' uses: 'actions/github-script@v7' env: ISSUE_NUMBER: '${{ steps.get_issue.outputs.number }}' OPENCODE_OUTPUT: '${{ steps.opencode_triage.outputs.result }}' with: script: | const rawOutput = process.env.OPENCODE_OUTPUT; if (!rawOutput) { core.warning('No triage output available'); return; } core.info(`Triage output: ${rawOutput}`); let parsed; try { parsed = JSON.parse(rawOutput.trim()); } catch (e) { // Try to extract JSON from code blocks first const codeBlockMatch = rawOutput.match(/```(?:json)?\s*([\s\S]*?)\s*```/); // Find the LAST JSON object containing triage fields (avoid matching code snippets) // Match JSON objects that contain "type_label" to ensure we get the triage output const triageJsonMatch = rawOutput.match(/\{"type_label"[\s\S]*?\}(?=\s*$|\s*```|\s*\n\n)/); // Fallback: find all potential JSON objects and try parsing from the end let jsonStr = codeBlockMatch?.[1] || triageJsonMatch?.[0]; if (!jsonStr) { // Last resort: find all { } pairs and try parsing from the last one const allMatches = [...rawOutput.matchAll(/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g)]; for (let i = allMatches.length - 1; i >= 0; i--) { try { const candidate = JSON.parse(allMatches[i][0]); if (candidate.type_label && candidate.area_label) { jsonStr = allMatches[i][0]; break; } } catch {} } } if (jsonStr) { try { parsed = JSON.parse(jsonStr.trim()); } catch (e2) { core.setFailed(`Failed to parse output: ${rawOutput}`); return; } } else { core.setFailed(`Failed to parse output: ${rawOutput}`); return; } } const typeLabel = parsed.type_label; const areaLabel = parsed.area_label; const duplicateOf = parsed.duplicate_of; const suggestedResponse = parsed.suggested_response; // Validate required fields if (!typeLabel || !areaLabel || suggestedResponse === undefined) { core.setFailed(`Invalid JSON structure: missing required fields. Parsed: ${JSON.stringify(parsed)}`); return; } const validTypeLabels = ['bug', 'enhancement', 'documentation', 'question', 'invalid']; const validAreaLabels = ['area/auth', 'area/models', 'area/config', 'area/compat']; if (!validTypeLabels.includes(typeLabel)) { core.warning(`Invalid type_label: ${typeLabel}`); return; } if (!validAreaLabels.includes(areaLabel)) { core.warning(`Invalid area_label: ${areaLabel}`); return; } let labelsToAdd = []; let labelsToRemove = ['needs-triage']; // Remove existing triage labels to prevent conflicts validTypeLabels.forEach(label => labelsToRemove.push(label)); validAreaLabels.forEach(label => labelsToRemove.push(label)); // Only add one type label and one area label if (typeLabel && validTypeLabels.includes(typeLabel)) { labelsToAdd.push(typeLabel); } if (areaLabel && validAreaLabels.includes(areaLabel)) { labelsToAdd.push(areaLabel); } if (duplicateOf) { labelsToAdd.push('duplicate'); } // Validate single label selection and deduplicate const typeLabels = labelsToAdd.filter(label => validTypeLabels.includes(label)); const areaLabels = labelsToAdd.filter(label => validAreaLabels.includes(label)); const otherLabels = labelsToAdd.filter(label => !validTypeLabels.includes(label) && !validAreaLabels.includes(label)); if (typeLabels.length > 1) { core.warning(`Multiple type labels detected: ${typeLabels.join(', ')}. Using only: ${typeLabel}`); } if (areaLabels.length > 1) { core.warning(`Multiple area labels detected: ${areaLabels.join(', ')}. Using only: ${areaLabel}`); } // Rebuild labelsToAdd with single type and area labels labelsToAdd = []; if (typeLabel && validTypeLabels.includes(typeLabel)) { labelsToAdd.push(typeLabel); } if (areaLabel && validAreaLabels.includes(areaLabel)) { labelsToAdd.push(areaLabel); } labelsToAdd.push(...otherLabels); // Avoid removing labels that are being added labelsToRemove = labelsToRemove.filter(label => !labelsToAdd.includes(label)); const issueNumber = parseInt(process.env.ISSUE_NUMBER); await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, labels: labelsToAdd }); core.info(`Added labels: ${labelsToAdd.join(', ')}`); for (const label of labelsToRemove) { try { await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, name: label }); core.info(`Removed label: ${label}`); } catch (e) { core.info(`Label ${label} not present, skipping removal`); } } if (suggestedResponse && suggestedResponse.trim()) { let body = `šŸ‘‹ Thanks for opening this issue!\n\n${suggestedResponse}`; if (duplicateOf) { body += `\n\nšŸ”— This appears to be related to #${duplicateOf}. Please check that issue for updates.`; } body += `\n\n---\n*This is an automated response. A maintainer will review your issue soon.*`; await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, body: body }); core.info('Posted response comment'); } if (duplicateOf) { await github.rest.issues.update({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, state: 'closed', state_reason: 'not_planned' }); core.info(`Closed as duplicate of #${duplicateOf}`); } - name: 'Comment on failure' if: failure() uses: 'actions/github-script@v7' env: ISSUE_NUMBER: '${{ steps.get_issue.outputs.number || github.event.issue.number }}' RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' with: script: | if (!process.env.ISSUE_NUMBER) return; await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: parseInt(process.env.ISSUE_NUMBER), body: `āš ļø Automated triage failed. [View logs](${process.env.RUN_URL})` });