diff --git a/.copilot/skills/init-mode/SKILL.md b/.copilot/skills/init-mode/SKILL.md index 4dce6628..5c8ed7e7 100644 --- a/.copilot/skills/init-mode/SKILL.md +++ b/.copilot/skills/init-mode/SKILL.md @@ -58,7 +58,7 @@ No team exists yet. Propose one — but **DO NOT create any files until the user **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. -**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. +**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. **Merge driver for append-only files:** Create or update `.gitattributes` at the repo root to enable conflict-free merging of `.squad/` state across branches: ``` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7bab75d5..ee39a479 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,9 +13,9 @@ jobs: name: Run APIOps Tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version: '22' cache: 'npm' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e87f2223..604a5bb8 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -57,7 +57,7 @@ jobs: # 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 steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 # Add any setup steps before running the `github/codeql-action/init` action. # This includes steps like installing compilers or runtimes (`actions/setup-node` diff --git a/.github/workflows/issue-labels-enforce-unique.yml b/.github/workflows/issue-labels-enforce-unique.yml new file mode 100644 index 00000000..096ba965 --- /dev/null +++ b/.github/workflows/issue-labels-enforce-unique.yml @@ -0,0 +1,98 @@ +name: Enforce Unique Category Labels + +on: + issues: + types: [labeled] + +permissions: + issues: write + contents: read + +jobs: + enforce: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Enforce one label per category + uses: actions/github-script@v8 + with: + script: | + const fs = require('fs'); + const issue = context.payload.issue; + const appliedLabel = context.payload.label.name; + + // Only handle "category:value" labels + const match = appliedLabel.match(/^([^:]+):/); + if (!match) { + core.info(`Label "${appliedLabel}" has no category prefix — skipping`); + return; + } + + const category = match[1]; + + // squad: labels are exempt from uniqueness enforcement + if (category === 'squad') { + core.info(`squad: labels are exempt — skipping`); + return; + } + + // Collect all labels in the same category currently on the issue + const allLabels = issue.labels.map(l => l.name); + const sameCategory = allLabels.filter(l => l.startsWith(category + ':')); + + if (sameCategory.length <= 1) { + core.info(`Only one "${category}:" label present — nothing to enforce`); + return; + } + + // Read the sync workflow to determine canonical label ordering. + // Labels listed earlier in issue-labels-sync.yml have higher priority. + const syncPath = '.github/workflows/issue-labels-sync.yml'; + let labelOrder = []; + if (fs.existsSync(syncPath)) { + const syncContent = fs.readFileSync(syncPath, 'utf8'); + const nameRegex = /name:\s*'([^']+)'/g; + let m; + while ((m = nameRegex.exec(syncContent)) !== null) { + if (m[1].startsWith(category + ':')) { + labelOrder.push(m[1]); + } + } + } + + // Determine winner: first label (by sync-file order) that is on the issue. + // If the category isn't in the sync file, fall back to keeping whichever + // label appeared first in the issue's current label list. + let winner = null; + if (labelOrder.length > 0) { + for (const ordered of labelOrder) { + if (sameCategory.includes(ordered)) { + winner = ordered; + break; + } + } + } + if (!winner) { + winner = sameCategory[0]; + } + + const toRemove = sameCategory.filter(l => l !== winner); + core.info(`Conflict in "${category}:" — keeping "${winner}", removing ${toRemove.join(', ')}`); + + for (const label of toRemove) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + name: label + }); + core.info(`Removed: ${label}`); + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: `🏷️ Label conflict resolved in \`${category}:\` — kept \`${winner}\`, removed ${toRemove.map(l => '`' + l + '`').join(', ')}.` + }); diff --git a/.github/workflows/issue-labels-migrate.yml b/.github/workflows/issue-labels-migrate.yml new file mode 100644 index 00000000..bd4e4e4e --- /dev/null +++ b/.github/workflows/issue-labels-migrate.yml @@ -0,0 +1,129 @@ +name: Migrate Legacy Labels + +on: + workflow_dispatch: + +permissions: + issues: write + +jobs: + migrate: + runs-on: ubuntu-latest + steps: + - name: "Rename legacy labels to type: equivalents" + uses: actions/github-script@v8 + with: + script: | + // Legacy label → canonical type: label. + // Rename preserves all issue associations automatically. + const MIGRATIONS = [ + { from: 'bug', to: 'type:bug' }, + { from: 'question', to: 'type:question' }, + { from: 'enhancement', to: 'type:enhancement' }, + { from: 'documentation', to: 'type:documentation' } + ]; + + function isAlreadyExistsError(err) { + const errors = err?.errors + || err?.response?.data?.errors + || err?.data?.errors + || []; + + const hasTypedError = Array.isArray(errors) + && errors.some(e => e?.code === 'already_exists' && e?.field === 'name'); + + const message = String(err?.message || ''); + const messageHasAlreadyExists = message.includes('already_exists') && message.includes('Label'); + + return (err?.status === 422 && hasTypedError) || messageHasAlreadyExists; + } + + async function relabelItems(from, to) { + const items = await github.paginate(github.rest.issues.listForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'all', + labels: from, + per_page: 100 + }); + + if (items.length === 0) { + core.info(`No issues or PRs found with legacy label "${from}"`); + return; + } + + for (const item of items) { + const existing = (item.labels || []) + .map(l => (typeof l === 'string' ? l : l.name)) + .filter(Boolean); + + if (!existing.includes(to)) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: item.number, + labels: [to] + }); + } + + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: item.number, + name: from + }); + } catch (err) { + if (err.status !== 404) { + throw err; + } + } + } + + core.info(`Re-labeled ${items.length} issues/PRs: ${from} → ${to}`); + } + + for (const { from, to } of MIGRATIONS) { + try { + await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: from + }); + // Old label exists — rename it + await github.rest.issues.updateLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: from, + new_name: to + }); + core.info(`Migrated label: ${from} → ${to}`); + } catch (err) { + if (err.status === 404) { + core.info(`Legacy label "${from}" not found — no migration needed`); + } else if (isAlreadyExistsError(err)) { + core.info(`Target label "${to}" already exists; applying fallback migration for "${from}"`); + + await relabelItems(from, to); + + try { + await github.rest.issues.deleteLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: from + }); + core.info(`Deleted legacy label "${from}" after fallback migration`); + } catch (deleteErr) { + if (deleteErr.status === 404) { + core.info(`Legacy label "${from}" already removed`); + } else { + throw deleteErr; + } + } + } else { + core.warning(`Failed to migrate ${from}: ${err.message}`); + } + } + } + + core.info('Label migration complete'); diff --git a/.github/workflows/sync-squad-labels.yml b/.github/workflows/issue-labels-sync.yml similarity index 89% rename from .github/workflows/sync-squad-labels.yml rename to .github/workflows/issue-labels-sync.yml index 6f6d0ae9..e7a7778b 100644 --- a/.github/workflows/sync-squad-labels.yml +++ b/.github/workflows/issue-labels-sync.yml @@ -17,10 +17,10 @@ jobs: sync-labels: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Parse roster and sync labels - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: script: | const fs = require('fs'); @@ -85,14 +85,21 @@ jobs: ]; const TYPE_LABELS = [ - { name: 'type:feature', color: 'DDD1F2', description: 'New capability' }, { name: 'type:bug', color: 'FF0422', description: 'Something broken' }, - { name: 'type:question', color: 'D876E3', description: 'Questions about usage or behavior' }, - { name: 'type:documentation', color: '0075CA', description: 'Documentation issues or requests' }, + { name: 'type:feature', color: 'DDD1F2', description: 'New capability' }, + { name: 'type:enhancement', color: 'A2EEEF', description: 'Improvement to existing functionality' }, { name: 'type:spike', color: 'F2DDD4', description: 'Research/investigation — produces a plan, not code' }, - { name: 'type:docs', color: 'D4E5F7', description: 'Documentation work' }, { name: 'type:chore', color: 'D4E5F7', description: 'Maintenance, refactoring, cleanup' }, - { name: 'type:epic', color: 'CC4455', description: 'Parent issue that decomposes into sub-issues' } + { name: 'type:epic', color: 'CC4455', description: 'Parent issue that decomposes into sub-issues' }, + { name: 'type:documentation', color: '0075CA', description: 'Documentation issues or requests' }, + { name: 'type:question', color: 'D876E3', description: 'Questions about usage or behavior' } + ]; + + const CLOSE_LABELS = [ + { name: 'close:fixed', color: '0E8A16', description: 'Fixed by a previous PR or release' }, + { name: 'close:wont-fix', color: 'FFFFFF', description: 'Will not be addressed' }, + { name: 'close:duplicate', color: 'CFD3D7', description: 'Duplicate of another issue' }, + { name: 'close:question-answered', color: 'D876E3', description: 'Question has been answered' } ]; const EFFORT_LABELS = [ @@ -104,7 +111,6 @@ jobs: // High-signal labels — these MUST visually dominate all others const SIGNAL_LABELS = [ - { name: 'bug', color: 'FF0422', description: 'Something isn\'t working' }, { name: 'feedback', color: '00E5FF', description: 'User feedback — high signal, needs attention' } ]; @@ -144,10 +150,11 @@ jobs: }); } - // Add go:, release:, type:, effort:, priority:, high-signal, and lifecycle labels + // Add go:, release:, type:, close:, effort:, priority:, high-signal, and lifecycle labels labels.push(...GO_LABELS); labels.push(...RELEASE_LABELS); labels.push(...TYPE_LABELS); + labels.push(...CLOSE_LABELS); labels.push(...EFFORT_LABELS); labels.push(...PRIORITY_LABELS); labels.push(...SIGNAL_LABELS); diff --git a/.github/workflows/squad-heartbeat.yml b/.github/workflows/squad-heartbeat.yml index 5494296f..5a70db11 100644 --- a/.github/workflows/squad-heartbeat.yml +++ b/.github/workflows/squad-heartbeat.yml @@ -25,7 +25,7 @@ jobs: heartbeat: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Check triage script id: check-script @@ -48,7 +48,7 @@ jobs: - name: Ralph — Apply triage decisions if: steps.check-script.outputs.has_script == 'true' && hashFiles('triage-results.json') != '' - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: script: | const fs = require('fs'); @@ -100,7 +100,7 @@ jobs: # Copilot auto-assign step (uses PAT if available) - name: Ralph — Assign @copilot issues if: success() - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN || secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/squad-issue-assign.yml b/.github/workflows/squad-issue-assign.yml index 022aab99..ddce206b 100644 --- a/.github/workflows/squad-issue-assign.yml +++ b/.github/workflows/squad-issue-assign.yml @@ -57,7 +57,7 @@ jobs: core.info(`Verified ${sender} as a ${permission.permission} collaborator`); - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 # Step 1: Assign issue to the maintainer who applied "go:yes" - name: Assign to label sender @@ -233,7 +233,7 @@ jobs: needs: assign-issue runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Verify assignment integrity uses: actions/github-script@v8 diff --git a/.github/workflows/squad-label-enforce.yml b/.github/workflows/squad-label-enforce.yml deleted file mode 100644 index 633d220d..00000000 --- a/.github/workflows/squad-label-enforce.yml +++ /dev/null @@ -1,181 +0,0 @@ -name: Squad Label Enforce - -on: - issues: - types: [labeled] - -permissions: - issues: write - contents: read - -jobs: - enforce: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Enforce mutual exclusivity - uses: actions/github-script@v7 - with: - script: | - const issue = context.payload.issue; - const appliedLabel = context.payload.label.name; - - // Namespaces with mutual exclusivity rules - const EXCLUSIVE_PREFIXES = ['go:', 'release:', 'type:', 'priority:']; - - // Skip if not a managed namespace label - if (!EXCLUSIVE_PREFIXES.some(p => appliedLabel.startsWith(p))) { - core.info(`Label ${appliedLabel} is not in a managed namespace — skipping`); - return; - } - - const allLabels = issue.labels.map(l => l.name); - - // Handle go: namespace (mutual exclusivity) - if (appliedLabel.startsWith('go:')) { - const otherGoLabels = allLabels.filter(l => - l.startsWith('go:') && l !== appliedLabel - ); - - if (otherGoLabels.length > 0) { - // Remove conflicting go: labels - for (const label of otherGoLabels) { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - name: label - }); - core.info(`Removed conflicting label: ${label}`); - } - - // Post update comment - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: `🏷️ Triage verdict updated → \`${appliedLabel}\`` - }); - } - - // Auto-apply release:backlog if go:yes and no release target - if (appliedLabel === 'go:yes') { - const hasReleaseLabel = allLabels.some(l => l.startsWith('release:')); - if (!hasReleaseLabel) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - labels: ['release:backlog'] - }); - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: `📋 Marked as \`release:backlog\` — assign a release target when ready.` - }); - - core.info('Applied release:backlog for go:yes issue'); - } - } - - // Remove release: labels if go:no - if (appliedLabel === 'go:no') { - const releaseLabels = allLabels.filter(l => l.startsWith('release:')); - if (releaseLabels.length > 0) { - for (const label of releaseLabels) { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - name: label - }); - core.info(`Removed release label from go:no issue: ${label}`); - } - } - } - } - - // Handle release: namespace (mutual exclusivity) - if (appliedLabel.startsWith('release:')) { - const otherReleaseLabels = allLabels.filter(l => - l.startsWith('release:') && l !== appliedLabel - ); - - if (otherReleaseLabels.length > 0) { - // Remove conflicting release: labels - for (const label of otherReleaseLabels) { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - name: label - }); - core.info(`Removed conflicting label: ${label}`); - } - - // Post update comment - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: `🏷️ Release target updated → \`${appliedLabel}\`` - }); - } - } - - // Handle type: namespace (mutual exclusivity) - if (appliedLabel.startsWith('type:')) { - const otherTypeLabels = allLabels.filter(l => - l.startsWith('type:') && l !== appliedLabel - ); - - if (otherTypeLabels.length > 0) { - for (const label of otherTypeLabels) { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - name: label - }); - core.info(`Removed conflicting label: ${label}`); - } - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: `🏷️ Issue type updated → \`${appliedLabel}\`` - }); - } - } - - // Handle priority: namespace (mutual exclusivity) - if (appliedLabel.startsWith('priority:')) { - const otherPriorityLabels = allLabels.filter(l => - l.startsWith('priority:') && l !== appliedLabel - ); - - if (otherPriorityLabels.length > 0) { - for (const label of otherPriorityLabels) { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - name: label - }); - core.info(`Removed conflicting label: ${label}`); - } - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: `🏷️ Priority updated → \`${appliedLabel}\`` - }); - } - } - - core.info(`Label enforcement complete for ${appliedLabel}`); diff --git a/.github/workflows/squad-preview.yml b/.github/workflows/squad-preview.yml index 9df39e07..a69d324b 100644 --- a/.github/workflows/squad-preview.yml +++ b/.github/workflows/squad-preview.yml @@ -11,9 +11,9 @@ jobs: validate: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version: 22 diff --git a/.github/workflows/squad-release.yml b/.github/workflows/squad-release.yml index f04d85f0..d9271919 100644 --- a/.github/workflows/squad-release.yml +++ b/.github/workflows/squad-release.yml @@ -11,11 +11,11 @@ jobs: release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version: 22 diff --git a/.squad/templates/skills/init-mode/SKILL.md b/.squad/templates/skills/init-mode/SKILL.md index f0a7831a..3f4bf997 100644 --- a/.squad/templates/skills/init-mode/SKILL.md +++ b/.squad/templates/skills/init-mode/SKILL.md @@ -58,7 +58,7 @@ No team exists yet. Propose one — but **DO NOT create any files until the user **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. -**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. +**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. **Merge driver for append-only files:** Create or update `.gitattributes` at the repo root to enable conflict-free merging of `.squad/` state across branches: ``` diff --git a/.squad/templates/squad.agent.md.template b/.squad/templates/squad.agent.md.template index 1e7cf934..f2c1a57a 100644 --- a/.squad/templates/squad.agent.md.template +++ b/.squad/templates/squad.agent.md.template @@ -92,7 +92,7 @@ No team exists yet. Propose one — but **DO NOT create any files until the user **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. Rai's charter is seeded from the `Rai-charter.md` template, and `.squad/rai/policy.md` is seeded from `rai-policy.md`. -**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. +**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. **Merge driver for append-only files:** Create or update `.gitattributes` at the repo root to enable conflict-free merging of `.squad/` state across branches: ``` @@ -186,7 +186,7 @@ For each squad member with assigned issues, note them in the session context. Wh **Proactive issue pickup:** If a user starts a session and there are open `squad:{member}` issues, mention them: *"Hey {user}, {AgentName} has an open issue — #42: Fix auth endpoint timeout. Want them to pick it up?"* -**Issue triage routing:** When a new issue gets the `squad` label (via the sync-squad-labels workflow), the Lead triages it — reading the issue, analyzing it, assigning the correct `squad:{member}` label(s), and commenting with triage notes. The Lead can also reassign by swapping labels. +**Issue triage routing:** When a new issue gets the `squad` label (via the issue-labels-sync workflow), the Lead triages it — reading the issue, analyzing it, assigning the correct `squad:{member}` label(s), and commenting with triage notes. The Lead can also reassign by swapping labels. **⚡ Read `.squad/team.md` (roster), `.squad/routing.md` (routing), and `.squad/casting/registry.json` (persistent names) as parallel tool calls in a single turn. Do NOT read these sequentially.**