Skip to content

Pipeline Updates

Pipeline Updates #77

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(', ')}]`);