Skip to content

Commit 67e8786

Browse files
EMaherCopilot
andauthored
refactor: rename label workflows and add generic uniqueness enforcement (#189)
* refactor: rename label workflows and add generic uniqueness enforcement - Rename sync-squad-labels.yml → issue-labels-sync.yml - Delete squad-label-enforce.yml (hardcoded per-category logic) - Add issue-labels-enforce-unique.yml: generic enforcement for any category:value labels, reads sync file for priority ordering, exempts squad:* labels - Update doc references in 3 files Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: add close: label group, type:enhancement, and legacy label migration - Add CLOSE_LABELS: close:fixed, close:wont-fix, close:duplicate, close:question-answered - Add type:enhancement to TYPE_LABELS - Remove standalone 'bug' from SIGNAL_LABELS (replaced by type:bug) - Add migration step to rename legacy labels (bug, question, enhancement) to their type: equivalents — rename preserves issue associations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: add documentation → type:documentation label migration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: run label migration after sync to ensure targets exist Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: move label migration to its own daily workflow Extracts migration logic into issue-labels-migrate.yml (runs daily at 07:00 UTC). Removes migration from issue-labels-sync.yml. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: trigger issue-labels-sync workflow * chore: trigger issue-labels-sync via team roster touch * fix: quote YAML step name containing colon Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Revert "chore: trigger issue-labels-sync via team roster touch" This reverts commit 62916ad. * fix: make issue-labels-migrate idempotent when target labels already exist * fix: handle label already_exists errors robustly and use github-script v8 * fix: preserve legacy labels in issue-labels-migrate workflow * Revert "fix: preserve legacy labels in issue-labels-migrate workflow" This reverts commit bef5f16. * updating workflow to manual-only * updating actions to use newer versions. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d8a2ed1 commit 67e8786

13 files changed

Lines changed: 259 additions & 206 deletions

File tree

.copilot/skills/init-mode/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ No team exists yet. Propose one — but **DO NOT create any files until the user
5858

5959
**Seeding:** Each agent's `history.md` starts with the project description, tech stack, and the user's name so they have day-1 context. Agent folder names are the cast name in lowercase (e.g., `.squad/agents/ripley/`). The Scribe's charter includes maintaining `decisions.md` and cross-agent context sharing.
6060

61-
**Team.md structure:** `team.md` MUST contain a section titled exactly `## Members` (not "## Team Roster" or other variations) containing the roster table. This header is hard-coded in GitHub workflows (`squad-heartbeat.yml`, `squad-issue-assign.yml`, `squad-triage.yml`, `sync-squad-labels.yml`) for label automation. If the header is missing or titled differently, label routing breaks.
61+
**Team.md structure:** `team.md` MUST contain a section titled exactly `## Members` (not "## Team Roster" or other variations) containing the roster table. This header is hard-coded in GitHub workflows (`squad-heartbeat.yml`, `squad-issue-assign.yml`, `squad-triage.yml`, `issue-labels-sync.yml`) for label automation. If the header is missing or titled differently, label routing breaks.
6262

6363
**Merge driver for append-only files:** Create or update `.gitattributes` at the repo root to enable conflict-free merging of `.squad/` state across branches:
6464
```

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ jobs:
1313
name: Run APIOps Tests
1414
runs-on: ubuntu-latest
1515
steps:
16-
- uses: actions/checkout@v4
16+
- uses: actions/checkout@v6
1717

18-
- uses: actions/setup-node@v4
18+
- uses: actions/setup-node@v5
1919
with:
2020
node-version: '22'
2121
cache: 'npm'

.github/workflows/codeql.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ jobs:
5757
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
5858
steps:
5959
- name: Checkout repository
60-
uses: actions/checkout@v4
60+
uses: actions/checkout@v6
6161

6262
# Add any setup steps before running the `github/codeql-action/init` action.
6363
# This includes steps like installing compilers or runtimes (`actions/setup-node`
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
name: Enforce Unique Category Labels
2+
3+
on:
4+
issues:
5+
types: [labeled]
6+
7+
permissions:
8+
issues: write
9+
contents: read
10+
11+
jobs:
12+
enforce:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v6
16+
17+
- name: Enforce one label per category
18+
uses: actions/github-script@v8
19+
with:
20+
script: |
21+
const fs = require('fs');
22+
const issue = context.payload.issue;
23+
const appliedLabel = context.payload.label.name;
24+
25+
// Only handle "category:value" labels
26+
const match = appliedLabel.match(/^([^:]+):/);
27+
if (!match) {
28+
core.info(`Label "${appliedLabel}" has no category prefix — skipping`);
29+
return;
30+
}
31+
32+
const category = match[1];
33+
34+
// squad: labels are exempt from uniqueness enforcement
35+
if (category === 'squad') {
36+
core.info(`squad: labels are exempt — skipping`);
37+
return;
38+
}
39+
40+
// Collect all labels in the same category currently on the issue
41+
const allLabels = issue.labels.map(l => l.name);
42+
const sameCategory = allLabels.filter(l => l.startsWith(category + ':'));
43+
44+
if (sameCategory.length <= 1) {
45+
core.info(`Only one "${category}:" label present — nothing to enforce`);
46+
return;
47+
}
48+
49+
// Read the sync workflow to determine canonical label ordering.
50+
// Labels listed earlier in issue-labels-sync.yml have higher priority.
51+
const syncPath = '.github/workflows/issue-labels-sync.yml';
52+
let labelOrder = [];
53+
if (fs.existsSync(syncPath)) {
54+
const syncContent = fs.readFileSync(syncPath, 'utf8');
55+
const nameRegex = /name:\s*'([^']+)'/g;
56+
let m;
57+
while ((m = nameRegex.exec(syncContent)) !== null) {
58+
if (m[1].startsWith(category + ':')) {
59+
labelOrder.push(m[1]);
60+
}
61+
}
62+
}
63+
64+
// Determine winner: first label (by sync-file order) that is on the issue.
65+
// If the category isn't in the sync file, fall back to keeping whichever
66+
// label appeared first in the issue's current label list.
67+
let winner = null;
68+
if (labelOrder.length > 0) {
69+
for (const ordered of labelOrder) {
70+
if (sameCategory.includes(ordered)) {
71+
winner = ordered;
72+
break;
73+
}
74+
}
75+
}
76+
if (!winner) {
77+
winner = sameCategory[0];
78+
}
79+
80+
const toRemove = sameCategory.filter(l => l !== winner);
81+
core.info(`Conflict in "${category}:" — keeping "${winner}", removing ${toRemove.join(', ')}`);
82+
83+
for (const label of toRemove) {
84+
await github.rest.issues.removeLabel({
85+
owner: context.repo.owner,
86+
repo: context.repo.repo,
87+
issue_number: issue.number,
88+
name: label
89+
});
90+
core.info(`Removed: ${label}`);
91+
}
92+
93+
await github.rest.issues.createComment({
94+
owner: context.repo.owner,
95+
repo: context.repo.repo,
96+
issue_number: issue.number,
97+
body: `🏷️ Label conflict resolved in \`${category}:\` — kept \`${winner}\`, removed ${toRemove.map(l => '`' + l + '`').join(', ')}.`
98+
});
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
name: Migrate Legacy Labels
2+
3+
on:
4+
workflow_dispatch:
5+
6+
permissions:
7+
issues: write
8+
9+
jobs:
10+
migrate:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- name: "Rename legacy labels to type: equivalents"
14+
uses: actions/github-script@v8
15+
with:
16+
script: |
17+
// Legacy label → canonical type: label.
18+
// Rename preserves all issue associations automatically.
19+
const MIGRATIONS = [
20+
{ from: 'bug', to: 'type:bug' },
21+
{ from: 'question', to: 'type:question' },
22+
{ from: 'enhancement', to: 'type:enhancement' },
23+
{ from: 'documentation', to: 'type:documentation' }
24+
];
25+
26+
function isAlreadyExistsError(err) {
27+
const errors = err?.errors
28+
|| err?.response?.data?.errors
29+
|| err?.data?.errors
30+
|| [];
31+
32+
const hasTypedError = Array.isArray(errors)
33+
&& errors.some(e => e?.code === 'already_exists' && e?.field === 'name');
34+
35+
const message = String(err?.message || '');
36+
const messageHasAlreadyExists = message.includes('already_exists') && message.includes('Label');
37+
38+
return (err?.status === 422 && hasTypedError) || messageHasAlreadyExists;
39+
}
40+
41+
async function relabelItems(from, to) {
42+
const items = await github.paginate(github.rest.issues.listForRepo, {
43+
owner: context.repo.owner,
44+
repo: context.repo.repo,
45+
state: 'all',
46+
labels: from,
47+
per_page: 100
48+
});
49+
50+
if (items.length === 0) {
51+
core.info(`No issues or PRs found with legacy label "${from}"`);
52+
return;
53+
}
54+
55+
for (const item of items) {
56+
const existing = (item.labels || [])
57+
.map(l => (typeof l === 'string' ? l : l.name))
58+
.filter(Boolean);
59+
60+
if (!existing.includes(to)) {
61+
await github.rest.issues.addLabels({
62+
owner: context.repo.owner,
63+
repo: context.repo.repo,
64+
issue_number: item.number,
65+
labels: [to]
66+
});
67+
}
68+
69+
try {
70+
await github.rest.issues.removeLabel({
71+
owner: context.repo.owner,
72+
repo: context.repo.repo,
73+
issue_number: item.number,
74+
name: from
75+
});
76+
} catch (err) {
77+
if (err.status !== 404) {
78+
throw err;
79+
}
80+
}
81+
}
82+
83+
core.info(`Re-labeled ${items.length} issues/PRs: ${from} → ${to}`);
84+
}
85+
86+
for (const { from, to } of MIGRATIONS) {
87+
try {
88+
await github.rest.issues.getLabel({
89+
owner: context.repo.owner,
90+
repo: context.repo.repo,
91+
name: from
92+
});
93+
// Old label exists — rename it
94+
await github.rest.issues.updateLabel({
95+
owner: context.repo.owner,
96+
repo: context.repo.repo,
97+
name: from,
98+
new_name: to
99+
});
100+
core.info(`Migrated label: ${from} → ${to}`);
101+
} catch (err) {
102+
if (err.status === 404) {
103+
core.info(`Legacy label "${from}" not found — no migration needed`);
104+
} else if (isAlreadyExistsError(err)) {
105+
core.info(`Target label "${to}" already exists; applying fallback migration for "${from}"`);
106+
107+
await relabelItems(from, to);
108+
109+
try {
110+
await github.rest.issues.deleteLabel({
111+
owner: context.repo.owner,
112+
repo: context.repo.repo,
113+
name: from
114+
});
115+
core.info(`Deleted legacy label "${from}" after fallback migration`);
116+
} catch (deleteErr) {
117+
if (deleteErr.status === 404) {
118+
core.info(`Legacy label "${from}" already removed`);
119+
} else {
120+
throw deleteErr;
121+
}
122+
}
123+
} else {
124+
core.warning(`Failed to migrate ${from}: ${err.message}`);
125+
}
126+
}
127+
}
128+
129+
core.info('Label migration complete');
Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ jobs:
1717
sync-labels:
1818
runs-on: ubuntu-latest
1919
steps:
20-
- uses: actions/checkout@v4
20+
- uses: actions/checkout@v6
2121

2222
- name: Parse roster and sync labels
23-
uses: actions/github-script@v7
23+
uses: actions/github-script@v8
2424
with:
2525
script: |
2626
const fs = require('fs');
@@ -85,14 +85,21 @@ jobs:
8585
];
8686
8787
const TYPE_LABELS = [
88-
{ name: 'type:feature', color: 'DDD1F2', description: 'New capability' },
8988
{ name: 'type:bug', color: 'FF0422', description: 'Something broken' },
90-
{ name: 'type:question', color: 'D876E3', description: 'Questions about usage or behavior' },
91-
{ name: 'type:documentation', color: '0075CA', description: 'Documentation issues or requests' },
89+
{ name: 'type:feature', color: 'DDD1F2', description: 'New capability' },
90+
{ name: 'type:enhancement', color: 'A2EEEF', description: 'Improvement to existing functionality' },
9291
{ name: 'type:spike', color: 'F2DDD4', description: 'Research/investigation — produces a plan, not code' },
93-
{ name: 'type:docs', color: 'D4E5F7', description: 'Documentation work' },
9492
{ name: 'type:chore', color: 'D4E5F7', description: 'Maintenance, refactoring, cleanup' },
95-
{ name: 'type:epic', color: 'CC4455', description: 'Parent issue that decomposes into sub-issues' }
93+
{ name: 'type:epic', color: 'CC4455', description: 'Parent issue that decomposes into sub-issues' },
94+
{ name: 'type:documentation', color: '0075CA', description: 'Documentation issues or requests' },
95+
{ name: 'type:question', color: 'D876E3', description: 'Questions about usage or behavior' }
96+
];
97+
98+
const CLOSE_LABELS = [
99+
{ name: 'close:fixed', color: '0E8A16', description: 'Fixed by a previous PR or release' },
100+
{ name: 'close:wont-fix', color: 'FFFFFF', description: 'Will not be addressed' },
101+
{ name: 'close:duplicate', color: 'CFD3D7', description: 'Duplicate of another issue' },
102+
{ name: 'close:question-answered', color: 'D876E3', description: 'Question has been answered' }
96103
];
97104
98105
const EFFORT_LABELS = [
@@ -104,7 +111,6 @@ jobs:
104111
105112
// High-signal labels — these MUST visually dominate all others
106113
const SIGNAL_LABELS = [
107-
{ name: 'bug', color: 'FF0422', description: 'Something isn\'t working' },
108114
{ name: 'feedback', color: '00E5FF', description: 'User feedback — high signal, needs attention' }
109115
];
110116
@@ -144,10 +150,11 @@ jobs:
144150
});
145151
}
146152
147-
// Add go:, release:, type:, effort:, priority:, high-signal, and lifecycle labels
153+
// Add go:, release:, type:, close:, effort:, priority:, high-signal, and lifecycle labels
148154
labels.push(...GO_LABELS);
149155
labels.push(...RELEASE_LABELS);
150156
labels.push(...TYPE_LABELS);
157+
labels.push(...CLOSE_LABELS);
151158
labels.push(...EFFORT_LABELS);
152159
labels.push(...PRIORITY_LABELS);
153160
labels.push(...SIGNAL_LABELS);

.github/workflows/squad-heartbeat.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ jobs:
2525
heartbeat:
2626
runs-on: ubuntu-latest
2727
steps:
28-
- uses: actions/checkout@v4
28+
- uses: actions/checkout@v6
2929

3030
- name: Check triage script
3131
id: check-script
@@ -48,7 +48,7 @@ jobs:
4848
4949
- name: Ralph — Apply triage decisions
5050
if: steps.check-script.outputs.has_script == 'true' && hashFiles('triage-results.json') != ''
51-
uses: actions/github-script@v7
51+
uses: actions/github-script@v8
5252
with:
5353
script: |
5454
const fs = require('fs');
@@ -100,7 +100,7 @@ jobs:
100100
# Copilot auto-assign step (uses PAT if available)
101101
- name: Ralph — Assign @copilot issues
102102
if: success()
103-
uses: actions/github-script@v7
103+
uses: actions/github-script@v8
104104
with:
105105
github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN || secrets.GITHUB_TOKEN }}
106106
script: |

.github/workflows/squad-issue-assign.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ jobs:
5757
5858
core.info(`Verified ${sender} as a ${permission.permission} collaborator`);
5959
60-
- uses: actions/checkout@v4
60+
- uses: actions/checkout@v6
6161

6262
# Step 1: Assign issue to the maintainer who applied "go:yes"
6363
- name: Assign to label sender
@@ -233,7 +233,7 @@ jobs:
233233
needs: assign-issue
234234
runs-on: ubuntu-latest
235235
steps:
236-
- uses: actions/checkout@v4
236+
- uses: actions/checkout@v6
237237

238238
- name: Verify assignment integrity
239239
uses: actions/github-script@v8

0 commit comments

Comments
 (0)