git2report 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
git2report/report.py ADDED
@@ -0,0 +1,884 @@
1
+ """STEP 7: combine sentences + numbers + commit IDs into Markdown, PDF, and interactive HTML."""
2
+ from __future__ import annotations
3
+
4
+ import html
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+ from jinja2 import Template
11
+ from reportlab.lib import colors
12
+ from reportlab.lib.pagesizes import A4
13
+ from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
14
+ from reportlab.lib.units import cm
15
+ from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
16
+
17
+ from git2report.models import BranchInfo
18
+
19
+ MARKDOWN = Template("""\
20
+ # Weekly Progress Report
21
+
22
+ **Project:** {{ d.project }} | **Period:** {{ d.start }} to {{ d.end }} | **Audience:** {{ d.audience }}
23
+ {% if d.executive_summary %}
24
+ > **Executive Summary (TL;DR):** {{ d.executive_summary }}
25
+ {% endif %}
26
+ **Commits:** {{ d.metrics.commits }} | **Lines changed:** {{ d.metrics.lines_changed }} | \
27
+ **Most active:** {{ d.metrics.top_module }} | **Test-to-code ratio:** {{ d.metrics.test_to_code_ratio }}
28
+
29
+ **At a glance:** {{ d.glance_text }}
30
+ {% if d.metrics.tech_stack and d.metrics.tech_stack|length > 0 %}
31
+ ### 💻 Tech Stack Breakdown
32
+ {% for item in d.metrics.tech_stack %}- **{{ item.tech }}**: {{ item.percentage }}%
33
+ {% endfor %}{% endif %}
34
+ {% if d.metrics.hotspots and d.metrics.hotspots|length > 0 %}
35
+ ### 🔥 Codebase Hotspots
36
+ | File | Commits | Lines Modified |
37
+ |:---|:---:|:---:|
38
+ {% for h in d.metrics.hotspots %}| `{{ h.path }}` | {{ h.commits }} | {{ h.lines }} |
39
+ {% endfor %}{% endif %}
40
+ {% if d.metrics.contributors and d.metrics.contributors|length > 0 %}
41
+ ### 👥 Contributor Leaderboard
42
+ | Contributor | Commits | Lines (+ / -) | Primary Focus |
43
+ |:---|:---:|:---:|:---|
44
+ {% for c in d.metrics.contributors %}| {{ c.name }} | {{ c.commits }} | +{{ c.added }} / -{{ c.deleted }} | {{ c.top_module }} |
45
+ {% endfor %}{% endif %}
46
+ {% for s in d.sections %}
47
+ ## {{ s.module }}
48
+ {{ s.text }}
49
+ _Evidence: {{ s.hashes | join(', ') }}_
50
+ {% if s.note %}_Note: {{ s.note }}_
51
+ {% endif %}{% if s.commits and s.commits|length > 0 %}
52
+ | Type | Commit | Author | Lines |
53
+ |:---|:---|:---|:---:|
54
+ {% for cm in s.commits %}| `{{ cm.type | upper }}` | {% if d.remote_url %}[`{{ cm.hash }}`]({{ d.remote_url }}/commit/{{ cm.hash }}){% else %}`{{ cm.hash }}`{% endif %} {{ cm.subject }} | {{ cm.author }} | +{{ cm.added }}/-{{ cm.deleted }} |
55
+ {% endfor %}{% endif %}{% endfor %}{% if d.in_progress %}
56
+ ## In progress (open branches)
57
+ {% for b in d.in_progress %}- `{{ b.name }}`: {{ b.ahead }} commit(s) not merged yet, last activity {{ b.last_date }} ("{{ b.subject }}")
58
+ {% endfor %}{% endif %}{% if d.planned %}
59
+ ## Planned goals (written by the developer)
60
+ {% for p in d.planned %}- {{ p }}
61
+ {% endfor %}{% endif %}
62
+ ---
63
+ _Generated by Git2Report on {{ d.generated }}. Summaries: {{ d.mode_label }}._
64
+ """, trim_blocks=False)
65
+
66
+
67
+ @dataclass
68
+ class CategorizedCommit:
69
+ hash: str
70
+ subject: str
71
+ author: str
72
+ date: str
73
+ type: str # feat, fix, perf, refactor, docs, chore, etc.
74
+ added: int = 0
75
+ deleted: int = 0
76
+
77
+
78
+ @dataclass
79
+ class Section:
80
+ module: str
81
+ text: str
82
+ hashes: list[str]
83
+ mode: str = "offline"
84
+ note: str = ""
85
+ commits: list[CategorizedCommit] = field(default_factory=list)
86
+
87
+
88
+ @dataclass
89
+ class ReportData:
90
+ project: str
91
+ start: str
92
+ end: str
93
+ audience: str
94
+ metrics: dict
95
+ sections: list[Section] = field(default_factory=list)
96
+ mode_label: str = ""
97
+ glance_text: str = ""
98
+ in_progress: list[BranchInfo] = field(default_factory=list)
99
+ planned: list[str] = field(default_factory=list)
100
+ generated: str = field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d %H:%M"))
101
+ remote_url: str | None = None
102
+ executive_summary: str = ""
103
+
104
+
105
+ def render_markdown(data: ReportData) -> str:
106
+ return MARKDOWN.render(d=data).rstrip() + "\n"
107
+
108
+
109
+ def _esc(t: str) -> str:
110
+ return t.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
111
+
112
+
113
+ def render_pdf(data: ReportData, path: Path) -> None:
114
+ ss = getSampleStyleSheet()
115
+ navy = colors.HexColor("#0F172A")
116
+ blue = colors.HexColor("#2563EB")
117
+ slate = colors.HexColor("#334155")
118
+ gray_bg = colors.HexColor("#F8FAFC")
119
+ gray_border = colors.HexColor("#CBD5E1")
120
+
121
+ title = ParagraphStyle("t", parent=ss["Title"], fontName="Helvetica-Bold", fontSize=18, leading=22, textColor=navy, alignment=0, spaceAfter=4)
122
+ meta = ParagraphStyle("m", parent=ss["Normal"], fontSize=8.5, leading=11, textColor=colors.HexColor("#64748B"), spaceAfter=8)
123
+ h = ParagraphStyle("h", parent=ss["Heading2"], fontName="Helvetica-Bold", fontSize=11, leading=14, textColor=navy, spaceBefore=8, spaceAfter=3)
124
+ body = ParagraphStyle("b", parent=ss["Normal"], fontSize=9, leading=13, textColor=slate)
125
+ ev = ParagraphStyle("e", parent=ss["Normal"], fontName="Courier", fontSize=7.5, leading=10, textColor=colors.HexColor("#64748B"), spaceAfter=2)
126
+ note_style = ParagraphStyle("n", parent=ss["Normal"], fontSize=8, leading=11, textColor=colors.HexColor("#B45309"), spaceAfter=2)
127
+
128
+ cell = ParagraphStyle("c", parent=ss["Normal"], fontSize=8, leading=10.5, textColor=slate)
129
+ cell_bold = ParagraphStyle("cb", parent=cell, fontName="Helvetica-Bold", fontSize=10.5, leading=12.5, textColor=navy)
130
+ cellh = ParagraphStyle("ch", parent=cell, fontName="Helvetica-Bold", fontSize=8, leading=10.5, textColor=colors.white)
131
+
132
+ m = data.metrics
133
+ lines_add = m.get("lines_added", 0)
134
+ lines_del = m.get("lines_deleted", 0)
135
+
136
+ # 1. Header & Meta
137
+ story = [
138
+ Paragraph("Weekly Progress Report", title),
139
+ Paragraph(
140
+ f"Project: <b>{_esc(data.project)}</b> &nbsp;|&nbsp; Period: {data.start} to {data.end} &nbsp;|&nbsp; Audience: {data.audience.capitalize()}",
141
+ meta
142
+ ),
143
+ ]
144
+
145
+ # 2. Executive Summary (TL;DR)
146
+ if getattr(data, "executive_summary", ""):
147
+ exec_title = ParagraphStyle("et", parent=ss["Normal"], fontName="Helvetica-Bold", fontSize=8.5, leading=11, textColor=colors.HexColor("#1D4ED8"), spaceAfter=2)
148
+ exec_body = ParagraphStyle("eb", parent=ss["Normal"], fontSize=8.5, leading=12.5, textColor=colors.HexColor("#1E293B"))
149
+ exec_box = Table([
150
+ [Paragraph("EXECUTIVE SUMMARY (TL;DR)", exec_title)],
151
+ [Paragraph(_esc(data.executive_summary), exec_body)],
152
+ ], colWidths=[17.4*cm])
153
+ exec_box.setStyle(TableStyle([
154
+ ("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#F8FAFC")),
155
+ ("BOX", (0, 0), (-1, -1), 0.5, colors.HexColor("#E2E8F0")),
156
+ ("LINEBEFORE", (0, 0), (0, -1), 3.0, blue),
157
+ ("TOPPADDING", (0, 0), (-1, -1), 4),
158
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
159
+ ("LEFTPADDING", (0, 0), (-1, -1), 8),
160
+ ("RIGHTPADDING", (0, 0), (-1, -1), 8),
161
+ ]))
162
+ story += [exec_box, Spacer(1, 4)]
163
+
164
+ # 3. KPI Grid (4 Cards)
165
+ kpi_rows = [
166
+ [
167
+ Paragraph("COMMITS", cell),
168
+ Paragraph("NET CODE CHURN", cell),
169
+ Paragraph("PRIMARY IMPACT", cell),
170
+ Paragraph("TEST RATIO", cell),
171
+ ],
172
+ [
173
+ Paragraph(f"<b>{m['commits']}</b> <font size=7 color='#64748B'>(of {m['commits_seen_before_filter']})</font>", cell_bold),
174
+ Paragraph(f"<b>{m['lines_changed']}</b> <font size=7 color='#059669'>+{lines_add}</font> <font size=7 color='#DC2626'>-{lines_del}</font>", cell_bold),
175
+ Paragraph(f"<b>{_esc(str(m['top_module']))}</b>", cell_bold),
176
+ Paragraph(f"<b>{m['test_to_code_ratio']}</b>", cell_bold),
177
+ ],
178
+ ]
179
+ tbl = Table(kpi_rows, colWidths=[4.35*cm, 4.45*cm, 4.4*cm, 4.2*cm])
180
+ tbl.setStyle(TableStyle([
181
+ ("BACKGROUND", (0, 0), (-1, -1), gray_bg),
182
+ ("BOX", (0, 0), (-1, -1), 0.5, gray_border),
183
+ ("INNERGRID", (0, 0), (-1, -1), 0.5, gray_border),
184
+ ("TOPPADDING", (0, 0), (-1, -1), 5),
185
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
186
+ ("LEFTPADDING", (0, 0), (-1, -1), 7),
187
+ ("RIGHTPADDING", (0, 0), (-1, -1), 7),
188
+ ]))
189
+ story += [tbl, Spacer(1, 4)]
190
+
191
+ # 4. At-a-glance banner
192
+ glance_box = Table([[Paragraph(f"<b>At a glance:</b> {_esc(data.glance_text)}", body)]], colWidths=[17.4*cm])
193
+ glance_box.setStyle(TableStyle([
194
+ ("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#EFF6FF")),
195
+ ("BOX", (0, 0), (-1, -1), 0.5, colors.HexColor("#BFDBFE")),
196
+ ("TOPPADDING", (0, 0), (-1, -1), 4),
197
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
198
+ ("LEFTPADDING", (0, 0), (-1, -1), 8),
199
+ ("RIGHTPADDING", (0, 0), (-1, -1), 8),
200
+ ]))
201
+ story += [glance_box, Spacer(1, 4)]
202
+
203
+ # 4b. Visual Analytics Chart: Commit Type Distribution
204
+ commits_by_type = m.get("commits_by_type", {})
205
+ if commits_by_type:
206
+ from git2report.charts import build_type_breakdown_bar
207
+ type_chart = build_type_breakdown_bar(commits_by_type, width=490, height=30)
208
+ story += [
209
+ Paragraph("Commit Type Distribution", h),
210
+ type_chart,
211
+ Spacer(1, 3)
212
+ ]
213
+
214
+ # 4c. Visual Analytics: Module Code Churn Chart (+Lines Added / -Lines Deleted)
215
+ if data.sections:
216
+ from git2report.charts import build_module_churn_chart
217
+ churn_chart = build_module_churn_chart(data.sections, width=490, height=60)
218
+ story += [
219
+ Paragraph("Code Churn by Module (+Lines Added / -Lines Deleted)", h),
220
+ churn_chart,
221
+ Spacer(1, 4)
222
+ ]
223
+
224
+ # 5. Two-column Analytics: Tech Stack + Codebase Hotspots
225
+ tech_stack = m.get("tech_stack", [])
226
+ hotspots = m.get("hotspots", [])
227
+ if tech_stack or hotspots:
228
+ # Tech stack table
229
+ ts_rows = [[Paragraph("Technology", cellh), Paragraph("Share", cellh)]]
230
+ if isinstance(tech_stack, list):
231
+ for item in tech_stack[:5]:
232
+ t_name = item.get("tech", "Other") if isinstance(item, dict) else str(item)
233
+ t_pct = item.get("percentage", 0) if isinstance(item, dict) else 0
234
+ ts_rows.append([
235
+ Paragraph(_esc(str(t_name)), cell),
236
+ Paragraph(f"<b>{t_pct}%</b>", cell),
237
+ ])
238
+ elif isinstance(tech_stack, dict):
239
+ for tech, pct in list(tech_stack.items())[:5]:
240
+ ts_rows.append([
241
+ Paragraph(_esc(str(tech)), cell),
242
+ Paragraph(f"<b>{pct}%</b>", cell),
243
+ ])
244
+ if len(ts_rows) == 1:
245
+ ts_rows.append([Paragraph("Not detected", cell), Paragraph("-", cell)])
246
+ ts_tbl = Table(ts_rows, colWidths=[5.4*cm, 2.6*cm])
247
+ ts_tbl.setStyle(TableStyle([
248
+ ("BACKGROUND", (0, 0), (-1, 0), navy),
249
+ ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, gray_bg]),
250
+ ("GRID", (0, 0), (-1, -1), 0.5, gray_border),
251
+ ("TOPPADDING", (0, 0), (-1, -1), 2.5),
252
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 2.5),
253
+ ("LEFTPADDING", (0, 0), (-1, -1), 5),
254
+ ("RIGHTPADDING", (0, 0), (-1, -1), 5),
255
+ ]))
256
+
257
+ # Hotspots table
258
+ hs_rows = [[Paragraph("Active File", cellh), Paragraph("Commits", cellh), Paragraph("Lines", cellh)]]
259
+ if isinstance(hotspots, list):
260
+ for hs in hotspots[:5]:
261
+ if isinstance(hs, dict):
262
+ fname = hs.get("path") or hs.get("file", "")
263
+ n_commits = hs.get("commits", 0)
264
+ n_lines = hs.get("lines", 0)
265
+ else:
266
+ fname = str(hs)
267
+ n_commits = 0
268
+ n_lines = 0
269
+ if len(fname) > 26:
270
+ fname = "..." + fname[-23:]
271
+ hs_rows.append([
272
+ Paragraph(f"<font face='Courier' size=7>{_esc(fname)}</font>", cell),
273
+ Paragraph(str(n_commits), cell),
274
+ Paragraph(str(n_lines), cell),
275
+ ])
276
+ if len(hs_rows) == 1:
277
+ hs_rows.append([Paragraph("None", cell), Paragraph("-", cell), Paragraph("-", cell)])
278
+ hs_tbl = Table(hs_rows, colWidths=[5.4*cm, 1.8*cm, 1.8*cm])
279
+ hs_tbl.setStyle(TableStyle([
280
+ ("BACKGROUND", (0, 0), (-1, 0), navy),
281
+ ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, gray_bg]),
282
+ ("GRID", (0, 0), (-1, -1), 0.5, gray_border),
283
+ ("TOPPADDING", (0, 0), (-1, -1), 2.5),
284
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 2.5),
285
+ ("LEFTPADDING", (0, 0), (-1, -1), 5),
286
+ ("RIGHTPADDING", (0, 0), (-1, -1), 5),
287
+ ]))
288
+
289
+ analytics_tbl = Table([
290
+ [Paragraph("Tech Stack Breakdown", h), Paragraph("Codebase Hotspots", h)],
291
+ [ts_tbl, hs_tbl]
292
+ ], colWidths=[8.4*cm, 9.0*cm])
293
+ analytics_tbl.setStyle(TableStyle([
294
+ ("VALIGN", (0, 0), (-1, -1), "TOP"),
295
+ ("LEFTPADDING", (0, 0), (-1, -1), 0),
296
+ ("RIGHTPADDING", (0, 0), (-1, -1), 0),
297
+ ("TOPPADDING", (0, 0), (-1, -1), 1),
298
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 2),
299
+ ]))
300
+ story += [analytics_tbl, Spacer(1, 4)]
301
+
302
+ # 6. Team Contributor Leaderboard
303
+ contributors = m.get("contributors", [])
304
+ if contributors and len(contributors) > 0:
305
+ c_rows = [[
306
+ Paragraph("Contributor", cellh),
307
+ Paragraph("Commits", cellh),
308
+ Paragraph("Lines (+ / -)", cellh),
309
+ Paragraph("Primary Focus", cellh),
310
+ ]]
311
+ for c_ in contributors[:6]:
312
+ c_rows.append([
313
+ Paragraph(_esc(c_["name"]), cell),
314
+ Paragraph(str(c_["commits"]), cell),
315
+ Paragraph(f"<font color='#059669'>+{c_['added']}</font> / <font color='#DC2626'>-{c_['deleted']}</font>", cell),
316
+ Paragraph(_esc(c_["top_module"]), cell),
317
+ ])
318
+ c_tbl = Table(c_rows, colWidths=[5.2*cm, 2.6*cm, 4.6*cm, 5.0*cm])
319
+ c_tbl.setStyle(TableStyle([
320
+ ("BACKGROUND", (0, 0), (-1, 0), navy),
321
+ ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, gray_bg]),
322
+ ("GRID", (0, 0), (-1, -1), 0.5, gray_border),
323
+ ("TOPPADDING", (0, 0), (-1, -1), 3),
324
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 3),
325
+ ("LEFTPADDING", (0, 0), (-1, -1), 6),
326
+ ("RIGHTPADDING", (0, 0), (-1, -1), 6),
327
+ ]))
328
+ story += [Paragraph("Team Contributors", h), c_tbl, Spacer(1, 4)]
329
+
330
+ bullet = ParagraphStyle("bl", parent=body, leftIndent=12, bulletIndent=2, fontSize=8.5, leading=12)
331
+
332
+ # 7. Module Deliverables & Categorized Badges
333
+ story.append(Paragraph("Module Deliverables", h))
334
+ for s in data.sections:
335
+ story.append(Paragraph(f"<b>{_esc(s.module)}</b>", h))
336
+ s_box = Table([[Paragraph(_esc(s.text), body)]], colWidths=[17.4*cm])
337
+ s_box.setStyle(TableStyle([
338
+ ("BACKGROUND", (0, 0), (-1, -1), gray_bg),
339
+ ("BOX", (0, 0), (-1, -1), 0.5, gray_border),
340
+ ("LINEBEFORE", (0, 0), (0, -1), 2.5, blue),
341
+ ("TOPPADDING", (0, 0), (-1, -1), 4),
342
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
343
+ ("LEFTPADDING", (0, 0), (-1, -1), 8),
344
+ ("RIGHTPADDING", (0, 0), (-1, -1), 8),
345
+ ]))
346
+ story.append(s_box)
347
+
348
+ # Categorized commits with distinct badge colors
349
+ if s.commits:
350
+ badge_colors = {
351
+ "FEAT": "#059669",
352
+ "FIX": "#DC2626",
353
+ "PERF": "#7C3AED",
354
+ "REFACTOR": "#D97706",
355
+ "DOCS": "#0284C7",
356
+ "STYLE": "#64748B",
357
+ "TEST": "#0D9488",
358
+ "CHORE": "#64748B",
359
+ }
360
+ commit_items = []
361
+ for cm_ in s.commits[:5]:
362
+ t_label = cm_.type.upper() if cm_.type else "OTHER"
363
+ b_color = badge_colors.get(t_label, "#2563EB")
364
+ commit_items.append(
365
+ Paragraph(
366
+ f"<font color='{b_color}'><b>[{t_label}]</b></font> {_esc(cm_.subject)} &nbsp;<font color='#64748B'>({_esc(cm_.author)}, +{cm_.added}/-{cm_.deleted})</font> <font face='Courier' color='#2563EB'>{cm_.hash}</font>",
367
+ bullet,
368
+ bulletText="\u2022"
369
+ )
370
+ )
371
+ story += commit_items
372
+ if len(s.commits) > 5:
373
+ story.append(Paragraph(f"<i>... and {len(s.commits) - 5} more commits</i>", bullet))
374
+
375
+ story.append(Paragraph("Evidence: " + ", ".join(s.hashes), ev))
376
+ if s.note:
377
+ story.append(Paragraph("Note: " + _esc(s.note), note_style))
378
+ story.append(Spacer(1, 3))
379
+
380
+ # 8. In progress branches
381
+ if data.in_progress:
382
+ story.append(Paragraph("In progress (open branches)", h))
383
+ for b in data.in_progress:
384
+ story.append(Paragraph(f"<b>{_esc(b.name)}</b>: {b.ahead} commit(s) not merged yet, last activity {b.last_date} ({_esc(b.subject)})",
385
+ bullet, bulletText="\u2022"))
386
+
387
+ # 9. Planned goals
388
+ if data.planned:
389
+ story.append(Paragraph("Planned goals (written by the developer)", h))
390
+ for g_ in data.planned:
391
+ story.append(Paragraph(_esc(g_), bullet, bulletText="\u2022"))
392
+
393
+ story += [Spacer(1, 8), Paragraph(f"Generated by Git2Report on {data.generated}. Summaries: {_esc(data.mode_label)}.", meta)]
394
+
395
+ def header_footer(c, doc):
396
+ c.saveState()
397
+ c.setFont("Helvetica", 7.5)
398
+ c.setFillColor(colors.HexColor("#64748B"))
399
+ # Running header (pages > 1)
400
+ if doc.page > 1:
401
+ c.drawString(1.8*cm, A4[1] - 1.2*cm, f"{data.project} — Weekly Progress Report")
402
+ c.drawRightString(A4[0] - 1.8*cm, A4[1] - 1.2*cm, f"Generated: {data.generated}")
403
+ c.setStrokeColor(gray_border)
404
+ c.setLineWidth(0.5)
405
+ c.line(1.8*cm, A4[1] - 1.35*cm, A4[0] - 1.8*cm, A4[1] - 1.35*cm)
406
+
407
+ # Running footer
408
+ c.drawString(1.8*cm, 1.1*cm, "Git2Report | Engineering Delivery Report")
409
+ c.drawRightString(A4[0] - 1.8*cm, 1.1*cm, f"Page {doc.page}")
410
+ c.setStrokeColor(gray_border)
411
+ c.setLineWidth(0.5)
412
+ c.line(1.8*cm, 1.3*cm, A4[0] - 1.8*cm, 1.3*cm)
413
+ c.restoreState()
414
+
415
+ SimpleDocTemplate(
416
+ str(path), pagesize=A4,
417
+ leftMargin=1.8*cm, rightMargin=1.8*cm,
418
+ topMargin=1.6*cm, bottomMargin=1.6*cm,
419
+ title=f"{data.project} Progress Report"
420
+ ).build(story, onFirstPage=header_footer, onLaterPages=header_footer)
421
+
422
+
423
+ HTML_TEMPLATE = """\
424
+ <!DOCTYPE html>
425
+ <html lang="en">
426
+ <head>
427
+ <meta charset="UTF-8">
428
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
429
+ <title>Progress Report - {{ d.project }}</title>
430
+ <style>
431
+ :root {
432
+ --bg: #0f172a;
433
+ --card: #1e293b;
434
+ --card-border: #334155;
435
+ --text: #f8fafc;
436
+ --text-muted: #94a3b8;
437
+ --primary: #3b82f6;
438
+ --primary-hover: #60a5fa;
439
+ --accent: #8b5cf6;
440
+ --success: #10b981;
441
+ --danger: #ef4444;
442
+ --warning: #f59e0b;
443
+ --info: #06b6d4;
444
+ --chip-bg: rgba(255, 255, 255, 0.06);
445
+ }
446
+ .light {
447
+ --bg: #f8fafc;
448
+ --card: #ffffff;
449
+ --card-border: #e2e8f0;
450
+ --text: #0f172a;
451
+ --text-muted: #64748b;
452
+ --chip-bg: rgba(0, 0, 0, 0.04);
453
+ }
454
+ * { box-sizing: border-box; margin: 0; padding: 0; }
455
+ body {
456
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
457
+ background-color: var(--bg);
458
+ color: var(--text);
459
+ line-height: 1.5;
460
+ padding: 24px 16px;
461
+ transition: background-color 0.2s, color 0.2s;
462
+ }
463
+ .container { max-width: 1100px; margin: 0 auto; }
464
+ header {
465
+ display: flex;
466
+ flex-wrap: wrap;
467
+ justify-content: space-between;
468
+ align-items: center;
469
+ gap: 16px;
470
+ padding-bottom: 24px;
471
+ border-bottom: 1px solid var(--card-border);
472
+ margin-bottom: 24px;
473
+ }
474
+ .title-group h1 { font-size: 1.85rem; font-weight: 700; display: flex; align-items: center; gap: 10px; }
475
+ .badge {
476
+ display: inline-flex;
477
+ align-items: center;
478
+ font-size: 0.75rem;
479
+ font-weight: 600;
480
+ padding: 3px 8px;
481
+ border-radius: 9999px;
482
+ text-transform: uppercase;
483
+ letter-spacing: 0.05em;
484
+ }
485
+ .badge-primary { background: rgba(59, 130, 246, 0.15); color: var(--primary); }
486
+ .badge-success { background: rgba(16, 185, 129, 0.15); color: var(--success); }
487
+ .badge-danger { background: rgba(239, 68, 68, 0.15); color: var(--danger); }
488
+ .badge-warning { background: rgba(245, 158, 11, 0.15); color: var(--warning); }
489
+ .badge-accent { background: rgba(139, 92, 246, 0.15); color: var(--accent); }
490
+ .meta-text { color: var(--text-muted); font-size: 0.9rem; margin-top: 4px; }
491
+ .actions { display: flex; gap: 10px; align-items: center; }
492
+ button, .btn {
493
+ background: var(--card);
494
+ border: 1px solid var(--card-border);
495
+ color: var(--text);
496
+ padding: 8px 14px;
497
+ border-radius: 8px;
498
+ cursor: pointer;
499
+ font-weight: 500;
500
+ font-size: 0.85rem;
501
+ display: inline-flex;
502
+ align-items: center;
503
+ gap: 6px;
504
+ text-decoration: none;
505
+ transition: all 0.2s;
506
+ }
507
+ button:hover, .btn:hover { border-color: var(--primary); color: var(--primary); }
508
+ .kpi-grid {
509
+ display: grid;
510
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
511
+ gap: 16px;
512
+ margin-bottom: 24px;
513
+ }
514
+ .kpi-card {
515
+ background: var(--card);
516
+ border: 1px solid var(--card-border);
517
+ border-radius: 12px;
518
+ padding: 18px;
519
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
520
+ }
521
+ .kpi-label { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; }
522
+ .kpi-value { font-size: 1.75rem; font-weight: 700; margin: 4px 0; }
523
+ .kpi-sub { font-size: 0.8rem; color: var(--text-muted); }
524
+ .glance-banner {
525
+ background: linear-gradient(135deg, rgba(59, 130, 246, 0.1), rgba(139, 92, 246, 0.1));
526
+ border: 1px solid rgba(59, 130, 246, 0.3);
527
+ padding: 14px 18px;
528
+ border-radius: 10px;
529
+ margin-bottom: 24px;
530
+ display: flex;
531
+ align-items: center;
532
+ gap: 10px;
533
+ font-size: 0.95rem;
534
+ }
535
+ .progress-bar-container {
536
+ background: var(--card);
537
+ border: 1px solid var(--card-border);
538
+ border-radius: 12px;
539
+ padding: 18px;
540
+ margin-bottom: 24px;
541
+ }
542
+ .progress-bar {
543
+ display: flex;
544
+ height: 12px;
545
+ border-radius: 6px;
546
+ overflow: hidden;
547
+ margin: 12px 0 8px 0;
548
+ background: rgba(255, 255, 255, 0.05);
549
+ }
550
+ .progress-segment { height: 100%; transition: width 0.3s; }
551
+ .progress-legend { display: flex; flex-wrap: wrap; gap: 14px; font-size: 0.8rem; color: var(--text-muted); }
552
+ .legend-item { display: flex; align-items: center; gap: 6px; }
553
+ .legend-dot { width: 8px; height: 8px; border-radius: 50%; }
554
+
555
+ .leaderboard {
556
+ background: var(--card);
557
+ border: 1px solid var(--card-border);
558
+ border-radius: 12px;
559
+ padding: 18px;
560
+ margin-bottom: 24px;
561
+ }
562
+ table { width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 0.9rem; }
563
+ th { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--card-border); color: var(--text-muted); font-weight: 600; font-size: 0.8rem; text-transform: uppercase; }
564
+ td { padding: 10px 12px; border-bottom: 1px solid var(--chip-bg); }
565
+ tr:last-child td { border-bottom: none; }
566
+
567
+ .module-card {
568
+ background: var(--card);
569
+ border: 1px solid var(--card-border);
570
+ border-radius: 12px;
571
+ padding: 20px;
572
+ margin-bottom: 20px;
573
+ }
574
+ .module-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
575
+ .module-title { font-size: 1.25rem; font-weight: 600; }
576
+ .summary-box {
577
+ background: var(--chip-bg);
578
+ border-left: 3px solid var(--primary);
579
+ padding: 10px 14px;
580
+ border-radius: 4px;
581
+ font-size: 0.95rem;
582
+ margin-bottom: 14px;
583
+ }
584
+ .commit-table { width: 100%; }
585
+ .commit-table td { padding: 8px 10px; font-size: 0.85rem; }
586
+ .commit-hash {
587
+ font-family: monospace;
588
+ font-size: 0.8rem;
589
+ background: var(--chip-bg);
590
+ padding: 2px 6px;
591
+ border-radius: 4px;
592
+ text-decoration: none;
593
+ color: var(--primary);
594
+ }
595
+ .commit-hash:hover { text-decoration: underline; }
596
+ .filter-bar {
597
+ display: flex;
598
+ flex-wrap: wrap;
599
+ justify-content: space-between;
600
+ gap: 12px;
601
+ margin-bottom: 20px;
602
+ }
603
+ .search-input {
604
+ background: var(--card);
605
+ border: 1px solid var(--card-border);
606
+ color: var(--text);
607
+ padding: 8px 14px;
608
+ border-radius: 8px;
609
+ font-size: 0.85rem;
610
+ min-width: 250px;
611
+ }
612
+ .search-input:focus { outline: none; border-color: var(--primary); }
613
+ .section-title { font-size: 1.2rem; font-weight: 600; margin: 24px 0 12px 0; }
614
+ .tag-feat { color: #10b981; }
615
+ .tag-fix { color: #ef4444; }
616
+ .tag-perf, .tag-refactor { color: #8b5cf6; }
617
+ .tag-other { color: #94a3b8; }
618
+ footer { text-align: center; color: var(--text-muted); font-size: 0.8rem; margin-top: 40px; padding-top: 20px; border-top: 1px solid var(--card-border); }
619
+ @media print {
620
+ body { background: #fff !important; color: #000 !important; }
621
+ .actions, .filter-bar, button { display: none !important; }
622
+ .kpi-card, .module-card, .leaderboard, .progress-bar-container { border: 1px solid #ccc !important; box-shadow: none !important; }
623
+ }
624
+ </style>
625
+ </head>
626
+ <body>
627
+ <div class="container">
628
+ <header>
629
+ <div class="title-group">
630
+ <h1>📊 {{ d.project }} <span class="badge badge-primary">{{ d.audience }}</span></h1>
631
+ <div class="meta-text">
632
+ Period: <strong>{{ d.start }}</strong> to <strong>{{ d.end }}</strong> &bull; Generated: {{ d.generated }}
633
+ </div>
634
+ </div>
635
+ <div class="actions">
636
+ {% if d.remote_url %}
637
+ <a href="{{ d.remote_url }}" target="_blank" class="btn">🔗 GitHub</a>
638
+ {% endif %}
639
+ <button onclick="window.print()">🖨️ Print / PDF</button>
640
+ <button onclick="toggleTheme()" id="themeBtn">🌓 Theme</button>
641
+ </div>
642
+ </header>
643
+
644
+ <!-- KPI Cards -->
645
+ <div class="kpi-grid">
646
+ <div class="kpi-card">
647
+ <div class="kpi-label">Commits</div>
648
+ <div class="kpi-value">{{ d.metrics.commits }}</div>
649
+ <div class="kpi-sub">{{ d.metrics.commits_seen_before_filter }} in period</div>
650
+ </div>
651
+ <div class="kpi-card">
652
+ <div class="kpi-label">Code Churn</div>
653
+ <div class="kpi-value" style="color: var(--success);">+{{ d.metrics.lines_added }}</div>
654
+ <div class="kpi-sub" style="color: var(--danger);">-{{ d.metrics.lines_deleted }} lines ({{ d.metrics.lines_changed }} total)</div>
655
+ </div>
656
+ <div class="kpi-card">
657
+ <div class="kpi-label">Top Module</div>
658
+ <div class="kpi-value" style="font-size: 1.35rem; color: var(--primary);">{{ d.metrics.top_module }}</div>
659
+ <div class="kpi-sub">{{ d.metrics.modules }} active module(s)</div>
660
+ </div>
661
+ <div class="kpi-card">
662
+ <div class="kpi-label">Test-to-Code</div>
663
+ <div class="kpi-value">{{ d.metrics.test_to_code_ratio }}</div>
664
+ <div class="kpi-sub">Test lines ratio</div>
665
+ </div>
666
+ <div class="kpi-card">
667
+ <div class="kpi-label">Contributors</div>
668
+ <div class="kpi-value">{{ d.metrics.authors }}</div>
669
+ <div class="kpi-sub">Active developers</div>
670
+ </div>
671
+ </div>
672
+
673
+ <!-- At a Glance -->
674
+ <div class="glance-banner">
675
+ <span>✨ <strong>At a glance:</strong> {{ d.glance_text }}</span>
676
+ </div>
677
+
678
+ <!-- Module Distribution Progress Bar -->
679
+ {% if d.metrics.module_distribution %}
680
+ <div class="progress-bar-container">
681
+ <div style="font-size: 0.85rem; font-weight: 600; text-transform: uppercase; color: var(--text-muted);">
682
+ Module Work Distribution
683
+ </div>
684
+ <div class="progress-bar">
685
+ {% set colors_list = ['#3b82f6', '#10b981', '#8b5cf6', '#f59e0b', '#06b6d4', '#ec4899', '#64748b'] %}
686
+ {% for mod, pct in d.metrics.module_distribution.items() %}
687
+ <div class="progress-segment" style="width: {{ pct }}%; background-color: {{ colors_list[loop.index0 % 7] }};" title="{{ mod }}: {{ pct }}%"></div>
688
+ {% endfor %}
689
+ </div>
690
+ <div class="progress-legend">
691
+ {% for mod, pct in d.metrics.module_distribution.items() %}
692
+ <div class="legend-item">
693
+ <div class="legend-dot" style="background-color: {{ colors_list[loop.index0 % 7] }};"></div>
694
+ <span>{{ mod }} ({{ pct }}%)</span>
695
+ </div>
696
+ {% endfor %}
697
+ </div>
698
+ </div>
699
+ {% endif %}
700
+
701
+ <!-- Module Code Churn Visual Chart -->
702
+ {% if d.sections and d.sections|length > 0 %}
703
+ <div class="progress-bar-container">
704
+ <div style="font-size: 0.85rem; font-weight: 600; text-transform: uppercase; color: var(--text-muted); margin-bottom: 12px; display: flex; justify-content: space-between;">
705
+ <span>📊 Code Churn by Module</span>
706
+ <span style="font-size: 0.8rem; font-weight: normal;"><font color="var(--success)">+Lines Added</font> &bull; <font color="var(--danger)">-Lines Deleted</font></span>
707
+ </div>
708
+ <div style="display: flex; flex-direction: column; gap: 10px;">
709
+ {% for s in d.sections %}
710
+ {% set s_add = s.commits | sum(attribute='added') %}
711
+ {% set s_del = s.commits | sum(attribute='deleted') %}
712
+ <div style="display: grid; grid-template-columns: 140px 1fr 110px; align-items: center; gap: 12px; font-size: 0.85rem;">
713
+ <div style="font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ s.module }}</div>
714
+ <div style="display: flex; height: 10px; border-radius: 5px; overflow: hidden; background: rgba(255,255,255,0.05);">
715
+ {% set total_churn = s_add + s_del %}
716
+ {% if total_churn > 0 %}
717
+ <div style="width: {{ (s_add / total_churn) * 100 }}%; background: var(--success);" title="+{{ s_add }} lines added"></div>
718
+ <div style="width: {{ (s_del / total_churn) * 100 }}%; background: var(--danger);" title="-{{ s_del }} lines deleted"></div>
719
+ {% endif %}
720
+ </div>
721
+ <div style="text-align: right; font-family: monospace; font-size: 0.8rem;">
722
+ <span style="color: var(--success);">+{{ s_add }}</span> / <span style="color: var(--danger);">-{{ s_del }}</span>
723
+ </div>
724
+ </div>
725
+ {% endfor %}
726
+ </div>
727
+ </div>
728
+ {% endif %}
729
+
730
+ <!-- Contributor Leaderboard -->
731
+ {% if d.metrics.contributors and d.metrics.contributors|length > 0 %}
732
+ <div class="leaderboard">
733
+ <div style="font-size: 0.9rem; font-weight: 600; text-transform: uppercase; color: var(--text-muted);">
734
+ 👥 Team Contributions
735
+ </div>
736
+ <table>
737
+ <thead>
738
+ <tr>
739
+ <th>Developer</th>
740
+ <th style="text-align: center;">Commits</th>
741
+ <th>Lines Added</th>
742
+ <th>Lines Deleted</th>
743
+ <th>Primary Module</th>
744
+ </tr>
745
+ </thead>
746
+ <tbody>
747
+ {% for c in d.metrics.contributors %}
748
+ <tr>
749
+ <td><strong>{{ c.name }}</strong></td>
750
+ <td style="text-align: center;"><span class="badge badge-primary">{{ c.commits }}</span></td>
751
+ <td style="color: var(--success);">+{{ c.added }}</td>
752
+ <td style="color: var(--danger);">-{{ c.deleted }}</td>
753
+ <td><span class="badge badge-accent">{{ c.top_module }}</span></td>
754
+ </tr>
755
+ {% endfor %}
756
+ </tbody>
757
+ </table>
758
+ </div>
759
+ {% endif %}
760
+
761
+ <!-- Filter Bar -->
762
+ <div class="filter-bar">
763
+ <div style="font-size: 1.1rem; font-weight: 600; display: flex; align-items: center; gap: 8px;">
764
+ 📦 Module Breakdown ({{ d.sections|length }})
765
+ </div>
766
+ <input type="text" id="commitSearch" class="search-input" placeholder="🔍 Search commits, authors, hashes..." onkeyup="filterCommits()">
767
+ </div>
768
+
769
+ <!-- Modules List -->
770
+ <div id="modulesContainer">
771
+ {% for s in d.sections %}
772
+ <div class="module-card" data-module="{{ s.module|lower }}">
773
+ <div class="module-header">
774
+ <div class="module-title">{{ s.module }}</div>
775
+ <span class="badge badge-primary">{{ s.hashes|length }} commit(s)</span>
776
+ </div>
777
+ <div class="summary-box">
778
+ {{ s.text }}
779
+ </div>
780
+ {% if s.note %}
781
+ <div style="font-size: 0.8rem; color: var(--warning); margin-bottom: 10px;">⚠️ {{ s.note }}</div>
782
+ {% endif %}
783
+
784
+ {% if s.commits %}
785
+ <table class="commit-table">
786
+ <tbody>
787
+ {% for cm in s.commits %}
788
+ <tr class="commit-row" data-search="{{ cm.subject|lower }} {{ cm.author|lower }} {{ cm.hash|lower }}">
789
+ <td style="width: 80px;">
790
+ {% if cm.type == 'feat' %}<span class="badge badge-success">FEAT</span>
791
+ {% elif cm.type == 'fix' %}<span class="badge badge-danger">FIX</span>
792
+ {% elif cm.type in ['perf', 'refactor'] %}<span class="badge badge-accent">{{ cm.type|upper }}</span>
793
+ {% else %}<span class="badge" style="background: var(--chip-bg); color: var(--text-muted);">{{ cm.type|upper }}</span>{% endif %}
794
+ </td>
795
+ <td style="width: 90px;">
796
+ {% if d.remote_url %}
797
+ <a href="{{ d.remote_url }}/commit/{{ cm.hash }}" target="_blank" class="commit-hash">{{ cm.hash }}</a>
798
+ {% else %}
799
+ <span class="commit-hash">{{ cm.hash }}</span>
800
+ {% endif %}
801
+ </td>
802
+ <td>{{ cm.subject }}</td>
803
+ <td style="width: 140px; color: var(--text-muted); font-size: 0.8rem;">{{ cm.author }}</td>
804
+ <td style="width: 100px; text-align: right; font-size: 0.8rem;">
805
+ <span style="color: var(--success);">+{{ cm.added }}</span> / <span style="color: var(--danger);">-{{ cm.deleted }}</span>
806
+ </td>
807
+ </tr>
808
+ {% endfor %}
809
+ </tbody>
810
+ </table>
811
+ {% endif %}
812
+ </div>
813
+ {% endfor %}
814
+ </div>
815
+
816
+ <!-- In Progress (Open Branches) -->
817
+ {% if d.in_progress %}
818
+ <div class="module-card">
819
+ <div class="module-title" style="margin-bottom: 12px;">🌿 In Progress (Open Branches)</div>
820
+ <table>
821
+ <thead>
822
+ <tr>
823
+ <th>Branch</th>
824
+ <th>Commits Ahead</th>
825
+ <th>Last Updated</th>
826
+ <th>Latest Subject</th>
827
+ </tr>
828
+ </thead>
829
+ <tbody>
830
+ {% for b in d.in_progress %}
831
+ <tr>
832
+ <td><code>{{ b.name }}</code></td>
833
+ <td><span class="badge badge-warning">{{ b.ahead }}</span></td>
834
+ <td>{{ b.last_date }}</td>
835
+ <td>{{ b.subject }}</td>
836
+ </tr>
837
+ {% endfor %}
838
+ </tbody>
839
+ </table>
840
+ </div>
841
+ {% endif %}
842
+
843
+ <!-- Planned Goals -->
844
+ {% if d.planned %}
845
+ <div class="module-card">
846
+ <div class="module-title" style="margin-bottom: 12px;">🎯 Planned Goals for Next Period</div>
847
+ <ul style="padding-left: 20px; line-height: 1.8;">
848
+ {% for p in d.planned %}
849
+ <li>{{ p }}</li>
850
+ {% endfor %}
851
+ </ul>
852
+ </div>
853
+ {% endif %}
854
+
855
+ <footer>
856
+ Generated by <strong>Git2Report</strong> on {{ d.generated }} &bull; Mode: {{ d.mode_label }}
857
+ </footer>
858
+ </div>
859
+
860
+ <script>
861
+ function toggleTheme() {
862
+ document.body.classList.toggle('light');
863
+ localStorage.setItem('g2r-theme', document.body.classList.contains('light') ? 'light' : 'dark');
864
+ }
865
+ if (localStorage.getItem('g2r-theme') === 'light') {
866
+ document.body.classList.add('light');
867
+ }
868
+ function filterCommits() {
869
+ const q = document.getElementById('commitSearch').value.toLowerCase();
870
+ const rows = document.querySelectorAll('.commit-row');
871
+ rows.forEach(r => {
872
+ const text = r.getAttribute('data-search') || '';
873
+ r.style.display = text.includes(q) ? '' : 'none';
874
+ });
875
+ }
876
+ </script>
877
+ </body>
878
+ </html>
879
+ """
880
+
881
+
882
+ def render_html(data: ReportData) -> str:
883
+ template = Template(HTML_TEMPLATE)
884
+ return template.render(d=data)