From c8f239fe5b568cd34e0c7eee05c6a6bd9463f738 Mon Sep 17 00:00:00 2001 From: Elizabeth Maher Date: Tue, 23 Jun 2026 10:21:45 -0700 Subject: [PATCH 1/9] feat: implement issue assignment agentic workflow Rewrite squad-issue-assign.yml to implement the go:yes label-triggered assignment workflow: - Trigger: issues labeled with go:yes (names filtering) - Assigns issue to the maintainer who applied the label (sender) - Reads prior triage analysis comment and .squad/routing.md - Applies squad label + matched squad:{member} labels - Safe outputs: assign-to-user max:1, add-labels allowed:[squad, squad:*] blocked:[go:*, priority:*, override:*, type:*] max:5, add-comment max:1 - Post-check job verifies assignee == sender and all squad:{member} labels exist in routing.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/squad-issue-assign.yml | 351 ++++++++++++++++------- 1 file changed, 242 insertions(+), 109 deletions(-) diff --git a/.github/workflows/squad-issue-assign.yml b/.github/workflows/squad-issue-assign.yml index ad140f42..584bf870 100644 --- a/.github/workflows/squad-issue-assign.yml +++ b/.github/workflows/squad-issue-assign.yml @@ -1,5 +1,10 @@ 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. + on: issues: types: [labeled] @@ -9,100 +14,204 @@ 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 + # Step 1: Assign issue to the maintainer who applied "go:yes" + - name: Assign to label sender + id: assign + uses: actions/github-script@v7 + 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@v7 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 team roster --- + const routingPath = '.squad/routing.md'; + const teamPath = '.squad/team.md'; - // 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(routingPath)) { + core.setFailed('.squad/routing.md 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(teamPath)) { + core.setFailed('.squad/team.md 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; + const routingContent = fs.readFileSync(routingPath, 'utf8'); + const teamContent = fs.readFileSync(teamPath, 'utf8'); + + // Parse routing table entries (Work Type → Route To) + const routingEntries = []; + const routingLines = routingContent.split('\n'); + let inRoutingTable = false; + for (const line of routingLines) { + if (line.match(/^\|\s*Work Type\s*\|/i)) { + inRoutingTable = true; + continue; + } + if (inRoutingTable && line.match(/^\|[-\s|]+\|$/)) { + continue; // separator row + } + if (inRoutingTable && line.startsWith('|')) { + const cells = line.split('|').map(c => c.trim()).filter(Boolean); + if (cells.length >= 3) { + routingEntries.push({ + workType: cells[0].toLowerCase(), + routeTo: cells[1], + examples: cells[2].toLowerCase() + }); } - if (inMembersTable && line.startsWith('## ')) { - break; + } else if (inRoutingTable && !line.startsWith('|')) { + inRoutingTable = false; + } + } + + // Parse valid squad labels from team roster + const validSquadLabels = new Set(); + const teamLines = teamContent.split('\n'); + let inTeamLabels = false; + for (const line of teamLines) { + if (line.match(/^##\s*Team Labels/i)) { + inTeamLabels = true; + continue; + } + if (inTeamLabels && line.startsWith('## ')) break; + if (inTeamLabels && line.startsWith('|') && !line.includes('---') && !line.includes('Member')) { + const cells = line.split('|').map(c => c.trim()).filter(Boolean); + if (cells.length >= 2) { + const labelMatch = cells[1].match(/`(squad:[^`]+)`/); + if (labelMatch) validSquadLabels.add(labelMatch[1]); } - 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; - } + } + } + // 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(); + } + + 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.split(',').map(k => k.trim()).filter(Boolean); + const workTypeWords = entry.workType.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 + 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\`.`, + ].join('\n'); await github.rest.issues.createComment({ owner: context.repo.owner, @@ -111,51 +220,75 @@ jobs: body: comment }); - core.info(`Issue #${issue.number} assigned to ${assignedMember.name} (${assignedMember.role})`); + core.info(`Applied labels [${safeLabels.join(', ')}] to issue #${issue.number}`); + + # Post-check: verify assignee and labels are correct + post-check: + needs: assign-issue + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 - # Separate step: assign @copilot using PAT (required for coding agent) - - name: Assign @copilot coding agent - if: github.event.label.name == 'squad:copilot' + - name: Verify assignment integrity uses: actions/github-script@v7 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 }}'); + const sender = context.payload.sender.login; + + // 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; + } - // 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' + // Verify: every squad:{member} label corresponds to routing.md + const routingContent = fs.readFileSync('.squad/routing.md', 'utf8'); + const routingLines = routingContent.split('\n'); + + // Extract all valid route-to members from routing table + const validMembers = new Set(); + let inTable = false; + for (const line of routingLines) { + if (line.match(/^\|\s*Work Type\s*\|/i)) { + inTable = true; + continue; + } + if (inTable && line.match(/^\|[-\s|]+\|$/)) continue; + if (inTable && line.startsWith('|')) { + const cells = line.split('|').map(c => c.trim()).filter(Boolean); + if (cells.length >= 2) { + const slug = cells[1].toLowerCase().replace(/[^a-z0-9]+/g, ''); + validMembers.add(`squad:${slug}`); } - }); - 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}`); + } else if (inTable && !line.startsWith('|')) { + inTable = false; } } + // 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(', ')}]` + ); + return; + } + + core.info(`✅ Post-check passed: assignee=${sender}, squad labels=[${squadLabels.join(', ')}]`); From 4fc9b0de9512b709e8448dd63e6e7e931e340f00 Mon Sep 17 00:00:00 2001 From: Elizabeth Maher Date: Tue, 23 Jun 2026 10:36:42 -0700 Subject: [PATCH 2/9] refactor: extract routing tables to JSON for simpler parsing Move the Routing Table and Issue Routing markdown tables from .squad/routing.md into dedicated JSON files: - .squad/routing-table.json (workType, routeTo, examples array) - .squad/issue-routing.json (label, action, who) routing.md now references these JSON files. The squad-issue-assign workflow reads JSON directly instead of parsing markdown tables, resulting in much simpler and more reliable code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/squad-issue-assign.yml | 100 ++++++----------------- .squad/issue-routing.json | 67 +++++++++++++++ .squad/routing-table.json | 82 +++++++++++++++++++ .squad/routing.md | 39 ++------- 4 files changed, 181 insertions(+), 107 deletions(-) create mode 100644 .squad/issue-routing.json create mode 100644 .squad/routing-table.json diff --git a/.github/workflows/squad-issue-assign.yml b/.github/workflows/squad-issue-assign.yml index 584bf870..15513042 100644 --- a/.github/workflows/squad-issue-assign.yml +++ b/.github/workflows/squad-issue-assign.yml @@ -52,66 +52,26 @@ jobs: const issue = context.payload.issue; const sender = context.payload.sender.login; - // --- System context: routing table and team roster --- - const routingPath = '.squad/routing.md'; - const teamPath = '.squad/team.md'; + // --- System context: routing table and issue routing (JSON) --- + const routingTablePath = '.squad/routing-table.json'; + const issueRoutingPath = '.squad/issue-routing.json'; - if (!fs.existsSync(routingPath)) { - core.setFailed('.squad/routing.md not found — cannot route issue'); + if (!fs.existsSync(routingTablePath)) { + core.setFailed('.squad/routing-table.json not found — cannot route issue'); return; } - if (!fs.existsSync(teamPath)) { - core.setFailed('.squad/team.md not found — cannot route issue'); + if (!fs.existsSync(issueRoutingPath)) { + core.setFailed('.squad/issue-routing.json not found — cannot route issue'); return; } - const routingContent = fs.readFileSync(routingPath, 'utf8'); - const teamContent = fs.readFileSync(teamPath, 'utf8'); - - // Parse routing table entries (Work Type → Route To) - const routingEntries = []; - const routingLines = routingContent.split('\n'); - let inRoutingTable = false; - for (const line of routingLines) { - if (line.match(/^\|\s*Work Type\s*\|/i)) { - inRoutingTable = true; - continue; - } - if (inRoutingTable && line.match(/^\|[-\s|]+\|$/)) { - continue; // separator row - } - if (inRoutingTable && line.startsWith('|')) { - const cells = line.split('|').map(c => c.trim()).filter(Boolean); - if (cells.length >= 3) { - routingEntries.push({ - workType: cells[0].toLowerCase(), - routeTo: cells[1], - examples: cells[2].toLowerCase() - }); - } - } else if (inRoutingTable && !line.startsWith('|')) { - inRoutingTable = false; - } - } + const routingEntries = JSON.parse(fs.readFileSync(routingTablePath, 'utf8')); + const issueRouting = JSON.parse(fs.readFileSync(issueRoutingPath, 'utf8')); - // Parse valid squad labels from team roster - const validSquadLabels = new Set(); - const teamLines = teamContent.split('\n'); - let inTeamLabels = false; - for (const line of teamLines) { - if (line.match(/^##\s*Team Labels/i)) { - inTeamLabels = true; - continue; - } - if (inTeamLabels && line.startsWith('## ')) break; - if (inTeamLabels && line.startsWith('|') && !line.includes('---') && !line.includes('Member')) { - const cells = line.split('|').map(c => c.trim()).filter(Boolean); - if (cells.length >= 2) { - const labelMatch = cells[1].match(/`(squad:[^`]+)`/); - if (labelMatch) validSquadLabels.add(labelMatch[1]); - } - } - } + // 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'); @@ -140,8 +100,8 @@ jobs: const matchedLabels = new Set(); for (const entry of routingEntries) { - const keywords = entry.examples.split(',').map(k => k.trim()).filter(Boolean); - const workTypeWords = entry.workType.split(',').map(k => k.trim()).filter(Boolean); + 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)); @@ -254,27 +214,19 @@ jobs: return; } - // Verify: every squad:{member} label corresponds to routing.md - const routingContent = fs.readFileSync('.squad/routing.md', 'utf8'); - const routingLines = routingContent.split('\n'); + // 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 + // Extract all valid route-to members from routing table + issue routing const validMembers = new Set(); - let inTable = false; - for (const line of routingLines) { - if (line.match(/^\|\s*Work Type\s*\|/i)) { - inTable = true; - continue; - } - if (inTable && line.match(/^\|[-\s|]+\|$/)) continue; - if (inTable && line.startsWith('|')) { - const cells = line.split('|').map(c => c.trim()).filter(Boolean); - if (cells.length >= 2) { - const slug = cells[1].toLowerCase().replace(/[^a-z0-9]+/g, ''); - validMembers.add(`squad:${slug}`); - } - } else if (inTable && !line.startsWith('|')) { - inTable = false; + 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 diff --git a/.squad/issue-routing.json b/.squad/issue-routing.json new file mode 100644 index 00000000..e0cea75b --- /dev/null +++ b/.squad/issue-routing.json @@ -0,0 +1,67 @@ +[ + { + "label": "squad", + "action": "Triage: analyze issue, assign squad:{member} label", + "who": "ApiOpsLead" + }, + { + "label": "squad:apiopslead", + "action": "Pick up issue — architecture or scope decision", + "who": "ApiOpsLead" + }, + { + "label": "squad:apimexpert", + "action": "Pick up issue — APIM REST API work", + "who": "ApimExpert" + }, + { + "label": "squad:apicexpert", + "action": "Pick up issue — APIC REST API work", + "who": "ApicExpert" + }, + { + "label": "squad:typescriptdev", + "action": "Pick up issue — TypeScript types or build", + "who": "TypeScriptDev" + }, + { + "label": "squad:nodejsdev", + "action": "Pick up issue — CLI wiring, packaging, init", + "who": "NodeJsDev" + }, + { + "label": "squad:testengineer", + "action": "Pick up issue — tests or coverage", + "who": "TestEngineer" + }, + { + "label": "squad:opensourceexpert", + "action": "Pick up issue — license or OSS compliance", + "who": "OpenSourceExpert" + }, + { + "label": "squad:codereviewer", + "action": "Pick up issue — code review or standards enforcement", + "who": "CodeReviewer" + }, + { + "label": "squad:azdoexpert", + "action": "Pick up issue — Azure DevOps pipelines or CLI", + "who": "AzdoExpert" + }, + { + "label": "squad:githubexpert", + "action": "Pick up issue — GitHub Actions or CLI", + "who": "GitHubExpert" + }, + { + "label": "squad:docwriter", + "action": "Pick up issue — documentation or diagrams", + "who": "DocWriter" + }, + { + "label": "squad:securityexpert", + "action": "Pick up issue — security, supply-chain, threat modeling", + "who": "SecurityExpert" + } +] diff --git a/.squad/routing-table.json b/.squad/routing-table.json new file mode 100644 index 00000000..16d2fcc3 --- /dev/null +++ b/.squad/routing-table.json @@ -0,0 +1,82 @@ +[ + { + "workType": "Architecture, design decisions, scope/priority", + "routeTo": "ApiOpsLead", + "examples": ["High-level design", "command structure", "trade-off calls"] + }, + { + "workType": "APIM REST API, resource types, policies", + "routeTo": "ApimExpert", + "examples": ["Extract/publish logic", "dependency ordering", "pagination", "retry", "workspace scoping"] + }, + { + "workType": "APIC REST API, API Center resources", + "routeTo": "ApicExpert", + "examples": ["APIC sync", "APIC resource model", "APIM-APIC integration"] + }, + { + "workType": "TypeScript types, interfaces, abstractions", + "routeTo": "TypeScriptDev", + "examples": ["tsconfig", "abstraction contracts", "ESLint", "build"] + }, + { + "workType": "CLI wiring, Commander, npm, init scaffolding", + "routeTo": "NodeJsDev", + "examples": ["Flag definitions", "help text", "exit codes", "apiops init", "package.json"] + }, + { + "workType": "Tests, mocking, edge cases, coverage", + "routeTo": "TestEngineer", + "examples": ["Vitest unit tests", "mock implementations", "spec edge cases"] + }, + { + "workType": "License compliance, OSS requirements, repo health", + "routeTo": "OpenSourceExpert", + "examples": ["Dependency audits", "LICENSE/SECURITY/CONTRIBUTING files", "CLA"] + }, + { + "workType": "Code review, standards enforcement", + "routeTo": "CodeReviewer", + "examples": ["Review PRs", "enforce constitution compliance", "check testability", "modern TypeScript standards"] + }, + { + "workType": "Azure DevOps, az devops, pipelines, service connections", + "routeTo": "AzdoExpert", + "examples": ["Azure Pipelines YAML", "variable groups", "service connections", "workload identity", "az pipelines"] + }, + { + "workType": "GitHub Actions, gh CLI, repository settings", + "routeTo": "GitHubExpert", + "examples": ["GitHub workflows", "reusable actions", "OIDC federation", "branch protection", "gh commands"] + }, + { + "workType": "Documentation, user guides, diagrams, /docs content", + "routeTo": "DocWriter", + "examples": ["Markdown docs", "Mermaid charts", "API developer guides", "README"] + }, + { + "workType": "Security, threat modeling, supply-chain defense", + "routeTo": "SecurityExpert", + "examples": ["Workflow hardening", "dependency pinning", "secret scanning", "fork PR threat modeling", "hostile contributor defense", "cross-team security review"] + }, + { + "workType": "Architecture review, scope decisions", + "routeTo": "ApiOpsLead", + "examples": ["High-level design review", "spec alignment", "scope gatekeeping"] + }, + { + "workType": "Issue triage", + "routeTo": "ApiOpsLead", + "examples": ["Read GitHub issues", "assign squad:{member} labels"] + }, + { + "workType": "Session logging", + "routeTo": "Scribe", + "examples": ["Automatic — never needs routing"] + }, + { + "workType": "Work queue monitoring", + "routeTo": "Ralph", + "examples": ["Automatic — activated with Ralph, go"] + } +] diff --git a/.squad/routing.md b/.squad/routing.md index 485a242d..d9a5d65b 100644 --- a/.squad/routing.md +++ b/.squad/routing.md @@ -4,42 +4,15 @@ How to decide who handles what. ## Routing Table -| Work Type | Route To | Examples | -|-----------|----------|----------| -| Architecture, design decisions, scope/priority | ApiOpsLead | High-level design, command structure, trade-off calls | -| APIM REST API, resource types, policies | ApimExpert | Extract/publish logic, dependency ordering, pagination, retry, workspace scoping | -| APIC REST API, API Center resources | ApicExpert | APIC sync, APIC resource model, APIM↔APIC integration | -| TypeScript types, interfaces, abstractions | TypeScriptDev | tsconfig, abstraction contracts, ESLint, build | -| CLI wiring, Commander, npm, init scaffolding | NodeJsDev | Flag definitions, help text, exit codes, `apiops init`, package.json | -| Tests, mocking, edge cases, coverage | TestEngineer | Vitest unit tests, mock implementations, spec edge cases | -| License compliance, OSS requirements, repo health | OpenSourceExpert | Dependency audits, LICENSE/SECURITY/CONTRIBUTING files, CLA | -| Code review, standards enforcement | CodeReviewer | Review PRs, enforce constitution compliance, check testability, modern TypeScript standards | -| Azure DevOps, `az devops`, pipelines, service connections | AzdoExpert | Azure Pipelines YAML, variable groups, service connections, workload identity, `az pipelines` | -| GitHub Actions, `gh` CLI, repository settings | GitHubExpert | GitHub workflows, reusable actions, OIDC federation, branch protection, `gh` commands | -| Documentation, user guides, diagrams, /docs content | DocWriter | Markdown docs, Mermaid charts, API developer guides, README | -| Security, threat modeling, supply-chain defense | SecurityExpert | Workflow hardening, dependency pinning, secret scanning, fork PR threat modeling, hostile contributor defense, cross-team security review | -| Architecture review, scope decisions | ApiOpsLead | High-level design review, spec alignment, scope gatekeeping | -| Issue triage | ApiOpsLead | Read GitHub issues, assign `squad:{member}` labels | -| Session logging | Scribe | Automatic — never needs routing | -| Work queue monitoring | Ralph | Automatic — activated with "Ralph, go" | +Machine-readable data: [`.squad/routing-table.json`](routing-table.json) + +Each entry has `workType`, `routeTo` (squad member name), and `examples` (keyword list for matching). ## Issue Routing -| Label | Action | Who | -|-------|--------|-----| -| `squad` | Triage: analyze issue, assign `squad:{member}` label | ApiOpsLead | -| `squad:apiopslead` | Pick up issue — architecture or scope decision | ApiOpsLead | -| `squad:apimexpert` | Pick up issue — APIM REST API work | ApimExpert | -| `squad:apicexpert` | Pick up issue — APIC REST API work | ApicExpert | -| `squad:typescriptdev` | Pick up issue — TypeScript types or build | TypeScriptDev | -| `squad:nodejsdev` | Pick up issue — CLI wiring, packaging, init | NodeJsDev | -| `squad:testengineer` | Pick up issue — tests or coverage | TestEngineer | -| `squad:opensourceexpert` | Pick up issue — license or OSS compliance | OpenSourceExpert | -| `squad:codereviewer` | Pick up issue — code review or standards enforcement | CodeReviewer | -| `squad:azdoexpert` | Pick up issue — Azure DevOps pipelines or CLI | AzdoExpert | -| `squad:githubexpert` | Pick up issue — GitHub Actions or CLI | GitHubExpert | -| `squad:docwriter` | Pick up issue — documentation or diagrams | DocWriter | -| `squad:securityexpert` | Pick up issue — security, supply-chain, threat modeling | SecurityExpert | +Machine-readable data: [`.squad/issue-routing.json`](issue-routing.json) + +Each entry has `label`, `action`, and `who` (squad member name). ### How Issue Assignment Works From 2366fe51cb3742e6de5ec76770b3ae7017eccef3 Mon Sep 17 00:00:00 2001 From: Elizabeth Maher Date: Tue, 23 Jun 2026 10:37:45 -0700 Subject: [PATCH 3/9] docs: add security constraints for issue assignment agent Document what the agent MUST NOT do: no assignee override, no blocked label patterns, no issue mutations, no external calls, no permission escalation, no skipping post-check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/squad-issue-assign.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/squad-issue-assign.yml b/.github/workflows/squad-issue-assign.yml index 15513042..4edf5d1b 100644 --- a/.github/workflows/squad-issue-assign.yml +++ b/.github/workflows/squad-issue-assign.yml @@ -4,6 +4,24 @@ name: Squad Issue Assign # 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. +# ──────────────────────────────────────────────────────────────────────── on: issues: From ee699d756f6a19f3926b7e6f8dcc13cf741c9b8b Mon Sep 17 00:00:00 2001 From: Elizabeth Maher Date: Tue, 23 Jun 2026 10:41:13 -0700 Subject: [PATCH 4/9] fix: enrich squad-triage routingContent with JSON routing data Parse .squad/routing-table.json and .squad/issue-routing.json and append structured entries to routingContent so the triage workflow has full routing information available for analysis. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/squad-triage.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/squad-triage.yml b/.github/workflows/squad-triage.yml index d118a281..cf3976e9 100644 --- a/.github/workflows/squad-triage.yml +++ b/.github/workflows/squad-triage.yml @@ -98,6 +98,24 @@ jobs: routingContent = fs.readFileSync(routingFile, 'utf8'); } + // Enrich routingContent with structured JSON data + const routingTablePath = '.squad/routing-table.json'; + const issueRoutingPath = '.squad/issue-routing.json'; + if (fs.existsSync(routingTablePath)) { + const routingTable = JSON.parse(fs.readFileSync(routingTablePath, 'utf8')); + routingContent += '\n\n## Routing Table (parsed)\n'; + for (const entry of routingTable) { + routingContent += `- ${entry.workType} → ${entry.routeTo} (keywords: ${entry.examples.join(', ')})\n`; + } + } + if (fs.existsSync(issueRoutingPath)) { + const issueRouting = JSON.parse(fs.readFileSync(issueRoutingPath, 'utf8')); + routingContent += '\n\n## Issue Routing (parsed)\n'; + for (const entry of issueRouting) { + routingContent += `- ${entry.label} → ${entry.who}: ${entry.action}\n`; + } + } + // Find the Lead const lead = members.find(m => m.role.toLowerCase().includes('lead') || From 3a4e0ef50420188fc0d5982ff1e23cbb29529422 Mon Sep 17 00:00:00 2001 From: Elizabeth Maher Date: Tue, 23 Jun 2026 10:42:51 -0700 Subject: [PATCH 5/9] remove: delete squad-triage workflow Remove the squad-triage workflow that auto-triaged on the 'squad' label. The issue assignment workflow now requires maintainers to explicitly apply 'go:yes' to approve and assign issues, enforcing human-in-the-loop approval before any routing happens. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/squad-triage.yml | 280 ----------------------------- 1 file changed, 280 deletions(-) delete mode 100644 .github/workflows/squad-triage.yml diff --git a/.github/workflows/squad-triage.yml b/.github/workflows/squad-triage.yml deleted file mode 100644 index cf3976e9..00000000 --- a/.github/workflows/squad-triage.yml +++ /dev/null @@ -1,280 +0,0 @@ -name: Squad Triage - -on: - issues: - types: [labeled] - -permissions: - issues: write - contents: read - -jobs: - triage: - if: github.event.label.name == 'squad' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Triage issue via Lead agent - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); - const issue = context.payload.issue; - - // 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.warning('No .squad/team.md or .ai-team/team.md found — cannot triage'); - return; - } - - const content = fs.readFileSync(teamFile, 'utf8'); - const lines = content.split('\n'); - - // Check if @copilot is on the team - const hasCopilot = content.includes('🤖 Coding Agent'); - const copilotAutoAssign = content.includes(''); - - // Parse @copilot capability profile - let goodFitKeywords = []; - let needsReviewKeywords = []; - let notSuitableKeywords = []; - - if (hasCopilot) { - // Extract capability tiers from team.md - const goodFitMatch = content.match(/🟢\s*Good fit[^:]*:\s*(.+)/i); - const needsReviewMatch = content.match(/🟡\s*Needs review[^:]*:\s*(.+)/i); - const notSuitableMatch = content.match(/🔴\s*Not suitable[^:]*:\s*(.+)/i); - - if (goodFitMatch) { - goodFitKeywords = goodFitMatch[1].toLowerCase().split(',').map(s => s.trim()); - } else { - goodFitKeywords = ['bug fix', 'test coverage', 'lint', 'format', 'dependency update', 'small feature', 'scaffolding', 'doc fix', 'documentation']; - } - if (needsReviewMatch) { - needsReviewKeywords = needsReviewMatch[1].toLowerCase().split(',').map(s => s.trim()); - } else { - needsReviewKeywords = ['medium feature', 'refactoring', 'api endpoint', 'migration']; - } - if (notSuitableMatch) { - notSuitableKeywords = notSuitableMatch[1].toLowerCase().split(',').map(s => s.trim()); - } else { - notSuitableKeywords = ['architecture', 'system design', 'security', 'auth', 'encryption', 'performance']; - } - } - - 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 && cells[0] !== 'Scribe') { - members.push({ - name: cells[0], - role: cells[1] - }); - } - } - } - - // Read routing rules — check .squad/ first, fall back to .ai-team/ - let routingFile = '.squad/routing.md'; - if (!fs.existsSync(routingFile)) { - routingFile = '.ai-team/routing.md'; - } - let routingContent = ''; - if (fs.existsSync(routingFile)) { - routingContent = fs.readFileSync(routingFile, 'utf8'); - } - - // Enrich routingContent with structured JSON data - const routingTablePath = '.squad/routing-table.json'; - const issueRoutingPath = '.squad/issue-routing.json'; - if (fs.existsSync(routingTablePath)) { - const routingTable = JSON.parse(fs.readFileSync(routingTablePath, 'utf8')); - routingContent += '\n\n## Routing Table (parsed)\n'; - for (const entry of routingTable) { - routingContent += `- ${entry.workType} → ${entry.routeTo} (keywords: ${entry.examples.join(', ')})\n`; - } - } - if (fs.existsSync(issueRoutingPath)) { - const issueRouting = JSON.parse(fs.readFileSync(issueRoutingPath, 'utf8')); - routingContent += '\n\n## Issue Routing (parsed)\n'; - for (const entry of issueRouting) { - routingContent += `- ${entry.label} → ${entry.who}: ${entry.action}\n`; - } - } - - // Find the Lead - const lead = members.find(m => - m.role.toLowerCase().includes('lead') || - m.role.toLowerCase().includes('architect') || - m.role.toLowerCase().includes('coordinator') - ); - - if (!lead) { - core.warning('No Lead role found in team roster — cannot triage'); - return; - } - - function slugify(t) { return t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); } - - // Build triage context - const memberList = members.map(m => - `- **${m.name}** (${m.role}) → label: \`squad:${slugify(m.name)}\`` - ).join('\n'); - - // Determine best assignee based on issue content and routing - const issueText = `${issue.title}\n${issue.body || ''}`.toLowerCase(); - - let assignedMember = null; - let triageReason = ''; - let copilotTier = null; - - // First, evaluate @copilot fit if enabled - if (hasCopilot) { - const isNotSuitable = notSuitableKeywords.some(kw => issueText.includes(kw)); - const isGoodFit = !isNotSuitable && goodFitKeywords.some(kw => issueText.includes(kw)); - const isNeedsReview = !isNotSuitable && !isGoodFit && needsReviewKeywords.some(kw => issueText.includes(kw)); - - if (isGoodFit) { - copilotTier = 'good-fit'; - assignedMember = { name: '@copilot', role: 'Coding Agent' }; - triageReason = '🟢 Good fit for @copilot — matches capability profile'; - } else if (isNeedsReview) { - copilotTier = 'needs-review'; - assignedMember = { name: '@copilot', role: 'Coding Agent' }; - triageReason = '🟡 Routing to @copilot (needs review) — a squad member should review the PR'; - } else if (isNotSuitable) { - copilotTier = 'not-suitable'; - // Fall through to normal routing - } - } - - // If not routed to @copilot, use keyword-based routing - if (!assignedMember) { - 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') || - issueText.includes('button') || issueText.includes('page') || - issueText.includes('layout') || issueText.includes('design'))) { - assignedMember = member; - triageReason = 'Issue relates to frontend/UI work'; - break; - } - if ((role.includes('backend') || role.includes('api') || role.includes('server')) && - (issueText.includes('api') || issueText.includes('backend') || - issueText.includes('database') || issueText.includes('endpoint') || - issueText.includes('server') || issueText.includes('auth'))) { - assignedMember = member; - triageReason = 'Issue relates to backend/API work'; - break; - } - if ((role.includes('test') || role.includes('qa') || role.includes('quality')) && - (issueText.includes('test') || issueText.includes('bug') || - issueText.includes('fix') || issueText.includes('regression') || - issueText.includes('coverage'))) { - assignedMember = member; - triageReason = 'Issue relates to testing/quality work'; - break; - } - if ((role.includes('devops') || role.includes('infra') || role.includes('ops')) && - (issueText.includes('deploy') || issueText.includes('ci') || - issueText.includes('pipeline') || issueText.includes('docker') || - issueText.includes('infrastructure'))) { - assignedMember = member; - triageReason = 'Issue relates to DevOps/infrastructure work'; - break; - } - } - } - - // Default to Lead if no routing match - if (!assignedMember) { - assignedMember = lead; - triageReason = 'No specific domain match — assigned to Lead for further analysis'; - } - - const isCopilot = assignedMember.name === '@copilot'; - const assignLabel = isCopilot ? 'squad:copilot' : `squad:${slugify(assignedMember.name)}`; - - // Add the member-specific label - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - labels: [assignLabel] - }); - - // Apply default triage verdict - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - labels: ['go:needs-research'] - }); - - // Auto-assign @copilot if enabled - if (isCopilot && copilotAutoAssign) { - try { - await github.rest.issues.addAssignees({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - assignees: ['copilot'] - }); - } catch (err) { - core.warning(`Could not auto-assign @copilot: ${err.message}`); - } - } - - // Build copilot evaluation note - let copilotNote = ''; - if (hasCopilot && !isCopilot) { - if (copilotTier === 'not-suitable') { - copilotNote = `\n\n**@copilot evaluation:** 🔴 Not suitable — issue involves work outside the coding agent's capability profile.`; - } else { - copilotNote = `\n\n**@copilot evaluation:** No strong capability match — routed to squad member.`; - } - } - - // Post triage comment - const comment = [ - `### 🏗️ Squad Triage — ${lead.name} (${lead.role})`, - '', - `**Issue:** #${issue.number} — ${issue.title}`, - `**Assigned to:** ${assignedMember.name} (${assignedMember.role})`, - `**Reason:** ${triageReason}`, - copilotTier === 'needs-review' ? `\n⚠️ **PR review recommended** — a squad member should review @copilot's work on this one.` : '', - copilotNote, - '', - `---`, - '', - `**Team roster:**`, - memberList, - hasCopilot ? `- **@copilot** (Coding Agent) → label: \`squad:copilot\`` : '', - '', - `> To reassign, remove the current \`squad:*\` label and add the correct one.`, - ].filter(Boolean).join('\n'); - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: comment - }); - - core.info(`Triaged issue #${issue.number} → ${assignedMember.name} (${assignLabel})`); From ef8e08ebccdf81f4991ff794bdc5e1f7e46325ba Mon Sep 17 00:00:00 2001 From: Elizabeth Maher Date: Tue, 23 Jun 2026 10:51:56 -0700 Subject: [PATCH 6/9] chore: upgrade actions/github-script from v7 to v8 v8 runs on Node.js 24 with no breaking API changes to the github/core context objects used in our scripts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/squad-issue-assign.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/squad-issue-assign.yml b/.github/workflows/squad-issue-assign.yml index 4edf5d1b..e19a0793 100644 --- a/.github/workflows/squad-issue-assign.yml +++ b/.github/workflows/squad-issue-assign.yml @@ -44,7 +44,7 @@ jobs: # Step 1: Assign issue to the maintainer who applied "go:yes" - name: Assign to label sender id: assign - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: script: | const sender = context.payload.sender.login; @@ -63,7 +63,7 @@ jobs: # Step 2: Read triage analysis and route to squad areas - name: Route to squad areas id: route - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: script: | const fs = require('fs'); @@ -208,7 +208,7 @@ jobs: - uses: actions/checkout@v4 - name: Verify assignment integrity - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: script: | const fs = require('fs'); From ea03fb15106924d66d3d453c0c4edfcbf3cee82d Mon Sep 17 00:00:00 2001 From: Elizabeth Maher Date: Tue, 23 Jun 2026 11:24:35 -0700 Subject: [PATCH 7/9] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/squad-issue-assign.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/squad-issue-assign.yml b/.github/workflows/squad-issue-assign.yml index e19a0793..24a05bbf 100644 --- a/.github/workflows/squad-issue-assign.yml +++ b/.github/workflows/squad-issue-assign.yml @@ -187,8 +187,8 @@ jobs: areaList, '', triageComment - ? `> Routing based on prior [triage analysis](${triageComment.html_url}) and \`.squad/routing.md\`.` - : `> Routing based on issue content and \`.squad/routing.md\`.`, + ? `> Routing based on prior [triage analysis](${triageComment.html_url}) and routing rules in \`.squad/routing-table.json\` / \`.squad/issue-routing.json\`.` + : `> Routing based on issue content and routing rules in \`.squad/routing-table.json\` / \`.squad/issue-routing.json\`.`, ].join('\n'); await github.rest.issues.createComment({ From 66ae1be458642643cfe6f7b1f3526f5142f3f119 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:29:28 +0000 Subject: [PATCH 8/9] fix: address squad issue assignment review feedback --- .github/workflows/squad-issue-assign.yml | 70 ++++++++++++++++-------- .squad/routing.md | 8 +-- 2 files changed, 52 insertions(+), 26 deletions(-) diff --git a/.github/workflows/squad-issue-assign.yml b/.github/workflows/squad-issue-assign.yml index 24a05bbf..cf5169c8 100644 --- a/.github/workflows/squad-issue-assign.yml +++ b/.github/workflows/squad-issue-assign.yml @@ -19,8 +19,8 @@ name: Squad Issue Assign # - 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. +# - Process go:* label events unless the repository owner is Azure and +# the sender has repository maintain/admin permission. # ──────────────────────────────────────────────────────────────────────── on: @@ -33,17 +33,34 @@ permissions: jobs: assign-issue: - if: github.event.label.name == 'go:yes' + if: github.event.label.name == 'go:yes' && github.repository_owner == 'Azure' runs-on: ubuntu-latest - outputs: - assignee: ${{ steps.assign.outputs.assignee }} - squad-labels: ${{ steps.route.outputs.squad-labels }} steps: + - name: Verify maintainer-triggered event + uses: actions/github-script@v8 + with: + script: | + const sender = context.payload.sender.login; + const { data: permission } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: sender + }); + const allowedPermissions = new Set(['admin', 'maintain']); + + if (!allowedPermissions.has(permission.permission)) { + core.setFailed( + `Only repository maintainers may apply go:* labels. ${sender} has ${permission.permission} permission.` + ); + return; + } + + core.info(`Verified ${sender} as a ${permission.permission} collaborator`); + - uses: actions/checkout@v4 # 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: | @@ -57,12 +74,10 @@ jobs: 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: | @@ -98,13 +113,28 @@ jobs: // 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 => + const perPage = 50; + const commentCount = issue.comments || 0; + const lastPage = Math.max(1, Math.ceil(commentCount / perPage)); + const pagesToFetch = new Set([lastPage]); + if (commentCount > perPage && commentCount % perPage !== 0) { + pagesToFetch.add(lastPage - 1); + } + + const commentPages = await Promise.all( + [...pagesToFetch] + .filter(page => page > 0) + .sort((left, right) => left - right) + .map(page => github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + per_page: perPage, + page + })) + ); + const comments = commentPages.flatMap(response => response.data).slice(-perPage); + const triageComment = [...comments].reverse().find(c => c.body && c.body.includes('Squad Triage') ); if (triageComment) { @@ -139,7 +169,7 @@ jobs: } } - // If no specific routing match, check if copilot is appropriate + // If no specific routing match, fall back to ApiOpsLead if (matchedAreas.length === 0) { matchedAreas.push({ workType: 'general', @@ -169,8 +199,6 @@ jobs: 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}\`)` @@ -213,8 +241,6 @@ jobs: script: | 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 }}'); const sender = context.payload.sender.login; // Verify: assignee equals the go:yes label sender @@ -256,7 +282,7 @@ jobs: const invalidLabels = squadLabels.filter(l => !validMembers.has(l)); if (invalidLabels.length > 0) { core.setFailed( - `Invalid squad labels not in .squad/routing.md: [${invalidLabels.join(', ')}]` + `Invalid squad labels not in .squad/routing-table.json or .squad/issue-routing.json: [${invalidLabels.join(', ')}]` ); return; } diff --git a/.squad/routing.md b/.squad/routing.md index d9a5d65b..8e154d1d 100644 --- a/.squad/routing.md +++ b/.squad/routing.md @@ -16,10 +16,10 @@ Each entry has `label`, `action`, and `who` (squad member name). ### How Issue Assignment Works -1. When a GitHub issue gets the `squad` label, the **Lead** triages it — analyzing content, assigning the right `squad:{member}` label, and commenting with triage notes. -2. When a `squad:{member}` label is applied, that member picks up the issue in their next session. -3. Members can reassign by removing their label and adding another member's label. -4. The `squad` label is the "inbox" — untriaged issues waiting for Lead review. +1. A maintainer reviews the issue and applies a `go:*` decision label. +2. When a maintainer applies `go:yes`, the issue assignment workflow assigns the issue to that maintainer. +3. The assignment workflow reads the issue text, optionally uses the most recent `Squad Triage` comment from the last 50 issue comments, and applies `squad` plus matching `squad:{member}` labels from [`.squad/routing-table.json`](routing-table.json) and [`.squad/issue-routing.json`](issue-routing.json). +4. The `squad` label marks the issue for squad routing, and each `squad:{member}` label identifies a matched follow-up area. ## Rules From dfbfe52b56be889848c90ab0af0222ca84b25c1c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:40:48 +0000 Subject: [PATCH 9/9] Apply remaining changes --- .github/workflows/squad-issue-assign.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/squad-issue-assign.yml b/.github/workflows/squad-issue-assign.yml index cf5169c8..022aab99 100644 --- a/.github/workflows/squad-issue-assign.yml +++ b/.github/workflows/squad-issue-assign.yml @@ -19,8 +19,8 @@ name: Squad Issue Assign # - 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 go:* label events unless the repository owner is Azure and -# the sender has repository maintain/admin permission. +# - Process go:* label events unless the sender has repository +# maintain/admin permission. # ──────────────────────────────────────────────────────────────────────── on: @@ -33,7 +33,7 @@ permissions: jobs: assign-issue: - if: github.event.label.name == 'go:yes' && github.repository_owner == 'Azure' + if: github.event.label.name == 'go:yes' runs-on: ubuntu-latest steps: - name: Verify maintainer-triggered event