From 15c8008cff67994f9ab68074f82fde8d23be6a8b Mon Sep 17 00:00:00 2001 From: Ahmet Taspinar Date: Fri, 2 Oct 2026 20:28:18 +0200 Subject: [PATCH] Create a feature Issue from a roadmap block create-feature-issue.sh takes the block under the roadmap heading that starts with the ID, ignoring headings in fenced code, and creates the Issue with that heading as title after approval. It refuses missing, duplicated, and empty blocks and a second Issue for the same feature, and warns about referenced features whose Issue is still open. The title-and-file form remains. Closes #41 Co-Authored-By: Claude Opus 5.5 --- README.md | 10 +- docs/agentic-workflow.md | 4 +- docs/development.md | 29 ++++ scripts/create-feature-issue.sh | 234 ++++++++++++++++++++++++----- scripts/verify.conf | 1 + tests/create-feature-issue-test.sh | 188 +++++++++++++++++++++++ 6 files changed, 424 insertions(+), 42 deletions(-) mode change 100644 => 100755 scripts/create-feature-issue.sh create mode 100755 tests/create-feature-issue-test.sh diff --git a/README.md b/README.md index c723fd0..2aa5140 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,14 @@ A lightweight, model-agnostic repository template for agentic software engineeri necessary ADRs, and roadmap. The script does not commit or push. 4. Review the planning worktree, run `./scripts/verify.sh`, commit and push the planning branch, then merge it through a PR before feature development. -5. Create a GitHub Issue only for the next actionable roadmap feature. For - non-trivial work, create `.agents/plans/-.md` using +5. Create a GitHub Issue only for the next actionable roadmap feature, from its + block in `docs/roadmap.md`: + + ```bash + ./scripts/create-feature-issue.sh F01 + ``` + + For non-trivial work, create `.agents/plans/-.md` using `.agents/prompts/planner.md`. 6. Start the feature and implementation agent: diff --git a/docs/agentic-workflow.md b/docs/agentic-workflow.md index 6a9aa55..d2a41fd 100644 --- a/docs/agentic-workflow.md +++ b/docs/agentic-workflow.md @@ -111,7 +111,9 @@ GitHub Issues: #2 F02 — optional next ``` -Before starting F03, review the roadmap again and only then create its Issue. +Before starting F03, review the roadmap again and only then create its Issue +with `./scripts/create-feature-issue.sh F03`, which copies the F03 block from +the roadmap into the Issue after your approval. GitHub Issues are the actionable source of truth once created. The roadmap remains the higher-level planning document. diff --git a/docs/development.md b/docs/development.md index a74a206..2ed5e29 100644 --- a/docs/development.md +++ b/docs/development.md @@ -166,6 +166,35 @@ Open and merge a planning PR before creating Issues for actionable roadmap features. The script never implements features, creates Issues, commits, pushes, opens or merges a PR, or deploys. +## Creating a feature Issue from the roadmap + +When a roadmap feature becomes active, create its Issue from its block in +`docs/roadmap.md`: + +```bash +./scripts/create-feature-issue.sh F03 +``` + +The script finds the heading that starts with the feature ID, such as +`### F03 — Sharing`, and uses everything up to the next heading of the same or +a higher level as the Issue body, unchanged. The heading becomes the title, so +the feature ID stays visible and review triage can use it for follow-up +provenance. The script shows the proposed Issue and creates it only after your +approval. + +It refuses a feature ID that is missing, used for more than one heading, or +has an empty block, and it does not create a second Issue for a feature that +already has one, open or closed. When the block refers to another feature +whose Issue is still open, it warns before asking. + +Create Issues one feature at a time, only for work that is about to start. An +Issue that is not a roadmap feature can still be created from a title and a +body file: + +```bash +./scripts/create-feature-issue.sh "Issue title" path/to/body.md [label] +``` + ## Canonical feature workflow Start with an actionable GitHub Issue. Create a detailed implementation plan diff --git a/scripts/create-feature-issue.sh b/scripts/create-feature-issue.sh old mode 100644 new mode 100755 index 6a9b597..1b70ce9 --- a/scripts/create-feature-issue.sh +++ b/scripts/create-feature-issue.sh @@ -2,57 +2,213 @@ set -euo pipefail -TITLE="${1:-}" -BODY_FILE="${2:-}" -LABEL="${3:-}" +# Creates the GitHub Issue for one roadmap feature from its block in +# docs/roadmap.md, or an Issue from an explicit title and body file. usage() { echo "Usage:" + echo " $0 " echo " $0 \"Issue title\" path/to/body.md [label]" + echo + echo "With a feature ID such as F03, the Issue is created from the block under the" + echo "roadmap heading that starts with that ID, after your approval." exit 1 } -if ! command -v gh >/dev/null 2>&1; then - echo "Error: GitHub CLI 'gh' is not installed." +fail() { + echo "Error: $*" >&2 exit 1 -fi +} -if ! gh auth status >/dev/null 2>&1; then - echo "Error: GitHub CLI is not authenticated." - echo "Run: gh auth login" - exit 1 -fi +require_gh() { + command -v gh >/dev/null 2>&1 || fail "GitHub CLI 'gh' is not installed." + gh auth status >/dev/null 2>&1 || fail "GitHub CLI is not authenticated. Run: gh auth login" +} -if [[ -z "$TITLE" || -z "$BODY_FILE" ]]; then - usage -fi +# Explicit title and body file. +create_from_file() { + local title="$1" + local body_file="$2" + local label="${3:-}" + local args=(issue create --title "$title" --body-file "$body_file") + local issue_url -if [[ ! -f "$BODY_FILE" ]]; then - echo "Error: body file not found: $BODY_FILE" - exit 1 -fi - -ARGS=( - issue create - --title "$TITLE" - --body-file "$BODY_FILE" -) - -if [[ -n "$LABEL" ]]; then - if gh label list --limit 100 --json name --jq '.[].name' | grep -Fxq "$LABEL"; then - ARGS+=(--label "$LABEL") - else - echo "Warning: label '$LABEL' does not exist." - echo "Creating issue without label." + [[ -f "$body_file" ]] || fail "body file not found: $body_file" + require_gh + + if [[ -n "$label" ]]; then + if gh label list --limit 100 --json name --jq '.[].name' | grep -Fxq "$label"; then + args+=(--label "$label") + else + echo "Warning: label '$label' does not exist." + echo "Creating issue without label." + fi fi -fi -echo "Creating GitHub issue:" -echo " Title: $TITLE" -echo " Body: $BODY_FILE" + echo "Creating GitHub issue:" + echo " Title: $title" + echo " Body: $body_file" + + issue_url="$(gh "${args[@]}")" + + echo + echo "Issue created:" + echo "$issue_url" +} + +# Prints the Issues whose title names the feature ID as "\t\t". +issues_for_feature() { + local feature="$1" + + gh issue list --state all --limit 200 --search "$feature in:title" --json number,state,title </dev/null | + jq -r --arg feature "$feature" ' + .[] | select(.title | test("(^|[^A-Za-z0-9])" + $feature + "([^A-Za-z0-9]|$)")) + | "\(.number)\t\(.state)\t\(.title)"' +} + +# roadmap_section <roadmap> <feature-id> <count|title|block> +# Reads Markdown headings, ignoring lines inside fenced code blocks. Prints the +# number of headings that start with the feature ID, the first such heading +# without its markers, or the lines below it up to the next heading of the +# same or a higher level. +roadmap_section() { + awk -v id="$2" -v mode="$3" ' + function starts_with_id(text) { + return index(text, id) == 1 && substr(text, length(id) + 1, 1) !~ /[A-Za-z0-9]/ + } + /^[[:space:]]*(```|~~~)/ { + fenced = !fenced + if (inside) { print } + next + } + !fenced && /^#+[[:space:]]/ { + level = match($0, /[^#]/) - 1 + text = $0 + sub(/^#+[[:space:]]+/, "", text) + sub(/[[:space:]]+$/, "", text) + if (inside && level <= block_level) { + exit + } + if (starts_with_id(text)) { + count++ + if (mode == "title" && count == 1) { + print text + exit + } + if (mode == "block" && !inside) { + inside = 1 + block_level = level + next + } + } + } + inside { print } + END { + if (mode == "count") { + print count + 0 + } + } + ' "$1" +} -ISSUE_URL="$(gh "${ARGS[@]}")" +create_from_roadmap() { + local feature="$1" + local root + local roadmap + local heading_count + local title + local block + local existing + local reference + local open_issue + local approval="" + local issue_url + + root="$(git rev-parse --show-toplevel)" + roadmap="$root/docs/roadmap.md" + [[ -f "$roadmap" ]] || fail "roadmap not found: docs/roadmap.md" + command -v jq >/dev/null 2>&1 || fail "jq is required." + + # The heading ID is the only machine-readable part of the roadmap. + heading_count="$(roadmap_section "$roadmap" "$feature" count)" + [[ "$heading_count" -ne 0 ]] || fail "no roadmap heading starts with $feature in docs/roadmap.md." + [[ "$heading_count" -eq 1 ]] || fail "$heading_count roadmap headings start with $feature; feature IDs must be unique." + + title="$(roadmap_section "$roadmap" "$feature" title)" + block="$(roadmap_section "$roadmap" "$feature" block)" + [[ "$block" =~ [^[:space:]] ]] || fail "the roadmap block of $feature is empty." + + require_gh + + existing="$(issues_for_feature "$feature")" || + fail "could not list the existing Issues for $feature; no Issue was created." + if [[ -n "$existing" ]]; then + echo "Error: $feature already has an Issue; no new Issue was created:" >&2 + printf '%s\n' "$existing" | awk -F '\t' '{ printf " #%s (%s) %s\n", $1, tolower($2), $3 }' >&2 + exit 1 + fi + + # Global, so the exit trap can still remove it after this function returns. + tmp_work="$(mktemp -d "${TMPDIR:-/tmp}/create-feature-issue.XXXXXX")" + trap 'rm -rf "$tmp_work"' EXIT + { + printf '%s\n' "$block" | awk 'NF { found = 1 } found' | awk '{ lines[NR] = $0 } END { last = NR; while (last > 0 && lines[last] !~ /[^[:space:]]/) last--; for (i = 1; i <= last; i++) print lines[i] }' + echo + echo "---" + echo + echo "Source: \`docs/roadmap.md\`, $feature." + } >"$tmp_work/body.md" + + echo "Proposed Issue for roadmap feature $feature:" + echo + echo "Title: $title" + echo + cat "$tmp_work/body.md" + echo + + # Features this block refers to that are not done yet. + while IFS= read -r reference; do + [[ "$reference" != "$feature" ]] || continue + if ! reference_issues="$(issues_for_feature "$reference")"; then + echo "Warning: could not check whether $reference, which $feature refers to, is done." + open_issue=1 + continue + fi + while IFS=$'\t' read -r number state issue_title; do + [[ -n "$number" ]] || continue + if [[ "$state" == "OPEN" ]]; then + echo "Warning: $feature refers to $reference, whose Issue #$number is still open: $issue_title" + open_issue=1 + fi + done <<<"$reference_issues" + done < <(printf '%s\n' "$block" | grep -oE '(^|[^A-Za-z0-9])F[0-9]+' | grep -oE 'F[0-9]+' | sort -u) + [[ -z "${open_issue:-}" ]] || echo + + printf "Create this Issue? [y/N] " + read -r approval || true + case "$approval" in + y | Y | yes | YES) ;; + *) + echo "Declined; no Issue was created." + exit 0 + ;; + esac + + issue_url="$(gh issue create --title "$title" --body-file "$tmp_work/body.md" </dev/null | tail -n 1)" + echo + echo "Issue created for $feature:" + echo "$issue_url" +} -echo -echo "Issue created:" -echo "$ISSUE_URL" +case "$#" in + 1) + [[ "$1" =~ ^F[0-9]+$ ]] || fail "feature ID must look like F03: $1" + create_from_roadmap "$1" + ;; + 2 | 3) + create_from_file "$@" + ;; + *) + usage + ;; +esac diff --git a/scripts/verify.conf b/scripts/verify.conf index 72c810f..ba8a93b 100644 --- a/scripts/verify.conf +++ b/scripts/verify.conf @@ -28,6 +28,7 @@ verify: ./tests/verify-test.sh doctor: ./tests/doctor-test.sh agent: ./tests/agent-test.sh fingerprint: ./tests/fingerprint-test.sh +create-feature-issue: ./tests/create-feature-issue-test.sh review-feature: ./tests/review-feature-test.sh triage-review: ./tests/triage-review-test.sh apply-triage: ./tests/apply-triage-test.sh diff --git a/tests/create-feature-issue-test.sh b/tests/create-feature-issue-test.sh new file mode 100755 index 0000000..bf417a7 --- /dev/null +++ b/tests/create-feature-issue-test.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +tmp="$(mktemp -d "${TMPDIR:-/tmp}/create-feature-issue-test.XXXXXX")" + +cleanup() { + rm -rf "$tmp" +} +trap cleanup EXIT + +fail() { + echo "create-feature-issue test failed: $*" >&2 + exit 1 +} + +mkdir -p "$tmp/bin" +ln -sf "$(command -v jq)" "$tmp/bin/jq" + +# The fake GitHub CLI lists the Issues in MOCK_ISSUES and logs created ones. +cat >"$tmp/bin/gh" <<'GH' +#!/usr/bin/env bash +set -euo pipefail +case "${1:-} ${2:-}" in + "auth status") exit 0 ;; + "issue list") + if [[ -n "${MOCK_FAIL_SEARCH:-}" && "$*" == *"$MOCK_FAIL_SEARCH in:title"* ]]; then + echo "simulated search failure" >&2 + exit 1 + fi + printf '%s\n' "${MOCK_ISSUES:-[]}" + exit 0 + ;; + "issue create") + shift 2 + while [[ $# -gt 0 ]]; do + case "$1" in + --title) echo "TITLE: $2" >>"$MOCK_GH_LOG"; shift 2 ;; + --body-file) cat "$2" >>"$MOCK_GH_LOG"; shift 2 ;; + *) shift ;; + esac + done + echo "https://github.com/example/project/issues/50" + exit 0 + ;; +esac +echo "Unexpected gh invocation: $*" >&2 +exit 1 +GH +chmod +x "$tmp/bin/gh" + +repo="$tmp/repo" +mkdir -p "$repo/scripts" "$repo/docs" +cp "$source_root/scripts/create-feature-issue.sh" "$repo/scripts/" +git -C "$repo" init -q -b main + +write_roadmap() { + cat >"$repo/docs/roadmap.md" +} + +run_create() { + local answer="$1" + shift + + rm -f "$tmp/gh.log" + ( + cd "$repo" + printf '%s\n' "$answer" | + PATH="$tmp/bin:/usr/bin:/bin" MOCK_GH_LOG="$tmp/gh.log" ./scripts/create-feature-issue.sh "$@" + ) >"$tmp/out.log" 2>&1 +} + +expect_nothing_created() { + local description="$1" + shift + + if run_create y "$@"; then + cat "$tmp/out.log" >&2 + fail "expected failure: $description" + fi + [[ ! -e "$tmp/gh.log" ]] || fail "an Issue was created although: $description" +} + +write_roadmap <<'ROADMAP' +# Roadmap + +## Phase 1 + +### F01 — Account sign-in + +- Goal: users can sign in. +- Acceptance criteria: sign-in works. + +### F02 — Recipe list + +- Goal: users see their recipes. +- Dependencies: F01. +- Acceptance criteria: + - The list shows every recipe. + - An empty list shows a hint. + +#### Notes + +Pagination comes later. + +### F03 — Sharing + +- Goal: share a recipe. +- Example: + + ```bash + # Share a recipe + ./share recipe-1 + ``` + +- Acceptance criteria: a shared link opens the recipe. + +## Phase 2 + +### F10 — Empty feature + +### F11 — Duplicate + +- First. + +### F11 — Duplicate again + +- Second. +ROADMAP + +# After approval, the Issue is created from exactly the feature's block, with +# the feature ID first in its title. +run_create y F02 || { + cat "$tmp/out.log" >&2 + fail "creating the F02 Issue failed" +} +grep -Fqx "TITLE: F02 — Recipe list" "$tmp/gh.log" || fail "the title does not start with the feature ID" +grep -Fqx " - An empty list shows a hint." "$tmp/gh.log" || fail "the body lacks the block content" +grep -Fqx "Pagination comes later." "$tmp/gh.log" || fail "the body lacks a subsection of the block" +if grep -Fq "Sharing" "$tmp/gh.log" || grep -Fq "Account sign-in" "$tmp/gh.log"; then + fail "the body contains another feature's block" +fi +grep -Fq "docs/roadmap.md" "$tmp/gh.log" || fail "the body does not reference the roadmap" + +# Heading-like lines inside fenced code do not end a block. +run_create y F03 || { + cat "$tmp/out.log" >&2 + fail "creating the F03 Issue failed" +} +grep -Fqx -- "- Acceptance criteria: a shared link opens the recipe." "$tmp/gh.log" || + fail "a heading-like line in fenced code truncated the block" + +# A failing lookup is never ignored: for the feature itself it stops, and for +# a referenced feature it warns. +MOCK_FAIL_SEARCH=F02 expect_nothing_created "the existing Issues cannot be listed" F02 +MOCK_FAIL_SEARCH=F01 run_create n F02 || fail "previewing F02 with a failing dependency lookup failed" +grep -Fq "could not check whether F01" "$tmp/out.log" || fail "a failing dependency lookup was not reported" + +# Declining creates nothing. +run_create n F03 || fail "declining returned an error" +[[ ! -e "$tmp/gh.log" ]] || fail "an Issue was created after declining" + +# An existing Issue for the feature, open or closed, prevents a duplicate; +# an ID that merely starts the same does not. +MOCK_ISSUES='[{"number": 7, "state": "CLOSED", "title": "F03 — Sharing"}]' \ + expect_nothing_created "F03 already has an Issue" F03 +grep -Fq "#7" "$tmp/out.log" || fail "the existing Issue was not reported" +MOCK_ISSUES='[{"number": 8, "state": "OPEN", "title": "F030 — Something else"}]' run_create y F03 || + fail "an Issue for F030 blocked F03" + +# An open Issue of a referenced feature is reported. +MOCK_ISSUES='[{"number": 4, "state": "OPEN", "title": "F01 — Account sign-in"}]' run_create n F02 || + fail "previewing F02 with an open dependency failed" +grep -Fq "refers to F01, whose Issue #4 is still open" "$tmp/out.log" || fail "an open dependency was not reported" + +# Unknown, duplicated, empty, and malformed feature IDs are rejected. +expect_nothing_created "the feature is not in the roadmap" F99 +expect_nothing_created "the feature heading is duplicated" F11 +expect_nothing_created "the feature block is empty" F10 +expect_nothing_created "the feature ID is malformed" 3 + +# The explicit title-and-file form still works. +printf 'Body text.\n' >"$tmp/body.md" +run_create y "Maintenance task" "$tmp/body.md" || fail "the title-and-file form failed" +grep -Fqx "TITLE: Maintenance task" "$tmp/gh.log" || fail "the title-and-file form used the wrong title" + +echo "create-feature-issue tests passed"