Coverage for src / rhiza / commands / summarise / _render.py: 100%

143 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-07-16 12:51 +0000

1"""Rendering helpers for the ``summarise`` command. 

2 

3Formats gathered change data into markdown, plain text, JSON, or a custom 

4Jinja2 template. Beyond reading a user-supplied Jinja2 template file, this 

5module performs no git or filesystem inspection — see 

6:mod:`rhiza.commands.summarise._gather` for data collection. 

7""" 

8 

9import json as _json 

10from dataclasses import dataclass, field 

11from datetime import datetime 

12from pathlib import Path 

13from typing import Any 

14 

15import jinja2 

16 

17from ._gather import _TemplateInfo 

18 

19 

20@dataclass(kw_only=True) 

21class SummariseOptions: 

22 """Options controlling the output of :func:`generate_pr_description`. 

23 

24 All fields are keyword-only and default to the standard behaviour so 

25 callers only need to set the fields they want to override. 

26 """ 

27 

28 include_header: bool = True 

29 """Whether to include the header section (markdown / plain formats).""" 

30 

31 include_footer: bool = True 

32 """Whether to include the footer section (markdown / plain formats).""" 

33 

34 include_categories: bool = True 

35 """Whether to group changes by category; when ``False`` a flat list is shown.""" 

36 

37 output_format: str = "markdown" 

38 """Output format: ``"markdown"`` (default), ``"plain"``, or ``"json"``.""" 

39 

40 title: str | None = None 

41 """Override the section heading; ``None`` uses the built-in default.""" 

42 

43 compare_ref: str | None = None 

44 """Compare against this git ref instead of the staged index.""" 

45 

46 jinja2_template: Path | None = field(default=None) 

47 """Path to a Jinja2 template file for fully custom output.""" 

48 

49 

50def _format_file_list(files: list[str], status_emoji: str) -> list[str]: 

51 """Format a list of files with the given status emoji. 

52 

53 Args: 

54 files: List of file paths 

55 status_emoji: Emoji to use (✅ for added, 📝 for modified, ❌ for deleted) 

56 

57 Returns: 

58 List of formatted lines 

59 """ 

60 lines = [] 

61 for f in sorted(files): 

62 lines.append(f"- {status_emoji} `{f}`") 

63 return lines 

64 

65 

66def _add_category_section(lines: list[str], title: str, count: int, files: list[str], emoji: str) -> None: 

67 """Add a collapsible section for a category and change type. 

68 

69 Args: 

70 lines: List to append lines to 

71 title: Section title (e.g., "Added", "Modified") 

72 count: Number of files 

73 files: List of file paths 

74 emoji: Status emoji 

75 """ 

76 if not files: 

77 return 

78 

79 lines.append("<details>") 

80 lines.append(f"<summary>{title} ({count})</summary>") 

81 lines.append("") 

82 lines.extend(_format_file_list(files, emoji)) 

83 lines.append("") 

84 lines.append("</details>") 

85 lines.append("") 

86 

87 

88def _build_header(template_repo: str, title: str | None = None) -> list[str]: 

89 """Build the PR description header. 

90 

91 Args: 

92 template_repo: Template repository name 

93 title: Optional override for the section heading 

94 

95 Returns: 

96 List of header lines 

97 """ 

98 header_title = title if title else "## 🔄 Template Synchronization" 

99 lines = [header_title, ""] 

100 if template_repo: 

101 url = f"https://github.com/{template_repo}" 

102 repo_link = f"[{template_repo}]({url})" 

103 sync_line = f"This PR synchronizes the repository with the {repo_link} template." 

104 lines.append(sync_line) 

105 else: 

106 lines.append("This PR synchronizes the repository with the upstream template.") 

107 lines.append("") 

108 return lines 

109 

110 

111def _build_summary(changes: dict[str, list[str]]) -> list[str]: 

112 """Build the change summary section. 

113 

114 Args: 

115 changes: Dictionary of changes by type 

116 

117 Returns: 

118 List of summary lines 

119 """ 

120 return [ 

121 "### 📊 Change Summary", 

122 "", 

123 f"- **{len(changes['added'])}** files added", 

124 f"- **{len(changes['modified'])}** files modified", 

125 f"- **{len(changes['deleted'])}** files deleted", 

126 "", 

127 ] 

128 

129 

130def _build_footer(tmpl: _TemplateInfo) -> list[str]: 

131 """Build the PR description footer with metadata. 

132 

133 Args: 

134 tmpl: Template metadata container 

135 

136 Returns: 

137 List of footer lines 

138 """ 

139 lines = [ 

140 "---", 

141 "", 

142 "**🤖 Generated by [rhiza](https://github.com/jebel-quant/rhiza-cli)**", 

143 "", 

144 ] 

145 if tmpl.repo: 

146 lines.append(f"- Template: `{tmpl.repo}@{tmpl.branch}`") 

147 if tmpl.last_sync: 

148 lines.append(f"- Last sync: {tmpl.last_sync}") 

149 lines.append(f"- Sync date: {datetime.now().astimezone().isoformat()}") 

150 return lines 

151 

152 

153def _generate_json_output( 

154 changes: dict[str, list[str]], 

155 categories: dict[str, list[str]], 

156 tmpl: _TemplateInfo, 

157) -> str: 

158 """Generate a JSON representation of the change data. 

159 

160 Args: 

161 changes: Dictionary of changes by type 

162 categories: Files grouped by category 

163 tmpl: Template metadata container 

164 

165 Returns: 

166 JSON-formatted string 

167 """ 

168 data = { 

169 "template_repo": tmpl.repo, 

170 "template_branch": tmpl.branch, 

171 "last_sync": tmpl.last_sync, 

172 "sync_date": datetime.now().astimezone().isoformat(), 

173 "changes": changes, 

174 "categories": categories, 

175 } 

176 return _json.dumps(data, indent=2) 

177 

178 

179def _plain_file_section(lines: list[str], label: str, files: list[str]) -> None: 

180 """Append a labelled block of files to *lines* in plain-text format. 

181 

182 Args: 

183 lines: List to append lines to 

184 label: Section label (e.g. "Added") 

185 files: List of file paths 

186 """ 

187 if not files: 

188 return 

189 lines.append(f"{label}:") 

190 lines.extend(f" {f}" for f in sorted(files)) 

191 lines.append("") 

192 

193 

194def _generate_plain_output( 

195 changes: dict[str, list[str]], 

196 categories: dict[str, list[str]], 

197 tmpl: _TemplateInfo, 

198 options: SummariseOptions, 

199) -> str: 

200 """Generate plain-text output from change data. 

201 

202 Args: 

203 changes: Dictionary of changes by type 

204 categories: Files grouped by category 

205 tmpl: Template metadata container 

206 options: Output customisation options 

207 

208 Returns: 

209 Plain-text formatted string 

210 """ 

211 lines: list[str] = [] 

212 

213 _plain_header(lines, tmpl, options) 

214 

215 total = sum(len(v) for v in changes.values()) 

216 if not total: 

217 lines.append("No changes detected.") 

218 return "\n".join(lines) 

219 

220 lines.append( 

221 f"Changes: {len(changes['added'])} added, " 

222 f"{len(changes['modified'])} modified, " 

223 f"{len(changes['deleted'])} deleted", 

224 ) 

225 lines.append("") 

226 

227 _plain_change_body(lines, changes, categories, options) 

228 _plain_footer(lines, tmpl, options) 

229 

230 return "\n".join(lines) 

231 

232 

233def _plain_header(lines: list[str], tmpl: _TemplateInfo, options: SummariseOptions) -> None: 

234 """Append the plain-text header (title + template line) to *lines*. 

235 

236 Args: 

237 lines: Output line accumulator, mutated in place. 

238 tmpl: Template metadata container. 

239 options: Output customisation options. 

240 """ 

241 if not options.include_header: 

242 return 

243 heading = options.title or "Template Synchronization" 

244 lines.extend([heading, "=" * len(heading), ""]) 

245 if tmpl.repo: 

246 lines.append(f"Template: {tmpl.repo}@{tmpl.branch}") 

247 lines.append("") 

248 

249 

250def _plain_change_body( 

251 lines: list[str], 

252 changes: dict[str, list[str]], 

253 categories: dict[str, list[str]], 

254 options: SummariseOptions, 

255) -> None: 

256 """Append the per-category or per-change-type file listing to *lines*. 

257 

258 Args: 

259 lines: Output line accumulator, mutated in place. 

260 changes: Dictionary of changes by type. 

261 categories: Files grouped by category. 

262 options: Output customisation options. 

263 """ 

264 if options.include_categories: 

265 for category, files in sorted(categories.items()): 

266 lines.append(f"{category}:") 

267 lines.extend(f" {f}" for f in sorted(files)) 

268 lines.append("") 

269 else: 

270 for label, files in [ 

271 ("Added", changes["added"]), 

272 ("Modified", changes["modified"]), 

273 ("Deleted", changes["deleted"]), 

274 ]: 

275 _plain_file_section(lines, label, files) 

276 

277 

278def _plain_footer(lines: list[str], tmpl: _TemplateInfo, options: SummariseOptions) -> None: 

279 """Append the plain-text footer (last-sync + sync-date) to *lines*. 

280 

281 Args: 

282 lines: Output line accumulator, mutated in place. 

283 tmpl: Template metadata container. 

284 options: Output customisation options. 

285 """ 

286 if not options.include_footer: 

287 return 

288 if tmpl.last_sync: 

289 lines.append(f"Last sync: {tmpl.last_sync}") 

290 lines.append(f"Sync date: {datetime.now().astimezone().isoformat()}") 

291 

292 

293def _generate_jinja2_output(template_path: Path, context: dict[str, Any]) -> str: 

294 """Render output using a custom Jinja2 template file. 

295 

296 The *context* dict is passed directly to the template. It should contain at 

297 minimum: ``template_repo``, ``template_branch``, ``last_sync``, ``sync_date``, 

298 ``changes``, ``categories``, and ``title``. 

299 

300 Note: 

301 Autoescape is disabled because this function generates plain text / Markdown, 

302 not HTML. Do **not** use the rendered output directly in a web context without 

303 first escaping it, as the template content is not sanitised for HTML. 

304 

305 Args: 

306 template_path: Path to the Jinja2 template file 

307 context: Template context variables 

308 

309 Returns: 

310 Rendered template string 

311 """ 

312 template_text = template_path.read_text(encoding="utf-8") 

313 env = jinja2.Environment( # nosec B701 

314 autoescape=False, # noqa: S701 

315 loader=jinja2.BaseLoader(), 

316 keep_trailing_newline=True, 

317 ) 

318 return env.from_string(template_text).render(**context) 

319 

320 

321def _render_category_group( 

322 lines: list[str], 

323 categories: dict[str, list[str]], 

324 changes: dict[str, list[str]], 

325) -> None: 

326 """Append the per-category collapsible markdown sections to *lines*. 

327 

328 Args: 

329 lines: List to append lines to 

330 categories: Files grouped by category 

331 changes: Dictionary of changes by type, used to split each category's 

332 files into added / modified / deleted subsections 

333 """ 

334 lines.append("### 📁 Changes by Category") 

335 lines.append("") 

336 

337 for category, files in sorted(categories.items()): 

338 lines.append(f"#### {category}") 

339 lines.append("") 

340 

341 category_added = [f for f in files if f in changes["added"]] 

342 category_modified = [f for f in files if f in changes["modified"]] 

343 category_deleted = [f for f in files if f in changes["deleted"]] 

344 

345 _add_category_section(lines, "Added", len(category_added), category_added, "✅") 

346 _add_category_section(lines, "Modified", len(category_modified), category_modified, "📝") 

347 _add_category_section(lines, "Deleted", len(category_deleted), category_deleted, "❌") 

348 

349 

350def _render_flat_files(lines: list[str], changes: dict[str, list[str]]) -> None: 

351 """Append a flat (uncategorised) markdown file listing to *lines*. 

352 

353 Args: 

354 lines: List to append lines to 

355 changes: Dictionary of changes by type 

356 """ 

357 lines.append("### 📁 Changed Files") 

358 lines.append("") 

359 _add_category_section(lines, "Added", len(changes["added"]), changes["added"], "✅") 

360 _add_category_section(lines, "Modified", len(changes["modified"]), changes["modified"], "📝") 

361 _add_category_section(lines, "Deleted", len(changes["deleted"]), changes["deleted"], "❌") 

362 

363 

364def _markdown_body( 

365 changes: dict[str, list[str]], 

366 categories: dict[str, list[str]], 

367 tmpl: _TemplateInfo, 

368 options: SummariseOptions, 

369) -> str: 

370 """Build the markdown PR description body. 

371 

372 Args: 

373 changes: Dictionary of changes by type 

374 categories: Files grouped by category 

375 tmpl: Template metadata container 

376 options: Output customisation options 

377 

378 Returns: 

379 Markdown-formatted string 

380 """ 

381 lines: list[str] = [] 

382 

383 if options.include_header: 

384 lines.extend(_build_header(tmpl.repo, title=options.title)) 

385 

386 total_changes = sum(len(files) for files in changes.values()) 

387 if not total_changes: 

388 lines.append("No changes detected.") 

389 if options.include_footer: 

390 lines.append("") 

391 lines.extend(_build_footer(tmpl)) 

392 return "\n".join(lines) 

393 

394 lines.extend(_build_summary(changes)) 

395 

396 if options.include_categories and categories: 

397 _render_category_group(lines, categories, changes) 

398 elif not options.include_categories: 

399 _render_flat_files(lines, changes) 

400 

401 if options.include_footer: 

402 lines.extend(_build_footer(tmpl)) 

403 

404 return "\n".join(lines)