main
yml 262 lines 11.1 KB
Raw
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 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})`);