|
| 1 | +name: Squad Heartbeat (Ralph) |
| 2 | + |
| 3 | +on: |
| 4 | + schedule: |
| 5 | + # Every 30 minutes — adjust or remove if not needed |
| 6 | + - cron: '*/30 * * * *' |
| 7 | + |
| 8 | + # React to completed work or new squad work |
| 9 | + issues: |
| 10 | + types: [closed, labeled] |
| 11 | + pull_request: |
| 12 | + types: [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 | + } |
0 commit comments