99from .story_keys import normalize_story_key , normalize_story_key_for_epic
1010
1111
12- STORY_HEADER_RE = re .compile (r"^###\s+(?:(?:Story\s+)?(\d+\.\d+)|Story\s+([^:]+)):\s*(.*)$" , re .IGNORECASE )
13- EPIC_HEADER_RE = re .compile (r"^##\s+Epic\s+([A-Za-z][\w-]*|\d+):\s*(.*)$" , re .IGNORECASE )
12+ STORY_HEADER_RE = re .compile (r"^###\s+(?:(?:Story\s+)?(\d+(?: \.\d+) +)|Story\s+([^:]+)):\s*(.*)$" , re .IGNORECASE )
13+ EPIC_HEADER_RE = re .compile (r"^##\s+Epic\s+([A-Za-z][\w-]*|\d+(?:\.\d+)* ):\s*(.*)$" , re .IGNORECASE )
1414
1515
1616def parse_epic_file (epic_file : str | Path ) -> dict [str , Any ]:
@@ -74,7 +74,11 @@ def parse_story(epic_file: str | Path, story_id: str, rules_file: str | Path) ->
7474 story_key = _normalize_header_story (project_root , current_epic , raw_story .strip ())
7575 if story_key is None :
7676 continue
77- if target_id not in {raw_story .strip (), story_key .id , story_key .prefix , story_key .key }:
77+ target_aliases = {raw_story .strip (), story_key .id , story_key .prefix , story_key .key }
78+ title_slug = _slugify_title (raw_title )
79+ if title_slug and not _is_explicit_header_story (raw_story .strip (), story_key .id ):
80+ target_aliases .add (f"{ story_key .prefix } -{ title_slug } " )
81+ if target_id not in target_aliases :
7882 continue
7983 start_index = index
8084 target_id = story_key .id
@@ -170,7 +174,7 @@ def _normalize_header_story(project_root: str, current_epic: str, raw_story: str
170174 story_key = normalize_story_key_for_epic (project_root , current_epic , raw_story )
171175 if story_key is None :
172176 return None
173- if story_key .id . rsplit ( "." , 1 )[ 0 ] != current_epic :
177+ if story_key .id != current_epic and not story_key . id . startswith ( f" { current_epic } ." ) :
174178 return None
175179 return story_key
176180
@@ -202,6 +206,8 @@ def parse_story_range(user_input: str, total: int, ids_csv: str = "") -> dict[st
202206 end = id_index [end_raw ]
203207 low , high = sorted ((start , end ))
204208 selected .update (range (low , high + 1 ))
209+ else :
210+ raise ValueError (f"invalid_story_range:{ part } " )
205211 elif part .isdigit ():
206212 selected .add (int (part ))
207213 indices = sorted (index for index in selected if 1 <= index <= total )
@@ -238,8 +244,7 @@ def _story_aliases(story: dict[str, str]) -> set[str]:
238244 if story_key :
239245 aliases .add (story_key )
240246 if not _is_explicit_header_story (header_story , story_id ):
241- epic , _ , story_num = story_id .rpartition ("." )
242- prefix = f"{ epic } -{ story_num } "
247+ prefix = story_id .replace ("." , "-" )
243248 aliases .add (prefix )
244249 title_slug = _slugify_title (story .get ("title" , "" ))
245250 if title_slug :
@@ -250,16 +255,15 @@ def _story_aliases(story: dict[str, str]) -> set[str]:
250255def _is_explicit_header_story (header_story : str , story_id : str ) -> bool :
251256 if not header_story :
252257 return False
253- epic , _ , story_num = story_id .rpartition ("." )
254- return header_story not in {story_id , f"{ epic } -{ story_num } " }
258+ return header_story not in {story_id , story_id .replace ("." , "-" )}
255259
256260
257261def _slugify_title (title : str ) -> str :
258262 return "-" .join (part for part in re .split (r"[^A-Za-z0-9]+" , title .lower ()) if part )
259263
260264
261- def _story_sort_key (value : str ) -> tuple [int , int , str , int , str ]:
265+ def _story_sort_key (value : str ) -> tuple [int , tuple [ int , ...] , str , int , str ]:
262266 epic , _ , story_num = value .rpartition ("." )
263- if epic .isdigit ():
264- return (0 , int (epic ), "" , int ( story_num ) if story_num . isdigit () else 0 , value )
265- return (1 , 0 , epic , int (story_num ) if story_num .isdigit () else 0 , value )
267+ if all ( part .isdigit () for part in value . split ( "." ) ):
268+ return (0 , tuple ( int (part ) for part in value . split ( "." )), "" , 0 , value )
269+ return (1 , () , epic , int (story_num ) if story_num .isdigit () else 0 , value )
0 commit comments