@@ -3,22 +3,39 @@ name: Squad Issue Assign
33on :
44 issues :
55 types : [labeled]
6+ workflow_dispatch :
7+ inputs :
8+ issue_number :
9+ description : Issue number to assign to Copilot
10+ required : true
11+ type : string
612
713permissions :
814 issues : write
915 contents : read
1016
1117# CI Hardening Phase 3 item A1: Prevent parallel runs from competing for resources
1218concurrency :
13- group : ${{ github.workflow }}-${{ github.event.issue.number || github.ref }}
19+ group : ${{ github.workflow }}-${{ github.event.issue.number || github.event.inputs.issue_number || github. ref }}
1420 cancel-in-progress : true
1521
1622jobs :
1723 assign-work :
1824 # Only trigger on squad:{member} labels (not the base "squad" label)
19- if : startsWith(github.event.label.name, 'squad:')
25+ if : github.event_name == 'workflow_dispatch' || startsWith(github.event.label.name, 'squad:')
2026 runs-on : ubuntu-latest
2127 steps :
28+ - name : Require Copilot assignment token
29+ if : github.event_name == 'workflow_dispatch' || github.event.label.name == 'squad:copilot'
30+ uses : actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
31+ env :
32+ COPILOT_ASSIGN_TOKEN : ${{ secrets.COPILOT_ASSIGN_TOKEN }}
33+ with :
34+ script : |
35+ if (!process.env.COPILOT_ASSIGN_TOKEN?.trim()) {
36+ throw new Error('COPILOT_ASSIGN_TOKEN is required for squad:copilot assignment; no routing or assignment attempted.');
37+ }
38+
2239 - uses : actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
2340
2441 # CI Hardening: GitHub API rate limit monitoring (item 9)
4663 with :
4764 script : |
4865 const fs = require('fs');
49- const issue = context.payload.issue;
50- const label = context.payload.label.name;
66+ const label = context.payload.label?.name || 'squad:copilot';
67+ const issue = context.payload.issue || (await github.rest.issues.get({
68+ owner: context.repo.owner,
69+ repo: context.repo.repo,
70+ issue_number: Number(context.payload.inputs.issue_number)
71+ })).data;
5172 const slugify = value => value
5273 .toLowerCase()
5374 .replace(/[^a-z0-9]+/g, '-')
7697
7798 // Extract member name from label (e.g., "squad:ripley" → "ripley")
7899 const memberName = label.replace('squad:', '').toLowerCase();
100+ // Copilot is acknowledged only after the assignment API accepts it.
101+ if (memberName === 'copilot') return;
79102
80103 // Read team roster — check .squad/ first, fall back to .ai-team/
81104 let teamFile = '.squad/team.md';
@@ -90,25 +113,17 @@ jobs:
90113 const content = fs.readFileSync(teamFile, 'utf8');
91114 const lines = content.split('\n');
92115
93- // Check if this is a coding agent assignment
94- const isCopilotAssignment = memberName === 'copilot';
95-
96- let assignedMember = null;
97- if (isCopilotAssignment) {
98- assignedMember = { name: '@copilot', role: 'Coding Agent' };
99- } else {
100- const match = findMemberBySlug(lines, memberName);
101- assignedMember = match.member;
102- if (match.ambiguous) {
103- core.warning(`Multiple squad members map to label "${label}" — refusing ambiguous assignment`);
104- await github.rest.issues.createComment({
105- owner: context.repo.owner,
106- repo: context.repo.repo,
107- issue_number: issue.number,
108- body: `⚠️ Multiple squad members map to label \`${label}\`. Rename the colliding members in \`.squad/team.md\` before assigning work.`
109- });
110- return;
111- }
116+ const match = findMemberBySlug(lines, memberName);
117+ const assignedMember = match.member;
118+ if (match.ambiguous) {
119+ core.warning(`Multiple squad members map to label "${label}" — refusing ambiguous assignment`);
120+ await github.rest.issues.createComment({
121+ owner: context.repo.owner,
122+ repo: context.repo.repo,
123+ issue_number: issue.number,
124+ body: `⚠️ Multiple squad members map to label \`${label}\`. Rename the colliding members in \`.squad/team.md\` before assigning work.`
125+ });
126+ return;
112127 }
113128
114129 if (!assignedMember) {
@@ -135,20 +150,7 @@ jobs:
135150 }
136151
137152 // Post assignment acknowledgment
138- let comment;
139- if (isCopilotAssignment) {
140- comment = [
141- `### 🤖 Routed to @copilot (Coding Agent)`,
142- '',
143- `**Issue:** #${issue.number} — ${issue.title}`,
144- '',
145- `@copilot has been assigned and will pick this up automatically.`,
146- '',
147- `> The coding agent will create a \`copilot/*\` branch and open a draft PR.`,
148- `> Review the PR as you would any team member's work.`,
149- ].join('\n');
150- } else {
151- comment = [
153+ const comment = [
152154 `### 📋 Assigned to ${assignedMember.name} (${assignedMember.role})`,
153155 '',
154156 `**Issue:** #${issue.number} — ${issue.title}`,
@@ -158,8 +160,7 @@ jobs:
158160 `> **For Copilot coding agent:** If enabled, this issue will be worked automatically.`,
159161 `> Otherwise, start a Copilot session and say:`,
160162 `> \`${assignedMember.name}, work on issue #${issue.number}\``,
161- ].join('\n');
162- }
163+ ].join('\n');
163164
164165 await github.rest.issues.createComment({
165166 owner: context.repo.owner,
@@ -172,21 +173,28 @@ jobs:
172173
173174 # Separate step: assign @copilot using PAT (required for coding agent)
174175 - name : Assign @copilot coding agent
175- if : github.event.label.name == 'squad:copilot'
176+ if : github.event_name == 'workflow_dispatch' || github. event.label.name == 'squad:copilot'
176177 uses : actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
177178 with :
178179 github-token : ${{ secrets.COPILOT_ASSIGN_TOKEN }}
179180 script : |
180181 const owner = context.repo.owner;
181182 const repo = context.repo.repo;
182- const issue_number = context.payload.issue.number;
183+ const issue_number = Number(context.payload.issue?.number || context.payload.inputs.issue_number);
184+ if (!Number.isSafeInteger(issue_number) || issue_number < 1) {
185+ throw new Error('A valid issue_number is required for Copilot assignment.');
186+ }
187+ const label = context.payload.label?.name || 'squad:copilot';
188+ const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number });
189+ if (!issue.labels.some(existing => existing.name === label)) {
190+ throw new Error(`Issue #${issue_number} no longer has ${label}; refusing stale Copilot handoff.`);
191+ }
183192
184193 // Get the default branch name (main, master, etc.)
185194 const { data: repoData } = await github.rest.repos.get({ owner, repo });
186195 const baseBranch = repoData.default_branch;
187196
188- try {
189- await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', {
197+ const response = await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', {
190198 owner,
191199 repo,
192200 issue_number,
@@ -201,18 +209,40 @@ jobs:
201209 headers: {
202210 'X-GitHub-Api-Version': '2022-11-28'
203211 }
204- });
205- core.info(`Assigned copilot-swe-agent to issue #${issue_number} (base: ${baseBranch})`);
206- } catch (err) {
207- core.warning(`Assignment with agent_assignment failed: ${err.message}`);
208- // Fallback: try without agent_assignment
209- try {
210- await github.rest.issues.addAssignees({
211- owner, repo, issue_number,
212- assignees: ['copilot-swe-agent']
213- });
214- core.info(`Fallback assigned copilot-swe-agent to issue #${issue_number}`);
215- } catch (err2) {
216- core.warning(`Fallback also failed: ${err2.message}`);
217- }
212+ });
213+ if (![200, 201].includes(response?.status) ||
214+ !Array.isArray(response?.data?.assignees) ||
215+ !response.data.assignees.some(assignee => assignee?.login === 'copilot-swe-agent[bot]')) {
216+ throw new Error(`Copilot assignment API did not confirm copilot-swe-agent[bot] for issue #${issue_number}; handoff is unverified.`);
217+ }
218+ core.info(`Copilot assignment API accepted issue #${issue_number} (base: ${baseBranch}); session creation is not verified.`);
219+ await github.rest.issues.createComment({
220+ owner, repo, issue_number,
221+ body: [
222+ `### 🤖 Copilot assignment API accepted`,
223+ '',
224+ `**Issue:** #${issue_number} — ${issue.title}`,
225+ `The API accepted the coding-agent request and returned \`copilot-swe-agent[bot]\` as an assignee (base: \`${baseBranch}\`).`,
226+ '',
227+ `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.`,
228+ ].join('\n')
229+ });
230+
231+ # Repair the parent label only after the PAT-backed assignment and its acknowledgment succeed.
232+ # The default GITHUB_TOKEN does not create another issues:labeled workflow run.
233+ - name : Ensure parent squad label
234+ if : github.event_name == 'workflow_dispatch' || github.event.label.name == 'squad:copilot'
235+ uses : actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
236+ with :
237+ script : |
238+ const owner = context.repo.owner;
239+ const repo = context.repo.repo;
240+ const issue_number = Number(context.payload.issue?.number || context.payload.inputs.issue_number);
241+ if (!Number.isSafeInteger(issue_number) || issue_number < 1) {
242+ throw new Error('A valid issue_number is required to repair the parent squad label.');
243+ }
244+ const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number });
245+ if (!issue.labels.some(label => label.name === 'squad')) {
246+ await github.rest.issues.addLabels({ owner, repo, issue_number, labels: ['squad'] });
247+ core.info(`Added parent "squad" label to #${issue_number}`);
218248 }
0 commit comments