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