Coverage for little_loops / issue_parser.py: 24%
339 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-16 13:08 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-16 13:08 -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 implementation_order_risk: bool | None = None
271 learning_tests_required: list[str] | None = None
272 session_commands: list[str] = field(default_factory=list)
273 session_command_counts: dict[str, int] = field(default_factory=dict)
274 labels: list[str] = field(default_factory=list)
275 milestone: str | None = None
276 status: str = "open"
278 @property
279 def priority_int(self) -> int:
280 """Convert priority to integer for comparison (lower = higher priority)."""
281 # Support P0-P5 priorities
282 match = re.match(r"^P(\d+)$", self.priority)
283 if match:
284 return int(match.group(1))
285 return 99 # Unknown priority sorts last
287 def to_dict(self) -> dict[str, Any]:
288 """Convert to dictionary for JSON serialization."""
289 return {
290 "path": str(self.path),
291 "issue_type": self.issue_type,
292 "priority": self.priority,
293 "issue_id": self.issue_id,
294 "title": self.title,
295 "blocked_by": self.blocked_by,
296 "blocks": self.blocks,
297 "parent": self.parent,
298 "depends_on": self.depends_on,
299 "relates_to": self.relates_to,
300 "duplicate_of": self.duplicate_of,
301 "discovered_by": self.discovered_by,
302 "epic": self.epic,
303 "product_impact": (self.product_impact.to_dict() if self.product_impact else None),
304 "effort": self.effort,
305 "impact": self.impact,
306 "confidence_score": self.confidence_score,
307 "outcome_confidence": self.outcome_confidence,
308 "score_complexity": self.score_complexity,
309 "score_test_coverage": self.score_test_coverage,
310 "score_ambiguity": self.score_ambiguity,
311 "score_change_surface": self.score_change_surface,
312 "size": self.size,
313 "testable": self.testable,
314 "decision_needed": self.decision_needed,
315 "missing_artifacts": self.missing_artifacts,
316 "implementation_order_risk": self.implementation_order_risk,
317 "learning_tests_required": self.learning_tests_required,
318 "session_commands": self.session_commands,
319 "session_command_counts": self.session_command_counts,
320 "labels": self.labels,
321 "milestone": self.milestone,
322 "status": self.status,
323 }
325 @classmethod
326 def from_dict(cls, data: dict[str, Any]) -> IssueInfo:
327 """Create IssueInfo from dictionary."""
328 return cls(
329 path=Path(data["path"]),
330 issue_type=data["issue_type"],
331 priority=data["priority"],
332 issue_id=data["issue_id"],
333 title=data["title"],
334 blocked_by=data.get("blocked_by", []),
335 blocks=data.get("blocks", []),
336 parent=data.get("parent"),
337 depends_on=data.get("depends_on", []),
338 relates_to=data.get("relates_to", []),
339 duplicate_of=data.get("duplicate_of"),
340 discovered_by=data.get("discovered_by"),
341 epic=data.get("epic"),
342 product_impact=ProductImpact.from_dict(data.get("product_impact")),
343 effort=data.get("effort"),
344 impact=data.get("impact"),
345 confidence_score=data.get("confidence_score"),
346 outcome_confidence=data.get("outcome_confidence"),
347 score_complexity=data.get("score_complexity"),
348 score_test_coverage=data.get("score_test_coverage"),
349 score_ambiguity=data.get("score_ambiguity"),
350 score_change_surface=data.get("score_change_surface"),
351 size=data.get("size"),
352 testable=data.get("testable"),
353 decision_needed=data.get("decision_needed"),
354 missing_artifacts=data.get("missing_artifacts"),
355 implementation_order_risk=data.get("implementation_order_risk"),
356 learning_tests_required=data.get("learning_tests_required"),
357 session_commands=data.get("session_commands", []),
358 session_command_counts=data.get("session_command_counts", {}),
359 labels=data.get("labels", []),
360 milestone=data.get("milestone"),
361 status=data.get("status", "open"),
362 )
365class IssueParser:
366 """Parses issue files based on project configuration.
368 Uses BRConfig to understand issue categories, prefixes, and priorities.
369 """
371 def __init__(self, config: BRConfig) -> None:
372 """Initialize parser with project configuration.
374 Args:
375 config: Project configuration
376 """
377 self.config = config
378 self._build_prefix_map()
380 def _build_prefix_map(self) -> None:
381 """Build mapping from issue prefixes to category names."""
382 self._prefix_to_category: dict[str, str] = {}
383 for category_name, category in self.config.issues.categories.items():
384 self._prefix_to_category[category.prefix] = category_name
386 def parse_file(self, issue_path: Path) -> IssueInfo:
387 """Parse an issue file to extract metadata.
389 Args:
390 issue_path: Path to the issue markdown file
392 Returns:
393 Parsed IssueInfo
394 """
395 filename = issue_path.name
397 # Parse priority from filename prefix (e.g., P1-BUG-123-...)
398 priority = self._parse_priority(filename)
400 # Parse issue type and ID from filename
401 issue_type, issue_id = self._parse_type_and_id(filename, issue_path)
403 # Read content once for all content-based parsing
404 content = self._read_content(issue_path)
406 # Parse frontmatter for discovered_by, epic, product impact, effort, and impact
407 frontmatter = parse_frontmatter(content)
408 discovered_by = frontmatter.get("discovered_by")
409 epic = frontmatter.get("epic")
410 size = frontmatter.get("size")
411 product_impact = self._parse_product_impact(frontmatter)
412 effort_raw = frontmatter.get("effort")
413 impact_raw = frontmatter.get("impact")
414 effort = int(effort_raw) if effort_raw is not None and str(effort_raw).isdigit() else None
415 impact = int(impact_raw) if impact_raw is not None and str(impact_raw).isdigit() else None
416 confidence_raw = frontmatter.get("confidence_score")
417 outcome_raw = frontmatter.get("outcome_confidence")
418 confidence_score = (
419 int(confidence_raw)
420 if confidence_raw is not None and str(confidence_raw).isdigit()
421 else None
422 )
423 outcome_confidence = (
424 int(outcome_raw) if outcome_raw is not None and str(outcome_raw).isdigit() else None
425 )
426 complexity_raw = frontmatter.get("score_complexity")
427 test_coverage_raw = frontmatter.get("score_test_coverage")
428 ambiguity_raw = frontmatter.get("score_ambiguity")
429 change_surface_raw = frontmatter.get("score_change_surface")
430 score_complexity = (
431 int(complexity_raw)
432 if complexity_raw is not None and str(complexity_raw).isdigit()
433 else None
434 )
435 score_test_coverage = (
436 int(test_coverage_raw)
437 if test_coverage_raw is not None and str(test_coverage_raw).isdigit()
438 else None
439 )
440 score_ambiguity = (
441 int(ambiguity_raw)
442 if ambiguity_raw is not None and str(ambiguity_raw).isdigit()
443 else None
444 )
445 score_change_surface = (
446 int(change_surface_raw)
447 if change_surface_raw is not None and str(change_surface_raw).isdigit()
448 else None
449 )
450 testable_raw = frontmatter.get("testable")
451 if isinstance(testable_raw, str):
452 testable_value: bool | None = (
453 testable_raw.lower() == "true"
454 if testable_raw.lower() in ("true", "false")
455 else None
456 )
457 else:
458 testable_value = testable_raw
460 decision_needed_raw = frontmatter.get("decision_needed")
461 if isinstance(decision_needed_raw, str):
462 decision_needed_value: bool | None = (
463 decision_needed_raw.lower() == "true"
464 if decision_needed_raw.lower() in ("true", "false")
465 else None
466 )
467 else:
468 decision_needed_value = decision_needed_raw
470 missing_artifacts_raw = frontmatter.get("missing_artifacts")
471 if isinstance(missing_artifacts_raw, str):
472 missing_artifacts_value: bool | None = (
473 missing_artifacts_raw.lower() == "true"
474 if missing_artifacts_raw.lower() in ("true", "false")
475 else None
476 )
477 else:
478 missing_artifacts_value = missing_artifacts_raw
480 implementation_order_risk_raw = frontmatter.get("implementation_order_risk")
481 if isinstance(implementation_order_risk_raw, str):
482 implementation_order_risk_value: bool | None = (
483 implementation_order_risk_raw.lower() == "true"
484 if implementation_order_risk_raw.lower() in ("true", "false")
485 else None
486 )
487 else:
488 implementation_order_risk_value = implementation_order_risk_raw
490 learning_tests_raw = frontmatter.get("learning_tests_required")
491 if isinstance(learning_tests_raw, str):
492 learning_tests_required_value: list[str] | None = [
493 t.strip() for t in learning_tests_raw.split(",") if t.strip()
494 ] or None
495 elif isinstance(learning_tests_raw, list):
496 learning_tests_required_value = [str(t) for t in learning_tests_raw] or None
497 else:
498 learning_tests_required_value = None
500 status = frontmatter.get("status", "open")
501 if status == "open" and frontmatter.get("completed_at"):
502 status = "done"
504 parent = frontmatter.get("parent")
505 if parent is None and (alias_val := frontmatter.get("parent_issue")):
506 logger.warning(
507 "%s: deprecated frontmatter key 'parent_issue' — rename to 'parent'",
508 issue_path.name,
509 )
510 parent = alias_val
512 duplicate_of = frontmatter.get("duplicate_of")
514 relates_to: list[str] = []
515 if alias_val := frontmatter.get("related"):
516 logger.warning(
517 "%s: deprecated frontmatter key 'related' — rename to 'relates_to'",
518 issue_path.name,
519 )
520 relates_to = (
521 [id.strip() for id in alias_val.strip("\"'").split(",") if id.strip()]
522 if isinstance(alias_val, str)
523 else list(alias_val)
524 )
526 depends_on: list[str] = []
528 # Parse title: prefer frontmatter title: field, then markdown header, then filename stem
529 title = frontmatter.get("title") or self._parse_title_from_content(content, issue_path)
530 blocked_by = self._parse_blocked_by(content)
531 blocks = self._parse_blocks(content)
533 # Also read blocked_by/blocks/depends_on/relates_to from frontmatter (canonical format).
534 # When both sources provide values and they differ, prefer frontmatter and warn
535 # so stale body sections are surfaced rather than silently merged.
536 for fm_key, body_ids in (
537 ("blocked_by", blocked_by),
538 ("blocks", blocks),
539 ("depends_on", depends_on),
540 ("relates_to", relates_to),
541 ):
542 fm_val = frontmatter.get(fm_key)
543 if not fm_val:
544 continue
545 fm_ids = (
546 [id.strip() for id in fm_val.strip("\"'").split(",") if id.strip()]
547 if isinstance(fm_val, str)
548 else list(fm_val)
549 )
550 if body_ids and set(fm_ids) != set(body_ids):
551 logger.warning(
552 "%s: frontmatter %s %s conflicts with body section %s; "
553 "preferring frontmatter — update or remove the stale body section",
554 issue_path.name,
555 fm_key,
556 fm_ids,
557 body_ids,
558 )
559 body_ids.clear()
560 body_ids.extend(fm_ids)
561 elif not body_ids:
562 body_ids.extend(fm_ids)
564 # Parse labels from frontmatter
565 labels: list[str] = []
566 fm_labels = frontmatter.get("labels")
567 if fm_labels:
568 if isinstance(fm_labels, str):
569 labels = [lb.strip() for lb in fm_labels.split(",") if lb.strip()]
570 else:
571 labels = [str(lb) for lb in fm_labels]
573 # Parse milestone from frontmatter
574 milestone: str | None = frontmatter.get("milestone") or None
576 # Parse session commands from ## Session Log section
577 from little_loops.session_log import count_session_commands, parse_session_log
579 session_commands = parse_session_log(content)
580 session_command_counts = count_session_commands(content)
582 return IssueInfo(
583 path=issue_path,
584 issue_type=issue_type,
585 priority=priority,
586 issue_id=issue_id,
587 title=title,
588 blocked_by=blocked_by,
589 blocks=blocks,
590 parent=parent,
591 depends_on=depends_on,
592 relates_to=relates_to,
593 duplicate_of=duplicate_of,
594 discovered_by=discovered_by,
595 epic=epic,
596 product_impact=product_impact,
597 effort=effort,
598 impact=impact,
599 confidence_score=confidence_score,
600 outcome_confidence=outcome_confidence,
601 score_complexity=score_complexity,
602 score_test_coverage=score_test_coverage,
603 score_ambiguity=score_ambiguity,
604 score_change_surface=score_change_surface,
605 size=size,
606 testable=testable_value,
607 decision_needed=decision_needed_value,
608 missing_artifacts=missing_artifacts_value,
609 implementation_order_risk=implementation_order_risk_value,
610 learning_tests_required=learning_tests_required_value,
611 session_commands=session_commands,
612 session_command_counts=session_command_counts,
613 labels=labels,
614 milestone=milestone,
615 status=status,
616 )
618 def _parse_priority(self, filename: str) -> str:
619 """Extract priority from filename.
621 Args:
622 filename: Issue filename
624 Returns:
625 Priority string (e.g., "P1") or last priority if not found
626 """
627 for priority in self.config.issue_priorities:
628 if filename.startswith(f"{priority}-"):
629 return priority
630 # Default to lowest priority if not found
631 return self.config.issue_priorities[-1] if self.config.issue_priorities else "P3"
633 def _get_category_for_prefix(self, prefix: str) -> str:
634 """Get category name from issue prefix.
636 Args:
637 prefix: Issue prefix (e.g., "BUG", "FEAT")
639 Returns:
640 Category name (e.g., "bugs", "features"), defaults to "bugs"
641 """
642 return self._prefix_to_category.get(prefix, "bugs")
644 def _parse_type_and_id(self, filename: str, issue_path: Path) -> tuple[str, str]:
645 """Extract issue type and ID from filename.
647 Args:
648 filename: Issue filename
649 issue_path: Full path to issue file
651 Returns:
652 Tuple of (issue_type, issue_id)
653 """
654 # Try to match known prefixes (BUG, FEAT, ENH, etc.)
655 for prefix, category in self._prefix_to_category.items():
656 pattern = rf"({prefix})-(\d+)"
657 match = re.search(pattern, filename)
658 if match:
659 issue_id = f"{match.group(1)}-{match.group(2)}"
660 return category, issue_id
662 # Fall back to inferring category from directory.
663 parent_name = issue_path.parent.name
664 for category_name, category_config in self.config.issues.categories.items():
665 if parent_name == category_config.dir:
666 # If the filename uses the standard P[0-5]-NNN-... shape but
667 # omits the type token, capture the number directly and pair
668 # it with the directory-derived prefix. Without this, generic
669 # number scanning would pick up the priority digit instead.
670 priority_match = re.match(r"^P\d+-(\d+)(?:[-.]|$)", filename)
671 if priority_match:
672 return category_name, f"{category_config.prefix}-{priority_match.group(1)}"
673 issue_id = self._generate_id_from_filename(filename, category_config.prefix)
674 return category_name, issue_id
676 # Last resort: use filename as ID
677 return "bugs", filename.replace(".md", "")
679 def _generate_id_from_filename(self, filename: str, prefix: str) -> str:
680 """Generate an issue ID from filename when not explicitly present.
682 Args:
683 filename: Issue filename
684 prefix: Issue prefix to use
686 Returns:
687 Generated issue ID
688 """
689 # Strip a leading priority token (e.g. "P2-") so it does not get
690 # picked up as the issue number by the generic digit scan below.
691 scan_target = re.sub(r"^P\d+-", "", filename)
692 numbers = re.findall(r"\d+", scan_target)
693 if numbers:
694 return f"{prefix}-{numbers[0]}"
695 # Use next sequential number instead of hash-based fallback
696 # This ensures IDs are deterministic and don't collide with existing issues
697 category = self._get_category_for_prefix(prefix)
698 next_num = get_next_issue_number(self.config, category)
699 return f"{prefix}-{next_num:03d}"
701 def _read_content(self, issue_path: Path) -> str:
702 """Read file content, returning empty string on error.
704 Args:
705 issue_path: Path to issue file
707 Returns:
708 File content or empty string on error
709 """
710 try:
711 return issue_path.read_text(encoding="utf-8")
712 except Exception as e:
713 logger.warning("Failed to read %s: %s", issue_path.name, e)
714 return ""
716 def _parse_title_from_content(self, content: str, issue_path: Path) -> str:
717 """Extract title from issue file content.
719 Args:
720 content: Pre-read file content
721 issue_path: Path to issue file (for fallback)
723 Returns:
724 Issue title or filename stem as fallback
725 """
726 if content:
727 # Look for markdown header: # ISSUE-ID: Title
728 match = re.search(r"^#\s+[\w-]+:\s*(.+)$", content, re.MULTILINE)
729 if match:
730 return match.group(1).strip()
731 # Try first header of any format
732 match = re.search(r"^#\s+(.+)$", content, re.MULTILINE)
733 if match:
734 return match.group(1).strip()
735 # Fall back to filename
736 return issue_path.stem
738 def _parse_section_items(self, content: str, section_name: str) -> list[str]:
739 """Extract issue IDs from a markdown section.
741 Finds section header (## Section Name) and extracts issue IDs
742 from list items until the next section or end of file.
743 Skips content inside code fences.
745 Args:
746 content: File content to parse
747 section_name: Section name to find (e.g., "Blocked By")
749 Returns:
750 List of issue IDs found in the section
751 """
752 if not content:
753 return []
755 # Strip code fences to avoid matching sections in examples
756 content_without_code = self._strip_code_fences(content)
758 # Match section header case-insensitively
759 section_pattern = rf"^##\s+{re.escape(section_name)}\s*$"
760 match = re.search(section_pattern, content_without_code, re.MULTILINE | re.IGNORECASE)
761 if not match:
762 return []
764 # Get content after section header until next ## header or end
765 start = match.end()
766 next_section = re.search(r"^##\s+", content_without_code[start:], re.MULTILINE)
767 if next_section:
768 section_content = content_without_code[start : start + next_section.start()]
769 else:
770 section_content = content_without_code[start:]
772 # Extract issue IDs from list items
773 issue_ids = ISSUE_ID_PATTERN.findall(section_content)
774 return issue_ids
776 def _strip_code_fences(self, content: str) -> str:
777 """Remove code fence blocks from content.
779 Replaces content between ``` markers with empty lines to preserve
780 line numbers while removing code fence content from parsing.
782 Args:
783 content: File content
785 Returns:
786 Content with code fence blocks replaced by empty lines
787 """
788 # Match code fences: ``` or ```language through closing ```
789 result = []
790 in_fence = False
791 for line in content.split("\n"):
792 if line.startswith("```"):
793 in_fence = not in_fence
794 result.append("") # Preserve line count
795 elif in_fence:
796 result.append("") # Replace fenced content with empty line
797 else:
798 result.append(line)
799 return "\n".join(result)
801 def _parse_blocked_by(self, content: str) -> list[str]:
802 """Extract issue IDs from ## Blocked By section.
804 Args:
805 content: File content to parse
807 Returns:
808 List of issue IDs that block this issue
809 """
810 return self._parse_section_items(content, "Blocked By")
812 def _parse_blocks(self, content: str) -> list[str]:
813 """Extract issue IDs from ## Blocks section.
815 Args:
816 content: File content to parse
818 Returns:
819 List of issue IDs that this issue blocks
820 """
821 return self._parse_section_items(content, "Blocks")
823 def _parse_product_impact(self, frontmatter: dict[str, Any]) -> ProductImpact | None:
824 """Extract product impact from frontmatter.
826 Args:
827 frontmatter: Dictionary of frontmatter fields
829 Returns:
830 ProductImpact instance if any product fields are present, None otherwise
831 """
832 # Check if any product fields are present
833 product_fields = ("goal_alignment", "persona_impact", "business_value", "user_benefit")
834 if not any(frontmatter.get(key) for key in product_fields):
835 return None
837 return ProductImpact(
838 goal_alignment=frontmatter.get("goal_alignment"),
839 persona_impact=frontmatter.get("persona_impact"),
840 business_value=frontmatter.get("business_value"),
841 user_benefit=frontmatter.get("user_benefit"),
842 )
845def find_issues(
846 config: BRConfig,
847 category: str | None = None,
848 skip_ids: set[str] | None = None,
849 only_ids: list[str] | set[str] | None = None,
850 type_prefixes: set[str] | None = None,
851 status_filter: set[str] | None = None,
852) -> list[IssueInfo]:
853 """Find all issues matching criteria.
855 Args:
856 config: Project configuration
857 category: Optional category to filter (e.g., "bugs")
858 skip_ids: Issue IDs to skip
859 only_ids: If provided, only include these issue IDs. When a list,
860 results are returned in list order (input sequence preserved).
861 When a set, results are sorted by priority as usual.
862 type_prefixes: If provided, only include issues whose ID starts with
863 one of these prefixes (e.g., {"BUG", "ENH"})
864 status_filter: If provided, only include issues whose status is in this
865 set. When None (default), skips done/cancelled/deferred issues
866 (preserves all existing caller behaviour).
868 Returns:
869 List of IssueInfo sorted by priority, or in only_ids list order when
870 only_ids is a list
871 """
872 skip_ids = skip_ids or set()
873 parser = IssueParser(config)
874 issues: list[IssueInfo] = []
876 # Determine which categories to search
877 if category:
878 categories = [category] if category in config.issue_categories else []
879 else:
880 categories = config.issue_categories
882 for cat in categories:
883 issue_dir = config.get_issue_dir(cat)
884 if not issue_dir.exists():
885 continue
887 for issue_file in issue_dir.glob("*.md"):
888 info = parser.parse_file(issue_file)
889 # Status-based filter
890 if status_filter is None:
891 if info.status in ("done", "cancelled", "deferred"):
892 continue
893 elif info.status not in status_filter:
894 continue
895 # Apply skip filter
896 if info.issue_id in skip_ids:
897 continue
898 # Apply only filter (if specified)
899 if only_ids is not None and not any(_id_matches(info.issue_id, p) for p in only_ids):
900 continue
901 # Apply type filter (if specified)
902 if type_prefixes is not None:
903 prefix = info.issue_id.split("-", 1)[0]
904 if prefix not in type_prefixes:
905 continue
906 issues.append(info)
908 # When only_ids is a list, preserve input order; otherwise sort by priority
909 if isinstance(only_ids, list):
910 issues.sort(
911 key=lambda x: next(
912 (i for i, p in enumerate(only_ids) if _id_matches(x.issue_id, p)),
913 len(only_ids),
914 )
915 )
916 else:
917 issues.sort(key=lambda x: (x.priority_int, x.issue_id))
918 return issues
921def find_highest_priority_issue(
922 config: BRConfig,
923 category: str | None = None,
924 skip_ids: set[str] | None = None,
925 only_ids: set[str] | None = None,
926 type_prefixes: set[str] | None = None,
927) -> IssueInfo | None:
928 """Find the highest priority issue.
930 Args:
931 config: Project configuration
932 category: Optional category to filter
933 skip_ids: Issue IDs to skip
934 only_ids: If provided, only include these issue IDs
935 type_prefixes: If provided, only include issues with these type prefixes
937 Returns:
938 Highest priority IssueInfo or None if no issues found
939 """
940 issues = find_issues(config, category, skip_ids, only_ids, type_prefixes)
941 return issues[0] if issues else None