Skip to content

Commit 8f5cc7c

Browse files
authored
Merge pull request #107 from fboucher/copilot/forthcoming-spoonbill
Add missing tests for issues #102#106: Summary endpoints, AISettingsProvider fallback chain, PostEndpoints edge cases, domain model validation
2 parents 7100ad7 + f3beec6 commit 8f5cc7c

File tree

108 files changed

+10898
-94
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

108 files changed

+10898
-94
lines changed

.gitattributes

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,8 @@
33
.ai-team/agents/*/history.md merge=union
44
.ai-team/log/** merge=union
55
.ai-team/orchestration-log/** merge=union
6+
# Squad: union merge for append-only team state files
7+
.squad/decisions.md merge=union
8+
.squad/agents/*/history.md merge=union
9+
.squad/log/** merge=union
10+
.squad/orchestration-log/** merge=union
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
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+
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
name: Squad Issue Assign
2+
3+
on:
4+
issues:
5+
types: [labeled]
6+
7+
permissions:
8+
issues: write
9+
contents: read
10+
11+
jobs:
12+
assign-work:
13+
# Only trigger on squad:{member} labels (not the base "squad" label)
14+
if: startsWith(github.event.label.name, 'squad:')
15+
runs-on: ubuntu-latest
16+
steps:
17+
- uses: actions/checkout@v4
18+
19+
- name: Identify assigned member and trigger work
20+
uses: actions/github-script@v7
21+
with:
22+
script: |
23+
const fs = require('fs');
24+
const issue = context.payload.issue;
25+
const label = context.payload.label.name;
26+
27+
// Extract member name from label (e.g., "squad:ripley" → "ripley")
28+
const memberName = label.replace('squad:', '').toLowerCase();
29+
30+
// Read team roster — check .squad/ first, fall back to .ai-team/
31+
let teamFile = '.squad/team.md';
32+
if (!fs.existsSync(teamFile)) {
33+
teamFile = '.ai-team/team.md';
34+
}
35+
if (!fs.existsSync(teamFile)) {
36+
core.warning('No .squad/team.md or .ai-team/team.md found — cannot assign work');
37+
return;
38+
}
39+
40+
const content = fs.readFileSync(teamFile, 'utf8');
41+
const lines = content.split('\n');
42+
43+
// Check if this is a coding agent assignment
44+
const isCopilotAssignment = memberName === 'copilot';
45+
46+
let assignedMember = null;
47+
if (isCopilotAssignment) {
48+
assignedMember = { name: '@copilot', role: 'Coding Agent' };
49+
} else {
50+
let inMembersTable = false;
51+
for (const line of lines) {
52+
if (line.match(/^##\s+(Members|Team Roster)/i)) {
53+
inMembersTable = true;
54+
continue;
55+
}
56+
if (inMembersTable && line.startsWith('## ')) {
57+
break;
58+
}
59+
if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) {
60+
const cells = line.split('|').map(c => c.trim()).filter(Boolean);
61+
if (cells.length >= 2 && cells[0].toLowerCase() === memberName) {
62+
assignedMember = { name: cells[0], role: cells[1] };
63+
break;
64+
}
65+
}
66+
}
67+
}
68+
69+
if (!assignedMember) {
70+
core.warning(`No member found matching label "${label}"`);
71+
await github.rest.issues.createComment({
72+
owner: context.repo.owner,
73+
repo: context.repo.repo,
74+
issue_number: issue.number,
75+
body: `⚠️ No squad member found matching label \`${label}\`. Check \`.squad/team.md\` (or \`.ai-team/team.md\`) for valid member names.`
76+
});
77+
return;
78+
}
79+
80+
// Post assignment acknowledgment
81+
let comment;
82+
if (isCopilotAssignment) {
83+
comment = [
84+
`### 🤖 Routed to @copilot (Coding Agent)`,
85+
'',
86+
`**Issue:** #${issue.number} — ${issue.title}`,
87+
'',
88+
`@copilot has been assigned and will pick this up automatically.`,
89+
'',
90+
`> The coding agent will create a \`copilot/*\` branch and open a draft PR.`,
91+
`> Review the PR as you would any team member's work.`,
92+
].join('\n');
93+
} else {
94+
comment = [
95+
`### 📋 Assigned to ${assignedMember.name} (${assignedMember.role})`,
96+
'',
97+
`**Issue:** #${issue.number} — ${issue.title}`,
98+
'',
99+
`${assignedMember.name} will pick this up in the next Copilot session.`,
100+
'',
101+
`> **For Copilot coding agent:** If enabled, this issue will be worked automatically.`,
102+
`> Otherwise, start a Copilot session and say:`,
103+
`> \`${assignedMember.name}, work on issue #${issue.number}\``,
104+
].join('\n');
105+
}
106+
107+
await github.rest.issues.createComment({
108+
owner: context.repo.owner,
109+
repo: context.repo.repo,
110+
issue_number: issue.number,
111+
body: comment
112+
});
113+
114+
core.info(`Issue #${issue.number} assigned to ${assignedMember.name} (${assignedMember.role})`);
115+
116+
# Separate step: assign @copilot using PAT (required for coding agent)
117+
- name: Assign @copilot coding agent
118+
if: github.event.label.name == 'squad:copilot'
119+
uses: actions/github-script@v7
120+
with:
121+
github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN }}
122+
script: |
123+
const owner = context.repo.owner;
124+
const repo = context.repo.repo;
125+
const issue_number = context.payload.issue.number;
126+
127+
// Get the default branch name (main, master, etc.)
128+
const { data: repoData } = await github.rest.repos.get({ owner, repo });
129+
const baseBranch = repoData.default_branch;
130+
131+
try {
132+
await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', {
133+
owner,
134+
repo,
135+
issue_number,
136+
assignees: ['copilot-swe-agent[bot]'],
137+
agent_assignment: {
138+
target_repo: `${owner}/${repo}`,
139+
base_branch: baseBranch,
140+
custom_instructions: '',
141+
custom_agent: '',
142+
model: ''
143+
},
144+
headers: {
145+
'X-GitHub-Api-Version': '2022-11-28'
146+
}
147+
});
148+
core.info(`Assigned copilot-swe-agent to issue #${issue_number} (base: ${baseBranch})`);
149+
} catch (err) {
150+
core.warning(`Assignment with agent_assignment failed: ${err.message}`);
151+
// Fallback: try without agent_assignment
152+
try {
153+
await github.rest.issues.addAssignees({
154+
owner, repo, issue_number,
155+
assignees: ['copilot-swe-agent']
156+
});
157+
core.info(`Fallback assigned copilot-swe-agent to issue #${issue_number}`);
158+
} catch (err2) {
159+
core.warning(`Fallback also failed: ${err2.message}`);
160+
}
161+
}

0 commit comments

Comments
 (0)