Coverage for little_loops / issue_parser.py: 25%
322 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-11 00:29 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-11 00:29 -0500
1"""Issue file parsing for little-loops.
3Parses issue markdown files to extract metadata like priority, ID, type, and title.
4"""
6from __future__ import annotations
8import logging
9import re
10from dataclasses import dataclass, field
11from pathlib import Path
12from typing import TYPE_CHECKING, Any
14from little_loops.cli_args import _id_matches
15from little_loops.frontmatter import parse_frontmatter
17if TYPE_CHECKING:
18 from little_loops.config import BRConfig
21logger = logging.getLogger(__name__)
23# Regex pattern for issue IDs in list items
24# Matches: "- FEAT-001", "- BUG-123", "* ENH-005", "- FEAT-001 (some note)"
25# Also handles bold markdown: "- **ENH-1000**: description"
26ISSUE_ID_PATTERN = re.compile(r"^[-*]\s+\*{0,2}([A-Z]+-\d+)", re.MULTILINE)
29_NORMALIZED_RE = re.compile(r"^P[0-5]-(BUG|FEAT|ENH|EPIC)-[0-9]{3,}-[a-z0-9-]+\.md$")
30_ISSUE_TYPE_RE = re.compile(r"-(BUG|FEAT|ENH|EPIC)-")
33def is_normalized(filename: str) -> bool:
34 """Check whether an issue filename conforms to naming conventions.
36 Args:
37 filename: The basename of the issue file (e.g. 'P2-BUG-010-my-issue.md').
39 Returns:
40 True if the filename matches ``^P[0-5]-(BUG|FEAT|ENH|EPIC)-[0-9]{3,}-[a-z0-9-]+\\.md$``.
41 """
42 return bool(_NORMALIZED_RE.match(filename))
45def is_formatted(issue_path: Path, templates_dir: Path | None = None) -> bool:
46 """Check whether an issue file has been formatted.
48 An issue is considered formatted if either:
49 1. Its ## Session Log contains a ``/ll:format-issue`` entry, OR
50 2. It has all required sections per its type template (structural check).
52 Args:
53 issue_path: Path to the issue markdown file.
54 templates_dir: Optional override for the templates directory.
56 Returns:
57 True if the issue is formatted by either criterion, False otherwise.
58 Returns False for files whose type cannot be determined or whose template
59 cannot be loaded.
60 """
61 from little_loops.issue_template import load_issue_sections
62 from little_loops.session_log import parse_session_log
64 try:
65 content = issue_path.read_text(encoding="utf-8")
66 except Exception:
67 return False
69 # Criterion 1: /ll:format-issue appears in the session log
70 if "/ll:format-issue" in parse_session_log(content):
71 return True
73 # Criterion 2: all required sections are present as ## headings
74 type_match = _ISSUE_TYPE_RE.search(issue_path.name)
75 if not type_match:
76 return False
77 issue_type = type_match.group(1)
79 try:
80 sections_data = load_issue_sections(issue_type, templates_dir)
81 except Exception:
82 return False
84 required: set[str] = set()
85 for name, defn in sections_data.get("common_sections", {}).items():
86 if defn.get("required") is True and not defn.get("deprecated", False):
87 required.add(name)
88 for name, defn in sections_data.get("type_sections", {}).items():
89 if defn.get("level") == "required" and not defn.get("deprecated", False):
90 required.add(name)
92 if not required:
93 return True
95 headings = {m.strip() for m in re.findall(r"^##\s+(.+)$", content, re.MULTILINE)}
96 return required.issubset(headings)
99def slugify(text: str) -> str:
100 """Convert text to slug format for filenames.
102 Args:
103 text: Text to convert
105 Returns:
106 Lowercase slug with hyphens
107 """
108 text = re.sub(r"[^\w\s-]", "", text)
109 text = re.sub(r"[-\s]+", "-", text)
110 return text.strip("-").lower()
113def get_next_issue_number(config: BRConfig, category: str | None = None) -> int:
114 """Determine the next globally unique issue number.
116 Scans ALL issue directories (active and completed) to find the highest
117 existing number across ALL issue types (BUG, FEAT, ENH). Issue numbers
118 are globally unique regardless of type.
120 Args:
121 config: Project configuration
122 category: Unused, kept for backwards compatibility
124 Returns:
125 Next available issue number (globally unique across all types)
126 """
127 max_num = 0
129 # Get all known prefixes from configuration
130 all_prefixes = [cat_config.prefix for cat_config in config.issues.categories.values()]
132 # Directories to scan: ALL category directories. Status (open/done/deferred)
133 # now lives in frontmatter, so all issues — active and inactive — are in
134 # their type dir. We still scan the legacy completed/ and deferred/ dirs
135 # if they happen to exist (in-flight migration safety).
136 dirs_to_scan: list[Path] = []
137 for cat_name in config.issues.categories:
138 dirs_to_scan.append(config.get_issue_dir(cat_name))
139 legacy_completed = config.project_root / config.issues.base_dir / "completed"
140 legacy_deferred = config.project_root / config.issues.base_dir / "deferred"
141 if legacy_completed.exists():
142 dirs_to_scan.append(legacy_completed)
143 if legacy_deferred.exists():
144 dirs_to_scan.append(legacy_deferred)
146 if not all_prefixes:
147 return max_num + 1
149 # Pre-compile a single union regex to match any known prefix
150 prefix_pattern = re.compile(r"(?:" + "|".join(re.escape(p) for p in all_prefixes) + r")-(\d+)")
152 for dir_path in dirs_to_scan:
153 if not dir_path.exists():
154 continue
155 for file in dir_path.glob("*.md"):
156 match = prefix_pattern.search(file.name)
157 if match:
158 num = int(match.group(1))
159 if num > max_num:
160 max_num = num
162 return max_num + 1
165@dataclass
166class ProductImpact:
167 """Product impact assessment for an issue.
169 Attributes:
170 goal_alignment: ID of the strategic priority this supports
171 persona_impact: ID of the persona affected
172 business_value: Business value assessment (high|medium|low)
173 user_benefit: Description of how this helps the target user
174 """
176 goal_alignment: str | None = None
177 persona_impact: str | None = None
178 business_value: str | None = None # high|medium|low
179 user_benefit: str | None = None
181 def to_dict(self) -> dict[str, Any]:
182 """Convert to dictionary for JSON serialization."""
183 return {
184 "goal_alignment": self.goal_alignment,
185 "persona_impact": self.persona_impact,
186 "business_value": self.business_value,
187 "user_benefit": self.user_benefit,
188 }
190 @classmethod
191 def from_dict(cls, data: dict[str, Any] | None) -> ProductImpact | None:
192 """Create ProductImpact from dictionary.
194 Args:
195 data: Dictionary with product impact fields, or None
197 Returns:
198 ProductImpact instance or None if data is None/empty
199 """
200 if not data:
201 return None
202 return cls(
203 goal_alignment=data.get("goal_alignment"),
204 persona_impact=data.get("persona_impact"),
205 business_value=data.get("business_value"),
206 user_benefit=data.get("user_benefit"),
207 )
210@dataclass
211class IssueInfo:
212 """Parsed information from an issue file.
214 Attributes:
215 path: Path to the issue file
216 issue_type: Type of issue (e.g., "bugs", "features")
217 priority: Priority level (e.g., "P0", "P1")
218 issue_id: Issue identifier (e.g., "BUG-123")
219 title: Issue title from markdown header
220 blocked_by: List of issue IDs that block this issue
221 blocks: List of issue IDs that this issue blocks
222 discovered_by: Source command/workflow that created this issue
223 product_impact: Product impact assessment (optional)
224 effort: Effort estimate (1=low, 2=medium, 3=high), inferred from priority if absent
225 impact: Impact estimate (1=low, 2=medium, 3=high), inferred from priority if absent
226 confidence_score: Readiness score (0-100) written by /ll:confidence-check, or None
227 outcome_confidence: Outcome confidence (0-100) written by /ll:confidence-check, or None
228 score_complexity: Outcome criterion A – Complexity (0-25), written by /ll:confidence-check, or None
229 score_test_coverage: Outcome criterion B – Test Coverage (0-25), written by /ll:confidence-check, or None
230 score_ambiguity: Outcome criterion C – Ambiguity (0-25), written by /ll:confidence-check, or None
231 score_change_surface: Outcome criterion D – Change Surface (0-25), written by /ll:confidence-check, or None
232 testable: Whether TDD phase should be applied; False skips TDD, None treated as testable
233 session_commands: Distinct /ll:* commands found in the ## Session Log section
234 session_command_counts: Per-command occurrence counts from the ## Session Log section
235 labels: Labels extracted from the ## Labels section of the issue file
236 milestone: Sprint or milestone name this issue is assigned to; None if unassigned
237 status: Issue lifecycle status read from frontmatter; defaults to "open"
238 parent: Parent issue ID (e.g., EPIC-123); populated from frontmatter `parent:` or deprecated `parent_issue:`
239 depends_on: List of issue IDs this issue depends on (soft prerequisite)
240 relates_to: List of related issue IDs; populated from frontmatter `relates_to:` or deprecated `related:`
241 duplicate_of: Issue ID that this issue duplicates
242 """
244 path: Path
245 issue_type: str
246 priority: str
247 issue_id: str
248 title: str
249 blocked_by: list[str] = field(default_factory=list)
250 blocks: list[str] = field(default_factory=list)
251 parent: str | None = None
252 depends_on: list[str] = field(default_factory=list)
253 relates_to: list[str] = field(default_factory=list)
254 duplicate_of: str | None = None
255 discovered_by: str | None = None
256 epic: str | None = None
257 product_impact: ProductImpact | None = None
258 effort: int | None = None
259 impact: int | None = None
260 confidence_score: int | None = None
261 outcome_confidence: int | None = None
262 score_complexity: int | None = None
263 score_test_coverage: int | None = None
264 score_ambiguity: int | None = None
265 score_change_surface: int | None = None
266 size: str | None = None
267 testable: bool | None = None
268 decision_needed: bool | None = None
269 missing_artifacts: bool | None = None
270 session_commands: list[str] = field(default_factory=list)
271 session_command_counts: dict[str, int] = field(default_factory=dict)
272 labels: list[str] = field(default_factory=list)
273 milestone: str | None = None
274 status: str = "open"
276 @property
277 def priority_int(self) -> int:
278 """Convert priority to integer for comparison (lower = higher priority)."""
279 # Support P0-P5 priorities
280 match = re.match(r"^P(\d+)$", self.priority)
281 if match:
282 return int(match.group(1))
283 return 99 # Unknown priority sorts last
285 def to_dict(self) -> dict[str, Any]:
286 """Convert to dictionary for JSON serialization."""
287 return {
288 "path": str(self.path),
289 "issue_type": self.issue_type,
290 "priority": self.priority,
291 "issue_id": self.issue_id,
292 "title": self.title,
293 "blocked_by": self.blocked_by,
294 "blocks": self.blocks,
295 "parent": self.parent,
296 "depends_on": self.depends_on,
297 "relates_to": self.relates_to,
298 "duplicate_of": self.duplicate_of,
299 "discovered_by": self.discovered_by,
300 "epic": self.epic,
301 "product_impact": (self.product_impact.to_dict() if self.product_impact else None),
302 "effort": self.effort,
303 "impact": self.impact,
304 "confidence_score": self.confidence_score,
305 "outcome_confidence": self.outcome_confidence,
306 "score_complexity": self.score_complexity,
307 "score_test_coverage": self.score_test_coverage,
308 "score_ambiguity": self.score_ambiguity,
309 "score_change_surface": self.score_change_surface,
310 "size": self.size,
311 "testable": self.testable,
312 "decision_needed": self.decision_needed,
313 "missing_artifacts": self.missing_artifacts,
314 "session_commands": self.session_commands,
315 "session_command_counts": self.session_command_counts,
316 "labels": self.labels,
317 "milestone": self.milestone,
318 "status": self.status,
319 }
321 @classmethod
322 def from_dict(cls, data: dict[str, Any]) -> IssueInfo:
323 """Create IssueInfo from dictionary."""
324 return cls(
325 path=Path(data["path"]),
326 issue_type=data["issue_type"],
327 priority=data["priority"],
328 issue_id=data["issue_id"],
329 title=data["title"],
330 blocked_by=data.get("blocked_by", []),
331 blocks=data.get("blocks", []),
332 parent=data.get("parent"),
333 depends_on=data.get("depends_on", []),
334 relates_to=data.get("relates_to", []),
335 duplicate_of=data.get("duplicate_of"),
336 discovered_by=data.get("discovered_by"),
337 epic=data.get("epic"),
338 product_impact=ProductImpact.from_dict(data.get("product_impact")),
339 effort=data.get("effort"),
340 impact=data.get("impact"),
341 confidence_score=data.get("confidence_score"),
342 outcome_confidence=data.get("outcome_confidence"),
343 score_complexity=data.get("score_complexity"),
344 score_test_coverage=data.get("score_test_coverage"),
345 score_ambiguity=data.get("score_ambiguity"),
346 score_change_surface=data.get("score_change_surface"),
347 size=data.get("size"),
348 testable=data.get("testable"),
349 decision_needed=data.get("decision_needed"),
350 missing_artifacts=data.get("missing_artifacts"),
351 session_commands=data.get("session_commands", []),
352 session_command_counts=data.get("session_command_counts", {}),
353 labels=data.get("labels", []),
354 milestone=data.get("milestone"),
355 status=data.get("status", "open"),
356 )
359class IssueParser:
360 """Parses issue files based on project configuration.
362 Uses BRConfig to understand issue categories, prefixes, and priorities.
363 """
365 def __init__(self, config: BRConfig) -> None:
366 """Initialize parser with project configuration.
368 Args:
369 config: Project configuration
370 """
371 self.config = config
372 self._build_prefix_map()
374 def _build_prefix_map(self) -> None:
375 """Build mapping from issue prefixes to category names."""
376 self._prefix_to_category: dict[str, str] = {}
377 for category_name, category in self.config.issues.categories.items():
378 self._prefix_to_category[category.prefix] = category_name
380 def parse_file(self, issue_path: Path) -> IssueInfo:
381 """Parse an issue file to extract metadata.
383 Args:
384 issue_path: Path to the issue markdown file
386 Returns:
387 Parsed IssueInfo
388 """
389 filename = issue_path.name
391 # Parse priority from filename prefix (e.g., P1-BUG-123-...)
392 priority = self._parse_priority(filename)
394 # Parse issue type and ID from filename
395 issue_type, issue_id = self._parse_type_and_id(filename, issue_path)
397 # Read content once for all content-based parsing
398 content = self._read_content(issue_path)
400 # Parse frontmatter for discovered_by, epic, product impact, effort, and impact
401 frontmatter = parse_frontmatter(content)
402 discovered_by = frontmatter.get("discovered_by")
403 epic = frontmatter.get("epic")
404 size = frontmatter.get("size")
405 product_impact = self._parse_product_impact(frontmatter)
406 effort_raw = frontmatter.get("effort")
407 impact_raw = frontmatter.get("impact")
408 effort = int(effort_raw) if effort_raw is not None and str(effort_raw).isdigit() else None
409 impact = int(impact_raw) if impact_raw is not None and str(impact_raw).isdigit() else None
410 confidence_raw = frontmatter.get("confidence_score")
411 outcome_raw = frontmatter.get("outcome_confidence")
412 confidence_score = (
413 int(confidence_raw)
414 if confidence_raw is not None and str(confidence_raw).isdigit()
415 else None
416 )
417 outcome_confidence = (
418 int(outcome_raw) if outcome_raw is not None and str(outcome_raw).isdigit() else None
419 )
420 complexity_raw = frontmatter.get("score_complexity")
421 test_coverage_raw = frontmatter.get("score_test_coverage")
422 ambiguity_raw = frontmatter.get("score_ambiguity")
423 change_surface_raw = frontmatter.get("score_change_surface")
424 score_complexity = (
425 int(complexity_raw)
426 if complexity_raw is not None and str(complexity_raw).isdigit()
427 else None
428 )
429 score_test_coverage = (
430 int(test_coverage_raw)
431 if test_coverage_raw is not None and str(test_coverage_raw).isdigit()
432 else None
433 )
434 score_ambiguity = (
435 int(ambiguity_raw)
436 if ambiguity_raw is not None and str(ambiguity_raw).isdigit()
437 else None
438 )
439 score_change_surface = (
440 int(change_surface_raw)
441 if change_surface_raw is not None and str(change_surface_raw).isdigit()
442 else None
443 )
444 testable_raw = frontmatter.get("testable")
445 if isinstance(testable_raw, str):
446 testable_value: bool | None = (
447 testable_raw.lower() == "true"
448 if testable_raw.lower() in ("true", "false")
449 else None
450 )
451 else:
452 testable_value = testable_raw
454 decision_needed_raw = frontmatter.get("decision_needed")
455 if isinstance(decision_needed_raw, str):
456 decision_needed_value: bool | None = (
457 decision_needed_raw.lower() == "true"
458 if decision_needed_raw.lower() in ("true", "false")
459 else None
460 )
461 else:
462 decision_needed_value = decision_needed_raw
464 missing_artifacts_raw = frontmatter.get("missing_artifacts")
465 if isinstance(missing_artifacts_raw, str):
466 missing_artifacts_value: bool | None = (
467 missing_artifacts_raw.lower() == "true"
468 if missing_artifacts_raw.lower() in ("true", "false")
469 else None
470 )
471 else:
472 missing_artifacts_value = missing_artifacts_raw
474 status = frontmatter.get("status", "open")
476 parent = frontmatter.get("parent")
477 if parent is None and (alias_val := frontmatter.get("parent_issue")):
478 logger.warning(
479 "%s: deprecated frontmatter key 'parent_issue' — rename to 'parent'",
480 issue_path.name,
481 )
482 parent = alias_val
484 duplicate_of = frontmatter.get("duplicate_of")
486 relates_to: list[str] = []
487 if alias_val := frontmatter.get("related"):
488 logger.warning(
489 "%s: deprecated frontmatter key 'related' — rename to 'relates_to'",
490 issue_path.name,
491 )
492 relates_to = (
493 [id.strip() for id in alias_val.strip("\"'").split(",") if id.strip()]
494 if isinstance(alias_val, str)
495 else list(alias_val)
496 )
498 depends_on: list[str] = []
500 # Parse title and dependencies from file content
501 title = self._parse_title_from_content(content, issue_path)
502 blocked_by = self._parse_blocked_by(content)
503 blocks = self._parse_blocks(content)
505 # Also read blocked_by/blocks/depends_on/relates_to from frontmatter (canonical format).
506 # When both sources provide values and they differ, prefer frontmatter and warn
507 # so stale body sections are surfaced rather than silently merged.
508 for fm_key, body_ids in (
509 ("blocked_by", blocked_by),
510 ("blocks", blocks),
511 ("depends_on", depends_on),
512 ("relates_to", relates_to),
513 ):
514 fm_val = frontmatter.get(fm_key)
515 if not fm_val:
516 continue
517 fm_ids = (
518 [id.strip() for id in fm_val.strip("\"'").split(",") if id.strip()]
519 if isinstance(fm_val, str)
520 else list(fm_val)
521 )
522 if body_ids and set(fm_ids) != set(body_ids):
523 logger.warning(
524 "%s: frontmatter %s %s conflicts with body section %s; "
525 "preferring frontmatter — update or remove the stale body section",
526 issue_path.name,
527 fm_key,
528 fm_ids,
529 body_ids,
530 )
531 body_ids.clear()
532 body_ids.extend(fm_ids)
533 elif not body_ids:
534 body_ids.extend(fm_ids)
536 # Parse labels from frontmatter
537 labels: list[str] = []
538 fm_labels = frontmatter.get("labels")
539 if fm_labels:
540 if isinstance(fm_labels, str):
541 labels = [lb.strip() for lb in fm_labels.split(",") if lb.strip()]
542 else:
543 labels = [str(lb) for lb in fm_labels]
545 # Parse milestone from frontmatter
546 milestone: str | None = frontmatter.get("milestone") or None
548 # Parse session commands from ## Session Log section
549 from little_loops.session_log import count_session_commands, parse_session_log
551 session_commands = parse_session_log(content)
552 session_command_counts = count_session_commands(content)
554 return IssueInfo(
555 path=issue_path,
556 issue_type=issue_type,
557 priority=priority,
558 issue_id=issue_id,
559 title=title,
560 blocked_by=blocked_by,
561 blocks=blocks,
562 parent=parent,
563 depends_on=depends_on,
564 relates_to=relates_to,
565 duplicate_of=duplicate_of,
566 discovered_by=discovered_by,
567 epic=epic,
568 product_impact=product_impact,
569 effort=effort,
570 impact=impact,
571 confidence_score=confidence_score,
572 outcome_confidence=outcome_confidence,
573 score_complexity=score_complexity,
574 score_test_coverage=score_test_coverage,
575 score_ambiguity=score_ambiguity,
576 score_change_surface=score_change_surface,
577 size=size,
578 testable=testable_value,
579 decision_needed=decision_needed_value,
580 missing_artifacts=missing_artifacts_value,
581 session_commands=session_commands,
582 session_command_counts=session_command_counts,
583 labels=labels,
584 milestone=milestone,
585 status=status,
586 )
588 def _parse_priority(self, filename: str) -> str:
589 """Extract priority from filename.
591 Args:
592 filename: Issue filename
594 Returns:
595 Priority string (e.g., "P1") or last priority if not found
596 """
597 for priority in self.config.issue_priorities:
598 if filename.startswith(f"{priority}-"):
599 return priority
600 # Default to lowest priority if not found
601 return self.config.issue_priorities[-1] if self.config.issue_priorities else "P3"
603 def _get_category_for_prefix(self, prefix: str) -> str:
604 """Get category name from issue prefix.
606 Args:
607 prefix: Issue prefix (e.g., "BUG", "FEAT")
609 Returns:
610 Category name (e.g., "bugs", "features"), defaults to "bugs"
611 """
612 return self._prefix_to_category.get(prefix, "bugs")
614 def _parse_type_and_id(self, filename: str, issue_path: Path) -> tuple[str, str]:
615 """Extract issue type and ID from filename.
617 Args:
618 filename: Issue filename
619 issue_path: Full path to issue file
621 Returns:
622 Tuple of (issue_type, issue_id)
623 """
624 # Try to match known prefixes (BUG, FEAT, ENH, etc.)
625 for prefix, category in self._prefix_to_category.items():
626 pattern = rf"({prefix})-(\d+)"
627 match = re.search(pattern, filename)
628 if match:
629 issue_id = f"{match.group(1)}-{match.group(2)}"
630 return category, issue_id
632 # Fall back to inferring category from directory.
633 parent_name = issue_path.parent.name
634 for category_name, category_config in self.config.issues.categories.items():
635 if parent_name == category_config.dir:
636 # If the filename uses the standard P[0-5]-NNN-... shape but
637 # omits the type token, capture the number directly and pair
638 # it with the directory-derived prefix. Without this, generic
639 # number scanning would pick up the priority digit instead.
640 priority_match = re.match(r"^P\d+-(\d+)(?:[-.]|$)", filename)
641 if priority_match:
642 return category_name, f"{category_config.prefix}-{priority_match.group(1)}"
643 issue_id = self._generate_id_from_filename(filename, category_config.prefix)
644 return category_name, issue_id
646 # Last resort: use filename as ID
647 return "bugs", filename.replace(".md", "")
649 def _generate_id_from_filename(self, filename: str, prefix: str) -> str:
650 """Generate an issue ID from filename when not explicitly present.
652 Args:
653 filename: Issue filename
654 prefix: Issue prefix to use
656 Returns:
657 Generated issue ID
658 """
659 # Strip a leading priority token (e.g. "P2-") so it does not get
660 # picked up as the issue number by the generic digit scan below.
661 scan_target = re.sub(r"^P\d+-", "", filename)
662 numbers = re.findall(r"\d+", scan_target)
663 if numbers:
664 return f"{prefix}-{numbers[0]}"
665 # Use next sequential number instead of hash-based fallback
666 # This ensures IDs are deterministic and don't collide with existing issues
667 category = self._get_category_for_prefix(prefix)
668 next_num = get_next_issue_number(self.config, category)
669 return f"{prefix}-{next_num:03d}"
671 def _read_content(self, issue_path: Path) -> str:
672 """Read file content, returning empty string on error.
674 Args:
675 issue_path: Path to issue file
677 Returns:
678 File content or empty string on error
679 """
680 try:
681 return issue_path.read_text(encoding="utf-8")
682 except Exception as e:
683 logger.warning("Failed to read %s: %s", issue_path.name, e)
684 return ""
686 def _parse_title_from_content(self, content: str, issue_path: Path) -> str:
687 """Extract title from issue file content.
689 Args:
690 content: Pre-read file content
691 issue_path: Path to issue file (for fallback)
693 Returns:
694 Issue title or filename stem as fallback
695 """
696 if content:
697 # Look for markdown header: # ISSUE-ID: Title
698 match = re.search(r"^#\s+[\w-]+:\s*(.+)$", content, re.MULTILINE)
699 if match:
700 return match.group(1).strip()
701 # Try first header of any format
702 match = re.search(r"^#\s+(.+)$", content, re.MULTILINE)
703 if match:
704 return match.group(1).strip()
705 # Fall back to filename
706 return issue_path.stem
708 def _parse_section_items(self, content: str, section_name: str) -> list[str]:
709 """Extract issue IDs from a markdown section.
711 Finds section header (## Section Name) and extracts issue IDs
712 from list items until the next section or end of file.
713 Skips content inside code fences.
715 Args:
716 content: File content to parse
717 section_name: Section name to find (e.g., "Blocked By")
719 Returns:
720 List of issue IDs found in the section
721 """
722 if not content:
723 return []
725 # Strip code fences to avoid matching sections in examples
726 content_without_code = self._strip_code_fences(content)
728 # Match section header case-insensitively
729 section_pattern = rf"^##\s+{re.escape(section_name)}\s*$"
730 match = re.search(section_pattern, content_without_code, re.MULTILINE | re.IGNORECASE)
731 if not match:
732 return []
734 # Get content after section header until next ## header or end
735 start = match.end()
736 next_section = re.search(r"^##\s+", content_without_code[start:], re.MULTILINE)
737 if next_section:
738 section_content = content_without_code[start : start + next_section.start()]
739 else:
740 section_content = content_without_code[start:]
742 # Extract issue IDs from list items
743 issue_ids = ISSUE_ID_PATTERN.findall(section_content)
744 return issue_ids
746 def _strip_code_fences(self, content: str) -> str:
747 """Remove code fence blocks from content.
749 Replaces content between ``` markers with empty lines to preserve
750 line numbers while removing code fence content from parsing.
752 Args:
753 content: File content
755 Returns:
756 Content with code fence blocks replaced by empty lines
757 """
758 # Match code fences: ``` or ```language through closing ```
759 result = []
760 in_fence = False
761 for line in content.split("\n"):
762 if line.startswith("```"):
763 in_fence = not in_fence
764 result.append("") # Preserve line count
765 elif in_fence:
766 result.append("") # Replace fenced content with empty line
767 else:
768 result.append(line)
769 return "\n".join(result)
771 def _parse_blocked_by(self, content: str) -> list[str]:
772 """Extract issue IDs from ## Blocked By section.
774 Args:
775 content: File content to parse
777 Returns:
778 List of issue IDs that block this issue
779 """
780 return self._parse_section_items(content, "Blocked By")
782 def _parse_blocks(self, content: str) -> list[str]:
783 """Extract issue IDs from ## Blocks section.
785 Args:
786 content: File content to parse
788 Returns:
789 List of issue IDs that this issue blocks
790 """
791 return self._parse_section_items(content, "Blocks")
793 def _parse_product_impact(self, frontmatter: dict[str, Any]) -> ProductImpact | None:
794 """Extract product impact from frontmatter.
796 Args:
797 frontmatter: Dictionary of frontmatter fields
799 Returns:
800 ProductImpact instance if any product fields are present, None otherwise
801 """
802 # Check if any product fields are present
803 product_fields = ("goal_alignment", "persona_impact", "business_value", "user_benefit")
804 if not any(frontmatter.get(key) for key in product_fields):
805 return None
807 return ProductImpact(
808 goal_alignment=frontmatter.get("goal_alignment"),
809 persona_impact=frontmatter.get("persona_impact"),
810 business_value=frontmatter.get("business_value"),
811 user_benefit=frontmatter.get("user_benefit"),
812 )
815def find_issues(
816 config: BRConfig,
817 category: str | None = None,
818 skip_ids: set[str] | None = None,
819 only_ids: list[str] | set[str] | None = None,
820 type_prefixes: set[str] | None = None,
821) -> list[IssueInfo]:
822 """Find all issues matching criteria.
824 Args:
825 config: Project configuration
826 category: Optional category to filter (e.g., "bugs")
827 skip_ids: Issue IDs to skip
828 only_ids: If provided, only include these issue IDs. When a list,
829 results are returned in list order (input sequence preserved).
830 When a set, results are sorted by priority as usual.
831 type_prefixes: If provided, only include issues whose ID starts with
832 one of these prefixes (e.g., {"BUG", "ENH"})
834 Returns:
835 List of IssueInfo sorted by priority, or in only_ids list order when
836 only_ids is a list
837 """
838 skip_ids = skip_ids or set()
839 parser = IssueParser(config)
840 issues: list[IssueInfo] = []
842 # Determine which categories to search
843 if category:
844 categories = [category] if category in config.issue_categories else []
845 else:
846 categories = config.issue_categories
848 for cat in categories:
849 issue_dir = config.get_issue_dir(cat)
850 if not issue_dir.exists():
851 continue
853 for issue_file in issue_dir.glob("*.md"):
854 info = parser.parse_file(issue_file)
855 # Status-based filter: skip done/cancelled/deferred regardless of dir
856 if info.status in ("done", "cancelled", "deferred"):
857 continue
858 # Apply skip filter
859 if info.issue_id in skip_ids:
860 continue
861 # Apply only filter (if specified)
862 if only_ids is not None and not any(_id_matches(info.issue_id, p) for p in only_ids):
863 continue
864 # Apply type filter (if specified)
865 if type_prefixes is not None:
866 prefix = info.issue_id.split("-", 1)[0]
867 if prefix not in type_prefixes:
868 continue
869 issues.append(info)
871 # When only_ids is a list, preserve input order; otherwise sort by priority
872 if isinstance(only_ids, list):
873 issues.sort(
874 key=lambda x: next(
875 (i for i, p in enumerate(only_ids) if _id_matches(x.issue_id, p)),
876 len(only_ids),
877 )
878 )
879 else:
880 issues.sort(key=lambda x: (x.priority_int, x.issue_id))
881 return issues
884def find_highest_priority_issue(
885 config: BRConfig,
886 category: str | None = None,
887 skip_ids: set[str] | None = None,
888 only_ids: set[str] | None = None,
889 type_prefixes: set[str] | None = None,
890) -> IssueInfo | None:
891 """Find the highest priority issue.
893 Args:
894 config: Project configuration
895 category: Optional category to filter
896 skip_ids: Issue IDs to skip
897 only_ids: If provided, only include these issue IDs
898 type_prefixes: If provided, only include issues with these type prefixes
900 Returns:
901 Highest priority IssueInfo or None if no issues found
902 """
903 issues = find_issues(config, category, skip_ids, only_ids, type_prefixes)
904 return issues[0] if issues else None