squad-triage.yml (11519B)
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@v4 17 18 - name: Triage issue via Lead agent 19 uses: actions/github-script@v7 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 // Build triage context 114 const memberList = members.map(m => 115 `- **${m.name}** (${m.role}) ā label: \`squad:${m.name.toLowerCase()}\`` 116 ).join('\n'); 117 118 // Determine best assignee based on issue content and routing 119 const issueText = `${issue.title}\n${issue.body || ''}`.toLowerCase(); 120 121 let assignedMember = null; 122 let triageReason = ''; 123 let copilotTier = null; 124 125 // First, evaluate @copilot fit if enabled 126 if (hasCopilot) { 127 const isNotSuitable = notSuitableKeywords.some(kw => issueText.includes(kw)); 128 const isGoodFit = !isNotSuitable && goodFitKeywords.some(kw => issueText.includes(kw)); 129 const isNeedsReview = !isNotSuitable && !isGoodFit && needsReviewKeywords.some(kw => issueText.includes(kw)); 130 131 if (isGoodFit) { 132 copilotTier = 'good-fit'; 133 assignedMember = { name: '@copilot', role: 'Coding Agent' }; 134 triageReason = 'š¢ Good fit for @copilot ā matches capability profile'; 135 } else if (isNeedsReview) { 136 copilotTier = 'needs-review'; 137 assignedMember = { name: '@copilot', role: 'Coding Agent' }; 138 triageReason = 'š” Routing to @copilot (needs review) ā a squad member should review the PR'; 139 } else if (isNotSuitable) { 140 copilotTier = 'not-suitable'; 141 // Fall through to normal routing 142 } 143 } 144 145 // If not routed to @copilot, use keyword-based routing 146 if (!assignedMember) { 147 for (const member of members) { 148 const role = member.role.toLowerCase(); 149 if ((role.includes('frontend') || role.includes('ui')) && 150 (issueText.includes('ui') || issueText.includes('frontend') || 151 issueText.includes('css') || issueText.includes('component') || 152 issueText.includes('button') || issueText.includes('page') || 153 issueText.includes('layout') || issueText.includes('design'))) { 154 assignedMember = member; 155 triageReason = 'Issue relates to frontend/UI work'; 156 break; 157 } 158 if ((role.includes('backend') || role.includes('api') || role.includes('server')) && 159 (issueText.includes('api') || issueText.includes('backend') || 160 issueText.includes('database') || issueText.includes('endpoint') || 161 issueText.includes('server') || issueText.includes('auth'))) { 162 assignedMember = member; 163 triageReason = 'Issue relates to backend/API work'; 164 break; 165 } 166 if ((role.includes('test') || role.includes('qa') || role.includes('quality')) && 167 (issueText.includes('test') || issueText.includes('bug') || 168 issueText.includes('fix') || issueText.includes('regression') || 169 issueText.includes('coverage'))) { 170 assignedMember = member; 171 triageReason = 'Issue relates to testing/quality work'; 172 break; 173 } 174 if ((role.includes('devops') || role.includes('infra') || role.includes('ops')) && 175 (issueText.includes('deploy') || issueText.includes('ci') || 176 issueText.includes('pipeline') || issueText.includes('docker') || 177 issueText.includes('infrastructure'))) { 178 assignedMember = member; 179 triageReason = 'Issue relates to DevOps/infrastructure work'; 180 break; 181 } 182 } 183 } 184 185 // Default to Lead if no routing match 186 if (!assignedMember) { 187 assignedMember = lead; 188 triageReason = 'No specific domain match ā assigned to Lead for further analysis'; 189 } 190 191 const isCopilot = assignedMember.name === '@copilot'; 192 const assignLabel = isCopilot ? 'squad:copilot' : `squad:${assignedMember.name.toLowerCase()}`; 193 194 // Add the member-specific label 195 await github.rest.issues.addLabels({ 196 owner: context.repo.owner, 197 repo: context.repo.repo, 198 issue_number: issue.number, 199 labels: [assignLabel] 200 }); 201 202 // Apply default triage verdict 203 await github.rest.issues.addLabels({ 204 owner: context.repo.owner, 205 repo: context.repo.repo, 206 issue_number: issue.number, 207 labels: ['go:needs-research'] 208 }); 209 210 // Auto-assign @copilot if enabled 211 if (isCopilot && copilotAutoAssign) { 212 try { 213 await github.rest.issues.addAssignees({ 214 owner: context.repo.owner, 215 repo: context.repo.repo, 216 issue_number: issue.number, 217 assignees: ['copilot'] 218 }); 219 } catch (err) { 220 core.warning(`Could not auto-assign @copilot: ${err.message}`); 221 } 222 } 223 224 // Build copilot evaluation note 225 let copilotNote = ''; 226 if (hasCopilot && !isCopilot) { 227 if (copilotTier === 'not-suitable') { 228 copilotNote = `\n\n**@copilot evaluation:** š“ Not suitable ā issue involves work outside the coding agent's capability profile.`; 229 } else { 230 copilotNote = `\n\n**@copilot evaluation:** No strong capability match ā routed to squad member.`; 231 } 232 } 233 234 // Post triage comment 235 const comment = [ 236 `### šļø Squad Triage ā ${lead.name} (${lead.role})`, 237 '', 238 `**Issue:** #${issue.number} ā ${issue.title}`, 239 `**Assigned to:** ${assignedMember.name} (${assignedMember.role})`, 240 `**Reason:** ${triageReason}`, 241 copilotTier === 'needs-review' ? `\nā ļø **PR review recommended** ā a squad member should review @copilot's work on this one.` : '', 242 copilotNote, 243 '', 244 `---`, 245 '', 246 `**Team roster:**`, 247 memberList, 248 hasCopilot ? `- **@copilot** (Coding Agent) ā label: \`squad:copilot\`` : '', 249 '', 250 `> To reassign, remove the current \`squad:*\` label and add the correct one.`, 251 ].filter(Boolean).join('\n'); 252 253 await github.rest.issues.createComment({ 254 owner: context.repo.owner, 255 repo: context.repo.repo, 256 issue_number: issue.number, 257 body: comment 258 }); 259 260 core.info(`Triaged issue #${issue.number} ā ${assignedMember.name} (${assignLabel})`);