Coverage for little_loops / issue_parser.py: 61%

256 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-04-11 23:20 -0500

1"""Issue file parsing for little-loops. 

2 

3Parses issue markdown files to extract metadata like priority, ID, type, and title. 

4""" 

5 

6from __future__ import annotations 

7 

8import logging 

9import re 

10from dataclasses import dataclass, field 

11from pathlib import Path 

12from typing import TYPE_CHECKING, Any 

13 

14from little_loops.cli_args import _id_matches 

15from little_loops.frontmatter import parse_frontmatter 

16 

17if TYPE_CHECKING: 

18 from little_loops.config import BRConfig 

19 

20 

21logger = logging.getLogger(__name__) 

22 

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) 

27 

28 

29_NORMALIZED_RE = re.compile(r"^P[0-5]-(BUG|FEAT|ENH)-[0-9]{3,}-[a-z0-9-]+\.md$") 

30_ISSUE_TYPE_RE = re.compile(r"-(BUG|FEAT|ENH)-") 

31 

32 

33def is_normalized(filename: str) -> bool: 

34 """Check whether an issue filename conforms to naming conventions. 

35 

36 Args: 

37 filename: The basename of the issue file (e.g. 'P2-BUG-010-my-issue.md'). 

38 

39 Returns: 

40 True if the filename matches ``^P[0-5]-(BUG|FEAT|ENH)-[0-9]{3,}-[a-z0-9-]+\\.md$``. 

41 """ 

42 return bool(_NORMALIZED_RE.match(filename)) 

43 

44 

45def is_formatted(issue_path: Path, templates_dir: Path | None = None) -> bool: 

46 """Check whether an issue file has been formatted. 

47 

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). 

51 

52 Args: 

53 issue_path: Path to the issue markdown file. 

54 templates_dir: Optional override for the templates directory. 

55 

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 

63 

64 try: 

65 content = issue_path.read_text(encoding="utf-8") 

66 except Exception: 

67 return False 

68 

69 # Criterion 1: /ll:format-issue appears in the session log 

70 if "/ll:format-issue" in parse_session_log(content): 

71 return True 

72 

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) 

78 

79 try: 

80 sections_data = load_issue_sections(issue_type, templates_dir) 

81 except Exception: 

82 return False 

83 

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) 

91 

92 if not required: 

93 return True 

94 

95 headings = {m.strip() for m in re.findall(r"^##\s+(.+)$", content, re.MULTILINE)} 

96 return required.issubset(headings) 

97 

98 

99def slugify(text: str) -> str: 

100 """Convert text to slug format for filenames. 

101 

102 Args: 

103 text: Text to convert 

104 

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() 

111 

112 

113def get_next_issue_number(config: BRConfig, category: str | None = None) -> int: 

114 """Determine the next globally unique issue number. 

115 

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. 

119 

120 Args: 

121 config: Project configuration 

122 category: Unused, kept for backwards compatibility 

123 

124 Returns: 

125 Next available issue number (globally unique across all types) 

126 """ 

127 max_num = 0 

128 

129 # Get all known prefixes from configuration 

130 all_prefixes = [cat_config.prefix for cat_config in config.issues.categories.values()] 

131 

132 # Directories to scan: ALL category directories + completed + deferred 

133 dirs_to_scan = [config.get_completed_dir(), config.get_deferred_dir()] 

134 for cat_name in config.issues.categories: 

135 dirs_to_scan.append(config.get_issue_dir(cat_name)) 

136 

137 if not all_prefixes: 

138 return max_num + 1 

139 

140 # Pre-compile a single union regex to match any known prefix 

141 prefix_pattern = re.compile(r"(?:" + "|".join(re.escape(p) for p in all_prefixes) + r")-(\d+)") 

142 

143 for dir_path in dirs_to_scan: 

144 if not dir_path.exists(): 

145 continue 

146 for file in dir_path.glob("*.md"): 

147 match = prefix_pattern.search(file.name) 

148 if match: 

149 num = int(match.group(1)) 

150 if num > max_num: 

151 max_num = num 

152 

153 return max_num + 1 

154 

155 

156@dataclass 

157class ProductImpact: 

158 """Product impact assessment for an issue. 

159 

160 Attributes: 

161 goal_alignment: ID of the strategic priority this supports 

162 persona_impact: ID of the persona affected 

163 business_value: Business value assessment (high|medium|low) 

164 user_benefit: Description of how this helps the target user 

165 """ 

166 

167 goal_alignment: str | None = None 

168 persona_impact: str | None = None 

169 business_value: str | None = None # high|medium|low 

170 user_benefit: str | None = None 

171 

172 def to_dict(self) -> dict[str, Any]: 

173 """Convert to dictionary for JSON serialization.""" 

174 return { 

175 "goal_alignment": self.goal_alignment, 

176 "persona_impact": self.persona_impact, 

177 "business_value": self.business_value, 

178 "user_benefit": self.user_benefit, 

179 } 

180 

181 @classmethod 

182 def from_dict(cls, data: dict[str, Any] | None) -> ProductImpact | None: 

183 """Create ProductImpact from dictionary. 

184 

185 Args: 

186 data: Dictionary with product impact fields, or None 

187 

188 Returns: 

189 ProductImpact instance or None if data is None/empty 

190 """ 

191 if not data: 

192 return None 

193 return cls( 

194 goal_alignment=data.get("goal_alignment"), 

195 persona_impact=data.get("persona_impact"), 

196 business_value=data.get("business_value"), 

197 user_benefit=data.get("user_benefit"), 

198 ) 

199 

200 

201@dataclass 

202class IssueInfo: 

203 """Parsed information from an issue file. 

204 

205 Attributes: 

206 path: Path to the issue file 

207 issue_type: Type of issue (e.g., "bugs", "features") 

208 priority: Priority level (e.g., "P0", "P1") 

209 issue_id: Issue identifier (e.g., "BUG-123") 

210 title: Issue title from markdown header 

211 blocked_by: List of issue IDs that block this issue 

212 blocks: List of issue IDs that this issue blocks 

213 discovered_by: Source command/workflow that created this issue 

214 product_impact: Product impact assessment (optional) 

215 effort: Effort estimate (1=low, 2=medium, 3=high), inferred from priority if absent 

216 impact: Impact estimate (1=low, 2=medium, 3=high), inferred from priority if absent 

217 confidence_score: Readiness score (0-100) written by /ll:confidence-check, or None 

218 outcome_confidence: Outcome confidence (0-100) written by /ll:confidence-check, or None 

219 testable: Whether TDD phase should be applied; False skips TDD, None treated as testable 

220 session_commands: Distinct /ll:* commands found in the ## Session Log section 

221 session_command_counts: Per-command occurrence counts from the ## Session Log section 

222 """ 

223 

224 path: Path 

225 issue_type: str 

226 priority: str 

227 issue_id: str 

228 title: str 

229 blocked_by: list[str] = field(default_factory=list) 

230 blocks: list[str] = field(default_factory=list) 

231 discovered_by: str | None = None 

232 product_impact: ProductImpact | None = None 

233 effort: int | None = None 

234 impact: int | None = None 

235 confidence_score: int | None = None 

236 outcome_confidence: int | None = None 

237 testable: bool | None = None 

238 session_commands: list[str] = field(default_factory=list) 

239 session_command_counts: dict[str, int] = field(default_factory=dict) 

240 

241 @property 

242 def priority_int(self) -> int: 

243 """Convert priority to integer for comparison (lower = higher priority).""" 

244 # Support P0-P5 priorities 

245 match = re.match(r"^P(\d+)$", self.priority) 

246 if match: 

247 return int(match.group(1)) 

248 return 99 # Unknown priority sorts last 

249 

250 def to_dict(self) -> dict[str, Any]: 

251 """Convert to dictionary for JSON serialization.""" 

252 return { 

253 "path": str(self.path), 

254 "issue_type": self.issue_type, 

255 "priority": self.priority, 

256 "issue_id": self.issue_id, 

257 "title": self.title, 

258 "blocked_by": self.blocked_by, 

259 "blocks": self.blocks, 

260 "discovered_by": self.discovered_by, 

261 "product_impact": (self.product_impact.to_dict() if self.product_impact else None), 

262 "effort": self.effort, 

263 "impact": self.impact, 

264 "confidence_score": self.confidence_score, 

265 "outcome_confidence": self.outcome_confidence, 

266 "testable": self.testable, 

267 "session_commands": self.session_commands, 

268 "session_command_counts": self.session_command_counts, 

269 } 

270 

271 @classmethod 

272 def from_dict(cls, data: dict[str, Any]) -> IssueInfo: 

273 """Create IssueInfo from dictionary.""" 

274 return cls( 

275 path=Path(data["path"]), 

276 issue_type=data["issue_type"], 

277 priority=data["priority"], 

278 issue_id=data["issue_id"], 

279 title=data["title"], 

280 blocked_by=data.get("blocked_by", []), 

281 blocks=data.get("blocks", []), 

282 discovered_by=data.get("discovered_by"), 

283 product_impact=ProductImpact.from_dict(data.get("product_impact")), 

284 effort=data.get("effort"), 

285 impact=data.get("impact"), 

286 confidence_score=data.get("confidence_score"), 

287 outcome_confidence=data.get("outcome_confidence"), 

288 testable=data.get("testable"), 

289 session_commands=data.get("session_commands", []), 

290 session_command_counts=data.get("session_command_counts", {}), 

291 ) 

292 

293 

294class IssueParser: 

295 """Parses issue files based on project configuration. 

296 

297 Uses BRConfig to understand issue categories, prefixes, and priorities. 

298 """ 

299 

300 def __init__(self, config: BRConfig) -> None: 

301 """Initialize parser with project configuration. 

302 

303 Args: 

304 config: Project configuration 

305 """ 

306 self.config = config 

307 self._build_prefix_map() 

308 

309 def _build_prefix_map(self) -> None: 

310 """Build mapping from issue prefixes to category names.""" 

311 self._prefix_to_category: dict[str, str] = {} 

312 for category_name, category in self.config.issues.categories.items(): 

313 self._prefix_to_category[category.prefix] = category_name 

314 

315 def parse_file(self, issue_path: Path) -> IssueInfo: 

316 """Parse an issue file to extract metadata. 

317 

318 Args: 

319 issue_path: Path to the issue markdown file 

320 

321 Returns: 

322 Parsed IssueInfo 

323 """ 

324 filename = issue_path.name 

325 

326 # Parse priority from filename prefix (e.g., P1-BUG-123-...) 

327 priority = self._parse_priority(filename) 

328 

329 # Parse issue type and ID from filename 

330 issue_type, issue_id = self._parse_type_and_id(filename, issue_path) 

331 

332 # Read content once for all content-based parsing 

333 content = self._read_content(issue_path) 

334 

335 # Parse frontmatter for discovered_by, product impact, effort, and impact 

336 frontmatter = parse_frontmatter(content) 

337 discovered_by = frontmatter.get("discovered_by") 

338 product_impact = self._parse_product_impact(frontmatter) 

339 effort_raw = frontmatter.get("effort") 

340 impact_raw = frontmatter.get("impact") 

341 effort = int(effort_raw) if effort_raw is not None and str(effort_raw).isdigit() else None 

342 impact = int(impact_raw) if impact_raw is not None and str(impact_raw).isdigit() else None 

343 confidence_raw = frontmatter.get("confidence_score") 

344 outcome_raw = frontmatter.get("outcome_confidence") 

345 confidence_score = ( 

346 int(confidence_raw) 

347 if confidence_raw is not None and str(confidence_raw).isdigit() 

348 else None 

349 ) 

350 outcome_confidence = ( 

351 int(outcome_raw) if outcome_raw is not None and str(outcome_raw).isdigit() else None 

352 ) 

353 testable_raw = frontmatter.get("testable") 

354 if isinstance(testable_raw, str): 

355 testable_value: bool | None = ( 

356 testable_raw.lower() == "true" 

357 if testable_raw.lower() in ("true", "false") 

358 else None 

359 ) 

360 else: 

361 testable_value = testable_raw 

362 

363 # Parse title and dependencies from file content 

364 title = self._parse_title_from_content(content, issue_path) 

365 blocked_by = self._parse_blocked_by(content) 

366 blocks = self._parse_blocks(content) 

367 

368 # Parse session commands from ## Session Log section 

369 from little_loops.session_log import count_session_commands, parse_session_log 

370 

371 session_commands = parse_session_log(content) 

372 session_command_counts = count_session_commands(content) 

373 

374 return IssueInfo( 

375 path=issue_path, 

376 issue_type=issue_type, 

377 priority=priority, 

378 issue_id=issue_id, 

379 title=title, 

380 blocked_by=blocked_by, 

381 blocks=blocks, 

382 discovered_by=discovered_by, 

383 product_impact=product_impact, 

384 effort=effort, 

385 impact=impact, 

386 confidence_score=confidence_score, 

387 outcome_confidence=outcome_confidence, 

388 testable=testable_value, 

389 session_commands=session_commands, 

390 session_command_counts=session_command_counts, 

391 ) 

392 

393 def _parse_priority(self, filename: str) -> str: 

394 """Extract priority from filename. 

395 

396 Args: 

397 filename: Issue filename 

398 

399 Returns: 

400 Priority string (e.g., "P1") or last priority if not found 

401 """ 

402 for priority in self.config.issue_priorities: 

403 if filename.startswith(f"{priority}-"): 

404 return priority 

405 # Default to lowest priority if not found 

406 return self.config.issue_priorities[-1] if self.config.issue_priorities else "P3" 

407 

408 def _get_category_for_prefix(self, prefix: str) -> str: 

409 """Get category name from issue prefix. 

410 

411 Args: 

412 prefix: Issue prefix (e.g., "BUG", "FEAT") 

413 

414 Returns: 

415 Category name (e.g., "bugs", "features"), defaults to "bugs" 

416 """ 

417 return self._prefix_to_category.get(prefix, "bugs") 

418 

419 def _parse_type_and_id(self, filename: str, issue_path: Path) -> tuple[str, str]: 

420 """Extract issue type and ID from filename. 

421 

422 Args: 

423 filename: Issue filename 

424 issue_path: Full path to issue file 

425 

426 Returns: 

427 Tuple of (issue_type, issue_id) 

428 """ 

429 # Try to match known prefixes (BUG, FEAT, ENH, etc.) 

430 for prefix, category in self._prefix_to_category.items(): 

431 pattern = rf"({prefix})-(\d+)" 

432 match = re.search(pattern, filename) 

433 if match: 

434 issue_id = f"{match.group(1)}-{match.group(2)}" 

435 return category, issue_id 

436 

437 # Fall back to inferring from directory 

438 parent_name = issue_path.parent.name 

439 for category_name, category_config in self.config.issues.categories.items(): 

440 if parent_name == category_config.dir: 

441 # Generate ID from filename 

442 issue_id = self._generate_id_from_filename(filename, category_config.prefix) 

443 return category_name, issue_id 

444 

445 # Last resort: use filename as ID 

446 return "bugs", filename.replace(".md", "") 

447 

448 def _generate_id_from_filename(self, filename: str, prefix: str) -> str: 

449 """Generate an issue ID from filename when not explicitly present. 

450 

451 Args: 

452 filename: Issue filename 

453 prefix: Issue prefix to use 

454 

455 Returns: 

456 Generated issue ID 

457 """ 

458 # Try to extract a number from the filename 

459 numbers = re.findall(r"\d+", filename) 

460 if numbers: 

461 return f"{prefix}-{numbers[0]}" 

462 # Use next sequential number instead of hash-based fallback 

463 # This ensures IDs are deterministic and don't collide with existing issues 

464 category = self._get_category_for_prefix(prefix) 

465 next_num = get_next_issue_number(self.config, category) 

466 return f"{prefix}-{next_num:03d}" 

467 

468 def _read_content(self, issue_path: Path) -> str: 

469 """Read file content, returning empty string on error. 

470 

471 Args: 

472 issue_path: Path to issue file 

473 

474 Returns: 

475 File content or empty string on error 

476 """ 

477 try: 

478 return issue_path.read_text(encoding="utf-8") 

479 except Exception as e: 

480 logger.warning("Failed to read %s: %s", issue_path.name, e) 

481 return "" 

482 

483 def _parse_title_from_content(self, content: str, issue_path: Path) -> str: 

484 """Extract title from issue file content. 

485 

486 Args: 

487 content: Pre-read file content 

488 issue_path: Path to issue file (for fallback) 

489 

490 Returns: 

491 Issue title or filename stem as fallback 

492 """ 

493 if content: 

494 # Look for markdown header: # ISSUE-ID: Title 

495 match = re.search(r"^#\s+[\w-]+:\s*(.+)$", content, re.MULTILINE) 

496 if match: 

497 return match.group(1).strip() 

498 # Try first header of any format 

499 match = re.search(r"^#\s+(.+)$", content, re.MULTILINE) 

500 if match: 

501 return match.group(1).strip() 

502 # Fall back to filename 

503 return issue_path.stem 

504 

505 def _parse_section_items(self, content: str, section_name: str) -> list[str]: 

506 """Extract issue IDs from a markdown section. 

507 

508 Finds section header (## Section Name) and extracts issue IDs 

509 from list items until the next section or end of file. 

510 Skips content inside code fences. 

511 

512 Args: 

513 content: File content to parse 

514 section_name: Section name to find (e.g., "Blocked By") 

515 

516 Returns: 

517 List of issue IDs found in the section 

518 """ 

519 if not content: 

520 return [] 

521 

522 # Strip code fences to avoid matching sections in examples 

523 content_without_code = self._strip_code_fences(content) 

524 

525 # Match section header case-insensitively 

526 section_pattern = rf"^##\s+{re.escape(section_name)}\s*$" 

527 match = re.search(section_pattern, content_without_code, re.MULTILINE | re.IGNORECASE) 

528 if not match: 

529 return [] 

530 

531 # Get content after section header until next ## header or end 

532 start = match.end() 

533 next_section = re.search(r"^##\s+", content_without_code[start:], re.MULTILINE) 

534 if next_section: 

535 section_content = content_without_code[start : start + next_section.start()] 

536 else: 

537 section_content = content_without_code[start:] 

538 

539 # Extract issue IDs from list items 

540 issue_ids = ISSUE_ID_PATTERN.findall(section_content) 

541 return issue_ids 

542 

543 def _strip_code_fences(self, content: str) -> str: 

544 """Remove code fence blocks from content. 

545 

546 Replaces content between ``` markers with empty lines to preserve 

547 line numbers while removing code fence content from parsing. 

548 

549 Args: 

550 content: File content 

551 

552 Returns: 

553 Content with code fence blocks replaced by empty lines 

554 """ 

555 # Match code fences: ``` or ```language through closing ``` 

556 result = [] 

557 in_fence = False 

558 for line in content.split("\n"): 

559 if line.startswith("```"): 

560 in_fence = not in_fence 

561 result.append("") # Preserve line count 

562 elif in_fence: 

563 result.append("") # Replace fenced content with empty line 

564 else: 

565 result.append(line) 

566 return "\n".join(result) 

567 

568 def _parse_blocked_by(self, content: str) -> list[str]: 

569 """Extract issue IDs from ## Blocked By section. 

570 

571 Args: 

572 content: File content to parse 

573 

574 Returns: 

575 List of issue IDs that block this issue 

576 """ 

577 return self._parse_section_items(content, "Blocked By") 

578 

579 def _parse_blocks(self, content: str) -> list[str]: 

580 """Extract issue IDs from ## Blocks section. 

581 

582 Args: 

583 content: File content to parse 

584 

585 Returns: 

586 List of issue IDs that this issue blocks 

587 """ 

588 return self._parse_section_items(content, "Blocks") 

589 

590 def _parse_product_impact(self, frontmatter: dict[str, Any]) -> ProductImpact | None: 

591 """Extract product impact from frontmatter. 

592 

593 Args: 

594 frontmatter: Dictionary of frontmatter fields 

595 

596 Returns: 

597 ProductImpact instance if any product fields are present, None otherwise 

598 """ 

599 # Check if any product fields are present 

600 product_fields = ("goal_alignment", "persona_impact", "business_value", "user_benefit") 

601 if not any(frontmatter.get(key) for key in product_fields): 

602 return None 

603 

604 return ProductImpact( 

605 goal_alignment=frontmatter.get("goal_alignment"), 

606 persona_impact=frontmatter.get("persona_impact"), 

607 business_value=frontmatter.get("business_value"), 

608 user_benefit=frontmatter.get("user_benefit"), 

609 ) 

610 

611 

612def find_issues( 

613 config: BRConfig, 

614 category: str | None = None, 

615 skip_ids: set[str] | None = None, 

616 only_ids: list[str] | set[str] | None = None, 

617 type_prefixes: set[str] | None = None, 

618) -> list[IssueInfo]: 

619 """Find all issues matching criteria. 

620 

621 Args: 

622 config: Project configuration 

623 category: Optional category to filter (e.g., "bugs") 

624 skip_ids: Issue IDs to skip 

625 only_ids: If provided, only include these issue IDs. When a list, 

626 results are returned in list order (input sequence preserved). 

627 When a set, results are sorted by priority as usual. 

628 type_prefixes: If provided, only include issues whose ID starts with 

629 one of these prefixes (e.g., {"BUG", "ENH"}) 

630 

631 Returns: 

632 List of IssueInfo sorted by priority, or in only_ids list order when 

633 only_ids is a list 

634 """ 

635 skip_ids = skip_ids or set() 

636 parser = IssueParser(config) 

637 issues: list[IssueInfo] = [] 

638 

639 # Get completed and deferred directories for duplicate detection 

640 completed_dir = config.get_completed_dir() 

641 deferred_dir = config.get_deferred_dir() 

642 

643 # Pre-materialize filename sets once to avoid O(N) stat syscalls in the hot loop 

644 completed_names = ( 

645 frozenset(p.name for p in completed_dir.glob("*.md")) 

646 if completed_dir.exists() 

647 else frozenset() 

648 ) 

649 deferred_names = ( 

650 frozenset(p.name for p in deferred_dir.glob("*.md")) 

651 if deferred_dir.exists() 

652 else frozenset() 

653 ) 

654 

655 # Determine which categories to search 

656 if category: 

657 categories = [category] if category in config.issue_categories else [] 

658 else: 

659 categories = config.issue_categories 

660 

661 for cat in categories: 

662 issue_dir = config.get_issue_dir(cat) 

663 if not issue_dir.exists(): 

664 continue 

665 

666 for issue_file in issue_dir.glob("*.md"): 

667 # Pre-flight check: skip if already exists in completed or deferred directory 

668 if issue_file.name in completed_names: 

669 continue 

670 if issue_file.name in deferred_names: 

671 continue 

672 

673 info = parser.parse_file(issue_file) 

674 # Apply skip filter 

675 if info.issue_id in skip_ids: 

676 continue 

677 # Apply only filter (if specified) 

678 if only_ids is not None and not any(_id_matches(info.issue_id, p) for p in only_ids): 

679 continue 

680 # Apply type filter (if specified) 

681 if type_prefixes is not None: 

682 prefix = info.issue_id.split("-", 1)[0] 

683 if prefix not in type_prefixes: 

684 continue 

685 issues.append(info) 

686 

687 # When only_ids is a list, preserve input order; otherwise sort by priority 

688 if isinstance(only_ids, list): 

689 issues.sort( 

690 key=lambda x: next( 

691 (i for i, p in enumerate(only_ids) if _id_matches(x.issue_id, p)), 

692 len(only_ids), 

693 ) 

694 ) 

695 else: 

696 issues.sort(key=lambda x: (x.priority_int, x.issue_id)) 

697 return issues 

698 

699 

700def find_highest_priority_issue( 

701 config: BRConfig, 

702 category: str | None = None, 

703 skip_ids: set[str] | None = None, 

704 only_ids: set[str] | None = None, 

705 type_prefixes: set[str] | None = None, 

706) -> IssueInfo | None: 

707 """Find the highest priority issue. 

708 

709 Args: 

710 config: Project configuration 

711 category: Optional category to filter 

712 skip_ids: Issue IDs to skip 

713 only_ids: If provided, only include these issue IDs 

714 type_prefixes: If provided, only include issues with these type prefixes 

715 

716 Returns: 

717 Highest priority IssueInfo or None if no issues found 

718 """ 

719 issues = find_issues(config, category, skip_ids, only_ids, type_prefixes) 

720 return issues[0] if issues else None