Coverage for little_loops / issue_template.py: 15%
71 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-26 17:38 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-26 17:38 -0500
1"""Issue template assembly using per-type section definition files.
3Reads per-type template files (bug-sections.json, feat-sections.json,
4enh-sections.json) from templates/ and assembles structured markdown for
5issue files. Used by sync pull to produce v2.0-compliant issues.
6"""
8from __future__ import annotations
10import json
11import os
12from pathlib import Path
13from typing import TYPE_CHECKING, Any
15if TYPE_CHECKING:
16 from little_loops.config import BRConfig
19def get_bundled_templates_dir() -> Path:
20 """Return the in-package templates/ directory (works in all install modes)."""
21 return Path(__file__).resolve().parent / "templates"
24def _default_templates_dir() -> Path:
25 """Return the bundled templates/ directory.
27 Honors CLAUDE_PLUGIN_ROOT only when its templates/ subdirectory exists
28 (legacy override). Falls back to the in-package location so pip-installed
29 (non-editable) and editable installs both resolve correctly without env vars.
30 """
31 env_root = os.environ.get("CLAUDE_PLUGIN_ROOT")
32 if env_root:
33 env_path = Path(env_root) / "templates"
34 if env_path.is_dir():
35 return env_path
36 return get_bundled_templates_dir()
39def resolve_templates_dir(config: BRConfig) -> Path:
40 """Return the templates directory using 4-tier precedence lookup.
42 Tiers (highest to lowest priority):
44 1. ``config.issues.templates_dir`` — explicit config override
45 2. ``<project_root>/.ll/templates/`` — per-project deployed copy
46 3. Bundled in-package ``templates/`` (always available)
48 The fourth tier (``CLAUDE_PLUGIN_ROOT`` env var) lives in
49 :func:`_default_templates_dir` and applies only to legacy callers of
50 :func:`load_issue_sections` that pass no ``templates_dir``.
52 Args:
53 config: Project configuration.
55 Returns:
56 Path to the resolved templates directory.
57 """
58 if config.issues.templates_dir:
59 return Path(config.issues.templates_dir)
60 ll_templates = config.project_root / ".ll" / "templates"
61 if ll_templates.exists():
62 return ll_templates
63 return get_bundled_templates_dir()
66def load_issue_sections(issue_type: str, templates_dir: Path | None = None) -> dict[str, Any]:
67 """Load per-type sections JSON from the given or default templates directory.
69 Args:
70 issue_type: Issue type prefix (BUG, FEAT, ENH).
71 templates_dir: Optional override path. Defaults to bundled templates/.
73 Returns:
74 Parsed JSON data as a dict.
76 Raises:
77 FileNotFoundError: If the per-type sections file does not exist.
78 """
79 base = templates_dir if templates_dir is not None else _default_templates_dir()
80 filename = f"{issue_type.lower()}-sections.json"
81 path = base / filename
82 with open(path, encoding="utf-8") as f:
83 return json.load(f)
86def assemble_issue_markdown(
87 sections_data: dict[str, Any],
88 issue_type: str,
89 variant: str,
90 issue_id: str,
91 title: str,
92 frontmatter: dict[str, Any],
93 content: dict[str, str] | None = None,
94 labels: list[str] | None = None,
95) -> str:
96 """Assemble structured markdown from template sections and content.
98 Args:
99 sections_data: Parsed per-type sections data.
100 issue_type: Issue type prefix (BUG, FEAT, ENH).
101 variant: Creation variant name (full, minimal, legacy).
102 issue_id: Issue identifier (e.g. "ENH-517").
103 title: Issue title text.
104 frontmatter: Dict of YAML frontmatter key-value pairs.
105 content: Optional mapping of section name to content string.
106 Sections not in this dict get their creation_template placeholder.
107 labels: Optional list of label strings for the Labels section.
109 Returns:
110 Complete markdown string with frontmatter, heading, and sections.
111 """
112 content = content or {}
113 variant_config = sections_data.get("creation_variants", {}).get(variant)
114 if variant_config is None:
115 raise ValueError(f"Unknown creation variant: {variant!r}")
117 common_sections = sections_data.get("common_sections", {})
118 type_sections = sections_data.get("type_sections", {})
119 exclude_deprecated = variant_config.get("exclude_deprecated", False)
120 include_common = variant_config.get("include_common", [])
121 include_type = variant_config.get("include_type_sections", False)
123 parts: list[str] = []
125 # YAML frontmatter
126 parts.append("---")
127 for key, value in frontmatter.items():
128 parts.append(f"{key}: {value}")
129 parts.append("---")
130 parts.append("")
132 # Title heading
133 parts.append(f"# {issue_id}: {title}")
134 parts.append("")
136 # Common sections from variant
137 for section_name in include_common:
138 section_def = common_sections.get(section_name)
139 if section_def is None:
140 continue
141 if exclude_deprecated and section_def.get("deprecated", False):
142 continue
143 _append_section(parts, section_name, section_def, content)
145 # Type-specific sections
146 if include_type and type_sections:
147 for section_name, section_def in type_sections.items():
148 if exclude_deprecated and section_def.get("deprecated", False):
149 continue
150 _append_section(parts, section_name, section_def, content)
152 # Ensure Labels section is present (even if not in variant's include_common)
153 if labels is not None and "Labels" not in include_common:
154 labels_str = ", ".join(f"`{lbl}`" for lbl in labels) if labels else ""
155 parts.append("## Labels")
156 parts.append("")
157 parts.append(labels_str)
158 parts.append("")
160 return "\n".join(parts)
163def _append_section(
164 parts: list[str],
165 section_name: str,
166 section_def: dict[str, Any],
167 content: dict[str, str],
168) -> None:
169 """Append a single section to the parts list."""
170 body = content.get(section_name, section_def.get("creation_template", ""))
171 parts.append(f"## {section_name}")
172 parts.append("")
173 if body:
174 parts.append(body)
175 parts.append("")