diff --git a/.claude/commands/file-jiras.md b/.claude/commands/file-jiras.md index 6f22eded5..15b384dc4 100644 --- a/.claude/commands/file-jiras.md +++ b/.claude/commands/file-jiras.md @@ -15,10 +15,55 @@ with FIXMEs and exits — the user must fill in their credentials before re-running. API tokens can be created at https://id.atlassian.com/manage-profile/security/api-tokens +## Spike doc shape the parser expects + +`file-jiras.sh` parses the spike doc's `## Proposed JIRAs` section into +Epic-grouped tickets: + +```markdown +## Proposed JIRAs + +### Epic: + + + +#### LCORE-???? ← H4, child of the Epic above + + +#### LCORE-???? + + +### Epic: ← optional second Epic +... +``` + +Each `### Epic: ` becomes a filed Epic; each `#### LCORE-????` +under it becomes a child of that Epic. Children carry a +`` metadata comment in their parsed +files; the script uses this to route each child to its parent Epic's +filed key at filing time. + +**Backward-compat**: spike docs without `### Epic:` boundaries (flat +`### LCORE-...` H3 stubs directly under `## Proposed JIRAs`) still +parse — they get a single auto-generated Epic derived from the spike +doc's parent directory name. + +**Already-filed keys**: if a heading reads `### LCORE-1569: ` +(or `#### LCORE-1569: ...`), the parser preserves the real key in the +output file. At filing time, the script sends a PUT (update) instead +of a POST (create), useful for re-syncing previously-filed tickets +with updated descriptions. + +**Incidental tickets** (under `## Proposed incidental JIRAs`) file +under the feature ticket directly, not under any Epic. + ## Process -1. Run `dev-tools/file-jiras.sh --spike-doc <path> --feature-ticket <key>` - with `echo "quit"` piped in, so it parses and exits without filing. +1. Run `dev-tools/file-jiras.sh --spike-doc <path> --feature-ticket <key> --parse-only` + to parse the spike doc into ticket files and exit. `--parse-only` skips + the interactive filing loop and the credentials check, so it works + even on machines without Jira credentials configured (CI, agent + inspection, pre-commit hooks). 2. Read every file in the output directory (default: `docs/design/<feature>/jiras/`). For each, verify: @@ -27,8 +72,24 @@ https://id.atlassian.com/manage-profile/security/api-tokens - File size is reasonable (a single JIRA should be under ~3KB; if any file is much larger, the parser likely grabbed too much). - The `<!-- type: ... -->` metadata is correct (Epic/Story/Task). + - For children: `<!-- parent_epic_file: <stub> -->` points at an + existing Epic file in the same directory. -3. Report any issues to the user. If all files look correct, tell the user +3. Watch the parser's stderr for `[LINT-WARNING]` lines (mixed shape, + empty Epics, duplicate titles). `[LINT-ERROR]` causes the parser + to exit non-zero — fix the spike doc and re-run. + +4. Report any issues to the user. If all files look correct, tell the user to run the script interactively — provide the full command including `cd` to the repository root: `cd <repo-path> && sh dev-tools/file-jiras.sh --spike-doc <path> --feature-ticket <key>` + +## Filing order + +The script files Epics first, then their children. With multi-Epic +spike docs, this means Epic A is filed → its children land under Epic +A → Epic B is filed → its children land under Epic B. Children whose +parent Epic hasn't been filed yet error out clearly; file the Epic +first and retry. + +Incidental tickets file last (under FEATURE_TICKET, no Epic parent). diff --git a/.claude/commands/spike.md b/.claude/commands/spike.md index c967cec38..d0487d81a 100644 --- a/.claude/commands/spike.md +++ b/.claude/commands/spike.md @@ -36,6 +36,19 @@ fetch the ticket content by running `sh dev-tools/fetch-jira.sh <number>`. The output includes child issues — decide which linked tickets to fetch for additional context. +Pass `--comments` (e.g., `sh dev-tools/fetch-jira.sh --comments 1234`) +when ticket comments may carry decisions or context not present in the +description (common for older feature tickets where the spike scope was +negotiated in comments). Off by default to keep output short. + +Pass `--linked-depth N` (e.g., +`sh dev-tools/fetch-jira.sh --linked-depth 1 1234`) to recurse N levels +into subtasks, linked issues, and parent-relation children. `N=1` is +typically what you want at spike kickoff: fetches the requested feature +ticket plus all its immediate Epics, subtasks, and blocking/blocked-by +issues in one call. Capped at 3. Cycle-safe (keys seen via multiple +paths are fetched once). Off by default (depth 0). + Otherwise, the user will provide context about the feature directly. ## Branch and working tree diff --git a/dev-tools/fetch-jira.sh b/dev-tools/fetch-jira.sh index b0f9d37a3..af1790246 100755 --- a/dev-tools/fetch-jira.sh +++ b/dev-tools/fetch-jira.sh @@ -17,17 +17,61 @@ set -euo pipefail # shellcheck disable=SC1091 . "$(dirname "$0")/jira-common.sh" -if [ $# -lt 1 ] || [ "$1" = "--help" ] || [ "$1" = "-h" ]; then - echo "Usage: fetch-jira.sh <ticket> [additional-tickets...]" +show_help() { + echo "Usage: fetch-jira.sh [--comments] [--linked-depth N] <ticket> [additional-tickets...]" echo "" echo "Fetches JIRA ticket content including description, status, and child issues." echo "Bare numbers default to LCORE- prefix." echo "" + echo "Options:" + echo " --comments Also fetch and print the ticket's comment thread." + echo " Comments often contain critical decisions ('we decided" + echo " in standup to defer X') that the description doesn't" + echo " capture. Off by default." + echo " --linked-depth N Recurse N levels deep into subtasks, linked issues," + echo " and parent-relation children. Default 0 (no recursion;" + echo " just lists related-ticket keys/summaries). N=1 fetches" + echo " the full content of immediate relations; N=2 fetches" + echo " their relations too. Capped at 3 to avoid runaway" + echo " fetches. Already-seen keys are skipped (cycle-safe)." + echo " --help Show this help" + echo "" echo "Examples:" - echo " fetch-jira.sh 1234 Fetch LCORE-1234" - echo " fetch-jira.sh LCORE-1234 Same" - echo " fetch-jira.sh 836 509 777 Fetch multiple tickets" - if [ $# -lt 1 ]; then exit 1; else exit 0; fi + echo " fetch-jira.sh 1234 Fetch LCORE-1234" + echo " fetch-jira.sh LCORE-1234 Same" + echo " fetch-jira.sh 836 509 777 Fetch multiple tickets" + echo " fetch-jira.sh --comments 1234 Fetch LCORE-1234 with comments" + echo " fetch-jira.sh --linked-depth 1 1311 Fetch LCORE-1311 + immediate relations" +} + +if [ $# -lt 1 ]; then + show_help; exit 1 +fi + +# Parse flags (must come before any positional ticket arg) +FETCH_COMMENTS=0 +LINKED_DEPTH=0 +while [ $# -gt 0 ]; do + case "$1" in + --comments) FETCH_COMMENTS=1; shift ;; + --linked-depth) + [ $# -ge 2 ] || { echo "Error: --linked-depth requires a value"; exit 1; } + LINKED_DEPTH="$2" + if ! echo "$LINKED_DEPTH" | grep -qE '^[0-9]+$'; then + echo "Error: --linked-depth must be a non-negative integer"; exit 1 + fi + if [ "$LINKED_DEPTH" -gt 3 ]; then + echo "Error: --linked-depth capped at 3 to avoid runaway fetches"; exit 1 + fi + shift 2 ;; + --help|-h) show_help; exit 0 ;; + --*) echo "Unknown flag: $1"; show_help; exit 1 ;; + *) break ;; # first positional → ticket key + esac +done + +if [ $# -lt 1 ]; then + echo "Error: no ticket specified"; show_help; exit 1 fi ensure_jira_credentials @@ -38,21 +82,42 @@ if echo "$TICKET" | grep -qE '^[0-9]+$'; then TICKET="LCORE-$TICKET" fi +# Tracks already-fetched keys across recursion (space-delimited, with +# leading and trailing spaces so substring matching works cleanly). +FETCHED_KEYS=" " + fetch_ticket() { local key="$1" local indent="${2:-}" + local depth="${3:-0}" + + # Cycle / dup protection + case "$FETCHED_KEYS" in + *" $key "*) return 0 ;; + esac + FETCHED_KEYS="$FETCHED_KEYS$key " local data data=$(curl -sS --connect-timeout 10 --max-time 30 \ -u "$JIRA_EMAIL:$JIRA_TOKEN" \ "$JIRA_INSTANCE/rest/api/3/issue/$key?fields=summary,status,issuetype,description,issuelinks,subtasks,parent" 2>/dev/null) + # Optional: fetch comments (only if --comments was passed). Empty + # JSON object signals "no comments fetched" to the Python printer. + local comments_data='{}' + if [ "$FETCH_COMMENTS" -eq 1 ]; then + comments_data=$(curl -sS --connect-timeout 10 --max-time 30 \ + -u "$JIRA_EMAIL:$JIRA_TOKEN" \ + "$JIRA_INSTANCE/rest/api/3/issue/$key/comment" 2>/dev/null) || comments_data='{}' + fi + if echo "$data" | python3 -c "import sys,json; json.load(sys.stdin)['key']" >/dev/null 2>&1; then python3 -c " import json, sys, textwrap data = json.loads(sys.argv[1]) indent = sys.argv[2] +comments_data = json.loads(sys.argv[3]) if len(sys.argv) > 3 else {} key = data['key'] fields = data['fields'] summary = fields['summary'] @@ -67,51 +132,54 @@ if parent_key: print(f'{indent}Parent: {parent_key}') print() + +# ADF (Atlassian Document Format) → markdown-ish text extractor. +# Hoisted to top-level so both description and comments can use it. +def extract_text(node, depth=0): + lines = [] + if isinstance(node, dict): + ntype = node.get('type', '') + if ntype == 'text': + text = node.get('text', '') + marks = node.get('marks', []) + for m in marks: + if m.get('type') == 'strong': + text = f'**{text}**' + elif m.get('type') == 'code': + text = f'\`{text}\`' + return [text] + if ntype == 'hardBreak': + return ['\n'] + if ntype == 'listItem': + child_text = [] + for c in node.get('content', []): + child_text.extend(extract_text(c, depth)) + return [' ' * depth + '- ' + ''.join(child_text).strip()] + if ntype in ('bulletList', 'orderedList'): + for c in node.get('content', []): + lines.extend(extract_text(c, depth + 1)) + return lines + if ntype == 'heading': + level = node.get('attrs', {}).get('level', 1) + child_text = [] + for c in node.get('content', []): + child_text.extend(extract_text(c, depth)) + return ['#' * level + ' ' + ''.join(child_text).strip()] + if ntype == 'codeBlock': + child_text = [] + for c in node.get('content', []): + child_text.extend(extract_text(c, depth)) + return ['\`\`\`\n' + ''.join(child_text) + '\n\`\`\`'] + for c in node.get('content', []): + lines.extend(extract_text(c, depth)) + if ntype == 'paragraph' and lines: + lines.append('') + return lines + + # Description desc = fields.get('description') if desc and isinstance(desc, dict): - # ADF format — extract text - def extract_text(node, depth=0): - lines = [] - if isinstance(node, dict): - ntype = node.get('type', '') - if ntype == 'text': - text = node.get('text', '') - marks = node.get('marks', []) - for m in marks: - if m.get('type') == 'strong': - text = f'**{text}**' - elif m.get('type') == 'code': - text = f'\`{text}\`' - return [text] - if ntype == 'hardBreak': - return ['\n'] - if ntype == 'listItem': - child_text = [] - for c in node.get('content', []): - child_text.extend(extract_text(c, depth)) - return [' ' * depth + '- ' + ''.join(child_text).strip()] - if ntype in ('bulletList', 'orderedList'): - for c in node.get('content', []): - lines.extend(extract_text(c, depth + 1)) - return lines - if ntype == 'heading': - level = node.get('attrs', {}).get('level', 1) - child_text = [] - for c in node.get('content', []): - child_text.extend(extract_text(c, depth)) - return ['#' * level + ' ' + ''.join(child_text).strip()] - if ntype == 'codeBlock': - child_text = [] - for c in node.get('content', []): - child_text.extend(extract_text(c, depth)) - return ['\`\`\`\n' + ''.join(child_text) + '\n\`\`\`'] - for c in node.get('content', []): - lines.extend(extract_text(c, depth)) - if ntype == 'paragraph' and lines: - lines.append('') - return lines - text_lines = extract_text(desc) desc_text = '\n'.join(text_lines).strip() if desc_text: @@ -149,21 +217,95 @@ if subtasks: sstatus = st['fields']['status']['name'] print(f'{indent} {skey} — {ssummary} [{sstatus}]') print() -" "$data" "$indent" + +# Comments (only when --comments was requested upstream; otherwise +# comments_data is the empty {} sentinel.) +comments = comments_data.get('comments', []) if isinstance(comments_data, dict) else [] +if comments: + print(f'{indent}Comments ({len(comments)}):') + for c in comments: + author = c.get('author', {}).get('displayName') or c.get('author', {}).get('emailAddress') or 'unknown' + created = c.get('created', '')[:10] # YYYY-MM-DD + body = c.get('body') + print(f'{indent} --- {author} ({created}) ---') + if isinstance(body, dict): + # Reuse the same ADF extractor used for descriptions. + text_lines = extract_text(body) + text = '\n'.join(text_lines).strip() + if not text: + text = '(comment body in ADF format; no text extracted)' + elif isinstance(body, str): + text = body + else: + text = '(comment has no body)' + for line in text.split('\n'): + print(f'{indent} {line}') + print() +" "$data" "$indent" "$comments_data" else echo "${indent}Error fetching $key" echo "$data" | head -3 + return 1 + fi + + # Recurse into related tickets if depth > 0 + if [ "$depth" -gt 0 ]; then + # Extract subtask + linked-issue keys from already-fetched data + local related_keys + related_keys=$(echo "$data" | python3 -c " +import json, sys +try: + d = json.load(sys.stdin) + fields = d.get('fields', {}) + out = [] + for st in fields.get('subtasks', []): + out.append(st['key']) + for link in fields.get('issuelinks', []): + if 'outwardIssue' in link: + out.append(link['outwardIssue']['key']) + elif 'inwardIssue' in link: + out.append(link['inwardIssue']['key']) + print(' '.join(out)) +except Exception: + pass +" 2>/dev/null) + + # Also fetch JQL parent= children + local jql_kids + jql_kids=$(curl -sS --connect-timeout 10 --max-time 30 \ + -u "$JIRA_EMAIL:$JIRA_TOKEN" \ + "$JIRA_INSTANCE/rest/api/3/search/jql?jql=parent%3D${key}&fields=key&maxResults=20" 2>/dev/null | \ + python3 -c " +import json, sys +try: + d = json.load(sys.stdin) + for issue in d.get('issues', []): + print(issue['key']) +except Exception: + pass +" 2>/dev/null | tr '\n' ' ') + + local rk + for rk in $related_keys $jql_kids; do + [ -z "$rk" ] && continue + echo + fetch_ticket "$rk" "${indent} " $((depth - 1)) + done fi } -# Fetch main ticket -fetch_ticket "$TICKET" +# Fetch main ticket (with depth recursion if requested) +fetch_ticket "$TICKET" "" "$LINKED_DEPTH" -# Search for child issues via parent= JQL (Jira Cloud hierarchy) -CHILD_KEYS=$(curl -sS --connect-timeout 10 --max-time 30 \ - -u "$JIRA_EMAIL:$JIRA_TOKEN" \ - "$JIRA_INSTANCE/rest/api/3/search/jql?jql=parent%3D${TICKET}&fields=key,summary,status,issuetype&maxResults=20" 2>/dev/null | \ - python3 -c " +# At depth 0, also list JQL parent= children as a flat summary (legacy +# behavior — useful as a quick "what's underneath" overview without +# fetching each one). At depth > 0, the recursive fetch_ticket already +# pulled them in, so skip this listing to avoid duplication. +if [ "$LINKED_DEPTH" -eq 0 ]; then + CHILD_KEYS=$(curl -sS --connect-timeout 10 --max-time 30 \ + -u "$JIRA_EMAIL:$JIRA_TOKEN" \ + "$JIRA_INSTANCE/rest/api/3/search/jql?jql=parent%3D${TICKET}&fields=key,summary,status,issuetype&maxResults=20" 2>/dev/null | \ + python3 -c " import json, sys try: data = json.load(sys.stdin) @@ -177,12 +319,13 @@ except Exception: pass " 2>/dev/null) -if [ -n "$CHILD_KEYS" ]; then - echo "Child issues:" - echo "$CHILD_KEYS" | while read -r line; do - echo " $line" - done - echo "" + if [ -n "$CHILD_KEYS" ]; then + echo "Child issues:" + echo "$CHILD_KEYS" | while read -r line; do + echo " $line" + done + echo "" + fi fi # If additional ticket keys are passed as arguments, fetch those too @@ -193,5 +336,5 @@ for extra in "$@"; do fi echo "────────────────────────────────────────────────────────" echo "" - fetch_ticket "$extra" + fetch_ticket "$extra" "" "$LINKED_DEPTH" done diff --git a/dev-tools/file-jiras.sh b/dev-tools/file-jiras.sh index 2dabff636..509554568 100755 --- a/dev-tools/file-jiras.sh +++ b/dev-tools/file-jiras.sh @@ -27,12 +27,16 @@ SPIKE_TICKET_KEY="" # --- Argument parsing --- show_help() { - echo "Usage: file-jiras.sh --spike-doc <path> --feature-ticket <key> [--output-dir <path>]" + echo "Usage: file-jiras.sh --spike-doc <path> --feature-ticket <key> [--output-dir <path>] [--parse-only]" echo "" echo "Options:" echo " --spike-doc Path to the spike doc containing proposed JIRAs" echo " --feature-ticket Parent feature ticket (e.g., LCORE-1311 or 1311)" echo " --output-dir Directory for parsed ticket files (default: <spike-doc-dir>/jiras/)" + echo " --parse-only Parse the spike doc into ticket files and exit;" + echo " skip the interactive filing loop and credentials check." + echo " Useful for inspecting parsed output, pre-commit hooks," + echo " and CI validation." echo " --help Show this help" echo "" echo "Example:" @@ -42,6 +46,7 @@ show_help() { SPIKE_DOC="" FEATURE_TICKET="" JIRA_DIR="" +PARSE_ONLY=0 while [ $# -gt 0 ]; do case "$1" in @@ -54,6 +59,8 @@ while [ $# -gt 0 ]; do --output-dir) [ $# -ge 2 ] || { echo "Error: --output-dir requires a value"; exit 1; } JIRA_DIR="$2"; shift 2 ;; + --parse-only|--dry-run) + PARSE_ONLY=1; shift ;; --help|-h) show_help; exit 0 ;; *) echo "Unknown argument: $1"; show_help; exit 1 ;; esac @@ -80,7 +87,13 @@ if [ -z "$JIRA_DIR" ]; then JIRA_DIR="$SPIKE_DIR/jiras" fi -ensure_jira_credentials +# Credentials are only required for actual filing (POST/PUT to JIRA). +# In --parse-only mode the script writes parsed files locally and exits; +# skip the credential prompt so the parser can run without a configured +# Jira account (useful for CI / pre-commit hooks / agent inspection). +if [ "$PARSE_ONLY" -eq 0 ]; then + ensure_jira_credentials +fi PROJECT_KEY="${FEATURE_TICKET%%-*}" @@ -96,6 +109,20 @@ get_key() { grep -o '<!-- key: [A-Z]*-[0-9]* -->' "$f" 2>/dev/null | head -1 | sed 's/<!-- key: //;s/ -->//' || true } +get_parent_epic_file() { + # Returns the parent_epic_file slug (filename without .md) for a child + # ticket file, or empty string if not present (legacy / Epic / incidental). + local f="$1" + grep -o '<!-- parent_epic_file: [^ ]* -->' "$f" 2>/dev/null | head -1 | sed 's/<!-- parent_epic_file: //;s/ -->//' || true +} + +is_incidental() { + # Returns 0 if this ticket file is marked incidental (no Epic parent; + # files under FEATURE_TICKET directly). + local f="$1" + grep -q '<!-- incidental: true -->' "$f" 2>/dev/null +} + # Portable sed -i (macOS requires '' argument, GNU doesn't) _sed_i() { if sed --version 2>/dev/null | grep -q GNU; then @@ -133,6 +160,7 @@ rm -rf "$JIRA_DIR" mkdir -p "$JIRA_DIR" python3 - "$SPIKE_DOC" "$JIRA_DIR" "$FEATURE_TICKET" << 'PYEOF' +import json import re import sys from pathlib import Path @@ -141,87 +169,340 @@ spike_doc = Path(sys.argv[1]).read_text() out_dir = Path(sys.argv[2]) feature_ticket = sys.argv[3] -# --- Extract spike ticket key from metadata table or first paragraph --- -spike_key_match = re.search(r'\*\*Spike\*\*.*?(LCORE-\d+)', spike_doc) -if not spike_key_match: - # Try "deliverable for LCORE-XXXX" pattern - spike_key_match = re.search(r'deliverable for (LCORE-\d+)', spike_doc) -if not spike_key_match: - # Try first LCORE- reference in the first 500 chars - spike_key_match = re.search(r'(LCORE-\d+)', spike_doc[:500]) -spike_key = spike_key_match.group(1) if spike_key_match else "" - -# --- Extract one-line problem statement for Epic description --- -problem_match = re.search(r'\*\*The problem\*\*:\s*(.+?)(?:\n\n|\n\*\*)', spike_doc, re.DOTALL) -problem_line = problem_match.group(1).strip().split('\n')[0] if problem_match else "" -# --- Generate Epic stub --- -# Derive Epic title from the spike doc's parent directory name -spike_path = Path(sys.argv[1]) -feature_dir = spike_path.parent.name -if feature_dir and feature_dir not in ('design', 'docs', '.'): - epic_title = f"Implement {feature_dir.replace('-', ' ')}" -else: - epic_title = "TODO: Epic title" +def strip_multiline_comments(text): + """Strip HTML comment blocks that span multiple lines. -epic_content = f"<!-- type: Epic -->\n<!-- key: LCORE-xxxx -->\n### {epic_title}\n" + Single-line metadata comments like <!-- type: Task --> or + <!-- key: LCORE-1234 --> are preserved (no newlines inside). + Multi-line commented-out examples in templates (e.g., the + `### Epic: Documentation` example block in the spike-template) + are stripped so the parser doesn't pick them up as real headings. + """ + def replace(m): + return '' if '\n' in m.group(0) else m.group(0) + return re.sub(r'<!--[\s\S]*?-->', replace, text) -(out_dir / "00-epic.md").write_text(epic_content) -# --- Parse JIRA sections --- -pattern = r'^### (LCORE-\?{4}.*?)$' -sections = re.split(pattern, spike_doc, flags=re.MULTILINE) +def slugify(text, max_words=8): + """Convert text to lowercase dash-separated slug, truncated to max_words.""" + words = re.findall(r'[a-z0-9]+', text.lower()) + if not words: + return 'ticket' + return '-'.join(words[:max_words]) -count = 0 -for i in range(1, len(sections), 2): - heading = sections[i].strip() - body = sections[i + 1].strip() if i + 1 < len(sections) else "" - if not heading.startswith("LCORE-"): - break +def extract_type(preceding_text): + """Extract <!-- type: X --> from the last few lines of preceding text.""" + for line in preceding_text.strip().split('\n')[-5:]: + m = re.search(r'<!--\s*type:\s*(\w+)\s*-->', line) + if m: + return m.group(1) + return "Task" - count += 1 - # Truncate body at the first # or ## heading (end of JIRAs section) - end_match = re.search(r'^#{1,2}\s', body, flags=re.MULTILINE) - if end_match: - body = body[:end_match.start()].strip() +def strip_leaked_metadata(body): + """Remove trailing <!-- type/key/parent_epic_file --> that leaks into body + from the next ticket's heading area.""" + body = re.sub(r'\n<!--\s*type:\s*\w+\s*-->\s*$', '', body) + body = re.sub(r'\n<!--\s*key:\s*[\w-]+\s*-->\s*$', '', body) + body = re.sub(r'\n<!--\s*parent_epic_file:[^>]*-->\s*$', '', body) + return body.strip() - # Strip "LCORE-????: " prefix to get clean title - clean_title = re.sub(r'^LCORE-\?+:?\s*', '', heading).strip() - - # Extract type: look in the preceding section's last line (the comment - # sits on the line before the ### heading), then fall back to body. - ticket_type = "Task" - if i > 0: - preceding = sections[i - 1] if i - 1 >= 0 else "" - for line in preceding.strip().split('\n')[-3:]: - m = re.search(r'<!--\s*type:\s*(\w+)\s*-->', line) - if m: - ticket_type = m.group(1) - break - # Strip any <!-- type: ... --> that leaked into body from the next ticket - body = re.sub(r'\n<!--\s*type:\s*\w+\s*-->\s*$', '', body).strip() +# Pre-process: strip multi-line HTML comments +clean_doc = strip_multiline_comments(spike_doc) - # Extract short name for filename - short_name = re.sub(r'[^a-z0-9]+', '-', clean_title.lower()).strip('-') - if not short_name: - short_name = f"ticket-{count}" +# --- Extract spike ticket key (unchanged behavior) --- +spike_key_match = re.search(r'\*\*Spike\*\*.*?(LCORE-\d+)', clean_doc) +if not spike_key_match: + spike_key_match = re.search(r'deliverable for (LCORE-\d+)', clean_doc) +if not spike_key_match: + spike_key_match = re.search(r'(LCORE-\d+)', clean_doc[:500]) +spike_key = spike_key_match.group(1) if spike_key_match else "" - filename = f"{count:02d}-{short_name}.md" - # Write with type and key metadata at top - content = f"<!-- type: {ticket_type} -->\n<!-- key: LCORE-xxxx -->\n### {clean_title}\n\n{body}\n" +# --- Locate Proposed JIRAs section (accepts H1 or H2 — older spikes used H1) --- +proposed_match = re.search( + r'^#{1,2}\s+Proposed JIRAs\s*$\n(.*?)(?=^#{1,2}\s|\Z)', + clean_doc, + re.MULTILINE | re.DOTALL, +) +if not proposed_match: + print(f"Error: 'Proposed JIRAs' section not found in {sys.argv[1]}", file=sys.stderr) + sys.exit(1) +proposed_section = proposed_match.group(1) - (out_dir / filename).write_text(content) -# Write metadata file for the script to read -meta = {"spike_ticket": spike_key, "count": count} -import json +# --- Locate Proposed incidental JIRAs section (optional, H1 or H2) --- +incidental_match = re.search( + r'^#{1,2}\s+Proposed incidental JIRAs\s*$\n(.*?)(?=^#{1,2}\s|\Z)', + clean_doc, + re.MULTILINE | re.DOTALL, +) +incidental_section = incidental_match.group(1) if incidental_match else "" + + +def parse_proposed_section(section_text): + """Parse the Proposed JIRAs section into (epic_blocks, parse_mode). + + epic_blocks is a list of (epic_name, epic_prose, [(child_heading, + child_body, child_type), ...]). + + parse_mode is 'epic_grouped' (new shape: ### Epic + #### LCORE) or + 'legacy_flat' (old shape: ### LCORE flat) or 'empty'. + """ + epic_pattern = re.compile(r'^###\s+Epic:\s*(.+?)\s*$', re.MULTILINE) + epic_matches = list(epic_pattern.finditer(section_text)) + + if epic_matches: + epic_blocks = [] + for i, em in enumerate(epic_matches): + epic_name = em.group(1).strip() + start = em.end() + end = (epic_matches[i + 1].start() + if i + 1 < len(epic_matches) else len(section_text)) + epic_text = section_text[start:end] + + # Children at H4 — match both LCORE-???? (placeholder) and + # LCORE-NNNN (real key, for re-syncing already-filed tickets) + child_pattern = re.compile(r'^####\s+(LCORE-[\d?]+.*?)$', re.MULTILINE) + child_matches = list(child_pattern.finditer(epic_text)) + + epic_prose = ( + epic_text[:child_matches[0].start()].strip() + if child_matches else epic_text.strip() + ) + + children = [] + for j, cm in enumerate(child_matches): + child_heading = cm.group(1).strip() + cstart = cm.end() + cend = (child_matches[j + 1].start() + if j + 1 < len(child_matches) else len(epic_text)) + child_body = epic_text[cstart:cend].strip() + preceding = epic_text[:cm.start()] + ticket_type = extract_type(preceding[-300:]) + child_body = strip_leaked_metadata(child_body) + children.append((child_heading, child_body, ticket_type)) + + epic_blocks.append((epic_name, epic_prose, children)) + return epic_blocks, "epic_grouped" + + # Backward compat: flat ### LCORE-... children, no Epic boundaries. + # Match both LCORE-???? (placeholder) and LCORE-NNNN (real key). + legacy_pattern = re.compile(r'^###\s+(LCORE-[\d?]+.*?)$', re.MULTILINE) + legacy_matches = list(legacy_pattern.finditer(section_text)) + if not legacy_matches: + return [], "empty" + + children = [] + for i, m in enumerate(legacy_matches): + heading = m.group(1).strip() + cstart = m.end() + cend = (legacy_matches[i + 1].start() + if i + 1 < len(legacy_matches) else len(section_text)) + body = section_text[cstart:cend].strip() + preceding = section_text[:m.start()] + ticket_type = extract_type(preceding[-300:]) + body = strip_leaked_metadata(body) + children.append((heading, body, ticket_type)) + + # Auto-generate Epic name from spike-doc parent dir + spike_path = Path(sys.argv[1]) + feature_dir = spike_path.parent.name + if feature_dir and feature_dir not in ('design', 'docs', '.'): + epic_name = f"Implement {feature_dir.replace('-', ' ')}" + else: + epic_name = "TODO: Epic title" + + return [(epic_name, "", children)], "legacy_flat" + + +epic_blocks, parse_mode = parse_proposed_section(proposed_section) + + +# --- Structure linter --- +# Warns about likely-mistakes in the spike doc's Proposed JIRAs shape. +# Errors (which exit non-zero) are reserved for unparseable structure; +# warnings (which print and continue) are for inconsistencies the user +# should know about. + +def lint_proposed_section(section_text, epic_blocks, parse_mode): + """Emit warnings to stderr; return True on success, False on error.""" + issues = [] # list of (severity, message) + + # Mixed shape: both `### Epic:` and `### LCORE-` at H3 level + epic_count = len(re.findall(r'^###\s+Epic:\s+', section_text, re.MULTILINE)) + h3_lcore_count = len(re.findall(r'^###\s+LCORE-[\d?]+', section_text, re.MULTILINE)) + if epic_count > 0 and h3_lcore_count > 0: + issues.append(( + "WARNING", + f"Mixed shape detected: {epic_count} `### Epic:` boundaries plus " + f"{h3_lcore_count} flat `### LCORE-...` H3 stubs. The flat ones " + f"will not be parsed under any Epic; demote them to `#### LCORE-...` " + f"under an `### Epic:` heading or remove the Epic boundaries." + )) + + # Epic with zero children + for epic_name, _, children in epic_blocks: + if not children: + issues.append(( + "WARNING", + f"Epic '{epic_name}' has no child JIRAs (no `#### LCORE-...` " + f"H4 sub-headings under it). Either add children or remove " + f"the empty Epic block." + )) + + # Duplicate child titles within or across Epics + all_titles = [] + for epic_name, _, children in epic_blocks: + for heading, _, _ in children: + clean = re.sub(r'^LCORE-[\d?]+\s*:?\s*', '', heading).strip().lower() + all_titles.append((epic_name, clean)) + seen = {} + for epic_name, title in all_titles: + if title in seen: + issues.append(( + "WARNING", + f"Duplicate JIRA title '{title}' (in Epic '{seen[title]}' and " + f"Epic '{epic_name}'). Each child JIRA should have a unique " + f"title; the parser uses titles for filename generation." + )) + else: + seen[title] = epic_name + + # No JIRAs at all + total_children = sum(len(c) for _, _, c in epic_blocks) + if total_children == 0: + issues.append(( + "ERROR", + "Proposed JIRAs section parsed zero JIRAs. Expected at least one " + "`### LCORE-...` (legacy) or `#### LCORE-...` under `### Epic:` (new)." + )) + + # Print issues + for severity, msg in issues: + prefix = " [LINT-{}]".format(severity) + # Wrap long messages for readability + words = msg.split(' ') + line = prefix + for w in words: + if len(line) + 1 + len(w) > 100: + print(line, file=sys.stderr) + line = " " + w + else: + line = line + " " + w + if line.strip(): + print(line, file=sys.stderr) + + return not any(s == "ERROR" for s, _ in issues) + + +if not lint_proposed_section(proposed_section, epic_blocks, parse_mode): + sys.exit(1) + + +def parse_incidental_section(section_text): + """Parse incidental section (always flat ### LCORE-).""" + if not section_text: + return [] + pattern = re.compile(r'^###\s+(LCORE-[\d?]+.*?)$', re.MULTILINE) + matches = list(pattern.finditer(section_text)) + out = [] + for i, m in enumerate(matches): + heading = m.group(1).strip() + cstart = m.end() + cend = (matches[i + 1].start() + if i + 1 < len(matches) else len(section_text)) + body = section_text[cstart:cend].strip() + preceding = section_text[:m.start()] + ticket_type = extract_type(preceding[-300:]) + body = strip_leaked_metadata(body) + out.append((heading, body, ticket_type)) + return out + + +incidental_tickets = parse_incidental_section(incidental_section) + + +# --- Write parsed files --- +file_count = 0 +total_jiras = 0 +total_epics = 0 + +for epic_name, epic_prose, children in epic_blocks: + epic_slug = slugify(epic_name) + epic_filename = f"{file_count:02d}-epic-{epic_slug}.md" + epic_content = ( + f"<!-- type: Epic -->\n" + f"<!-- key: LCORE-xxxx -->\n" + f"### {epic_name}\n" + f"\n" + f"{epic_prose}\n" + ) + (out_dir / epic_filename).write_text(epic_content) + parent_epic_stub = epic_filename.rsplit('.md', 1)[0] + file_count += 1 + total_epics += 1 + + for child_heading, child_body, ticket_type in children: + # Detect real ticket key in heading (e.g., "LCORE-1569: Add ...") + # vs placeholder ("LCORE-???? E2E ..."). Real keys are preserved + # so the script PUT-updates the existing ticket rather than POSTing + # a duplicate. + key_match = re.match(r'LCORE-(\d+)\s*:?\s*', child_heading) + ticket_key = f"LCORE-{key_match.group(1)}" if key_match else "LCORE-xxxx" + clean_title = re.sub(r'^LCORE-[\d?]+\s*:?\s*', '', child_heading).strip() + short_name = slugify(clean_title) + child_filename = f"{file_count:02d}-{short_name}.md" + child_content = ( + f"<!-- type: {ticket_type} -->\n" + f"<!-- key: {ticket_key} -->\n" + f"<!-- parent_epic_file: {parent_epic_stub} -->\n" + f"### {clean_title}\n" + f"\n" + f"{child_body}\n" + ) + (out_dir / child_filename).write_text(child_content) + file_count += 1 + total_jiras += 1 + +# Incidental tickets — file under FEATURE_TICKET directly (no Epic parent) +for heading, body, ticket_type in incidental_tickets: + key_match = re.match(r'LCORE-(\d+)\s*:?\s*', heading) + ticket_key = f"LCORE-{key_match.group(1)}" if key_match else "LCORE-xxxx" + clean_title = re.sub(r'^LCORE-[\d?]+\s*:?\s*', '', heading).strip() + short_name = slugify(clean_title) + inc_filename = f"{file_count:02d}-incidental-{short_name}.md" + inc_content = ( + f"<!-- type: {ticket_type} -->\n" + f"<!-- key: {ticket_key} -->\n" + f"<!-- incidental: true -->\n" + f"### {clean_title}\n" + f"\n" + f"{body}\n" + ) + (out_dir / inc_filename).write_text(inc_content) + file_count += 1 + total_jiras += 1 + + +# --- Metadata file --- +meta = { + "spike_ticket": spike_key, + "epic_count": total_epics, + "jira_count": total_jiras, + "incidental_count": len(incidental_tickets), + "parse_mode": parse_mode, +} (out_dir / ".meta.json").write_text(json.dumps(meta)) -print(f"Parsed {count} JIRAs + 1 Epic from {sys.argv[1]}") +inc_str = f", {len(incidental_tickets)} incidental" if incidental_tickets else "" +print( + f"Parsed {total_epics} Epic(s) + {total_jiras - len(incidental_tickets)} JIRA(s)" + f"{inc_str} from {sys.argv[1]} (mode: {parse_mode})" +) if spike_key: print(f"Spike ticket: {spike_key}") PYEOF @@ -261,17 +542,39 @@ show_summary() { fi if [ "$ttype" = "Epic" ]; then parent="$FEATURE_TICKET" - elif [ -n "$EPIC_KEY" ] && [ "$EPIC_KEY" != "__NONE__" ]; then - parent="$EPIC_KEY" + elif is_incidental "$f"; then + parent="$FEATURE_TICKET (incidental)" else - parent="Epic #0" + local parent_epic_file + parent_epic_file=$(get_parent_epic_file "$f") + if [ -n "$parent_epic_file" ]; then + local epic_path="$JIRA_DIR/${parent_epic_file}.md" + if [ -f "$epic_path" ]; then + local pk + pk=$(get_key "$epic_path") + if [ -n "$pk" ]; then + parent="$pk" + else + parent="(unfiled: $parent_epic_file)" + fi + else + parent="(missing: $parent_epic_file)" + fi + else + # Legacy / fallback (no parent_epic_file metadata) + if [ -n "$EPIC_KEY" ] && [ "$EPIC_KEY" != "__NONE__" ]; then + parent="$EPIC_KEY" + else + parent="(no epic)" + fi + fi fi printf " %-3d %-7s %-13s %-35s %s\n" "$i" "$ttype" "$status" "$title" "$parent" i=$((i + 1)) done echo "" if [ -n "$SPIKE_TICKET_KEY" ]; then - echo " Spike ticket $SPIKE_TICKET_KEY will be linked to Epic with \"Informs\"" + echo " Spike ticket $SPIKE_TICKET_KEY will be linked to first filed Epic with \"Informs\"" fi echo "" } @@ -281,17 +584,32 @@ get_file_by_number() { } ensure_epic_key() { - # If we already have an Epic key, nothing to do + # If we already have an Epic key in this shell, nothing to do if [ -n "$EPIC_KEY" ]; then return 0 fi - echo "" - echo " No Epic filed yet. Children need an Epic parent." - echo " 1. File Epic #0 first, then continue" - echo " 2. Enter an existing Epic key (e.g., LCORE-1600)" - echo " 3. File without Epic (Blocks link to $FEATURE_TICKET instead)" - printf " Choice (1/2/3): " + # Try to restore from state file (set in a previous subshell that + # had `key=$(file_ticket ...)` capture, so its EPIC_KEY didn't + # propagate back to the parent shell). + if [ -f "$JIRA_DIR/.epic-key" ]; then + EPIC_KEY=$(cat "$JIRA_DIR/.epic-key") + if [ -n "$EPIC_KEY" ]; then + return 0 + fi + fi + + # All user-facing prompts go to stderr so they're visible even when + # this function is called from inside a `$(...)` capture context + # (which would otherwise swallow stdout into a variable). + { + echo "" + echo " No Epic filed yet. Children need an Epic parent." + echo " 1. File Epic #0 first, then continue" + echo " 2. Enter an existing Epic key (e.g., LCORE-1600)" + echo " 3. File without Epic (Blocks link to $FEATURE_TICKET instead)" + printf " Choice (1/2/3): " + } >&2 read -r choice < /dev/tty case "$choice" in @@ -315,17 +633,23 @@ ensure_epic_key() { fi ;; 2) - printf " Epic key: " + printf " Epic key: " >&2 read -r EPIC_KEY < /dev/tty ;; 3) EPIC_KEY="__NONE__" ;; *) - echo " Invalid choice." + echo " Invalid choice." >&2 return 1 ;; esac + + # Persist for subsequent subshells so the user isn't re-prompted for + # every ticket in the file loop. + if [ -n "$EPIC_KEY" ]; then + echo "$EPIC_KEY" > "$JIRA_DIR/.epic-key" + fi } link_spike_to_epic() { @@ -451,7 +775,66 @@ def make_paragraph(text): return {"type": "paragraph", "content": parse_inline(text)} +def is_table_paragraph(para): + """Detect a GitHub-flavored markdown table. + + A markdown table is at least 2 contiguous lines: + Line 1: starts with `|` and has cell content + Line 2: separator with only `|`, `-`, `:`, and whitespace + (e.g., `|---|---|` or `|:---|---:|`) + """ + lines = para.strip().split('\n') + if len(lines) < 2: + return False + if not lines[0].lstrip().startswith('|'): + return False + sep = lines[1].strip() + return bool(re.match(r'^\|[\s\-:|]+\|?\s*$', sep)) + + +def parse_table_row(line, cell_kind): + """Parse a single `| a | b | c |` line into a tableRow ADF node.""" + s = line.strip() + if s.startswith('|'): + s = s[1:] + if s.endswith('|'): + s = s[:-1] + cells_text = s.split('|') + cells = [] + for ct in cells_text: + text = ct.strip() + # ADF cells require at least one block-level node — use an empty + # paragraph for empty cells. + if text: + content = [make_paragraph(text)] + else: + content = [{"type": "paragraph", "content": []}] + cells.append({"type": cell_kind, "attrs": {}, "content": content}) + return {"type": "tableRow", "content": cells} + + +def parse_table(para): + """Convert a markdown-table paragraph into an ADF table node.""" + lines = para.strip().split('\n') + header_line = lines[0] + # lines[1] is the separator — skipped (no semantic content) + body_lines = lines[2:] if len(lines) > 2 else [] + + rows = [parse_table_row(header_line, cell_kind="tableHeader")] + for bl in body_lines: + if bl.strip(): + rows.append(parse_table_row(bl, cell_kind="tableCell")) + + return { + "type": "table", + "attrs": {"isNumberColumnEnabled": False, "layout": "default"}, + "content": rows, + } + + def parse_block(para): + if is_table_paragraph(para): + return parse_table(para) m = re.match(r'^(#{1,6})\s+(.*)', para) if m: level = len(m.group(1)) @@ -577,26 +960,58 @@ file_ticket() { ttype=$(get_type "$ticket_file") if [ "$ttype" = "Epic" ]; then - # Check if Epic already has a key (pre-existing) - local epic_existing - epic_existing=$(get_key "$ticket_file") - if [ -n "$epic_existing" ]; then - EPIC_KEY="$epic_existing" - fi + # File Epic under FEATURE_TICKET. With multi-Epic support, each Epic + # is filed independently; we capture the FIRST filed Epic into + # EPIC_KEY so the spike-to-Epic "Informs" link uses it (per + # convention: link to the primary/first Epic). local filed_key filed_key=$(file_single_ticket "$ticket_file" "Epic" "$FEATURE_TICKET") - if [ -n "$filed_key" ]; then + if [ -z "$filed_key" ]; then + return 1 + fi + if [ -z "$EPIC_KEY" ] || [ "$EPIC_KEY" = "__NONE__" ]; then EPIC_KEY="$filed_key" + # Persist for subsequent subshells (file_ticket runs inside + # `key=$(...)` capture; assignments don't propagate back). + echo "$EPIC_KEY" > "$JIRA_DIR/.epic-key" + if [ -n "$SPIKE_TICKET_KEY" ]; then + link_spike_to_epic + fi fi - if [ -n "$EPIC_KEY" ] && [ -n "$SPIKE_TICKET_KEY" ]; then - link_spike_to_epic + echo "$filed_key" + return 0 + fi + + if is_incidental "$ticket_file"; then + # Incidental tickets file directly under FEATURE_TICKET (no Epic). + file_single_ticket "$ticket_file" "$ttype" "$FEATURE_TICKET" + return $? + fi + + # Regular child: route to its parent_epic_file's filed key. + local parent_epic_file + parent_epic_file=$(get_parent_epic_file "$ticket_file") + + local parent + if [ -n "$parent_epic_file" ]; then + local epic_path="$JIRA_DIR/${parent_epic_file}.md" + if [ ! -f "$epic_path" ]; then + echo " Error: parent_epic_file '$parent_epic_file' not found at $epic_path" >&2 + return 1 + fi + parent=$(get_key "$epic_path") + if [ -z "$parent" ]; then + echo " Error: parent epic '$parent_epic_file' has not been filed yet (no key)." >&2 + echo " File the Epic first, then retry filing this ticket." >&2 + return 1 fi - echo "$EPIC_KEY" else - # Need an Epic key for children — refresh from Epic file if not set + # Legacy fallback: no parent_epic_file metadata. Use single-Epic + # flow with the global EPIC_KEY (refresh from any epic file in the + # directory if not yet set). if [ -z "$EPIC_KEY" ] || [ "$EPIC_KEY" = "__NONE__" ]; then local epic_file - epic_file=$(find "$JIRA_DIR" -maxdepth 1 -name '00-epic.md' 2>/dev/null | head -1) + epic_file=$(find "$JIRA_DIR" -maxdepth 1 \( -name '*-epic-*.md' -o -name '00-epic.md' \) 2>/dev/null | sort | head -1) if [ -n "$epic_file" ]; then local ek ek=$(get_key "$epic_file") @@ -609,14 +1024,27 @@ file_ticket() { ensure_epic_key || return 1 fi if [ "$EPIC_KEY" = "__NONE__" ]; then - # Fallback: Blocks link (filed as standalone Task linked to feature) - file_single_ticket "$ticket_file" "$ttype" "$FEATURE_TICKET" + parent="$FEATURE_TICKET" else - file_single_ticket "$ticket_file" "$ttype" "$EPIC_KEY" + parent="$EPIC_KEY" fi fi + + file_single_ticket "$ticket_file" "$ttype" "$parent" } +# --- Parse-only short-circuit --- +# In --parse-only mode the parsed files are already written by the parse +# step above. Skip the interactive loop and exit cleanly. +if [ "$PARSE_ONLY" -eq 1 ]; then + echo "" + echo "Parsed files in $JIRA_DIR/" + ls -1 "$JIRA_DIR" 2>/dev/null + echo "" + echo "(--parse-only: skipping interactive filing loop and exiting.)" + exit 0 +fi + # --- Interactive loop --- show_summary @@ -719,10 +1147,13 @@ while true; do echo "" echo "Done:$created_keys" fi - # Refresh EPIC_KEY from file (subshell can't propagate variable changes) - _epic_file=$(find "$JIRA_DIR" -maxdepth 1 -name '00-epic.md' 2>/dev/null | head -1) - if [ -n "$_epic_file" ]; then - _ek=$(get_key "$_epic_file") + # Refresh EPIC_KEY from the state file. Subshells (`key=$(file_ticket ...)`) + # cannot propagate variable assignments back to the parent shell, so + # ensure_epic_key and the Epic-filing path persist EPIC_KEY to + # $JIRA_DIR/.epic-key. Re-read it here so the parent shell sees the + # current value for show_summary and subsequent commands. + if [ -f "$JIRA_DIR/.epic-key" ]; then + _ek=$(cat "$JIRA_DIR/.epic-key") if [ -n "$_ek" ]; then EPIC_KEY="$_ek" fi diff --git a/docs/contributing/howto-run-a-spike.md b/docs/contributing/howto-run-a-spike.md index 519df8a18..1282aeb8c 100644 --- a/docs/contributing/howto-run-a-spike.md +++ b/docs/contributing/howto-run-a-spike.md @@ -114,6 +114,16 @@ Key principles: Description, Scope, Acceptance Criteria, and an Agentic tool instruction pointing to the spec doc. Use [jira-ticket-template.md](templates/jira-ticket-template.md). +- **JIRAs are grouped by Epic.** Each `### Epic: <name>` H3 sub-section + becomes a JIRA Epic when filed; each `#### LCORE-???? <title>` H4 + sub-sub-section becomes a child of that Epic. The prose under the + Epic heading (Goals, optional Scope and Success criteria) becomes + the Epic's filed description. Single-Epic features keep one Epic + block; larger features group by aspect (Implementation, Docs, + Tests, ...). The spike-template demonstrates the shape; `file-jiras.sh` + parses and files accordingly. Backward-compat: the old flat shape + (no `### Epic:` boundaries; `### LCORE-...` H3 stubs directly under + Proposed JIRAs) still parses, with a single auto-generated Epic. - **The first proposed JIRA is the e2e kickoff Story** — write the behave `.feature` files for the feature, no step implementation. This kicks off the work *before* implementation lands so the test shape isn't shaped by diff --git a/docs/contributing/templates/spike-template.md b/docs/contributing/templates/spike-template.md index 75803a8e4..de5dc9ee3 100644 --- a/docs/contributing/templates/spike-template.md +++ b/docs/contributing/templates/spike-template.md @@ -84,9 +84,29 @@ The first two stubs below are required-by-default per project convention or remove if explicitly not applicable; document the removal rationale in `Out of scope` above. +JIRAs are grouped by Epic. Each `### Epic: <name>` H3 sub-section becomes +a JIRA Epic when filed; each `#### LCORE-???? <title>` H4 sub-sub-section +becomes a child of that Epic. For simple features, one Epic block covers +everything. For larger features, group by aspect (Implementation, Docs, +Tests, etc.). The prose under each `### Epic:` heading (Goals, Scope, +Success criteria) becomes the Epic's filed description. + +### Epic: Implement TODO feature + +TODO: Brief prose describing what this Epic groups. Convention: include +**Goals** (outcomes when the Epic is complete) and optionally **Scope** +(what's in / out) and **Success criteria** (observable outcomes). +Pattern follows existing LCORE Epics (e.g., LCORE-1623, LCORE-1631). + +**Goals**: +- TODO + +**Scope**: +- TODO + <!-- type: Story --> <!-- key: LCORE-???? --> -### LCORE-???? E2E feature files for TODO feature (no step implementation) +#### LCORE-???? E2E feature files for TODO feature (no step implementation) **User story**: As a Lightspeed Core e2e engineer, I want the behave feature files for TODO feature scenarios written before the feature @@ -125,7 +145,7 @@ additions to tests/e2e/test_list.txt. Do NOT create step definitions. <!-- type: Task --> <!-- key: LCORE-???? --> -### LCORE-???? Implement behave step definitions for TODO feature files +#### LCORE-???? Implement behave step definitions for TODO feature files **Description**: Implement Python step definitions under `tests/e2e/features/steps/` for the `.feature` files authored in @@ -149,7 +169,7 @@ To verify: `uv run make test-e2e` runs every new scenario green. <!-- type: Task --> <!-- key: LCORE-???? --> -### LCORE-???? TODO fill in title +#### LCORE-???? TODO fill in title TODO: Use the format from `docs/contributing/templates/jira-ticket-template.md`. Change type above to Story if user-facing. @@ -170,6 +190,22 @@ Read the "[section]" section in docs/design/<feature>/<feature>.md. Key files: [files]. ``` +<!-- Add another Epic block below by starting a new `### Epic: <name>` +heading; or keep all JIRAs under the single Epic above. + +Example multi-Epic shape (uncomment and adapt): + +### Epic: Documentation + +**Goals**: +- TODO + +#### LCORE-???? Migrate docs to TODO feature + +[body following the same pattern as the kickoff/step-defs stubs above] + +--> + ## Proposed incidental JIRAs REMOVE THIS WHOLE SECTION IF NOT APPLICABLE (controlled by diff --git a/docs/contributing_guide.md b/docs/contributing_guide.md index e38121f80..9f8bf3ade 100644 --- a/docs/contributing_guide.md +++ b/docs/contributing_guide.md @@ -173,7 +173,7 @@ Personal overrides go in `.feature-design.config.local` at the repo root at the start of each session and announce which files were loaded. **CLI tools** (work without Claude Code): -- `dev-tools/fetch-jira.sh <ticket>` — fetch JIRA ticket content and child issues +- `dev-tools/fetch-jira.sh [--comments] [--linked-depth N] <ticket>` — fetch JIRA ticket content and child issues; `--comments` also includes the comment thread; `--linked-depth N` (capped at 3) recurses through subtasks, linked issues, and parent-relation children, indented under the requesting ticket. Cycle-safe. - `dev-tools/file-jiras.sh --spike-doc <path> --feature-ticket <key>` — parse and file JIRAs ## AI assistants