Repository navigation
248 lines (228 loc) · 11.7 KB
/
Copy pathsquad-issue-assign.yml
File metadata and controls
248 lines (228 loc) · 11.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
name: Squad Issue Assign
on:
issues:
types: [labeled]
workflow_dispatch:
inputs:
issue_number:
description: Issue number to assign to Copilot
required: true
type: string
permissions:
issues: write
contents: read
# CI Hardening Phase 3 item A1: Prevent parallel runs from competing for resources
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.inputs.issue_number || github.ref }}
cancel-in-progress: true
jobs:
assign-work:
# Only trigger on squad:{member} labels (not the base "squad" label)
if: github.event_name == 'workflow_dispatch' || startsWith(github.event.label.name, 'squad:')
runs-on: ubuntu-latest
steps:
- name: Require Copilot assignment token
if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'squad:copilot'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 #v9
env:
COPILOT_ASSIGN_TOKEN: ${{ secrets.COPILOT_ASSIGN_TOKEN }}
with:
script: |
if (!process.env.COPILOT_ASSIGN_TOKEN?.trim()) {
throw new Error('COPILOT_ASSIGN_TOKEN is required for squad:copilot assignment; no routing or assignment attempted.');
}
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7
# CI Hardening: GitHub API rate limit monitoring (item 9)
# Issue assignment makes multiple API calls (read team file, add labels, comment, assign).
- name: "📊 Check GitHub API rate limit"
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 #v9
with:
script: |
const { data: rateLimit } = await github.rest.rateLimit.get();
const { remaining, limit, reset } = rateLimit.rate;
// Security: mask numeric values to prevent fingerprinting via CI logs
core.setSecret(String(remaining));
core.setSecret(String(limit));
const resetDate = new Date(reset * 1000).toISOString();
if (remaining < 10) {
core.setFailed(`❌ Rate limit: CRITICAL — below minimum threshold. Aborting to prevent silent failures.`);
} else if (remaining < 100) {
core.warning(`⚠️ Rate limit: WARNING — below safe threshold (resets ${resetDate})`);
} else {
core.info(`✅ Rate limit: OK — above safe threshold (resets ${resetDate})`);
}
- name: Identify assigned member and trigger work
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 #v9
with:
script: |
const fs = require('fs');
const label = context.payload.label?.name || 'squad:copilot';
const issue = context.payload.issue || (await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: Number(context.payload.inputs.issue_number)
})).data;
const slugify = value => value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
const findMemberBySlug = (lines, memberName) => {
let inMembersTable = false;
const matches = [];
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 && slugify(cells[0]) === memberName) {
matches.push({ name: cells[0], role: cells[1] });
}
}
}
return {
member: matches.length === 1 ? matches[0] : null,
ambiguous: matches.length > 1,
};
};
// Extract member name from label (e.g., "squad:ripley" → "ripley")
const memberName = label.replace('squad:', '').toLowerCase();
// Copilot is acknowledged only after the assignment API accepts it.
if (memberName === 'copilot') return;
// 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 assign work');
return;
}
const content = fs.readFileSync(teamFile, 'utf8');
const lines = content.split('\n');
const match = findMemberBySlug(lines, memberName);
const assignedMember = match.member;
if (match.ambiguous) {
core.warning(`Multiple squad members map to label "${label}" — refusing ambiguous assignment`);
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: `⚠️ Multiple squad members map to label \`${label}\`. Rename the colliding members in \`.squad/team.md\` before assigning work.`
});
return;
}
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.`
});
return;
}
// Ensure parent "squad" label exists (prevents orphaned squad:{member} labels)
const issueLabels = issue.labels.map(l => l.name);
if (!issueLabels.includes('squad')) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ['squad']
});
core.info(`Added parent "squad" label to #${issue.number}`);
}
// Post assignment acknowledgment
const 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');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: comment
});
core.info(`Issue #${issue.number} assigned to ${assignedMember.name} (${assignedMember.role})`);
# Separate step: assign @copilot using PAT (required for coding agent)
- name: Assign @copilot coding agent
if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'squad:copilot'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 #v9
with:
github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN }}
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const issue_number = Number(context.payload.issue?.number || context.payload.inputs.issue_number);
if (!Number.isSafeInteger(issue_number) || issue_number < 1) {
throw new Error('A valid issue_number is required for Copilot assignment.');
}
const label = context.payload.label?.name || 'squad:copilot';
const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number });
if (!issue.labels.some(existing => existing.name === label)) {
throw new Error(`Issue #${issue_number} no longer has ${label}; refusing stale Copilot handoff.`);
}
// Get the default branch name (main, master, etc.)
const { data: repoData } = await github.rest.repos.get({ owner, repo });
const baseBranch = repoData.default_branch;
const response = 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'
}
});
if (![200, 201].includes(response?.status) ||
!Array.isArray(response?.data?.assignees) ||
!response.data.assignees.some(assignee => assignee?.login === 'copilot-swe-agent[bot]')) {
throw new Error(`Copilot assignment API did not confirm copilot-swe-agent[bot] for issue #${issue_number}; handoff is unverified.`);
}
core.info(`Copilot assignment API accepted issue #${issue_number} (base: ${baseBranch}); session creation is not verified.`);
await github.rest.issues.createComment({
owner, repo, issue_number,
body: [
`### 🤖 Copilot assignment API accepted`,
'',
`**Issue:** #${issue_number} — ${issue.title}`,
`The API accepted the coding-agent request and returned \`copilot-swe-agent[bot]\` as an assignee (base: \`${baseBranch}\`).`,
'',
`This is not proof of a running session. Verify a Copilot session linked to this issue or a linked \`copilot/*\` draft PR before treating the handoff as started.`,
].join('\n')
});
# Repair the parent label only after the PAT-backed assignment and its acknowledgment succeed.
# The default GITHUB_TOKEN does not create another issues:labeled workflow run.
- name: Ensure parent squad label
if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'squad:copilot'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 #v9
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const issue_number = Number(context.payload.issue?.number || context.payload.inputs.issue_number);
if (!Number.isSafeInteger(issue_number) || issue_number < 1) {
throw new Error('A valid issue_number is required to repair the parent squad label.');
}
const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number });
if (!issue.labels.some(label => label.name === 'squad')) {
await github.rest.issues.addLabels({ owner, repo, issue_number, labels: ['squad'] });
core.info(`Added parent "squad" label to #${issue_number}`);
}