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