main
yml 167 lines 6.43 KB
Raw
1 name: Squad Heartbeat (Ralph)
2 # ⚠️ SYNC: This workflow is maintained in 4 locations. Changes must be applied to all:
3 # - templates/workflows/squad-heartbeat.yml (source template)
4 # - packages/squad-cli/templates/workflows/squad-heartbeat.yml (CLI package)
5 # - .squad/templates/workflows/squad-heartbeat.yml (installed template)
6 # - .github/workflows/squad-heartbeat.yml (active workflow)
7 # Run 'squad upgrade' to sync installed copies from source templates.
8
9 on:
10 # React to completed work or new squad work
11 issues:
12 types: [closed, labeled]
13 pull_request:
14 types: [closed]
15
16 # Manual trigger
17 workflow_dispatch:
18
19 permissions:
20 issues: write
21 contents: read
22 pull-requests: read
23
24 jobs:
25 heartbeat:
26 runs-on: ubuntu-latest
27 steps:
28 - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
29
30 - name: Check triage script
31 id: check-script
32 run: |
33 if [ -f ".squad/templates/ralph-triage.js" ]; then
34 echo "has_script=true" >> $GITHUB_OUTPUT
35 else
36 echo "has_script=false" >> $GITHUB_OUTPUT
37 echo "⚠️ ralph-triage.js not found — run 'squad upgrade' to install"
38 fi
39
40 - name: Ralph — Smart triage
41 if: steps.check-script.outputs.has_script == 'true'
42 env:
43 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
44 run: |
45 node .squad/templates/ralph-triage.js \
46 --squad-dir .squad \
47 --output triage-results.json
48
49 - name: Ralph — Apply triage decisions
50 if: steps.check-script.outputs.has_script == 'true' && hashFiles('triage-results.json') != ''
51 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
52 with:
53 script: |
54 const fs = require('fs');
55 const path = 'triage-results.json';
56 if (!fs.existsSync(path)) {
57 core.info('No triage results — board is clear');
58 return;
59 }
60
61 const results = JSON.parse(fs.readFileSync(path, 'utf8'));
62 if (results.length === 0) {
63 core.info('📋 Board is clear — Ralph found no untriaged issues');
64 return;
65 }
66
67 for (const decision of results) {
68 try {
69 await github.rest.issues.addLabels({
70 owner: context.repo.owner,
71 repo: context.repo.repo,
72 issue_number: decision.issueNumber,
73 labels: [decision.label]
74 });
75
76 await github.rest.issues.createComment({
77 owner: context.repo.owner,
78 repo: context.repo.repo,
79 issue_number: decision.issueNumber,
80 body: [
81 '### 🔄 Ralph — Auto-Triage',
82 '',
83 `**Assigned to:** ${decision.assignTo}`,
84 `**Reason:** ${decision.reason}`,
85 `**Source:** ${decision.source}`,
86 '',
87 '> Ralph auto-triaged this issue using routing rules.',
88 '> To reassign, swap the `squad:*` label.'
89 ].join('\n')
90 });
91
92 core.info(`Triaged #${decision.issueNumber} → ${decision.assignTo} (${decision.source})`);
93 } catch (e) {
94 core.warning(`Failed to triage #${decision.issueNumber}: ${e.message}`);
95 }
96 }
97
98 core.info(`🔄 Ralph triaged ${results.length} issue(s)`);
99
100 # Copilot auto-assign step (uses PAT if available)
101 - name: Ralph — Assign @copilot issues
102 if: success()
103 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
104 with:
105 github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN || secrets.GITHUB_TOKEN }}
106 script: |
107 const fs = require('fs');
108
109 let teamFile = '.squad/team.md';
110 if (!fs.existsSync(teamFile)) {
111 teamFile = '.ai-team/team.md';
112 }
113 if (!fs.existsSync(teamFile)) return;
114
115 const content = fs.readFileSync(teamFile, 'utf8');
116
117 // Check if @copilot is on the team with auto-assign
118 const hasCopilot = content.includes('🤖 Coding Agent') || content.includes('@copilot');
119 const autoAssign = content.includes('<!-- copilot-auto-assign: true -->');
120 if (!hasCopilot || !autoAssign) return;
121
122 // Find issues labeled squad:copilot with no assignee
123 try {
124 const { data: copilotIssues } = await github.rest.issues.listForRepo({
125 owner: context.repo.owner,
126 repo: context.repo.repo,
127 labels: 'squad:copilot',
128 state: 'open',
129 per_page: 5
130 });
131
132 const unassigned = copilotIssues.filter(i =>
133 !i.assignees || i.assignees.length === 0
134 );
135
136 if (unassigned.length === 0) {
137 core.info('No unassigned squad:copilot issues');
138 return;
139 }
140
141 // Get repo default branch
142 const { data: repoData } = await github.rest.repos.get({
143 owner: context.repo.owner,
144 repo: context.repo.repo
145 });
146
147 for (const issue of unassigned) {
148 try {
149 await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', {
150 owner: context.repo.owner,
151 repo: context.repo.repo,
152 issue_number: issue.number,
153 assignees: ['copilot-swe-agent[bot]'],
154 agent_assignment: {
155 target_repo: `${context.repo.owner}/${context.repo.repo}`,
156 base_branch: repoData.default_branch,
157 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.`
158 }
159 });
160 core.info(`Assigned copilot-swe-agent[bot] to #${issue.number}`);
161 } catch (e) {
162 core.warning(`Failed to assign @copilot to #${issue.number}: ${e.message}`);
163 }
164 }
165 } catch (e) {
166 core.info(`No squad:copilot label found or error: ${e.message}`);
167 }