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"