11#! /usr/bin/env bash
2- # File JIRA sub-tickets from a spike doc.
2+ # File JIRA sub-tickets from a spike doc or template with placeholders .
33#
44# Usage:
55# file-jiras.sh --spike-doc <path> --feature-ticket <key>
66# file-jiras.sh --spike-doc spike.md --feature-ticket LCORE-1311
77# file-jiras.sh --spike-doc spike.md --feature-ticket 1311
8+ # file-jiras.sh --spike-doc release-jiras.md --feature-ticket LCORE-2000 \
9+ # --var X=0 --var Y=7 --var Z=0 --var PRE=rc1
810#
911# Bare numbers default to LCORE- prefix.
1012#
1113# The script:
12- # 1. Parses JIRA sections from the spike doc (### LCORE-???? headings)
13- # 2. Auto-generates an Epic stub (ticket #0)
14- # 3. Reads <!-- type: Task/Story/Epic --> metadata from each ticket
15- # 4. Opens an interactive menu: view, edit, drop, file
16- # 5. Files Epic first, then children under it
17- # 6. Links spike ticket to Epic with "Informs" relationship
14+ # 1. Replaces {KEY} placeholders with --var values (if any)
15+ # 2. Parses JIRA sections from the doc (### LCORE-???? headings)
16+ # 3. Auto-generates an Epic stub (ticket #0)
17+ # 4. Reads <!-- type: Task/Story/Epic --> metadata from each ticket
18+ # 5. Opens an interactive menu: view, edit, drop, file
19+ # 6. Files Epic first, then children under it
20+ # 7. Links spike ticket to Epic with "Informs" relationship
1821
1922set -euo pipefail
2023
@@ -28,25 +31,32 @@ SPIKE_TICKET_KEY=""
2831
2932show_help () {
3033 echo " Usage: file-jiras.sh --spike-doc <path> --feature-ticket <key> [--output-dir <path>] [--parse-only]"
34+ echo " [--var KEY=VALUE ...]"
3135 echo " "
3236 echo " Options:"
3337 echo " --spike-doc Path to the spike doc containing proposed JIRAs"
3438 echo " --feature-ticket Parent feature ticket (e.g., LCORE-1311 or 1311)"
3539 echo " --output-dir Directory for parsed ticket files (default: <spike-doc-dir>/jiras/)"
40+ echo " --var KEY=VALUE Replace {KEY} placeholders in the doc before parsing."
41+ echo " Repeatable. Useful for template docs like release-jiras.md."
42+ echo " Example: --var X=0 --var Y=7 --var Z=0 --var PRE=rc1"
3643 echo " --parse-only Parse the spike doc into ticket files and exit;"
3744 echo " skip the interactive filing loop and credentials check."
3845 echo " Useful for inspecting parsed output, pre-commit hooks,"
3946 echo " and CI validation."
4047 echo " --help Show this help"
4148 echo " "
42- echo " Example :"
49+ echo " Examples :"
4350 echo " file-jiras.sh --spike-doc docs/design/.../spike.md --feature-ticket 1311"
51+ echo " file-jiras.sh --spike-doc dev-tools/release-jiras.md --feature-ticket 2000 \\ "
52+ echo " --var X=0 --var Y=7 --var Z=0 --var PRE=rc1"
4453}
4554
4655SPIKE_DOC=" "
4756FEATURE_TICKET=" "
4857JIRA_DIR=" "
4958PARSE_ONLY=0
59+ PLACEHOLDER_VARS=()
5060
5161while [ $# -gt 0 ]; do
5262 case " $1 " in
@@ -59,6 +69,13 @@ while [ $# -gt 0 ]; do
5969 --output-dir)
6070 [ $# -ge 2 ] || { echo " Error: --output-dir requires a value" ; exit 1; }
6171 JIRA_DIR=" $2 " ; shift 2 ;;
72+ --var)
73+ [ $# -ge 2 ] || { echo " Error: --var requires KEY=VALUE" ; exit 1; }
74+ case " $2 " in
75+ * =* ) PLACEHOLDER_VARS+=(" $2 " ) ;;
76+ * ) echo " Error: --var value must be KEY=VALUE (got '$2 ')" ; exit 1 ;;
77+ esac
78+ shift 2 ;;
6279 --parse-only|--dry-run)
6380 PARSE_ONLY=1; shift ;;
6481 --help|-h) show_help; exit 0 ;;
@@ -159,7 +176,7 @@ if [ "${SKIP_PARSE:-}" != "1" ]; then
159176rm -rf " $JIRA_DIR "
160177mkdir -p " $JIRA_DIR "
161178
162- python3 - " $SPIKE_DOC " " $JIRA_DIR " " $FEATURE_TICKET " << 'PYEOF '
179+ python3 - " $SPIKE_DOC " " $JIRA_DIR " " $FEATURE_TICKET " ${PLACEHOLDER_VARS[@]+ " ${PLACEHOLDER_VARS[@]} " } << 'PYEOF '
163180import json
164181import re
165182import sys
@@ -169,6 +186,15 @@ spike_doc = Path(sys.argv[1]).read_text()
169186out_dir = Path(sys.argv[2])
170187feature_ticket = sys.argv[3]
171188
189+ # Apply {KEY} placeholder substitutions from --var arguments
190+ for kv in sys.argv[4:]:
191+ key, value = kv.split('=', 1)
192+ spike_doc = spike_doc.replace('{' + key + '}', value)
193+
194+ unreplaced = set(re.findall(r'\{([A-Z][A-Z0-9_]*)\}', spike_doc))
195+ if unreplaced:
196+ print(f" Warning: unreplaced placeholders: {', '.join(sorted(unreplaced))}", file=sys.stderr)
197+
172198
173199def strip_multiline_comments(text):
174200 """Strip HTML comment blocks that span multiple lines.
@@ -287,14 +313,14 @@ def parse_proposed_section(section_text):
287313 children.append((child_heading, child_body, ticket_type))
288314
289315 epic_blocks.append((epic_name, epic_prose, children))
290- return epic_blocks, "epic_grouped"
316+ return epic_blocks, "epic_grouped", []
291317
292318 # Backward compat: flat ### LCORE-... children, no Epic boundaries.
293319 # Match both LCORE-???? (placeholder) and LCORE-NNNN (real key).
294320 legacy_pattern = re.compile(r'^###\s+(LCORE-[\d?]+.*?)$', re.MULTILINE)
295321 legacy_matches = list(legacy_pattern.finditer(section_text))
296322 if not legacy_matches:
297- return [], "empty"
323+ return [], "empty", []
298324
299325 children = []
300326 for i, m in enumerate(legacy_matches):
@@ -308,6 +334,10 @@ def parse_proposed_section(section_text):
308334 body = strip_leaked_metadata(body)
309335 children.append((heading, body, ticket_type))
310336
337+ # <!-- no-epic --> directive: skip auto-Epic generation.
338+ if '<!-- no-epic -->' in spike_doc:
339+ return [], "no_epic", children
340+
311341 # Auto-generate Epic name from spike-doc parent dir
312342 spike_path = Path(sys.argv[1])
313343 feature_dir = spike_path.parent.name
@@ -316,10 +346,10 @@ def parse_proposed_section(section_text):
316346 else:
317347 epic_name = "TODO: Epic title"
318348
319- return [(epic_name, "", children)], "legacy_flat"
349+ return [(epic_name, "", children)], "legacy_flat", []
320350
321351
322- epic_blocks, parse_mode = parse_proposed_section(proposed_section)
352+ epic_blocks, parse_mode, no_epic_tickets = parse_proposed_section(proposed_section)
323353
324354
325355# --- Structure linter ---
@@ -372,9 +402,9 @@ def lint_proposed_section(section_text, epic_blocks, parse_mode):
372402 else:
373403 seen[title] = epic_name
374404
375- # No JIRAs at all
405+ # No JIRAs at all (no_epic mode has its own tickets, so skip this check)
376406 total_children = sum(len(c) for _, _, c in epic_blocks)
377- if total_children == 0:
407+ if total_children == 0 and parse_mode != "no_epic" :
378408 issues.append((
379409 "ERROR",
380410 "Proposed JIRAs section parsed zero JIRAs. Expected at least one "
@@ -425,6 +455,9 @@ def parse_incidental_section(section_text):
425455
426456incidental_tickets = parse_incidental_section(incidental_section)
427457
458+ # <!-- no-epic --> tickets are treated as incidental (filed under feature-ticket)
459+ incidental_tickets = no_epic_tickets + incidental_tickets
460+
428461
429462# --- Write parsed files ---
430463file_count = 0
@@ -737,7 +770,7 @@ except Exception as e:
737770
738771 # Extract description body (everything after the heading, skip metadata comments)
739772 local body
740- body=$( grep -v ' ^<!-- \(type\|key\):' " $ticket_file " | tail -n +2 )
773+ body=$( grep -v ' ^<!-- \(type\|key\|incidental\|parent_epic_file\ ):' " $ticket_file " | grep -v ' ^### ' | tail -n +1 )
741774
742775 # Build ADF description
743776 local adf_desc
0 commit comments