From 436c84d2df40884c4be08de31ab11fb820a29d2d Mon Sep 17 00:00:00 2001 From: Sami Marreed Date: Thu, 14 May 2026 14:56:12 +0300 Subject: [PATCH 1/2] feat(issues): enforce Epic association and in-progress rules - Add `enforce-issue-epic` GitHub Actions workflow that: - Blocks any non-Bug/non-Epic issue opened or edited without an `Epic: #` line - Blocks applying `status: todo` or `status: in-progress` to an issue whose referenced Epic does not carry `status: in-progress`; also removes the premature label and posts an explanatory comment - Update `cuga-new-feature` agent command (Cursor/Claude/Bob) to require Epic association before creating an issue - Add new `cuga-move-issue` agent command (Cursor/Claude/Bob) that validates both rules before applying a status label - Document Issue Workflow Rules and status label setup in CONTRIBUTING.md Signed-off-by: Sami Marreed --- .bob/commands/cuga-move-issue.md | 39 ++++++++ .bob/commands/cuga-new-feature.md | 14 ++- .claude/commands/cuga-move-issue.md | 39 ++++++++ .claude/commands/cuga-new-feature.md | 14 ++- .cursor/commands/cuga-move-issue.md | 39 ++++++++ .cursor/commands/cuga-new-feature.md | 14 ++- .github/workflows/enforce-issue-epic.yml | 110 +++++++++++++++++++++++ CONTRIBUTING.md | 47 +++++++++- 8 files changed, 309 insertions(+), 7 deletions(-) create mode 100644 .bob/commands/cuga-move-issue.md create mode 100644 .claude/commands/cuga-move-issue.md create mode 100644 .cursor/commands/cuga-move-issue.md create mode 100644 .github/workflows/enforce-issue-epic.yml diff --git a/.bob/commands/cuga-move-issue.md b/.bob/commands/cuga-move-issue.md new file mode 100644 index 00000000..ef2fa4fd --- /dev/null +++ b/.bob/commands/cuga-move-issue.md @@ -0,0 +1,39 @@ +# Move an issue to a new status + +Moves an issue to **Todo** (`status: todo`) or **In Progress** (`status: in-progress`) after +validating Epic association rules. + +## Steps + +1. Identify the issue number and the target status from the user's request. + - Accepted status values: `todo` → label `status: todo` | `in-progress` → label `status: in-progress`. + +2. Fetch the issue: + ```bash + gh issue view --json number,title,body,labels + ``` + +3. **Skip Epic checks** if the title starts with `[Bug]` or `[Epic]`. Jump directly to step 7. + +4. Parse `Epic: #` from the first occurrence of that pattern in the issue body (case-insensitive). + - If the line is missing, **abort** and tell the user: + > "This issue has no Epic association. Add `Epic: #` to the issue body before changing its status." + +5. Fetch the referenced Epic: + ```bash + gh issue view --json number,title,labels,state + ``` + +6. Verify the Epic has the label `status: in-progress`. + - If not, **abort** and tell the user: + > "Epic # is not in progress. Move the Epic to in-progress first: + > `gh issue edit --add-label 'status: in-progress'`" + +7. Remove any conflicting status labels from the issue, then apply the new one: + ```bash + gh issue edit --remove-label "status: todo" --remove-label "status: in-progress" + gh issue edit --add-label "" + ``` + (Ignore errors from removing a label that is not present.) + +8. Print the updated issue URL. 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..ef2fa4fd --- /dev/null +++ b/.claude/commands/cuga-move-issue.md @@ -0,0 +1,39 @@ +# Move an issue to a new status + +Moves an issue to **Todo** (`status: todo`) or **In Progress** (`status: in-progress`) after +validating Epic association rules. + +## Steps + +1. Identify the issue number and the target status from the user's request. + - Accepted status values: `todo` → label `status: todo` | `in-progress` → label `status: in-progress`. + +2. Fetch the issue: + ```bash + gh issue view --json number,title,body,labels + ``` + +3. **Skip Epic checks** if the title starts with `[Bug]` or `[Epic]`. Jump directly to step 7. + +4. Parse `Epic: #` from the first occurrence of that pattern in the issue body (case-insensitive). + - If the line is missing, **abort** and tell the user: + > "This issue has no Epic association. Add `Epic: #` to the issue body before changing its status." + +5. Fetch the referenced Epic: + ```bash + gh issue view --json number,title,labels,state + ``` + +6. Verify the Epic has the label `status: in-progress`. + - If not, **abort** and tell the user: + > "Epic # is not in progress. Move the Epic to in-progress first: + > `gh issue edit --add-label 'status: in-progress'`" + +7. Remove any conflicting status labels from the issue, then apply the new one: + ```bash + gh issue edit --remove-label "status: todo" --remove-label "status: in-progress" + gh issue edit --add-label "" + ``` + (Ignore errors from removing a label that is not present.) + +8. Print the updated issue URL. 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..ef2fa4fd --- /dev/null +++ b/.cursor/commands/cuga-move-issue.md @@ -0,0 +1,39 @@ +# Move an issue to a new status + +Moves an issue to **Todo** (`status: todo`) or **In Progress** (`status: in-progress`) after +validating Epic association rules. + +## Steps + +1. Identify the issue number and the target status from the user's request. + - Accepted status values: `todo` → label `status: todo` | `in-progress` → label `status: in-progress`. + +2. Fetch the issue: + ```bash + gh issue view --json number,title,body,labels + ``` + +3. **Skip Epic checks** if the title starts with `[Bug]` or `[Epic]`. Jump directly to step 7. + +4. Parse `Epic: #` from the first occurrence of that pattern in the issue body (case-insensitive). + - If the line is missing, **abort** and tell the user: + > "This issue has no Epic association. Add `Epic: #` to the issue body before changing its status." + +5. Fetch the referenced Epic: + ```bash + gh issue view --json number,title,labels,state + ``` + +6. Verify the Epic has the label `status: in-progress`. + - If not, **abort** and tell the user: + > "Epic # is not in progress. Move the Epic to in-progress first: + > `gh issue edit --add-label 'status: in-progress'`" + +7. Remove any conflicting status labels from the issue, then apply the new one: + ```bash + gh issue edit --remove-label "status: todo" --remove-label "status: in-progress" + gh issue edit --add-label "" + ``` + (Ignore errors from removing a label that is not present.) + +8. Print the updated issue URL. 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..843ddad4 --- /dev/null +++ b/.github/workflows/enforce-issue-epic.yml @@ -0,0 +1,110 @@ +name: Enforce Epic Association + +on: + issues: + types: [opened, edited, labeled] + +jobs: + enforce-epic: + name: Validate Epic rules + runs-on: ubuntu-latest + permissions: + issues: write + + steps: + - name: Check Epic association and in-progress status + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const issue = context.payload.issue; + const title = issue.title || ''; + const body = issue.body || ''; + const labels = issue.labels.map(l => l.name); + const action = context.payload.action; + const addedLabel = context.payload.label?.name ?? ''; + + const isBug = /^\[Bug\]/i.test(title.trimStart()); + const isEpic = /^\[Epic\]/i.test(title.trimStart()); + + // Epics and Bugs are exempt from the "must reference an Epic" rule. + if (isBug || isEpic) { + console.log(`Skipping epic checks for issue type (isBug=${isBug}, isEpic=${isEpic})`); + return; + } + + // ── Rule 1: every non-Bug, non-Epic issue must reference a parent Epic ── + // Triggered on open or edit so stale issues are caught when edited. + if (action === 'opened' || action === 'edited') { + const epicRef = body.match(/^Epic:\s*#(\d+)/im); + if (!epicRef) { + await github.rest.issues.createComment({ + owner, repo, + issue_number: issue.number, + body: [ + '## ❌ Epic association required', + '', + 'Every non-Bug issue must reference its parent Epic. Add this line anywhere in the issue body:', + '', + '```', + 'Epic: #', + '```', + '', + 'If no Epic exists yet, create one first (use the `[Epic]` issue type).', + ].join('\n'), + }); + core.setFailed('Issue is missing `Epic: #` in the body.'); + return; + } + } + + // ── Rule 2: Epic must be in-progress before an issue can be moved to Todo / In-Progress ── + const STATUS_LABELS = ['status: todo', 'status: in-progress']; + if (action === 'labeled' && STATUS_LABELS.includes(addedLabel)) { + const epicRef = body.match(/^Epic:\s*#(\d+)/im); + if (!epicRef) { + // Rule 1 should have caught this earlier, but guard here too. + core.setFailed('Cannot apply status label: issue has no `Epic: #` reference.'); + return; + } + + const epicNumber = parseInt(epicRef[1], 10); + let epicIssue; + try { + epicIssue = await github.rest.issues.get({ owner, repo, issue_number: epicNumber }); + } catch (err) { + core.setFailed(`Could not fetch Epic #${epicNumber}: ${err.message}`); + return; + } + + const epicLabels = epicIssue.data.labels.map(l => l.name); + if (!epicLabels.includes('status: in-progress')) { + await github.rest.issues.createComment({ + owner, repo, + issue_number: issue.number, + body: [ + `## ❌ Epic #${epicNumber} is not in progress`, + '', + `The associated Epic (#${epicNumber}) must be labeled \`status: in-progress\` before this issue can move to **${addedLabel}**.`, + '', + 'Mark the Epic as in-progress first:', + '```bash', + `gh issue edit ${epicNumber} --add-label "status: in-progress"`, + '```', + ].join('\n'), + }); + + // Remove the prematurely applied label so the issue stays in its prior state. + try { + await github.rest.issues.removeLabel({ + owner, repo, + issue_number: issue.number, + name: addedLabel, + }); + } catch (_) { + // Label may already be gone; ignore. + } + + core.setFailed(`Epic #${epicNumber} must have label "status: in-progress" first.`); + } + } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4466ca64..85d1b5c6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -213,11 +213,56 @@ 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 + +Before applying the `status: todo` or `status: in-progress` label to an issue, the referenced Epic must already carry the `status: in-progress` label. The `enforce-issue-epic` workflow will remove the label and post a comment if this requirement is not met. + +To mark an Epic as in-progress: + +```bash +gh issue edit --add-label "status: in-progress" +``` + +### Status labels + +| Label | Meaning | +|---|---| +| `status: todo` | Issue is ready to be worked on | +| `status: in-progress` | Issue (or Epic) is actively being worked on | + +These labels must exist in the repository. Create them once if they are missing: + +```bash +gh label create "status: todo" --color "#e2e8f0" --description "Ready to be worked on" +gh label create "status: in-progress" --color "#3b82f6" --description "Actively being worked on" +``` + ## IDE Setup Quick Links First make sure that your IDE environment is properly configured From 66f26cca27db9b82fd95d219c2ab722024a0c825 Mon Sep 17 00:00:00 2001 From: Sami Marreed Date: Thu, 14 May 2026 15:48:09 +0300 Subject: [PATCH 2/2] fix(issues): use project board Status field instead of labels for Rule 2 Switch the Epic in-progress check from label-based to GitHub Projects Status field. The workflow now triggers on `projects_v2_item.edited`, reads the issue's and Epic's Status via GraphQL, and clears the Status field (reverting the move) if the Epic is not In Progress on the board. Update cuga-move-issue agent command to query project item Status via `gh issue view --json projectItems` and use `gh project item-edit` to apply the new status after validation. Requires a `PROJECT_TOKEN` secret (PAT with `read:project` + `repo` scopes). Signed-off-by: Sami Marreed --- .bob/commands/cuga-move-issue.md | 56 +++-- .claude/commands/cuga-move-issue.md | 56 +++-- .cursor/commands/cuga-move-issue.md | 56 +++-- .github/workflows/enforce-issue-epic.yml | 260 ++++++++++++++++------- CONTRIBUTING.md | 27 +-- 5 files changed, 294 insertions(+), 161 deletions(-) diff --git a/.bob/commands/cuga-move-issue.md b/.bob/commands/cuga-move-issue.md index ef2fa4fd..fa83a2b5 100644 --- a/.bob/commands/cuga-move-issue.md +++ b/.bob/commands/cuga-move-issue.md @@ -1,39 +1,53 @@ -# Move an issue to a new status +# Move an issue to a new status on the project board -Moves an issue to **Todo** (`status: todo`) or **In Progress** (`status: in-progress`) after -validating Epic association rules. +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 the target status from the user's request. - - Accepted status values: `todo` → label `status: todo` | `in-progress` → label `status: in-progress`. +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,labels + gh issue view --json number,title,body,projectItems ``` -3. **Skip Epic checks** if the title starts with `[Bug]` or `[Epic]`. Jump directly to step 7. +3. **Skip Epic checks** if the title starts with `[Bug]` or `[Epic]`. Jump to step 7. -4. Parse `Epic: #` from the first occurrence of that pattern in the issue body (case-insensitive). - - If the line is missing, **abort** and tell the user: - > "This issue has no Epic association. Add `Epic: #` to the issue body before changing its status." +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 referenced Epic: +5. Fetch the Epic's project board Status: ```bash - gh issue view --json number,title,labels,state + gh issue view --json projectItems \ + --jq '[.projectItems.nodes[].fieldValues.nodes[] + | select(.field.name == "Status") | .name] | first' ``` -6. Verify the Epic has the label `status: in-progress`. - - If not, **abort** and tell the user: - > "Epic # is not in progress. Move the Epic to in-progress first: - > `gh issue edit --add-label 'status: in-progress'`" +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. Remove any conflicting status labels from the issue, then apply the new one: +7. Find the project and item IDs for the issue: ```bash - gh issue edit --remove-label "status: todo" --remove-label "status: in-progress" - gh issue edit --add-label "" + gh issue view --json projectItems \ + --jq '.projectItems.nodes[] | {projectId: .project.id, itemId: .id}' ``` - (Ignore errors from removing a label that is not present.) -8. Print the updated issue URL. +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-move-issue.md b/.claude/commands/cuga-move-issue.md index ef2fa4fd..fa83a2b5 100644 --- a/.claude/commands/cuga-move-issue.md +++ b/.claude/commands/cuga-move-issue.md @@ -1,39 +1,53 @@ -# Move an issue to a new status +# Move an issue to a new status on the project board -Moves an issue to **Todo** (`status: todo`) or **In Progress** (`status: in-progress`) after -validating Epic association rules. +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 the target status from the user's request. - - Accepted status values: `todo` → label `status: todo` | `in-progress` → label `status: in-progress`. +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,labels + gh issue view --json number,title,body,projectItems ``` -3. **Skip Epic checks** if the title starts with `[Bug]` or `[Epic]`. Jump directly to step 7. +3. **Skip Epic checks** if the title starts with `[Bug]` or `[Epic]`. Jump to step 7. -4. Parse `Epic: #` from the first occurrence of that pattern in the issue body (case-insensitive). - - If the line is missing, **abort** and tell the user: - > "This issue has no Epic association. Add `Epic: #` to the issue body before changing its status." +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 referenced Epic: +5. Fetch the Epic's project board Status: ```bash - gh issue view --json number,title,labels,state + gh issue view --json projectItems \ + --jq '[.projectItems.nodes[].fieldValues.nodes[] + | select(.field.name == "Status") | .name] | first' ``` -6. Verify the Epic has the label `status: in-progress`. - - If not, **abort** and tell the user: - > "Epic # is not in progress. Move the Epic to in-progress first: - > `gh issue edit --add-label 'status: in-progress'`" +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. Remove any conflicting status labels from the issue, then apply the new one: +7. Find the project and item IDs for the issue: ```bash - gh issue edit --remove-label "status: todo" --remove-label "status: in-progress" - gh issue edit --add-label "" + gh issue view --json projectItems \ + --jq '.projectItems.nodes[] | {projectId: .project.id, itemId: .id}' ``` - (Ignore errors from removing a label that is not present.) -8. Print the updated issue URL. +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-move-issue.md b/.cursor/commands/cuga-move-issue.md index ef2fa4fd..fa83a2b5 100644 --- a/.cursor/commands/cuga-move-issue.md +++ b/.cursor/commands/cuga-move-issue.md @@ -1,39 +1,53 @@ -# Move an issue to a new status +# Move an issue to a new status on the project board -Moves an issue to **Todo** (`status: todo`) or **In Progress** (`status: in-progress`) after -validating Epic association rules. +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 the target status from the user's request. - - Accepted status values: `todo` → label `status: todo` | `in-progress` → label `status: in-progress`. +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,labels + gh issue view --json number,title,body,projectItems ``` -3. **Skip Epic checks** if the title starts with `[Bug]` or `[Epic]`. Jump directly to step 7. +3. **Skip Epic checks** if the title starts with `[Bug]` or `[Epic]`. Jump to step 7. -4. Parse `Epic: #` from the first occurrence of that pattern in the issue body (case-insensitive). - - If the line is missing, **abort** and tell the user: - > "This issue has no Epic association. Add `Epic: #` to the issue body before changing its status." +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 referenced Epic: +5. Fetch the Epic's project board Status: ```bash - gh issue view --json number,title,labels,state + gh issue view --json projectItems \ + --jq '[.projectItems.nodes[].fieldValues.nodes[] + | select(.field.name == "Status") | .name] | first' ``` -6. Verify the Epic has the label `status: in-progress`. - - If not, **abort** and tell the user: - > "Epic # is not in progress. Move the Epic to in-progress first: - > `gh issue edit --add-label 'status: in-progress'`" +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. Remove any conflicting status labels from the issue, then apply the new one: +7. Find the project and item IDs for the issue: ```bash - gh issue edit --remove-label "status: todo" --remove-label "status: in-progress" - gh issue edit --add-label "" + gh issue view --json projectItems \ + --jq '.projectItems.nodes[] | {projectId: .project.id, itemId: .id}' ``` - (Ignore errors from removing a label that is not present.) -8. Print the updated issue URL. +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/.github/workflows/enforce-issue-epic.yml b/.github/workflows/enforce-issue-epic.yml index 843ddad4..143cfecc 100644 --- a/.github/workflows/enforce-issue-epic.yml +++ b/.github/workflows/enforce-issue-epic.yml @@ -2,109 +2,207 @@ name: Enforce Epic Association on: issues: - types: [opened, edited, labeled] + 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: - enforce-epic: - name: Validate Epic rules + check-epic-ref: + name: Epic reference required + if: github.event_name == 'issues' runs-on: ubuntu-latest permissions: issues: write steps: - - name: Check Epic association and in-progress status - uses: actions/github-script@v7 + - uses: actions/github-script@v7 with: script: | - const { owner, repo } = context.repo; const issue = context.payload.issue; - const title = issue.title || ''; - const body = issue.body || ''; - const labels = issue.labels.map(l => l.name); - const action = context.payload.action; - const addedLabel = context.payload.label?.name ?? ''; + 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; - // Epics and Bugs are exempt from the "must reference an Epic" rule. - if (isBug || isEpic) { - console.log(`Skipping epic checks for issue type (isBug=${isBug}, isEpic=${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.'); } - // ── Rule 1: every non-Bug, non-Epic issue must reference a parent Epic ── - // Triggered on open or edit so stale issues are caught when edited. - if (action === 'opened' || action === 'edited') { - const epicRef = body.match(/^Epic:\s*#(\d+)/im); - if (!epicRef) { - await github.rest.issues.createComment({ - owner, repo, - issue_number: issue.number, - body: [ - '## ❌ Epic association required', - '', - 'Every non-Bug issue must reference its parent Epic. Add this line anywhere in the issue body:', - '', - '```', - 'Epic: #', - '```', - '', - 'If no Epic exists yet, create one first (use the `[Epic]` issue type).', - ].join('\n'), - }); - core.setFailed('Issue is missing `Epic: #` in the body.'); - return; + 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; } - // ── Rule 2: Epic must be in-progress before an issue can be moved to Todo / In-Progress ── - const STATUS_LABELS = ['status: todo', 'status: in-progress']; - if (action === 'labeled' && STATUS_LABELS.includes(addedLabel)) { - const epicRef = body.match(/^Epic:\s*#(\d+)/im); - if (!epicRef) { - // Rule 1 should have caught this earlier, but guard here too. - core.setFailed('Cannot apply status label: issue has no `Epic: #` reference.'); - return; - } + const epicNumber = parseInt(epicMatch[1], 10); - const epicNumber = parseInt(epicRef[1], 10); - let epicIssue; - try { - epicIssue = await github.rest.issues.get({ owner, repo, issue_number: epicNumber }); - } catch (err) { - core.setFailed(`Could not fetch Epic #${epicNumber}: ${err.message}`); - return; + // ── 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 epicLabels = epicIssue.data.labels.map(l => l.name); - if (!epicLabels.includes('status: in-progress')) { - await github.rest.issues.createComment({ - owner, repo, - issue_number: issue.number, - body: [ - `## ❌ Epic #${epicNumber} is not in progress`, - '', - `The associated Epic (#${epicNumber}) must be labeled \`status: in-progress\` before this issue can move to **${addedLabel}**.`, - '', - 'Mark the Epic as in-progress first:', - '```bash', - `gh issue edit ${epicNumber} --add-label "status: in-progress"`, - '```', - ].join('\n'), - }); - - // Remove the prematurely applied label so the issue stays in its prior state. - try { - await github.rest.issues.removeLabel({ - owner, repo, - issue_number: issue.number, - name: addedLabel, - }); - } catch (_) { - // Label may already be gone; ignore. - } + const epicIssue = epicData?.repository?.issue; + if (!epicIssue) { + core.setFailed(`Could not fetch Epic #${epicNumber}.`); + return; + } - core.setFailed(`Epic #${epicNumber} must have label "status: in-progress" first.`); + 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 85d1b5c6..39cd019b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -239,29 +239,22 @@ To list open Epics: 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 +### Rule 2 — An issue cannot move to Todo or In Progress unless its Epic is In Progress on the board -Before applying the `status: todo` or `status: in-progress` label to an issue, the referenced Epic must already carry the `status: in-progress` label. The `enforce-issue-epic` workflow will remove the label and post a comment if this requirement is not met. +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 mark an Epic as in-progress: +To check an Epic's current board status: ```bash -gh issue edit --add-label "status: in-progress" +gh issue view --json projectItems \ + --jq '[.projectItems.nodes[].fieldValues.nodes[] + | select(.field.name == "Status") | .name] | first' ``` -### Status labels - -| Label | Meaning | -|---|---| -| `status: todo` | Issue is ready to be worked on | -| `status: in-progress` | Issue (or Epic) is actively being worked on | - -These labels must exist in the repository. Create them once if they are missing: - -```bash -gh label create "status: todo" --color "#e2e8f0" --description "Ready to be worked on" -gh label create "status: in-progress" --color "#3b82f6" --description "Actively being worked on" -``` +> **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