Pipeline Updates #77
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 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 go:* label events unless the sender has repository | |
| # maintain/admin permission. | |
| # ──────────────────────────────────────────────────────────────────────── | |
| on: | |
| issues: | |
| types: [labeled] | |
| permissions: | |
| issues: write | |
| contents: read | |
| jobs: | |
| assign-issue: | |
| if: github.event.label.name == 'go:yes' | |
| runs-on: ubuntu-latest | |
| 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 | |
| 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.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 | |
| uses: actions/github-script@v8 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const issue = context.payload.issue; | |
| const sender = context.payload.sender.login; | |
| // --- System context: routing table and issue routing (JSON) --- | |
| const routingTablePath = '.squad/routing-table.json'; | |
| const issueRoutingPath = '.squad/issue-routing.json'; | |
| if (!fs.existsSync(routingTablePath)) { | |
| core.setFailed('.squad/routing-table.json not found — cannot route issue'); | |
| return; | |
| } | |
| if (!fs.existsSync(issueRoutingPath)) { | |
| core.setFailed('.squad/issue-routing.json not found — cannot route issue'); | |
| return; | |
| } | |
| 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 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) { | |
| 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.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 no specific routing match, fall back to ApiOpsLead | |
| if (matchedAreas.length === 0) { | |
| matchedAreas.push({ | |
| workType: 'general', | |
| routeTo: 'ApiOpsLead', | |
| label: 'squad:apiopslead' | |
| }); | |
| matchedLabels.add('squad:apiopslead'); | |
| } | |
| // 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 | |
| }); | |
| // 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 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({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issue.number, | |
| body: comment | |
| }); | |
| 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 | |
| - name: Verify assignment integrity | |
| uses: actions/github-script@v8 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const issue_number = context.payload.issue.number; | |
| 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; | |
| } | |
| // 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-table.json or .squad/issue-routing.json: [${invalidLabels.join(', ')}]` | |
| ); | |
| return; | |
| } | |
| core.info(`✅ Post-check passed: assignee=${sender}, squad labels=[${squadLabels.join(', ')}]`); |