diff --git a/.bob/commands/cuga-move-issue.md b/.bob/commands/cuga-move-issue.md new file mode 100644 index 00000000..fa83a2b5 --- /dev/null +++ b/.bob/commands/cuga-move-issue.md @@ -0,0 +1,53 @@ +# Move an issue to a new status on the project board + +Moves an issue's **Status** field on the GitHub Projects board to **Todo** or **In Progress** +after validating Epic association rules. + +## Steps + +1. Identify the issue number and target status (`Todo` or `In Progress`) from the user. + +2. Fetch the issue: + ```bash + gh issue view --json number,title,body,projectItems + ``` + +3. **Skip Epic checks** if the title starts with `[Bug]` or `[Epic]`. Jump to step 7. + +4. Parse `Epic: #` from the issue body (first match, case-insensitive). + - If the line is missing, **abort**: + > "This issue has no Epic association. Add `Epic: #` to the body before changing its status." + +5. Fetch the Epic's project board Status: + ```bash + gh issue view --json projectItems \ + --jq '[.projectItems.nodes[].fieldValues.nodes[] + | select(.field.name == "Status") | .name] | first' + ``` + +6. If the result is not `"In Progress"`, **abort**: + > "Epic # has Status '' on the board. + > Move the Epic to **In Progress** first, then retry." + +7. Find the project and item IDs for the issue: + ```bash + gh issue view --json projectItems \ + --jq '.projectItems.nodes[] | {projectId: .project.id, itemId: .id}' + ``` + +8. Find the Status field ID and the option ID for the target status: + ```bash + gh project field-list --owner --format json \ + --jq '.fields[] | select(.name == "Status") | {fieldId: .id, options: .options}' + ``` + +9. Apply the new status: + ```bash + gh project item-edit \ + --id \ + --project-id \ + --field-id \ + --single-select-option-id + ``` + +10. Print the updated issue URL and new status. diff --git a/.bob/commands/cuga-new-feature.md b/.bob/commands/cuga-new-feature.md index c05534da..a7eb10d6 100644 --- a/.bob/commands/cuga-new-feature.md +++ b/.bob/commands/cuga-new-feature.md @@ -18,5 +18,15 @@ | `[Epic]` | Large body of work grouping multiple issues | | `[Question]` | Clarification needed, not a task | -5. Write the body using the same sections as `.github/ISSUE_TEMPLATE/feature_request.yml`: What you want and why, How it could work, Links or extra context (if any). Incorporate the user's message and any selected editor/context so the issue is concrete and complete. -6. Do not add "Made with Cursor" or similar promotional footers to the issue. +5. **Epic association (mandatory for every non-`[Epic]` issue):** + - Ask the user which Epic this issue belongs to if they have not already specified one. + - Run `gh issue list --label "type: epic" --state open` to show available open Epics. + - If no suitable Epic exists, offer to create one first using the `[Epic]` prefix. + - Add the following line verbatim at the **top** of the issue body (before any other content): + ``` + Epic: # + ``` + - Do **not** skip this step. A missing `Epic:` line will cause the GitHub Actions check to fail. + +6. Write the body using the same sections as `.github/ISSUE_TEMPLATE/feature_request.yml`: What you want and why, How it could work, Links or extra context (if any). Incorporate the user's message and any selected editor/context so the issue is concrete and complete. +7. Do not add "Made with Cursor" or similar promotional footers to the issue. diff --git a/.claude/commands/cuga-move-issue.md b/.claude/commands/cuga-move-issue.md new file mode 100644 index 00000000..fa83a2b5 --- /dev/null +++ b/.claude/commands/cuga-move-issue.md @@ -0,0 +1,53 @@ +# Move an issue to a new status on the project board + +Moves an issue's **Status** field on the GitHub Projects board to **Todo** or **In Progress** +after validating Epic association rules. + +## Steps + +1. Identify the issue number and target status (`Todo` or `In Progress`) from the user. + +2. Fetch the issue: + ```bash + gh issue view --json number,title,body,projectItems + ``` + +3. **Skip Epic checks** if the title starts with `[Bug]` or `[Epic]`. Jump to step 7. + +4. Parse `Epic: #` from the issue body (first match, case-insensitive). + - If the line is missing, **abort**: + > "This issue has no Epic association. Add `Epic: #` to the body before changing its status." + +5. Fetch the Epic's project board Status: + ```bash + gh issue view --json projectItems \ + --jq '[.projectItems.nodes[].fieldValues.nodes[] + | select(.field.name == "Status") | .name] | first' + ``` + +6. If the result is not `"In Progress"`, **abort**: + > "Epic # has Status '' on the board. + > Move the Epic to **In Progress** first, then retry." + +7. Find the project and item IDs for the issue: + ```bash + gh issue view --json projectItems \ + --jq '.projectItems.nodes[] | {projectId: .project.id, itemId: .id}' + ``` + +8. Find the Status field ID and the option ID for the target status: + ```bash + gh project field-list --owner --format json \ + --jq '.fields[] | select(.name == "Status") | {fieldId: .id, options: .options}' + ``` + +9. Apply the new status: + ```bash + gh project item-edit \ + --id \ + --project-id \ + --field-id \ + --single-select-option-id + ``` + +10. Print the updated issue URL and new status. diff --git a/.claude/commands/cuga-new-feature.md b/.claude/commands/cuga-new-feature.md index c05534da..a7eb10d6 100644 --- a/.claude/commands/cuga-new-feature.md +++ b/.claude/commands/cuga-new-feature.md @@ -18,5 +18,15 @@ | `[Epic]` | Large body of work grouping multiple issues | | `[Question]` | Clarification needed, not a task | -5. Write the body using the same sections as `.github/ISSUE_TEMPLATE/feature_request.yml`: What you want and why, How it could work, Links or extra context (if any). Incorporate the user's message and any selected editor/context so the issue is concrete and complete. -6. Do not add "Made with Cursor" or similar promotional footers to the issue. +5. **Epic association (mandatory for every non-`[Epic]` issue):** + - Ask the user which Epic this issue belongs to if they have not already specified one. + - Run `gh issue list --label "type: epic" --state open` to show available open Epics. + - If no suitable Epic exists, offer to create one first using the `[Epic]` prefix. + - Add the following line verbatim at the **top** of the issue body (before any other content): + ``` + Epic: # + ``` + - Do **not** skip this step. A missing `Epic:` line will cause the GitHub Actions check to fail. + +6. Write the body using the same sections as `.github/ISSUE_TEMPLATE/feature_request.yml`: What you want and why, How it could work, Links or extra context (if any). Incorporate the user's message and any selected editor/context so the issue is concrete and complete. +7. Do not add "Made with Cursor" or similar promotional footers to the issue. diff --git a/.cursor/commands/cuga-move-issue.md b/.cursor/commands/cuga-move-issue.md new file mode 100644 index 00000000..fa83a2b5 --- /dev/null +++ b/.cursor/commands/cuga-move-issue.md @@ -0,0 +1,53 @@ +# Move an issue to a new status on the project board + +Moves an issue's **Status** field on the GitHub Projects board to **Todo** or **In Progress** +after validating Epic association rules. + +## Steps + +1. Identify the issue number and target status (`Todo` or `In Progress`) from the user. + +2. Fetch the issue: + ```bash + gh issue view --json number,title,body,projectItems + ``` + +3. **Skip Epic checks** if the title starts with `[Bug]` or `[Epic]`. Jump to step 7. + +4. Parse `Epic: #` from the issue body (first match, case-insensitive). + - If the line is missing, **abort**: + > "This issue has no Epic association. Add `Epic: #` to the body before changing its status." + +5. Fetch the Epic's project board Status: + ```bash + gh issue view --json projectItems \ + --jq '[.projectItems.nodes[].fieldValues.nodes[] + | select(.field.name == "Status") | .name] | first' + ``` + +6. If the result is not `"In Progress"`, **abort**: + > "Epic # has Status '' on the board. + > Move the Epic to **In Progress** first, then retry." + +7. Find the project and item IDs for the issue: + ```bash + gh issue view --json projectItems \ + --jq '.projectItems.nodes[] | {projectId: .project.id, itemId: .id}' + ``` + +8. Find the Status field ID and the option ID for the target status: + ```bash + gh project field-list --owner --format json \ + --jq '.fields[] | select(.name == "Status") | {fieldId: .id, options: .options}' + ``` + +9. Apply the new status: + ```bash + gh project item-edit \ + --id \ + --project-id \ + --field-id \ + --single-select-option-id + ``` + +10. Print the updated issue URL and new status. diff --git a/.cursor/commands/cuga-new-feature.md b/.cursor/commands/cuga-new-feature.md index c05534da..a7eb10d6 100644 --- a/.cursor/commands/cuga-new-feature.md +++ b/.cursor/commands/cuga-new-feature.md @@ -18,5 +18,15 @@ | `[Epic]` | Large body of work grouping multiple issues | | `[Question]` | Clarification needed, not a task | -5. Write the body using the same sections as `.github/ISSUE_TEMPLATE/feature_request.yml`: What you want and why, How it could work, Links or extra context (if any). Incorporate the user's message and any selected editor/context so the issue is concrete and complete. -6. Do not add "Made with Cursor" or similar promotional footers to the issue. +5. **Epic association (mandatory for every non-`[Epic]` issue):** + - Ask the user which Epic this issue belongs to if they have not already specified one. + - Run `gh issue list --label "type: epic" --state open` to show available open Epics. + - If no suitable Epic exists, offer to create one first using the `[Epic]` prefix. + - Add the following line verbatim at the **top** of the issue body (before any other content): + ``` + Epic: # + ``` + - Do **not** skip this step. A missing `Epic:` line will cause the GitHub Actions check to fail. + +6. Write the body using the same sections as `.github/ISSUE_TEMPLATE/feature_request.yml`: What you want and why, How it could work, Links or extra context (if any). Incorporate the user's message and any selected editor/context so the issue is concrete and complete. +7. Do not add "Made with Cursor" or similar promotional footers to the issue. diff --git a/.github/workflows/enforce-issue-epic.yml b/.github/workflows/enforce-issue-epic.yml new file mode 100644 index 00000000..143cfecc --- /dev/null +++ b/.github/workflows/enforce-issue-epic.yml @@ -0,0 +1,208 @@ +name: Enforce Epic Association + +on: + issues: + types: [opened, edited] + projects_v2_item: + types: [edited] + +# ── Rule 1: every non-Bug/non-Epic issue must have `Epic: #` in the body ── +# Triggered on issues.opened / issues.edited. +# +# ── Rule 2: an issue's project Status cannot be set to Todo or In Progress +# unless the referenced Epic already has Status = In Progress on the board ── +# Triggered on projects_v2_item.edited (Status field change). + +jobs: + check-epic-ref: + name: Epic reference required + if: github.event_name == 'issues' + runs-on: ubuntu-latest + permissions: + issues: write + + steps: + - uses: actions/github-script@v7 + with: + script: | + const issue = context.payload.issue; + const title = issue.title || ''; + const body = issue.body || ''; + + const isBug = /^\[Bug\]/i.test(title.trimStart()); + const isEpic = /^\[Epic\]/i.test(title.trimStart()); + if (isBug || isEpic) return; + + if (!/^Epic:\s*#\d+/im.test(body)) { + await github.rest.issues.createComment({ + ...context.repo, + issue_number: issue.number, + body: [ + '## ❌ Epic association required', + '', + 'Every non-Bug issue must reference its parent Epic. Add this line to the issue body:', + '', + '```', + 'Epic: #', + '```', + '', + 'If no Epic exists yet, create one first (use the `[Epic]` title prefix).', + ].join('\n'), + }); + core.setFailed('Issue is missing `Epic: #` in the body.'); + } + + check-epic-status: + name: Epic must be In Progress on the board + if: github.event_name == 'projects_v2_item' && github.event.action == 'edited' + runs-on: ubuntu-latest + permissions: + issues: write + repository-projects: write + + steps: + - uses: actions/github-script@v7 + with: + # Requires a PAT with `read:project` + `repo` stored as PROJECT_TOKEN. + # Falls back to GITHUB_TOKEN if PROJECT_TOKEN is not set (read:project + # scope must then be granted via a GitHub App or org-level token). + github-token: ${{ secrets.PROJECT_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { owner, repo } = context.repo; + const changes = context.payload.changes ?? {}; + + // Only act when the Status field changed. + if (!changes.field_value) return; + + const item = context.payload.projects_v2_item; + const itemNodeId = item.node_id; + const projectNodeId = item.project_node_id; + + if (item.content_type !== 'Issue') return; + + // ── 1. Fetch item details: issue content + current Status ── + const itemData = await github.graphql(` + query($itemId: ID!) { + node(id: $itemId) { + ... on ProjectV2Item { + content { + ... on Issue { number title body } + } + fieldValues(first: 30) { + nodes { + ... on ProjectV2ItemFieldSingleSelectValue { + name + field { + ... on ProjectV2SingleSelectField { + id + name + } + } + } + } + } + } + } + } + `, { itemId: itemNodeId }); + + const node = itemData?.node; + if (!node?.content) return; + + const { number: issueNumber, title = '', body = '' } = node.content; + + const isBug = /^\[Bug\]/i.test(title.trimStart()); + const isEpic = /^\[Epic\]/i.test(title.trimStart()); + if (isBug || isEpic) return; + + const statusEntry = node.fieldValues.nodes.find( + fv => fv?.field?.name?.toLowerCase() === 'status' + ); + if (!statusEntry) return; + + const newStatus = (statusEntry.name || '').toLowerCase(); + const BLOCKED_STATUSES = ['todo', 'in progress']; + if (!BLOCKED_STATUSES.includes(newStatus)) return; + + // ── 2. Ensure the issue references an Epic ── + const epicMatch = body.match(/^Epic:\s*#(\d+)/im); + if (!epicMatch) { + await revert(statusEntry.field.id, projectNodeId, itemNodeId); + await github.rest.issues.createComment({ + owner, repo, issue_number: issueNumber, + body: [ + `## ❌ Cannot move to **${statusEntry.name}**`, + '', + 'This issue has no Epic association. Add `Epic: #` to the issue body first.', + ].join('\n'), + }); + core.setFailed('Issue has no Epic association.'); + return; + } + + const epicNumber = parseInt(epicMatch[1], 10); + + // ── 3. Fetch Epic's Status from the project board ── + const epicData = await github.graphql(` + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $number) { + title + projectItems(first: 10) { + nodes { + fieldValues(first: 30) { + nodes { + ... on ProjectV2ItemFieldSingleSelectValue { + name + field { + ... on ProjectV2SingleSelectField { name } + } + } + } + } + } + } + } + } + } + `, { owner, repo, number: epicNumber }); + + const epicIssue = epicData?.repository?.issue; + if (!epicIssue) { + core.setFailed(`Could not fetch Epic #${epicNumber}.`); + return; + } + + const epicStatus = epicIssue.projectItems.nodes + .flatMap(pi => pi.fieldValues.nodes) + .find(fv => fv?.field?.name?.toLowerCase() === 'status') + ?.name ?? ''; + + if (epicStatus.toLowerCase() !== 'in progress') { + await revert(statusEntry.field.id, projectNodeId, itemNodeId); + await github.rest.issues.createComment({ + owner, repo, issue_number: issueNumber, + body: [ + `## ❌ Epic #${epicNumber} is not In Progress`, + '', + `Cannot move this issue to **${statusEntry.name}** because the associated Epic (#${epicNumber} — *${epicIssue.title}*) has Status **${epicStatus || 'No Status'}** on the project board.`, + '', + 'Move the Epic to **In Progress** on the board first.', + ].join('\n'), + }); + core.setFailed(`Epic #${epicNumber} must be In Progress first.`); + } + + async function revert(fieldId, projectId, itemId) { + try { + await github.graphql(` + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!) { + clearProjectV2ItemFieldValue(input: { + projectId: $projectId, itemId: $itemId, fieldId: $fieldId + }) { projectV2Item { id } } + } + `, { projectId, itemId, fieldId }); + } catch (e) { + console.warn(`Could not revert Status field: ${e.message}`); + } + } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4466ca64..39cd019b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -213,11 +213,49 @@ If you are working in an AI-assisted IDE or using an AI agent (Cursor, Claude, B | `cuga-commit` | Stages and commits changes using Conventional Commits with scoped messages and bullet-point descriptions | | `cuga-create-pr` | Validates local state, picks the right PR template, fills it out from current changes, and opens the PR via `gh` | | `cuga-report-bug` | Creates a GitHub issue using the `bug_report.yml` template with context from the current code | -| `cuga-new-feature` | Creates a GitHub issue using the `feature_request.yml` template | +| `cuga-new-feature` | Creates a GitHub issue using the `feature_request.yml` template — **requires Epic association** (see below) | +| `cuga-move-issue` | Moves an issue to `status: todo` or `status: in-progress`, enforcing Epic-in-progress rules | | `cuga-ruff-check` | Runs `uv run ruff check --fix` and `uv run ruff format` on the project | These commands follow all repo conventions (Conventional Commits, `gh` CLI, no promotional footers). To invoke them, use the slash-command syntax of your tool (e.g. `/cuga-commit` in Cursor). +## Issue Workflow Rules + +The following rules are enforced both by the `enforce-issue-epic` GitHub Actions workflow and by the agent commands above. + +### Rule 1 — Every non-Bug issue must reference an Epic + +Every issue whose title does **not** start with `[Bug]` or `[Epic]` must include the following line somewhere in the issue body: + +``` +Epic: # +``` + +This is checked automatically when an issue is opened or edited. The CI job will post a comment and fail if the line is missing. + +To list open Epics: + +```bash +gh issue list --label "type: epic" --state open +``` + +### Rule 2 — An issue cannot move to Todo or In Progress unless its Epic is In Progress on the board + +Status is tracked via the **GitHub Projects Status field**, not labels. The `enforce-issue-epic` workflow listens for `projects_v2_item.edited` events: whenever an issue's Status changes to **Todo** or **In Progress**, it checks whether the referenced Epic already has Status **In Progress** on the same board. If not, it clears the Status back and posts a comment. + +To check an Epic's current board status: + +```bash +gh issue view --json projectItems \ + --jq '[.projectItems.nodes[].fieldValues.nodes[] + | select(.field.name == "Status") | .name] | first' +``` + +> **Token requirement:** the `check-epic-status` job uses GraphQL to read project data and +> revert the Status field. Add a PAT with `read:project` + `repo` scopes as a repository +> secret named `PROJECT_TOKEN`. Without it the job falls back to `GITHUB_TOKEN`, which may +> lack project read access depending on your organisation settings. + ## IDE Setup Quick Links First make sure that your IDE environment is properly configured