Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
331 changes: 217 additions & 114 deletions .github/workflows/squad-issue-assign.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
name: Squad Issue Assign

# Agentic Workflow: Issue Assignment
# Trigger: maintainer applies "go:yes" label to approve an issue for work.
# The agent assigns the issue to the label sender, reads the prior triage
# analysis and routing table, then applies squad labels for matched areas.
#
# ─── SECURITY CONSTRAINTS ───────────────────────────────────────────────
# The agent MUST NOT:
# - Assign the issue to anyone other than the go:yes label sender.
# The assignee is deterministic — always context.payload.sender.login.
# - Apply labels outside the allowed set (squad, squad:*).
# Blocked patterns: go:*, priority:*, override:*, type:*.
# - Remove existing labels, milestones, or project associations.
# - Close, lock, or transfer the issue.
# - Modify issue title or body content.
# - Create branches, PRs, or trigger deployments.
# - Invoke external APIs, webhooks, or services beyond the GitHub API.
# - Expose secrets, tokens, or internal routing logic in comments.
# - Override or skip the post-check verification job.
# - Escalate its own permissions (e.g., request write access to contents).
# - Process label events from forks or untrusted senders without
# verifying repository_owner matches the expected org.
Comment thread
EMaher marked this conversation as resolved.
Outdated
# ────────────────────────────────────────────────────────────────────────

on:
issues:
types: [labeled]
Expand All @@ -9,100 +32,164 @@ permissions:
contents: read

jobs:
assign-work:
# Only trigger on squad:{member} labels (not the base "squad" label)
if: startsWith(github.event.label.name, 'squad:')
assign-issue:
if: github.event.label.name == 'go:yes'
runs-on: ubuntu-latest
outputs:
assignee: ${{ steps.assign.outputs.assignee }}
squad-labels: ${{ steps.route.outputs.squad-labels }}
steps:
- uses: actions/checkout@v4

- name: Identify assigned member and trigger work
uses: actions/github-script@v7
# Step 1: Assign issue to the maintainer who applied "go:yes"
- name: Assign to label sender
id: assign
uses: actions/github-script@v8
with:
script: |
const sender = context.payload.sender.login;
const issue_number = context.payload.issue.number;

await github.rest.issues.addAssignees({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number,
assignees: [sender]
});

core.setOutput('assignee', sender);
core.info(`Assigned issue #${issue_number} to ${sender} (go:yes sender)`);

# Step 2: Read triage analysis and route to squad areas
- name: Route to squad areas
id: route
uses: actions/github-script@v8
with:
script: |
const fs = require('fs');
const issue = context.payload.issue;
const label = context.payload.label.name;
const sender = context.payload.sender.login;

// Extract member name from label (e.g., "squad:ripley" → "ripley")
const memberName = label.replace('squad:', '').toLowerCase();
// --- System context: routing table and issue routing (JSON) ---
const routingTablePath = '.squad/routing-table.json';
const issueRoutingPath = '.squad/issue-routing.json';

// 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(routingTablePath)) {
core.setFailed('.squad/routing-table.json not found — cannot route issue');
return;
}
if (!fs.existsSync(teamFile)) {
core.warning('No .squad/team.md or .ai-team/team.md found — cannot assign work');
if (!fs.existsSync(issueRoutingPath)) {
core.setFailed('.squad/issue-routing.json not found — cannot route issue');
return;
}

const content = fs.readFileSync(teamFile, 'utf8');
const lines = content.split('\n');

// Check if this is a coding agent assignment
const isCopilotAssignment = memberName === 'copilot';

let assignedMember = null;
if (isCopilotAssignment) {
assignedMember = { name: '@copilot', role: 'Coding Agent' };
} else {
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 && cells[0].toLowerCase() === memberName) {
assignedMember = { name: cells[0], role: cells[1] };
break;
}
const routingEntries = JSON.parse(fs.readFileSync(routingTablePath, 'utf8'));
const issueRouting = JSON.parse(fs.readFileSync(issueRoutingPath, 'utf8'));

// Build valid squad labels from issue routing entries
const validSquadLabels = new Set(
issueRouting.map(r => r.label).filter(l => l.startsWith('squad:'))
);
// Always allow squad:copilot
validSquadLabels.add('squad:copilot');

// --- User context: issue content + prior triage comment ---
const issueText = `${issue.title}\n${issue.body || ''}`.toLowerCase();

// Fetch prior triage analysis comment
let triageAnalysis = '';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
per_page: 100
});
const triageComment = comments.find(c =>
c.body && c.body.includes('Squad Triage')
);
if (triageComment) {
triageAnalysis = triageComment.body.toLowerCase();
}
Comment thread
EMaher marked this conversation as resolved.

const combinedText = `${issueText}\n${triageAnalysis}`;

// Match routing entries against issue content
const matchedAreas = [];
const matchedLabels = new Set();

for (const entry of routingEntries) {
const keywords = entry.examples.map(k => k.toLowerCase().trim());
const workTypeWords = entry.workType.toLowerCase().split(',').map(k => k.trim()).filter(Boolean);
const allKeywords = [...keywords, ...workTypeWords];

const matched = allKeywords.some(kw => combinedText.includes(kw));
if (matched) {
const memberSlug = entry.routeTo.toLowerCase().replace(/[^a-z0-9]+/g, '');
const label = `squad:${memberSlug}`;

// Only add if it's a valid squad label
if (validSquadLabels.has(label) && !matchedLabels.has(label)) {
matchedLabels.add(label);
matchedAreas.push({
workType: entry.workType,
routeTo: entry.routeTo,
label
});
}
}
}

if (!assignedMember) {
core.warning(`No member found matching label "${label}"`);
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: `⚠️ No squad member found matching label \`${label}\`. Check \`.squad/team.md\` (or \`.ai-team/team.md\`) for valid member names.`
// If no specific routing match, check if copilot is appropriate
Comment thread
EMaher marked this conversation as resolved.
Outdated
if (matchedAreas.length === 0) {
matchedAreas.push({
workType: 'general',
routeTo: 'ApiOpsLead',
label: 'squad:apiopslead'
});
return;
matchedLabels.add('squad:apiopslead');
}

// Post assignment acknowledgment
let comment;
if (isCopilotAssignment) {
comment = [
`### 🤖 Routed to @copilot (Coding Agent)`,
'',
`**Issue:** #${issue.number} — ${issue.title}`,
'',
`@copilot has been assigned and will pick this up automatically.`,
'',
`> The coding agent will create a \`copilot/*\` branch and open a draft PR.`,
`> Review the PR as you would any team member's work.`,
].join('\n');
} else {
comment = [
`### 📋 Assigned to ${assignedMember.name} (${assignedMember.role})`,
'',
`**Issue:** #${issue.number} — ${issue.title}`,
'',
`${assignedMember.name} will pick this up in the next Copilot session.`,
'',
`> **For Copilot coding agent:** If enabled, this issue will be worked automatically.`,
`> Otherwise, start a Copilot session and say:`,
`> \`${assignedMember.name}, work on issue #${issue.number}\``,
].join('\n');
}
// Enforce max: 5 labels (squad + up to 4 squad:{member})
const labelsToApply = ['squad'];
const squadMemberLabels = [...matchedLabels].slice(0, 4);
labelsToApply.push(...squadMemberLabels);

// Validate: only allowed patterns (squad, squad:*)
// Blocked: go:*, priority:*, override:*, type:*
const blockedPatterns = [/^go:/, /^priority:/, /^override:/, /^type:/];
const safeLabels = labelsToApply.filter(l =>
!blockedPatterns.some(p => p.test(l))
);

// Apply labels
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: safeLabels
});

core.setOutput('squad-labels', JSON.stringify(squadMemberLabels));

// Post assignment rationale comment (max: 1)
const areaList = matchedAreas.map(a =>
`- **${a.workType}** → ${a.routeTo} (\`${a.label}\`)`
).join('\n');

const comment = [
`### 📋 Issue Assignment — approved by @${sender}`,
'',
`**Assignee:** @${sender}`,
`**Labels applied:** ${safeLabels.map(l => '`' + l + '`').join(', ')}`,
'',
`#### Matched Squad Areas`,
'',
areaList,
'',
triageComment
? `> Routing based on prior [triage analysis](${triageComment.html_url}) and \`.squad/routing.md\`.`
: `> Routing based on issue content and \`.squad/routing.md\`.`,
Comment thread
Copilot marked this conversation as resolved.
Outdated
].join('\n');

await github.rest.issues.createComment({
owner: context.repo.owner,
Expand All @@ -111,51 +198,67 @@ jobs:
body: comment
});

core.info(`Issue #${issue.number} assigned to ${assignedMember.name} (${assignedMember.role})`);
core.info(`Applied labels [${safeLabels.join(', ')}] to issue #${issue.number}`);

# Separate step: assign @copilot using PAT (required for coding agent)
- name: Assign @copilot coding agent
if: github.event.label.name == 'squad:copilot'
uses: actions/github-script@v7
# Post-check: verify assignee and labels are correct
post-check:
needs: assign-issue
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Verify assignment integrity
uses: actions/github-script@v8
with:
github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN }}
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const fs = require('fs');
const issue_number = context.payload.issue.number;
const expectedAssignee = '${{ needs.assign-issue.outputs.assignee }}';
const expectedLabels = JSON.parse('${{ needs.assign-issue.outputs.squad-labels }}');
Comment thread
Copilot marked this conversation as resolved.
Outdated
const sender = context.payload.sender.login;

// Get the default branch name (main, master, etc.)
const { data: repoData } = await github.rest.repos.get({ owner, repo });
const baseBranch = repoData.default_branch;

try {
await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', {
owner,
repo,
issue_number,
assignees: ['copilot-swe-agent[bot]'],
agent_assignment: {
target_repo: `${owner}/${repo}`,
base_branch: baseBranch,
custom_instructions: '',
custom_agent: '',
model: ''
},
headers: {
'X-GitHub-Api-Version': '2022-11-28'
}
});
core.info(`Assigned copilot-swe-agent to issue #${issue_number} (base: ${baseBranch})`);
} catch (err) {
core.warning(`Assignment with agent_assignment failed: ${err.message}`);
// Fallback: try without agent_assignment
try {
await github.rest.issues.addAssignees({
owner, repo, issue_number,
assignees: ['copilot-swe-agent']
});
core.info(`Fallback assigned copilot-swe-agent to issue #${issue_number}`);
} catch (err2) {
core.warning(`Fallback also failed: ${err2.message}`);
// Verify: assignee equals the go:yes label sender
const { data: issue } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number
});

const assignees = issue.assignees.map(a => a.login);
if (!assignees.includes(sender)) {
core.setFailed(
`Assignee mismatch: expected ${sender} (go:yes sender) but got [${assignees.join(', ')}]`
);
return;
}

// Verify: every squad:{member} label corresponds to routing-table.json
const routingEntries = JSON.parse(fs.readFileSync('.squad/routing-table.json', 'utf8'));
const issueRouting = JSON.parse(fs.readFileSync('.squad/issue-routing.json', 'utf8'));

// Extract all valid route-to members from routing table + issue routing
const validMembers = new Set();
for (const entry of routingEntries) {
const slug = entry.routeTo.toLowerCase().replace(/[^a-z0-9]+/g, '');
validMembers.add(`squad:${slug}`);
}
for (const entry of issueRouting) {
if (entry.label.startsWith('squad:')) {
validMembers.add(entry.label);
}
}
// Always valid
validMembers.add('squad:copilot');

const issueLabels = issue.labels.map(l => l.name);
const squadLabels = issueLabels.filter(l => l.startsWith('squad:'));

const invalidLabels = squadLabels.filter(l => !validMembers.has(l));
if (invalidLabels.length > 0) {
core.setFailed(
`Invalid squad labels not in .squad/routing.md: [${invalidLabels.join(', ')}]`
);
Comment thread
EMaher marked this conversation as resolved.
return;
}

core.info(`✅ Post-check passed: assignee=${sender}, squad labels=[${squadLabels.join(', ')}]`);
Loading
Loading