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

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

2 

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

7 

8from __future__ import annotations 

9 

10import json 

11import os 

12from pathlib import Path 

13from typing import TYPE_CHECKING, Any 

14 

15if TYPE_CHECKING: 

16 from little_loops.config import BRConfig 

17 

18 

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" 

22 

23 

24def _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 

39def 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 

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. 

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 

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. 

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 

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