Investigation - are incorrect responses reverted on user override #165
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Squad Heartbeat (Ralph) | |
| on: | |
| # DISABLED: Cron heartbeat commented out pre-migration β re-enable when ready | |
| # schedule: | |
| # # Every 30 minutes β adjust or remove if not needed | |
| # - cron: '*/30 * * * *' | |
| # React to completed work or new squad work | |
| issues: | |
| types: [closed, labeled] | |
| pull_request: | |
| types: [closed] | |
| # Manual trigger | |
| workflow_dispatch: | |
| permissions: | |
| issues: write | |
| contents: read | |
| pull-requests: read | |
| jobs: | |
| heartbeat: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Ralph β Check for squad work | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| // Read team roster β check .squad/ first, fall back to .ai-team/ | |
| let teamFile = '.squad/team.md'; | |
| if (!fs.existsSync(teamFile)) { | |
| teamFile = '.ai-team/team.md'; | |
| } | |
| if (!fs.existsSync(teamFile)) { | |
| core.info('No .squad/team.md or .ai-team/team.md found β Ralph has nothing to monitor'); | |
| return; | |
| } | |
| const content = fs.readFileSync(teamFile, 'utf8'); | |
| // Check if Ralph is on the roster | |
| if (!content.includes('Ralph') || !content.includes('π')) { | |
| core.info('Ralph not on roster β heartbeat disabled'); | |
| return; | |
| } | |
| // Parse members from roster | |
| const lines = content.split('\n'); | |
| const members = []; | |
| let inMembersTable = false; | |
| for (const line of lines) { | |
| if (line.match(/^##\s+(Members|Team Roster)/i)) { | |
| inMembersTable = true; | |
| continue; | |
| } | |
| if (inMembersTable && line.startsWith('## ')) break; | |
| if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) { | |
| const cells = line.split('|').map(c => c.trim()).filter(Boolean); | |
| if (cells.length >= 2 && !['Scribe', 'Ralph'].includes(cells[0])) { | |
| members.push({ | |
| name: cells[0], | |
| role: cells[1], | |
| label: `squad:${cells[0].toLowerCase()}` | |
| }); | |
| } | |
| } | |
| } | |
| if (members.length === 0) { | |
| core.info('No squad members found β nothing to monitor'); | |
| return; | |
| } | |
| // 1. Find untriaged issues (labeled "squad" but no "squad:{member}" label) | |
| const { data: squadIssues } = await github.rest.issues.listForRepo({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| labels: 'squad', | |
| state: 'open', | |
| per_page: 20 | |
| }); | |
| const memberLabels = members.map(m => m.label); | |
| const untriaged = squadIssues.filter(issue => { | |
| const issueLabels = issue.labels.map(l => l.name); | |
| return !memberLabels.some(ml => issueLabels.includes(ml)); | |
| }); | |
| // 2. Find assigned but unstarted issues (has squad:{member} label, no assignee) | |
| const unstarted = []; | |
| for (const member of members) { | |
| try { | |
| const { data: memberIssues } = await github.rest.issues.listForRepo({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| labels: member.label, | |
| state: 'open', | |
| per_page: 10 | |
| }); | |
| for (const issue of memberIssues) { | |
| if (!issue.assignees || issue.assignees.length === 0) { | |
| unstarted.push({ issue, member }); | |
| } | |
| } | |
| } catch (e) { | |
| // Label may not exist yet | |
| } | |
| } | |
| // 3. Find squad issues missing triage verdict (no go:* label) | |
| const missingVerdict = squadIssues.filter(issue => { | |
| const labels = issue.labels.map(l => l.name); | |
| return !labels.some(l => l.startsWith('go:')); | |
| }); | |
| // 4. Find go:yes issues missing release target | |
| const goYesIssues = squadIssues.filter(issue => { | |
| const labels = issue.labels.map(l => l.name); | |
| return labels.includes('go:yes') && !labels.some(l => l.startsWith('release:')); | |
| }); | |
| // 4b. Find issues missing type: label | |
| const missingType = squadIssues.filter(issue => { | |
| const labels = issue.labels.map(l => l.name); | |
| return !labels.some(l => l.startsWith('type:')); | |
| }); | |
| // 5. Find open PRs that need attention | |
| const { data: openPRs } = await github.rest.pulls.list({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| state: 'open', | |
| per_page: 20 | |
| }); | |
| const squadPRs = openPRs.filter(pr => | |
| pr.labels.some(l => l.name.startsWith('squad')) | |
| ); | |
| // Build status summary | |
| const summary = []; | |
| if (untriaged.length > 0) { | |
| summary.push(`π΄ **${untriaged.length} untriaged issue(s)** need triage`); | |
| } | |
| if (unstarted.length > 0) { | |
| summary.push(`π‘ **${unstarted.length} assigned issue(s)** have no assignee`); | |
| } | |
| if (missingVerdict.length > 0) { | |
| summary.push(`βͺ **${missingVerdict.length} issue(s)** missing triage verdict (no \`go:\` label)`); | |
| } | |
| if (goYesIssues.length > 0) { | |
| summary.push(`βͺ **${goYesIssues.length} approved issue(s)** missing release target (no \`release:\` label)`); | |
| } | |
| if (missingType.length > 0) { | |
| summary.push(`βͺ **${missingType.length} issue(s)** missing \`type:\` label`); | |
| } | |
| if (squadPRs.length > 0) { | |
| const drafts = squadPRs.filter(pr => pr.draft).length; | |
| const ready = squadPRs.length - drafts; | |
| if (drafts > 0) summary.push(`π‘ **${drafts} draft PR(s)** in progress`); | |
| if (ready > 0) summary.push(`π’ **${ready} PR(s)** open for review/merge`); | |
| } | |
| if (summary.length === 0) { | |
| core.info('π Board is clear β Ralph found no pending work'); | |
| return; | |
| } | |
| core.info(`π Ralph found work:\n${summary.join('\n')}`); | |
| // Auto-triage untriaged issues | |
| for (const issue of untriaged) { | |
| const issueText = `${issue.title}\n${issue.body || ''}`.toLowerCase(); | |
| let assignedMember = null; | |
| let reason = ''; | |
| // Simple keyword-based routing | |
| for (const member of members) { | |
| const role = member.role.toLowerCase(); | |
| if ((role.includes('frontend') || role.includes('ui')) && | |
| (issueText.includes('ui') || issueText.includes('frontend') || | |
| issueText.includes('css') || issueText.includes('component'))) { | |
| assignedMember = member; | |
| reason = 'Matches frontend/UI domain'; | |
| break; | |
| } | |
| if ((role.includes('backend') || role.includes('api') || role.includes('server')) && | |
| (issueText.includes('api') || issueText.includes('backend') || | |
| issueText.includes('database') || issueText.includes('endpoint'))) { | |
| assignedMember = member; | |
| reason = 'Matches backend/API domain'; | |
| break; | |
| } | |
| if ((role.includes('test') || role.includes('qa')) && | |
| (issueText.includes('test') || issueText.includes('bug') || | |
| issueText.includes('fix') || issueText.includes('regression'))) { | |
| assignedMember = member; | |
| reason = 'Matches testing/QA domain'; | |
| break; | |
| } | |
| } | |
| // Default to Lead | |
| if (!assignedMember) { | |
| const lead = members.find(m => | |
| m.role.toLowerCase().includes('lead') || | |
| m.role.toLowerCase().includes('architect') | |
| ); | |
| if (lead) { | |
| assignedMember = lead; | |
| reason = 'No domain match β routed to Lead'; | |
| } | |
| } | |
| if (assignedMember) { | |
| // Add member label | |
| await github.rest.issues.addLabels({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issue.number, | |
| labels: [assignedMember.label] | |
| }); | |
| // Post triage comment | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issue.number, | |
| body: [ | |
| `### π Ralph β Auto-Triage`, | |
| '', | |
| `**Assigned to:** ${assignedMember.name} (${assignedMember.role})`, | |
| `**Reason:** ${reason}`, | |
| '', | |
| `> Ralph auto-triaged this issue via the squad heartbeat. To reassign, swap the \`squad:*\` label.` | |
| ].join('\n') | |
| }); | |
| core.info(`Auto-triaged #${issue.number} β ${assignedMember.name}`); | |
| } | |
| } | |
| # Copilot auto-assign step (uses PAT if available) | |
| - name: Ralph β Assign @copilot issues | |
| if: success() | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN || secrets.GITHUB_TOKEN }} | |
| script: | | |
| const fs = require('fs'); | |
| let teamFile = '.squad/team.md'; | |
| if (!fs.existsSync(teamFile)) { | |
| teamFile = '.ai-team/team.md'; | |
| } | |
| if (!fs.existsSync(teamFile)) return; | |
| const content = fs.readFileSync(teamFile, 'utf8'); | |
| // Check if @copilot is on the team with auto-assign | |
| const hasCopilot = content.includes('π€ Coding Agent') || content.includes('@copilot'); | |
| const autoAssign = content.includes('<!-- copilot-auto-assign: true -->'); | |
| if (!hasCopilot || !autoAssign) return; | |
| // Find issues labeled squad:copilot with no assignee | |
| try { | |
| const { data: copilotIssues } = await github.rest.issues.listForRepo({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| labels: 'squad:copilot', | |
| state: 'open', | |
| per_page: 5 | |
| }); | |
| const unassigned = copilotIssues.filter(i => | |
| !i.assignees || i.assignees.length === 0 | |
| ); | |
| if (unassigned.length === 0) { | |
| core.info('No unassigned squad:copilot issues'); | |
| return; | |
| } | |
| // Get repo default branch | |
| const { data: repoData } = await github.rest.repos.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo | |
| }); | |
| for (const issue of unassigned) { | |
| try { | |
| await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issue.number, | |
| assignees: ['copilot-swe-agent[bot]'], | |
| agent_assignment: { | |
| target_repo: `${context.repo.owner}/${context.repo.repo}`, | |
| base_branch: repoData.default_branch, | |
| 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.` | |
| } | |
| }); | |
| core.info(`Assigned copilot-swe-agent[bot] to #${issue.number}`); | |
| } catch (e) { | |
| core.warning(`Failed to assign @copilot to #${issue.number}: ${e.message}`); | |
| } | |
| } | |
| } catch (e) { | |
| core.info(`No squad:copilot label found or error: ${e.message}`); | |
| } |