| 1 | name: Squad Triage |
| 2 | |
| 3 | on: |
| 4 | issues: |
| 5 | types: [labeled] |
| 6 | |
| 7 | permissions: |
| 8 | issues: write |
| 9 | contents: read |
| 10 | |
| 11 | jobs: |
| 12 | triage: |
| 13 | if: github.event.label.name == 'squad' |
| 14 | runs-on: ubuntu-latest |
| 15 | steps: |
| 16 | - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 |
| 17 | |
| 18 | - name: Triage issue via Lead agent |
| 19 | uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 |
| 20 | with: |
| 21 | script: | |
| 22 | const fs = require('fs'); |
| 23 | const issue = context.payload.issue; |
| 24 | |
| 25 | // Read team roster — check .squad/ first, fall back to .ai-team/ |
| 26 | let teamFile = '.squad/team.md'; |
| 27 | if (!fs.existsSync(teamFile)) { |
| 28 | teamFile = '.ai-team/team.md'; |
| 29 | } |
| 30 | if (!fs.existsSync(teamFile)) { |
| 31 | core.warning('No .squad/team.md or .ai-team/team.md found — cannot triage'); |
| 32 | return; |
| 33 | } |
| 34 | |
| 35 | const content = fs.readFileSync(teamFile, 'utf8'); |
| 36 | const lines = content.split('\n'); |
| 37 | |
| 38 | // Check if @copilot is on the team |
| 39 | const hasCopilot = content.includes('🤖 Coding Agent'); |
| 40 | const copilotAutoAssign = content.includes('<!-- copilot-auto-assign: true -->'); |
| 41 | |
| 42 | // Parse @copilot capability profile |
| 43 | let goodFitKeywords = []; |
| 44 | let needsReviewKeywords = []; |
| 45 | let notSuitableKeywords = []; |
| 46 | |
| 47 | if (hasCopilot) { |
| 48 | // Extract capability tiers from team.md |
| 49 | const goodFitMatch = content.match(/🟢\s*Good fit[^:]*:\s*(.+)/i); |
| 50 | const needsReviewMatch = content.match(/🟡\s*Needs review[^:]*:\s*(.+)/i); |
| 51 | const notSuitableMatch = content.match(/🔴\s*Not suitable[^:]*:\s*(.+)/i); |
| 52 | |
| 53 | if (goodFitMatch) { |
| 54 | goodFitKeywords = goodFitMatch[1].toLowerCase().split(',').map(s => s.trim()); |
| 55 | } else { |
| 56 | goodFitKeywords = ['bug fix', 'test coverage', 'lint', 'format', 'dependency update', 'small feature', 'scaffolding', 'doc fix', 'documentation']; |
| 57 | } |
| 58 | if (needsReviewMatch) { |
| 59 | needsReviewKeywords = needsReviewMatch[1].toLowerCase().split(',').map(s => s.trim()); |
| 60 | } else { |
| 61 | needsReviewKeywords = ['medium feature', 'refactoring', 'api endpoint', 'migration']; |
| 62 | } |
| 63 | if (notSuitableMatch) { |
| 64 | notSuitableKeywords = notSuitableMatch[1].toLowerCase().split(',').map(s => s.trim()); |
| 65 | } else { |
| 66 | notSuitableKeywords = ['architecture', 'system design', 'security', 'auth', 'encryption', 'performance']; |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | const members = []; |
| 71 | let inMembersTable = false; |
| 72 | for (const line of lines) { |
| 73 | if (line.match(/^##\s+(Members|Team Roster)/i)) { |
| 74 | inMembersTable = true; |
| 75 | continue; |
| 76 | } |
| 77 | if (inMembersTable && line.startsWith('## ')) { |
| 78 | break; |
| 79 | } |
| 80 | if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) { |
| 81 | const cells = line.split('|').map(c => c.trim()).filter(Boolean); |
| 82 | if (cells.length >= 2 && cells[0] !== 'Scribe') { |
| 83 | members.push({ |
| 84 | name: cells[0], |
| 85 | role: cells[1] |
| 86 | }); |
| 87 | } |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | // Read routing rules — check .squad/ first, fall back to .ai-team/ |
| 92 | let routingFile = '.squad/routing.md'; |
| 93 | if (!fs.existsSync(routingFile)) { |
| 94 | routingFile = '.ai-team/routing.md'; |
| 95 | } |
| 96 | let routingContent = ''; |
| 97 | if (fs.existsSync(routingFile)) { |
| 98 | routingContent = fs.readFileSync(routingFile, 'utf8'); |
| 99 | } |
| 100 | |
| 101 | // Find the Lead |
| 102 | const lead = members.find(m => |
| 103 | m.role.toLowerCase().includes('lead') || |
| 104 | m.role.toLowerCase().includes('architect') || |
| 105 | m.role.toLowerCase().includes('coordinator') |
| 106 | ); |
| 107 | |
| 108 | if (!lead) { |
| 109 | core.warning('No Lead role found in team roster — cannot triage'); |
| 110 | return; |
| 111 | } |
| 112 | |
| 113 | function slugify(t) { return t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); } |
| 114 | |
| 115 | // Build triage context |
| 116 | const memberList = members.map(m => |
| 117 | `- **${m.name}** (${m.role}) → label: \`squad:${slugify(m.name)}\`` |
| 118 | ).join('\n'); |
| 119 | |
| 120 | // Determine best assignee based on issue content and routing |
| 121 | const issueText = `${issue.title}\n${issue.body || ''}`.toLowerCase(); |
| 122 | |
| 123 | let assignedMember = null; |
| 124 | let triageReason = ''; |
| 125 | let copilotTier = null; |
| 126 | |
| 127 | // First, evaluate @copilot fit if enabled |
| 128 | if (hasCopilot) { |
| 129 | const isNotSuitable = notSuitableKeywords.some(kw => issueText.includes(kw)); |
| 130 | const isGoodFit = !isNotSuitable && goodFitKeywords.some(kw => issueText.includes(kw)); |
| 131 | const isNeedsReview = !isNotSuitable && !isGoodFit && needsReviewKeywords.some(kw => issueText.includes(kw)); |
| 132 | |
| 133 | if (isGoodFit) { |
| 134 | copilotTier = 'good-fit'; |
| 135 | assignedMember = { name: '@copilot', role: 'Coding Agent' }; |
| 136 | triageReason = '🟢 Good fit for @copilot — matches capability profile'; |
| 137 | } else if (isNeedsReview) { |
| 138 | copilotTier = 'needs-review'; |
| 139 | assignedMember = { name: '@copilot', role: 'Coding Agent' }; |
| 140 | triageReason = '🟡 Routing to @copilot (needs review) — a squad member should review the PR'; |
| 141 | } else if (isNotSuitable) { |
| 142 | copilotTier = 'not-suitable'; |
| 143 | // Fall through to normal routing |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | // If not routed to @copilot, use keyword-based routing |
| 148 | if (!assignedMember) { |
| 149 | for (const member of members) { |
| 150 | const role = member.role.toLowerCase(); |
| 151 | if ((role.includes('frontend') || role.includes('ui')) && |
| 152 | (issueText.includes('ui') || issueText.includes('frontend') || |
| 153 | issueText.includes('css') || issueText.includes('component') || |
| 154 | issueText.includes('button') || issueText.includes('page') || |
| 155 | issueText.includes('layout') || issueText.includes('design'))) { |
| 156 | assignedMember = member; |
| 157 | triageReason = 'Issue relates to frontend/UI work'; |
| 158 | break; |
| 159 | } |
| 160 | if ((role.includes('backend') || role.includes('api') || role.includes('server')) && |
| 161 | (issueText.includes('api') || issueText.includes('backend') || |
| 162 | issueText.includes('database') || issueText.includes('endpoint') || |
| 163 | issueText.includes('server') || issueText.includes('auth'))) { |
| 164 | assignedMember = member; |
| 165 | triageReason = 'Issue relates to backend/API work'; |
| 166 | break; |
| 167 | } |
| 168 | if ((role.includes('test') || role.includes('qa') || role.includes('quality')) && |
| 169 | (issueText.includes('test') || issueText.includes('bug') || |
| 170 | issueText.includes('fix') || issueText.includes('regression') || |
| 171 | issueText.includes('coverage'))) { |
| 172 | assignedMember = member; |
| 173 | triageReason = 'Issue relates to testing/quality work'; |
| 174 | break; |
| 175 | } |
| 176 | if ((role.includes('devops') || role.includes('infra') || role.includes('ops')) && |
| 177 | (issueText.includes('deploy') || issueText.includes('ci') || |
| 178 | issueText.includes('pipeline') || issueText.includes('docker') || |
| 179 | issueText.includes('infrastructure'))) { |
| 180 | assignedMember = member; |
| 181 | triageReason = 'Issue relates to DevOps/infrastructure work'; |
| 182 | break; |
| 183 | } |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | // Default to Lead if no routing match |
| 188 | if (!assignedMember) { |
| 189 | assignedMember = lead; |
| 190 | triageReason = 'No specific domain match — assigned to Lead for further analysis'; |
| 191 | } |
| 192 | |
| 193 | const isCopilot = assignedMember.name === '@copilot'; |
| 194 | const assignLabel = isCopilot ? 'squad:copilot' : `squad:${slugify(assignedMember.name)}`; |
| 195 | |
| 196 | // Add the member-specific label |
| 197 | await github.rest.issues.addLabels({ |
| 198 | owner: context.repo.owner, |
| 199 | repo: context.repo.repo, |
| 200 | issue_number: issue.number, |
| 201 | labels: [assignLabel] |
| 202 | }); |
| 203 | |
| 204 | // Apply default triage verdict |
| 205 | await github.rest.issues.addLabels({ |
| 206 | owner: context.repo.owner, |
| 207 | repo: context.repo.repo, |
| 208 | issue_number: issue.number, |
| 209 | labels: ['go:needs-research'] |
| 210 | }); |
| 211 | |
| 212 | // Auto-assign @copilot if enabled |
| 213 | if (isCopilot && copilotAutoAssign) { |
| 214 | try { |
| 215 | await github.rest.issues.addAssignees({ |
| 216 | owner: context.repo.owner, |
| 217 | repo: context.repo.repo, |
| 218 | issue_number: issue.number, |
| 219 | assignees: ['copilot'] |
| 220 | }); |
| 221 | } catch (err) { |
| 222 | core.warning(`Could not auto-assign @copilot: ${err.message}`); |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | // Build copilot evaluation note |
| 227 | let copilotNote = ''; |
| 228 | if (hasCopilot && !isCopilot) { |
| 229 | if (copilotTier === 'not-suitable') { |
| 230 | copilotNote = `\n\n**@copilot evaluation:** 🔴 Not suitable — issue involves work outside the coding agent's capability profile.`; |
| 231 | } else { |
| 232 | copilotNote = `\n\n**@copilot evaluation:** No strong capability match — routed to squad member.`; |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | // Post triage comment |
| 237 | const comment = [ |
| 238 | `### 🏗️ Squad Triage — ${lead.name} (${lead.role})`, |
| 239 | '', |
| 240 | `**Issue:** #${issue.number} — ${issue.title}`, |
| 241 | `**Assigned to:** ${assignedMember.name} (${assignedMember.role})`, |
| 242 | `**Reason:** ${triageReason}`, |
| 243 | copilotTier === 'needs-review' ? `\n⚠️ **PR review recommended** — a squad member should review @copilot's work on this one.` : '', |
| 244 | copilotNote, |
| 245 | '', |
| 246 | `---`, |
| 247 | '', |
| 248 | `**Team roster:**`, |
| 249 | memberList, |
| 250 | hasCopilot ? `- **@copilot** (Coding Agent) → label: \`squad:copilot\`` : '', |
| 251 | '', |
| 252 | `> To reassign, remove the current \`squad:*\` label and add the correct one.`, |
| 253 | ].filter(Boolean).join('\n'); |
| 254 | |
| 255 | await github.rest.issues.createComment({ |
| 256 | owner: context.repo.owner, |
| 257 | repo: context.repo.repo, |
| 258 | issue_number: issue.number, |
| 259 | body: comment |
| 260 | }); |
| 261 | |
| 262 | core.info(`Triaged issue #${issue.number} → ${assignedMember.name} (${assignLabel})`); |