1"""Issue template assembly using per-type section definition files.

2

3 Reads per-type template files (bug-sections.json, feat-sections.json,

4 enh-sections.json) from templates/ and assembles structured markdown for

5 issue files. Used by sync pull to produce v2.0-compliant issues.

6"""

7

8 from __future__ import annotations

9

10 import json

11 import os

12 from pathlib import Path

13 from typing import TYPE_CHECKING, Any

14

15 if TYPE_CHECKING:

16 from little_loops.config import BRConfig

17

18

19 def get_bundled_templates_dir() -> Path:

20 """Return the in-package templates/ directory (works in all install modes)."""

21 return Path(__file__).resolve().parent / "templates"

22

23

24 def _default_templates_dir() -> Path:

25 """Return the bundled templates/ directory.

26

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

37

38

39 def resolve_templates_dir(config: BRConfig) -> Path:

40 """Return the templates directory using 4-tier precedence lookup.

41

42 Tiers (highest to lowest priority):

43

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)

47

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

51

52 Args:

53 config: Project configuration.

54

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

64

65

66 def 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.

68

69 Args:

70 issue_type: Issue type prefix (BUG, FEAT, ENH).

71 templates_dir: Optional override path. Defaults to bundled templates/.

72

73 Returns:

74 Parsed JSON data as a dict.

75

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)

84

85

86 def 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.

97

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.

108

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}")

116

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)

122

123 parts: list[str] = []

124

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

131

132 # Title heading

133 parts.append(f"# {issue_id}: {title}")

134 parts.append("")

135

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)

144

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)

151

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

159

160 return "\n".join(parts)

161

162

163 def _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("")