Setting the file. One moment. Squad Triage · {skill Name} · microsoft/waza · Skills Docs11.14
Plugin Marketplace
workflows/squad-triage.yml
workflows/squad-triage.yml
YAML·260 lines·11 KB
:
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})`);