Setting the file. One moment.
Squad Heartbeat · {skill Name} · microsoft/waza · Skills Docs
ContentsBack to the top of the page 11.14
Plugin Marketplace
workflows/squad-heartbeat.yml
workflows/ squad-heartbeat.yml
YAML · 315 lines · 12 KB
: [
closed
]
13
14 # Manual trigger
15 workflow_dispatch :
16
17 permissions :
18 issues : write
19 contents : read
20 pull-requests : read
21
22 jobs :
23 heartbeat :
24 runs-on : ubuntu-latest
25 steps :
26 - uses : actions/checkout@v4
27
28 - name : Ralph — Check for squad work
29 uses : actions/github-script@v7
30 with :
31 script : |
32 const fs = require('fs');
33
34 // Read team roster — check .squad/ first, fall back to .ai-team/
35 let teamFile = '.squad/team.md';
36 if (!fs.existsSync(teamFile)) {
37 teamFile = '.ai-team/team.md';
38 }
39 if (!fs.existsSync(teamFile)) {
40 core.info('No .squad/team.md or .ai-team/team.md found — Ralph has nothing to monitor');
41 return;
42 }
43
44 const content = fs.readFileSync(teamFile, 'utf8');
45
46 // Check if Ralph is on the roster
47 if (!content.includes('Ralph') || !content.includes('🔄')) {
48 core.info('Ralph not on roster — heartbeat disabled');
49 return;
50 }
51
52 // Parse members from roster
53 const lines = content.split('\n');
54 const members = [];
55 let inMembersTable = false;
56 for (const line of lines) {
57 if (line.match(/^##\s+(Members|Team Roster)/i)) {
58 inMembersTable = true;
59 continue;
60 }
61 if (inMembersTable && line.startsWith('## ')) break;
62 if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) {
63 const cells = line.split('|').map(c => c.trim()).filter(Boolean);
64 if (cells.length >= 2 && !['Scribe', 'Ralph'].includes(cells[0])) {
65 members.push({
66 name: cells[0],
67 role: cells[1],
68 label: `squad:${cells[0].toLowerCase()}`
69 });
70 }
71 }
72 }
73
74 if (members.length === 0) {
75 core.info('No squad members found — nothing to monitor');
76 return;
77 }
78
79 // 1. Find untriaged issues (labeled "squad" but no "squad:{member}" label)
80 const { data: squadIssues } = await github.rest.issues.listForRepo({
81 owner: context.repo.owner,
82 repo: context.repo.repo,
83 labels: 'squad',
84 state: 'open',
85 per_page: 20
86 });
87
88 const memberLabels = members.map(m => m.label);
89 const untriaged = squadIssues.filter(issue => {
90 const issueLabels = issue.labels.map(l => l.name);
91 return !memberLabels.some(ml => issueLabels.includes(ml));
92 });
93
94 // 2. Find assigned but unstarted issues (has squad:{member} label, no assignee)
95 const unstarted = [];
96 for (const member of members) {
97 try {
98 const { data: memberIssues } = await github.rest.issues.listForRepo({
99 owner: context.repo.owner,
100 repo: context.repo.repo,
101 labels: member.label,
102 state: 'open',
103 per_page: 10
104 });
105 for (const issue of memberIssues) {
106 if (!issue.assignees || issue.assignees.length === 0) {
107 unstarted.push({ issue, member });
108 }
109 }
110 } catch (e) {
111 // Label may not exist yet
112 }
113 }
114
115 // 3. Find squad issues missing triage verdict (no go:* label)
116 const missingVerdict = squadIssues.filter(issue => {
117 const labels = issue.labels.map(l => l.name);
118 return !labels.some(l => l.startsWith('go:'));
119 });
120
121 // 4. Find go:yes issues missing release target
122 const goYesIssues = squadIssues.filter(issue => {
123 const labels = issue.labels.map(l => l.name);
124 return labels.includes('go:yes') && !labels.some(l => l.startsWith('release:'));
125 });
126
127 // 4b. Find issues missing type: label
128 const missingType = squadIssues.filter(issue => {
129 const labels = issue.labels.map(l => l.name);
130 return !labels.some(l => l.startsWith('type:'));
131 });
132
133 // 5. Find open PRs that need attention
134 const { data: openPRs } = await github.rest.pulls.list({
135 owner: context.repo.owner,
136 repo: context.repo.repo,
137 state: 'open',
138 per_page: 20
139 });
140
141 const squadPRs = openPRs.filter(pr =>
142 pr.labels.some(l => l.name.startsWith('squad'))
143 );
144
145 // Build status summary
146 const summary = [];
147 if (untriaged.length > 0) {
148 summary.push(`🔴 **${untriaged.length} untriaged issue(s)** need triage`);
149 }
150 if (unstarted.length > 0) {
151 summary.push(`🟡 **${unstarted.length} assigned issue(s)** have no assignee`);
152 }
153 if (missingVerdict.length > 0) {
154 summary.push(`⚪ **${missingVerdict.length} issue(s)** missing triage verdict (no \`go:\` label)`);
155 }
156 if (goYesIssues.length > 0) {
157 summary.push(`⚪ **${goYesIssues.length} approved issue(s)** missing release target (no \`release:\` label)`);
158 }
159 if (missingType.length > 0) {
160 summary.push(`⚪ **${missingType.length} issue(s)** missing \`type:\` label`);
161 }
162 if (squadPRs.length > 0) {
163 const drafts = squadPRs.filter(pr => pr.draft).length;
164 const ready = squadPRs.length - drafts;
165 if (drafts > 0) summary.push(`🟡 **${drafts} draft PR(s)** in progress`);
166 if (ready > 0) summary.push(`🟢 **${ready} PR(s)** open for review/merge`);
167 }
168
169 if (summary.length === 0) {
170 core.info('📋 Board is clear — Ralph found no pending work');
171 return;
172 }
173
174 core.info(`🔄 Ralph found work:\n${summary.join('\n')}`);
175
176 // Auto-triage untriaged issues
177 for (const issue of untriaged) {
178 const issueText = `${issue.title}\n${issue.body || ''}`.toLowerCase();
179 let assignedMember = null;
180 let reason = '';
181
182 // Simple keyword-based routing
183 for (const member of members) {
184 const role = member.role.toLowerCase();
185 if ((role.includes('frontend') || role.includes('ui')) &&
186 (issueText.includes('ui') || issueText.includes('frontend') ||
187 issueText.includes('css') || issueText.includes('component'))) {
188 assignedMember = member;
189 reason = 'Matches frontend/UI domain';
190 break;
191 }
192 if ((role.includes('backend') || role.includes('api') || role.includes('server')) &&
193 (issueText.includes('api') || issueText.includes('backend') ||
194 issueText.includes('database') || issueText.includes('endpoint'))) {
195 assignedMember = member;
196 reason = 'Matches backend/API domain';
197 break;
198 }
199 if ((role.includes('test') || role.includes('qa')) &&
200 (issueText.includes('test') || issueText.includes('bug') ||
201 issueText.includes('fix') || issueText.includes('regression'))) {
202 assignedMember = member;
203 reason = 'Matches testing/QA domain';
204 break;
205 }
206 }
207
208 // Default to Lead
209 if (!assignedMember) {
210 const lead = members.find(m =>
211 m.role.toLowerCase().includes('lead') ||
212 m.role.toLowerCase().includes('architect')
213 );
214 if (lead) {
215 assignedMember = lead;
216 reason = 'No domain match — routed to Lead';
217 }
218 }
219
220 if (assignedMember) {
221 // Add member label
222 await github.rest.issues.addLabels({
223 owner: context.repo.owner,
224 repo: context.repo.repo,
225 issue_number: issue.number,
226 labels: [assignedMember.label]
227 });
228
229 // Post triage comment
230 await github.rest.issues.createComment({
231 owner: context.repo.owner,
232 repo: context.repo.repo,
233 issue_number: issue.number,
234 body: [
235 `### 🔄 Ralph — Auto-Triage`,
236 '',
237 `**Assigned to:** ${assignedMember.name} (${assignedMember.role})`,
238 `**Reason:** ${reason}`,
239 '',
240 `> Ralph auto-triaged this issue via the squad heartbeat. To reassign, swap the \`squad:*\` label.`
241 ].join('\n')
242 });
243
244 core.info(`Auto-triaged #${issue.number} → ${assignedMember.name}`);
245 }
246 }
247
248 # Copilot auto-assign step (uses PAT if available)
249 - name : Ralph — Assign @copilot issues
250 if : success()
251 uses : actions/github-script@v7
252 with :
253 github-token : ${{ secrets.COPILOT_ASSIGN_TOKEN || secrets.GITHUB_TOKEN }}
254 script : |
255 const fs = require('fs');
256
257 let teamFile = '.squad/team.md';
258 if (!fs.existsSync(teamFile)) {
259 teamFile = '.ai-team/team.md';
260 }
261 if (!fs.existsSync(teamFile)) return;
262
263 const content = fs.readFileSync(teamFile, 'utf8');
264
265 // Check if @copilot is on the team with auto-assign
266 const hasCopilot = content.includes('🤖 Coding Agent') || content.includes('@copilot');
267 const autoAssign = content.includes('<!-- copilot-auto-assign: true -->');
268 if (!hasCopilot || !autoAssign) return;
269
270 // Find issues labeled squad:copilot with no assignee
271 try {
272 const { data: copilotIssues } = await github.rest.issues.listForRepo({
273 owner: context.repo.owner,
274 repo: context.repo.repo,
275 labels: 'squad:copilot',
276 state: 'open',
277 per_page: 5
278 });
279
280 const unassigned = copilotIssues.filter(i =>
281 !i.assignees || i.assignees.length === 0
282 );
283
284 if (unassigned.length === 0) {
285 core.info('No unassigned squad:copilot issues');
286 return;
287 }
288
289 // Get repo default branch
290 const { data: repoData } = await github.rest.repos.get({
291 owner: context.repo.owner,
292 repo: context.repo.repo
293 });
294
295 for (const issue of unassigned) {
296 try {
297 await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', {
298 owner: context.repo.owner,
299 repo: context.repo.repo,
300 issue_number: issue.number,
301 assignees: ['copilot-swe-agent[bot]'],
302 agent_assignment: {
303 target_repo: `${context.repo.owner}/${context.repo.repo}`,
304 base_branch: repoData.default_branch,
305 custom_instructions: `Read .squad/team.md (or .ai-team/team.md) for team context and .squad/routing.md (or .ai-team/routing.md) for routing rules.`
306 }
307 });
308 core.info(`Assigned copilot-swe-agent[bot] to #${issue.number}`);
309 } catch (e) {
310 core.warning(`Failed to assign @copilot to #${issue.number}: ${e.message}`);
311 }
312 }
313 } catch (e) {
314 core.info(`No squad:copilot label found or error: ${e.message}`);
315 }