Coverage for little_loops/issue_template.py: 14%

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 from pathlib import Path

12 from typing import Any

13

14

15 def _default_templates_dir() -> Path:

16 """Return the bundled templates/ directory relative to this package."""

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

18

19

20 def load_issue_sections(issue_type: str, templates_dir: Path | None = None) -> dict[str, Any]:

21 """Load per-type sections JSON from the given or default templates directory.

22

23 Args:

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

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

26

27 Returns:

28 Parsed JSON data as a dict.

29

30 Raises:

31 FileNotFoundError: If the per-type sections file does not exist.

32 """

33 base = templates_dir if templates_dir is not None else _default_templates_dir()

34 filename = f"{issue_type.lower()}-sections.json"

35 path = base / filename

36 with open(path, encoding="utf-8") as f:

37 return json.load(f)

38

39

40 def assemble_issue_markdown(

41 sections_data: dict[str, Any],

42 issue_type: str,

43 variant: str,

44 issue_id: str,

45 title: str,

46 frontmatter: dict[str, Any],

47 content: dict[str, str] | None = None,

48 labels: list[str] | None = None,

49) -> str:

50 """Assemble structured markdown from template sections and content.

51

52 Args:

53 sections_data: Parsed per-type sections data.

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

55 variant: Creation variant name (full, minimal, legacy).

56 issue_id: Issue identifier (e.g. "ENH-517").

57 title: Issue title text.

58 frontmatter: Dict of YAML frontmatter key-value pairs.

59 content: Optional mapping of section name to content string.

60 Sections not in this dict get their creation_template placeholder.

61 labels: Optional list of label strings for the Labels section.

62

63 Returns:

64 Complete markdown string with frontmatter, heading, and sections.

65 """

66 content = content or {}

67 variant_config = sections_data.get("creation_variants", {}).get(variant)

68 if variant_config is None:

69 raise ValueError(f"Unknown creation variant: {variant! r}")

70

71 common_sections = sections_data.get("common_sections", {})

72 type_sections = sections_data.get("type_sections", {})

73 exclude_deprecated = variant_config.get("exclude_deprecated", False)

74 include_common = variant_config.get("include_common", [])

75 include_type = variant_config.get("include_type_sections", False)

76

77 parts: list[str] = []

78

79 # YAML frontmatter

80 parts.append("---")

81 for key, value in frontmatter.items():

82 parts.append(f"{key}: {value}")

83 parts.append("---")

84 parts.append("")

85

86 # Title heading

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

88 parts.append("")

89

90 # Common sections from variant

91 for section_name in include_common:

92 section_def = common_sections.get(section_name)

93 if section_def is None:

94 continue

95 if exclude_deprecated and section_def.get("deprecated", False):

96 continue

97 _append_section(parts, section_name, section_def, content)

98

99 # Type-specific sections

100 if include_type and type_sections:

101 for section_name, section_def in type_sections.items():

102 if exclude_deprecated and section_def.get("deprecated", False):

103 continue

104 _append_section(parts, section_name, section_def, content)

105

106 # Ensure Labels section is present (even if not in variant's include_common)

107 if labels is not None and "Labels" not in include_common:

108 labels_str = ", ".join(f"`{lbl}`" for lbl in labels) if labels else ""

109 parts.append("## Labels")

110 parts.append("")

111 parts.append(labels_str)

112 parts.append("")

113

114 return "\n".join(parts)

115

116

117 def _append_section(

118 parts: list[str],

119 section_name: str,

120 section_def: dict[str, Any],

121 content: dict[str, str],

122) -> None:

123 """Append a single section to the parts list."""

124 body = content.get(section_name, section_def.get("creation_template", ""))

125 parts.append(f"## {section_name}")

126 parts.append("")

127 if body:

128 parts.append(body)

129 parts.append("")