@theihtisham/dev-pulse 1.0.0 → 1.1.0
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.
- package/.editorconfig +12 -0
- package/.github/ISSUE_TEMPLATE/bug_report.yml +43 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +33 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +18 -0
- package/.github/dependabot.yml +16 -0
- package/.github/workflows/ci.yml +33 -0
- package/CODE_OF_CONDUCT.md +27 -0
- package/Dockerfile +8 -0
- package/LICENSE +21 -21
- package/README.md +135 -39
- package/SECURITY.md +22 -0
- package/devpulse/__init__.py +4 -4
- package/devpulse/api/__init__.py +1 -1
- package/devpulse/api/app.py +371 -371
- package/devpulse/cli/__init__.py +1 -1
- package/devpulse/cli/dashboard.py +131 -131
- package/devpulse/cli/main.py +678 -678
- package/devpulse/cli/render.py +175 -175
- package/devpulse/core/__init__.py +34 -34
- package/devpulse/core/analytics.py +487 -487
- package/devpulse/core/config.py +77 -77
- package/devpulse/core/database.py +612 -612
- package/devpulse/core/github_client.py +281 -281
- package/devpulse/core/models.py +142 -142
- package/devpulse/core/report_generator.py +454 -454
- package/devpulse/static/.gitkeep +1 -1
- package/devpulse/templates/report.html +64 -64
- package/package.json +35 -35
- package/pyproject.toml +80 -80
- package/requirements.txt +14 -14
- package/tests/__init__.py +1 -1
- package/tests/conftest.py +208 -208
- package/tests/test_analytics.py +284 -284
- package/tests/test_api.py +313 -313
- package/tests/test_cli.py +204 -204
- package/tests/test_config.py +47 -47
- package/tests/test_database.py +255 -255
- package/tests/test_models.py +107 -107
- package/tests/test_report_generator.py +173 -173
- package/jest.config.js +0 -7
|
@@ -1,454 +1,454 @@
|
|
|
1
|
-
"""Report generator — produces Markdown, JSON, and HTML reports."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
import os
|
|
5
|
-
from datetime import datetime, timedelta
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
from typing import Any, Optional
|
|
8
|
-
|
|
9
|
-
from jinja2 import Environment, FileSystemLoader, BaseLoader
|
|
10
|
-
|
|
11
|
-
from devpulse.core.database import Database
|
|
12
|
-
from devpulse.core.analytics import AnalyticsEngine
|
|
13
|
-
from devpulse.core.config import get_settings
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
class ReportGenerator:
|
|
17
|
-
"""Generate daily, weekly, and monthly reports in multiple formats."""
|
|
18
|
-
|
|
19
|
-
def __init__(self, db: Optional[Database] = None) -> None:
|
|
20
|
-
self.db = db or Database()
|
|
21
|
-
self.engine = AnalyticsEngine(db=self.db)
|
|
22
|
-
settings = get_settings()
|
|
23
|
-
self.reports_dir = settings.reports_dir
|
|
24
|
-
Path(self.reports_dir).mkdir(parents=True, exist_ok=True)
|
|
25
|
-
|
|
26
|
-
# Set up Jinja2 for HTML reports
|
|
27
|
-
template_dir = Path(__file__).parent.parent / "templates"
|
|
28
|
-
if template_dir.exists():
|
|
29
|
-
self.jinja_env = Environment(loader=FileSystemLoader(str(template_dir)))
|
|
30
|
-
else:
|
|
31
|
-
self.jinja_env = Environment(loader=BaseLoader())
|
|
32
|
-
|
|
33
|
-
# ── Daily Report ─────────────────────────────────────────────────
|
|
34
|
-
|
|
35
|
-
def daily_report(
|
|
36
|
-
self,
|
|
37
|
-
target_date: Optional[str] = None,
|
|
38
|
-
author: Optional[str] = None,
|
|
39
|
-
repo: Optional[str] = None,
|
|
40
|
-
) -> dict[str, Any]:
|
|
41
|
-
"""Generate a daily report."""
|
|
42
|
-
if target_date is None:
|
|
43
|
-
target_date = datetime.utcnow().strftime("%Y-%m-%d")
|
|
44
|
-
|
|
45
|
-
start = f"{target_date}T00:00:00Z"
|
|
46
|
-
end = f"{target_date}T23:59:59Z"
|
|
47
|
-
|
|
48
|
-
commits = self.db.get_commits(repo=repo, author=author, since=start, until=end, limit=1000)
|
|
49
|
-
prs = self.db.get_pull_requests(repo=repo, author=author, since=start, limit=500)
|
|
50
|
-
issues = self.db.get_issues(repo=repo, since=start, limit=500)
|
|
51
|
-
reviews = self.db.get_reviews(repo=repo, author=author, since=start, limit=500)
|
|
52
|
-
|
|
53
|
-
prs_opened = len([p for p in prs if (p.get("created_at") or "").startswith(target_date)])
|
|
54
|
-
prs_merged = len([p for p in prs if (p.get("merged_at") or "").startswith(target_date)])
|
|
55
|
-
issues_opened = len([i for i in issues if (i.get("created_at") or "").startswith(target_date)])
|
|
56
|
-
issues_closed = len([i for i in issues if (i.get("closed_at") or "").startswith(target_date)])
|
|
57
|
-
lines_changed = sum(c.get("additions", 0) + c.get("deletions", 0) for c in commits)
|
|
58
|
-
|
|
59
|
-
# Build summary
|
|
60
|
-
parts: list[str] = []
|
|
61
|
-
parts.append(f"**{len(commits)} commit(s)**")
|
|
62
|
-
if prs_opened:
|
|
63
|
-
parts.append(f"{prs_opened} PR(s) opened")
|
|
64
|
-
if prs_merged:
|
|
65
|
-
parts.append(f"{prs_merged} PR(s) merged")
|
|
66
|
-
if issues_closed:
|
|
67
|
-
parts.append(f"{issues_closed} issue(s) closed")
|
|
68
|
-
if not parts:
|
|
69
|
-
parts.append("No activity recorded")
|
|
70
|
-
|
|
71
|
-
return {
|
|
72
|
-
"date": target_date,
|
|
73
|
-
"author": author or "all",
|
|
74
|
-
"commits": len(commits),
|
|
75
|
-
"prs_opened": prs_opened,
|
|
76
|
-
"prs_merged": prs_merged,
|
|
77
|
-
"issues_opened": issues_opened,
|
|
78
|
-
"issues_closed": issues_closed,
|
|
79
|
-
"reviews_given": len(reviews),
|
|
80
|
-
"lines_changed": lines_changed,
|
|
81
|
-
"summary": ", ".join(parts),
|
|
82
|
-
"commit_details": [
|
|
83
|
-
{"message": c.get("message", ""), "repo": c.get("repo", ""), "sha": c.get("sha", "")[:7]}
|
|
84
|
-
for c in commits[:10]
|
|
85
|
-
],
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
# ── Weekly Report ────────────────────────────────────────────────
|
|
89
|
-
|
|
90
|
-
def weekly_report(
|
|
91
|
-
self,
|
|
92
|
-
week_start: Optional[str] = None,
|
|
93
|
-
repo: Optional[str] = None,
|
|
94
|
-
) -> dict[str, Any]:
|
|
95
|
-
"""Generate a weekly report."""
|
|
96
|
-
if week_start is None:
|
|
97
|
-
today = datetime.utcnow()
|
|
98
|
-
week_start = (today - timedelta(days=today.weekday())).strftime("%Y-%m-%d")
|
|
99
|
-
|
|
100
|
-
start_dt = datetime.strptime(week_start, "%Y-%m-%d")
|
|
101
|
-
end_dt = start_dt + timedelta(days=6)
|
|
102
|
-
week_end = end_dt.strftime("%Y-%m-%d")
|
|
103
|
-
since = start_dt.isoformat()
|
|
104
|
-
|
|
105
|
-
commits = self.db.get_commits(repo=repo, since=since, limit=2000)
|
|
106
|
-
prs = self.db.get_pull_requests(repo=repo, since=since, limit=500)
|
|
107
|
-
issues = self.db.get_issues(repo=repo, since=since, limit=500)
|
|
108
|
-
|
|
109
|
-
# Top contributors
|
|
110
|
-
author_counts: dict[str, int] = {}
|
|
111
|
-
for c in commits:
|
|
112
|
-
a = c.get("author", "unknown")
|
|
113
|
-
author_counts[a] = author_counts.get(a, 0) + 1
|
|
114
|
-
top = sorted(author_counts.items(), key=lambda x: x[1], reverse=True)[:5]
|
|
115
|
-
|
|
116
|
-
# Avg merge time
|
|
117
|
-
merge_hours: list[float] = []
|
|
118
|
-
for p in prs:
|
|
119
|
-
if p.get("merged_at") and p.get("created_at"):
|
|
120
|
-
from devpulse.core.analytics import _days_between
|
|
121
|
-
merge_hours.append(_days_between(p["created_at"], p["merged_at"]))
|
|
122
|
-
avg_merge = sum(merge_hours) / len(merge_hours) if merge_hours else 0.0
|
|
123
|
-
|
|
124
|
-
# Insights
|
|
125
|
-
insights_data = self.engine.generate_insights(days=7, repo=repo)
|
|
126
|
-
|
|
127
|
-
closed_issues = [i for i in issues if i.get("state") == "closed"]
|
|
128
|
-
|
|
129
|
-
return {
|
|
130
|
-
"week_start": week_start,
|
|
131
|
-
"week_end": week_end,
|
|
132
|
-
"authors": list(author_counts.keys()),
|
|
133
|
-
"total_commits": len(commits),
|
|
134
|
-
"total_prs": len(prs),
|
|
135
|
-
"total_issues_closed": len(closed_issues),
|
|
136
|
-
"avg_merge_time_hours": round(avg_merge, 1),
|
|
137
|
-
"top_contributors": [{"author": a, "commits": c} for a, c in top],
|
|
138
|
-
"insights": [i["message"] for i in insights_data],
|
|
139
|
-
"recommendations": [i["recommendation"] for i in insights_data],
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
# ── Monthly Report ───────────────────────────────────────────────
|
|
143
|
-
|
|
144
|
-
def monthly_report(
|
|
145
|
-
self,
|
|
146
|
-
year: Optional[int] = None,
|
|
147
|
-
month: Optional[int] = None,
|
|
148
|
-
repo: Optional[str] = None,
|
|
149
|
-
) -> dict[str, Any]:
|
|
150
|
-
"""Generate a monthly report."""
|
|
151
|
-
now = datetime.utcnow()
|
|
152
|
-
year = year or now.year
|
|
153
|
-
month = month or now.month
|
|
154
|
-
since = f"{year}-{month:02d}-01T00:00:00Z"
|
|
155
|
-
|
|
156
|
-
# Compute end of month
|
|
157
|
-
if month == 12:
|
|
158
|
-
until_dt = datetime(year + 1, 1, 1)
|
|
159
|
-
else:
|
|
160
|
-
until_dt = datetime(year, month + 1, 1)
|
|
161
|
-
until = until_dt.isoformat()
|
|
162
|
-
|
|
163
|
-
commits = self.db.get_commits(repo=repo, since=since, until=until, limit=5000)
|
|
164
|
-
prs = self.db.get_pull_requests(repo=repo, since=since, limit=1000)
|
|
165
|
-
issues = self.db.get_issues(repo=repo, since=since, limit=1000)
|
|
166
|
-
|
|
167
|
-
# Team health
|
|
168
|
-
health = self.engine.team_health(days=30, repo=repo)
|
|
169
|
-
insights = self.engine.generate_insights(days=30, repo=repo)
|
|
170
|
-
|
|
171
|
-
author_counts: dict[str, int] = {}
|
|
172
|
-
for c in commits:
|
|
173
|
-
a = c.get("author", "unknown")
|
|
174
|
-
author_counts[a] = author_counts.get(a, 0) + 1
|
|
175
|
-
top = sorted(author_counts.items(), key=lambda x: x[1], reverse=True)[:10]
|
|
176
|
-
|
|
177
|
-
return {
|
|
178
|
-
"period": f"{year}-{month:02d}",
|
|
179
|
-
"total_commits": len(commits),
|
|
180
|
-
"total_prs": len(prs),
|
|
181
|
-
"total_issues_closed": len([i for i in issues if i.get("state") == "closed"]),
|
|
182
|
-
"team_health_score": health.overall_score,
|
|
183
|
-
"top_contributors": [{"author": a, "commits": c} for a, c in top],
|
|
184
|
-
"insights": [i["message"] for i in insights],
|
|
185
|
-
"recommendations": [i["recommendation"] for i in insights],
|
|
186
|
-
"velocity_trend": health.velocity_trend,
|
|
187
|
-
"burnout_risks": health.burnout_risk,
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
# ── Output Formats ───────────────────────────────────────────────
|
|
191
|
-
|
|
192
|
-
def to_markdown(self, data: dict[str, Any], report_type: str = "daily") -> str:
|
|
193
|
-
"""Convert report data to Markdown."""
|
|
194
|
-
lines: list[str] = []
|
|
195
|
-
|
|
196
|
-
if report_type == "daily":
|
|
197
|
-
lines.append(f"# Daily Report — {data['date']}")
|
|
198
|
-
lines.append("")
|
|
199
|
-
lines.append(f"**Author**: {data['author']}")
|
|
200
|
-
lines.append(f"**Summary**: {data['summary']}")
|
|
201
|
-
lines.append("")
|
|
202
|
-
lines.append("## Metrics")
|
|
203
|
-
lines.append("")
|
|
204
|
-
lines.append(f"| Metric | Value |")
|
|
205
|
-
lines.append(f"|--------|-------|")
|
|
206
|
-
lines.append(f"| Commits | {data['commits']} |")
|
|
207
|
-
lines.append(f"| PRs Opened | {data['prs_opened']} |")
|
|
208
|
-
lines.append(f"| PRs Merged | {data['prs_merged']} |")
|
|
209
|
-
lines.append(f"| Issues Opened | {data['issues_opened']} |")
|
|
210
|
-
lines.append(f"| Issues Closed | {data['issues_closed']} |")
|
|
211
|
-
lines.append(f"| Reviews Given | {data['reviews_given']} |")
|
|
212
|
-
lines.append(f"| Lines Changed | {data['lines_changed']} |")
|
|
213
|
-
lines.append("")
|
|
214
|
-
if data.get("commit_details"):
|
|
215
|
-
lines.append("## Recent Commits")
|
|
216
|
-
lines.append("")
|
|
217
|
-
for c in data["commit_details"]:
|
|
218
|
-
lines.append(f"- `[{c['sha']}]` {c['message']} ({c['repo']})")
|
|
219
|
-
lines.append("")
|
|
220
|
-
|
|
221
|
-
elif report_type == "weekly":
|
|
222
|
-
lines.append(f"# Weekly Report — {data['week_start']} to {data['week_end']}")
|
|
223
|
-
lines.append("")
|
|
224
|
-
lines.append("## Overview")
|
|
225
|
-
lines.append("")
|
|
226
|
-
lines.append(f"- **Total Commits**: {data['total_commits']}")
|
|
227
|
-
lines.append(f"- **Total PRs**: {data['total_prs']}")
|
|
228
|
-
lines.append(f"- **Issues Closed**: {data['total_issues_closed']}")
|
|
229
|
-
lines.append(f"- **Avg Merge Time**: {data['avg_merge_time_hours']}h")
|
|
230
|
-
lines.append("")
|
|
231
|
-
lines.append("## Top Contributors")
|
|
232
|
-
lines.append("")
|
|
233
|
-
for tc in data["top_contributors"]:
|
|
234
|
-
lines.append(f"- **{tc['author']}**: {tc['commits']} commits")
|
|
235
|
-
lines.append("")
|
|
236
|
-
if data.get("insights"):
|
|
237
|
-
lines.append("## AI Insights")
|
|
238
|
-
lines.append("")
|
|
239
|
-
for ins in data["insights"]:
|
|
240
|
-
lines.append(f"- {ins}")
|
|
241
|
-
lines.append("")
|
|
242
|
-
if data.get("recommendations"):
|
|
243
|
-
lines.append("## Recommendations")
|
|
244
|
-
lines.append("")
|
|
245
|
-
for rec in data["recommendations"]:
|
|
246
|
-
lines.append(f"- {rec}")
|
|
247
|
-
lines.append("")
|
|
248
|
-
|
|
249
|
-
elif report_type == "monthly":
|
|
250
|
-
lines.append(f"# Monthly Report — {data['period']}")
|
|
251
|
-
lines.append("")
|
|
252
|
-
lines.append("## Overview")
|
|
253
|
-
lines.append("")
|
|
254
|
-
lines.append(f"- **Total Commits**: {data['total_commits']}")
|
|
255
|
-
lines.append(f"- **Total PRs**: {data['total_prs']}")
|
|
256
|
-
lines.append(f"- **Issues Closed**: {data['total_issues_closed']}")
|
|
257
|
-
lines.append(f"- **Team Health Score**: {data['team_health_score']}/100")
|
|
258
|
-
lines.append(f"- **Velocity Trend**: {data['velocity_trend']}")
|
|
259
|
-
lines.append("")
|
|
260
|
-
lines.append("## Top Contributors")
|
|
261
|
-
lines.append("")
|
|
262
|
-
for tc in data["top_contributors"]:
|
|
263
|
-
lines.append(f"- **{tc['author']}**: {tc['commits']} commits")
|
|
264
|
-
lines.append("")
|
|
265
|
-
if data.get("burnout_risks"):
|
|
266
|
-
lines.append("## Burnout Risk")
|
|
267
|
-
lines.append("")
|
|
268
|
-
for name, risk in data["burnout_risks"].items():
|
|
269
|
-
emoji = ":red_circle:" if risk >= 0.6 else ":yellow_circle:" if risk >= 0.3 else ":green_circle:"
|
|
270
|
-
lines.append(f"- {emoji} **{name}**: {risk:.0%}")
|
|
271
|
-
lines.append("")
|
|
272
|
-
if data.get("insights"):
|
|
273
|
-
lines.append("## AI Insights")
|
|
274
|
-
lines.append("")
|
|
275
|
-
for ins in data["insights"]:
|
|
276
|
-
lines.append(f"- {ins}")
|
|
277
|
-
lines.append("")
|
|
278
|
-
|
|
279
|
-
return "\n".join(lines)
|
|
280
|
-
|
|
281
|
-
def to_json(self, data: dict[str, Any]) -> str:
|
|
282
|
-
"""Convert report data to JSON."""
|
|
283
|
-
return json.dumps(data, indent=2, default=str)
|
|
284
|
-
|
|
285
|
-
def to_html(self, data: dict[str, Any], report_type: str = "daily") -> str:
|
|
286
|
-
"""Convert report data to a standalone HTML page."""
|
|
287
|
-
md_content = self.to_markdown(data, report_type)
|
|
288
|
-
# Simple markdown-to-HTML conversion for key elements
|
|
289
|
-
html_body = _md_to_html(md_content)
|
|
290
|
-
|
|
291
|
-
html = f"""<!DOCTYPE html>
|
|
292
|
-
<html lang="en">
|
|
293
|
-
<head>
|
|
294
|
-
<meta charset="UTF-8">
|
|
295
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
296
|
-
<title>DevPulse Report</title>
|
|
297
|
-
<style>
|
|
298
|
-
:root {{
|
|
299
|
-
--bg: #0d1117;
|
|
300
|
-
--card: #161b22;
|
|
301
|
-
--border: #30363d;
|
|
302
|
-
--text: #c9d1d9;
|
|
303
|
-
--heading: #f0f6fc;
|
|
304
|
-
--accent: #58a6ff;
|
|
305
|
-
--green: #3fb950;
|
|
306
|
-
--yellow: #d29922;
|
|
307
|
-
--red: #f85149;
|
|
308
|
-
}}
|
|
309
|
-
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|
310
|
-
body {{
|
|
311
|
-
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
|
312
|
-
background: var(--bg);
|
|
313
|
-
color: var(--text);
|
|
314
|
-
line-height: 1.6;
|
|
315
|
-
padding: 2rem;
|
|
316
|
-
max-width: 960px;
|
|
317
|
-
margin: 0 auto;
|
|
318
|
-
}}
|
|
319
|
-
h1 {{ color: var(--heading); margin-bottom: 1rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; }}
|
|
320
|
-
h2 {{ color: var(--accent); margin-top: 1.5rem; margin-bottom: 0.5rem; }}
|
|
321
|
-
table {{ border-collapse: collapse; width: 100%; margin: 1rem 0; }}
|
|
322
|
-
th, td {{ border: 1px solid var(--border); padding: 0.5rem 1rem; text-align: left; }}
|
|
323
|
-
th {{ background: var(--card); color: var(--heading); }}
|
|
324
|
-
td {{ background: var(--card); }}
|
|
325
|
-
ul {{ padding-left: 1.5rem; }}
|
|
326
|
-
li {{ margin: 0.25rem 0; }}
|
|
327
|
-
strong {{ color: var(--heading); }}
|
|
328
|
-
code {{ background: var(--card); padding: 0.15rem 0.4rem; border-radius: 3px; font-size: 0.9em; }}
|
|
329
|
-
.header {{ text-align: center; margin-bottom: 2rem; }}
|
|
330
|
-
.header p {{ color: var(--accent); }}
|
|
331
|
-
.metric-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin: 1rem 0; }}
|
|
332
|
-
.metric-card {{ background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; text-align: center; }}
|
|
333
|
-
.metric-card .value {{ font-size: 2rem; font-weight: bold; color: var(--heading); }}
|
|
334
|
-
.metric-card .label {{ font-size: 0.85rem; color: var(--text); }}
|
|
335
|
-
.badge {{ display: inline-block; padding: 0.2rem 0.6rem; border-radius: 12px; font-size: 0.8rem; font-weight: 600; }}
|
|
336
|
-
.badge-green {{ background: #1a3a2a; color: var(--green); }}
|
|
337
|
-
.badge-yellow {{ background: #3a2a1a; color: var(--yellow); }}
|
|
338
|
-
.badge-red {{ background: #3a1a1a; color: var(--red); }}
|
|
339
|
-
</style>
|
|
340
|
-
</head>
|
|
341
|
-
<body>
|
|
342
|
-
<div class="header">
|
|
343
|
-
<h1>DevPulse Report</h1>
|
|
344
|
-
<p>AI-Powered Developer Productivity Dashboard</p>
|
|
345
|
-
</div>
|
|
346
|
-
{html_body}
|
|
347
|
-
</body>
|
|
348
|
-
</html>"""
|
|
349
|
-
return html
|
|
350
|
-
|
|
351
|
-
# ── Save to File ─────────────────────────────────────────────────
|
|
352
|
-
|
|
353
|
-
def save_report(
|
|
354
|
-
self,
|
|
355
|
-
data: dict[str, Any],
|
|
356
|
-
report_type: str,
|
|
357
|
-
fmt: str = "markdown",
|
|
358
|
-
) -> str:
|
|
359
|
-
"""Save report to file and return the path."""
|
|
360
|
-
period = data.get("date", data.get("week_start", data.get("period", "unknown")))
|
|
361
|
-
|
|
362
|
-
if fmt == "json":
|
|
363
|
-
content = self.to_json(data)
|
|
364
|
-
ext = "json"
|
|
365
|
-
elif fmt == "html":
|
|
366
|
-
content = self.to_html(data, report_type)
|
|
367
|
-
ext = "html"
|
|
368
|
-
else:
|
|
369
|
-
content = self.to_markdown(data, report_type)
|
|
370
|
-
ext = "md"
|
|
371
|
-
|
|
372
|
-
filename = f"{report_type}_report_{period}.{ext}"
|
|
373
|
-
filepath = os.path.join(self.reports_dir, filename)
|
|
374
|
-
|
|
375
|
-
with open(filepath, "w", encoding="utf-8") as f:
|
|
376
|
-
f.write(content)
|
|
377
|
-
|
|
378
|
-
# Cache in database
|
|
379
|
-
self.db.save_report(report_type, period, content)
|
|
380
|
-
|
|
381
|
-
return filepath
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
def _md_to_html(md: str) -> str:
|
|
385
|
-
"""Minimal Markdown to HTML converter."""
|
|
386
|
-
lines = md.split("\n")
|
|
387
|
-
html_parts: list[str] = []
|
|
388
|
-
in_list = False
|
|
389
|
-
in_table = False
|
|
390
|
-
|
|
391
|
-
for line in lines:
|
|
392
|
-
stripped = line.strip()
|
|
393
|
-
|
|
394
|
-
if stripped.startswith("# "):
|
|
395
|
-
if in_list:
|
|
396
|
-
html_parts.append("</ul>")
|
|
397
|
-
in_list = False
|
|
398
|
-
html_parts.append(f"<h1>{stripped[2:]}</h1>")
|
|
399
|
-
elif stripped.startswith("## "):
|
|
400
|
-
if in_list:
|
|
401
|
-
html_parts.append("</ul>")
|
|
402
|
-
in_list = False
|
|
403
|
-
html_parts.append(f"<h2>{stripped[3:]}</h2>")
|
|
404
|
-
elif stripped.startswith("| ") and "---" not in stripped:
|
|
405
|
-
if not in_table:
|
|
406
|
-
html_parts.append("<table>")
|
|
407
|
-
in_table = True
|
|
408
|
-
cells = [c.strip() for c in stripped.split("|")[1:-1]]
|
|
409
|
-
tag = "th" if not html_parts or not any("<td>" in p for p in html_parts[-5:]) else "td"
|
|
410
|
-
row = "".join(f"<{tag}>{c}</{tag}>" for c in cells)
|
|
411
|
-
html_parts.append(f"<tr>{row}</tr>")
|
|
412
|
-
elif stripped.startswith("- "):
|
|
413
|
-
if in_table:
|
|
414
|
-
html_parts.append("</table>")
|
|
415
|
-
in_table = False
|
|
416
|
-
if not in_list:
|
|
417
|
-
html_parts.append("<ul>")
|
|
418
|
-
in_list = True
|
|
419
|
-
content = stripped[2:]
|
|
420
|
-
# Bold
|
|
421
|
-
content = content.replace("**", "<strong>", 1).replace("**", "</strong>", 1)
|
|
422
|
-
# Code
|
|
423
|
-
if "`" in content:
|
|
424
|
-
parts = content.split("`")
|
|
425
|
-
content = parts[0] + "".join(
|
|
426
|
-
f"<code>{parts[i]}</code>" + parts[i + 1] if i + 1 < len(parts) else ""
|
|
427
|
-
for i in range(1, len(parts), 2)
|
|
428
|
-
)
|
|
429
|
-
html_parts.append(f"<li>{content}</li>")
|
|
430
|
-
elif stripped == "":
|
|
431
|
-
if in_list:
|
|
432
|
-
html_parts.append("</ul>")
|
|
433
|
-
in_list = False
|
|
434
|
-
if in_table:
|
|
435
|
-
html_parts.append("</table>")
|
|
436
|
-
in_table = False
|
|
437
|
-
html_parts.append("<br>")
|
|
438
|
-
else:
|
|
439
|
-
if in_list:
|
|
440
|
-
html_parts.append("</ul>")
|
|
441
|
-
in_list = False
|
|
442
|
-
if in_table:
|
|
443
|
-
html_parts.append("</table>")
|
|
444
|
-
in_table = False
|
|
445
|
-
content = stripped
|
|
446
|
-
content = content.replace("**", "<strong>", 1).replace("**", "</strong>", 1)
|
|
447
|
-
html_parts.append(f"<p>{content}</p>")
|
|
448
|
-
|
|
449
|
-
if in_list:
|
|
450
|
-
html_parts.append("</ul>")
|
|
451
|
-
if in_table:
|
|
452
|
-
html_parts.append("</table>")
|
|
453
|
-
|
|
454
|
-
return "\n".join(html_parts)
|
|
1
|
+
"""Report generator — produces Markdown, JSON, and HTML reports."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
from jinja2 import Environment, FileSystemLoader, BaseLoader
|
|
10
|
+
|
|
11
|
+
from devpulse.core.database import Database
|
|
12
|
+
from devpulse.core.analytics import AnalyticsEngine
|
|
13
|
+
from devpulse.core.config import get_settings
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ReportGenerator:
|
|
17
|
+
"""Generate daily, weekly, and monthly reports in multiple formats."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, db: Optional[Database] = None) -> None:
|
|
20
|
+
self.db = db or Database()
|
|
21
|
+
self.engine = AnalyticsEngine(db=self.db)
|
|
22
|
+
settings = get_settings()
|
|
23
|
+
self.reports_dir = settings.reports_dir
|
|
24
|
+
Path(self.reports_dir).mkdir(parents=True, exist_ok=True)
|
|
25
|
+
|
|
26
|
+
# Set up Jinja2 for HTML reports
|
|
27
|
+
template_dir = Path(__file__).parent.parent / "templates"
|
|
28
|
+
if template_dir.exists():
|
|
29
|
+
self.jinja_env = Environment(loader=FileSystemLoader(str(template_dir)))
|
|
30
|
+
else:
|
|
31
|
+
self.jinja_env = Environment(loader=BaseLoader())
|
|
32
|
+
|
|
33
|
+
# ── Daily Report ─────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
def daily_report(
|
|
36
|
+
self,
|
|
37
|
+
target_date: Optional[str] = None,
|
|
38
|
+
author: Optional[str] = None,
|
|
39
|
+
repo: Optional[str] = None,
|
|
40
|
+
) -> dict[str, Any]:
|
|
41
|
+
"""Generate a daily report."""
|
|
42
|
+
if target_date is None:
|
|
43
|
+
target_date = datetime.utcnow().strftime("%Y-%m-%d")
|
|
44
|
+
|
|
45
|
+
start = f"{target_date}T00:00:00Z"
|
|
46
|
+
end = f"{target_date}T23:59:59Z"
|
|
47
|
+
|
|
48
|
+
commits = self.db.get_commits(repo=repo, author=author, since=start, until=end, limit=1000)
|
|
49
|
+
prs = self.db.get_pull_requests(repo=repo, author=author, since=start, limit=500)
|
|
50
|
+
issues = self.db.get_issues(repo=repo, since=start, limit=500)
|
|
51
|
+
reviews = self.db.get_reviews(repo=repo, author=author, since=start, limit=500)
|
|
52
|
+
|
|
53
|
+
prs_opened = len([p for p in prs if (p.get("created_at") or "").startswith(target_date)])
|
|
54
|
+
prs_merged = len([p for p in prs if (p.get("merged_at") or "").startswith(target_date)])
|
|
55
|
+
issues_opened = len([i for i in issues if (i.get("created_at") or "").startswith(target_date)])
|
|
56
|
+
issues_closed = len([i for i in issues if (i.get("closed_at") or "").startswith(target_date)])
|
|
57
|
+
lines_changed = sum(c.get("additions", 0) + c.get("deletions", 0) for c in commits)
|
|
58
|
+
|
|
59
|
+
# Build summary
|
|
60
|
+
parts: list[str] = []
|
|
61
|
+
parts.append(f"**{len(commits)} commit(s)**")
|
|
62
|
+
if prs_opened:
|
|
63
|
+
parts.append(f"{prs_opened} PR(s) opened")
|
|
64
|
+
if prs_merged:
|
|
65
|
+
parts.append(f"{prs_merged} PR(s) merged")
|
|
66
|
+
if issues_closed:
|
|
67
|
+
parts.append(f"{issues_closed} issue(s) closed")
|
|
68
|
+
if not parts:
|
|
69
|
+
parts.append("No activity recorded")
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
"date": target_date,
|
|
73
|
+
"author": author or "all",
|
|
74
|
+
"commits": len(commits),
|
|
75
|
+
"prs_opened": prs_opened,
|
|
76
|
+
"prs_merged": prs_merged,
|
|
77
|
+
"issues_opened": issues_opened,
|
|
78
|
+
"issues_closed": issues_closed,
|
|
79
|
+
"reviews_given": len(reviews),
|
|
80
|
+
"lines_changed": lines_changed,
|
|
81
|
+
"summary": ", ".join(parts),
|
|
82
|
+
"commit_details": [
|
|
83
|
+
{"message": c.get("message", ""), "repo": c.get("repo", ""), "sha": c.get("sha", "")[:7]}
|
|
84
|
+
for c in commits[:10]
|
|
85
|
+
],
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
# ── Weekly Report ────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
def weekly_report(
|
|
91
|
+
self,
|
|
92
|
+
week_start: Optional[str] = None,
|
|
93
|
+
repo: Optional[str] = None,
|
|
94
|
+
) -> dict[str, Any]:
|
|
95
|
+
"""Generate a weekly report."""
|
|
96
|
+
if week_start is None:
|
|
97
|
+
today = datetime.utcnow()
|
|
98
|
+
week_start = (today - timedelta(days=today.weekday())).strftime("%Y-%m-%d")
|
|
99
|
+
|
|
100
|
+
start_dt = datetime.strptime(week_start, "%Y-%m-%d")
|
|
101
|
+
end_dt = start_dt + timedelta(days=6)
|
|
102
|
+
week_end = end_dt.strftime("%Y-%m-%d")
|
|
103
|
+
since = start_dt.isoformat()
|
|
104
|
+
|
|
105
|
+
commits = self.db.get_commits(repo=repo, since=since, limit=2000)
|
|
106
|
+
prs = self.db.get_pull_requests(repo=repo, since=since, limit=500)
|
|
107
|
+
issues = self.db.get_issues(repo=repo, since=since, limit=500)
|
|
108
|
+
|
|
109
|
+
# Top contributors
|
|
110
|
+
author_counts: dict[str, int] = {}
|
|
111
|
+
for c in commits:
|
|
112
|
+
a = c.get("author", "unknown")
|
|
113
|
+
author_counts[a] = author_counts.get(a, 0) + 1
|
|
114
|
+
top = sorted(author_counts.items(), key=lambda x: x[1], reverse=True)[:5]
|
|
115
|
+
|
|
116
|
+
# Avg merge time
|
|
117
|
+
merge_hours: list[float] = []
|
|
118
|
+
for p in prs:
|
|
119
|
+
if p.get("merged_at") and p.get("created_at"):
|
|
120
|
+
from devpulse.core.analytics import _days_between
|
|
121
|
+
merge_hours.append(_days_between(p["created_at"], p["merged_at"]))
|
|
122
|
+
avg_merge = sum(merge_hours) / len(merge_hours) if merge_hours else 0.0
|
|
123
|
+
|
|
124
|
+
# Insights
|
|
125
|
+
insights_data = self.engine.generate_insights(days=7, repo=repo)
|
|
126
|
+
|
|
127
|
+
closed_issues = [i for i in issues if i.get("state") == "closed"]
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
"week_start": week_start,
|
|
131
|
+
"week_end": week_end,
|
|
132
|
+
"authors": list(author_counts.keys()),
|
|
133
|
+
"total_commits": len(commits),
|
|
134
|
+
"total_prs": len(prs),
|
|
135
|
+
"total_issues_closed": len(closed_issues),
|
|
136
|
+
"avg_merge_time_hours": round(avg_merge, 1),
|
|
137
|
+
"top_contributors": [{"author": a, "commits": c} for a, c in top],
|
|
138
|
+
"insights": [i["message"] for i in insights_data],
|
|
139
|
+
"recommendations": [i["recommendation"] for i in insights_data],
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
# ── Monthly Report ───────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
def monthly_report(
|
|
145
|
+
self,
|
|
146
|
+
year: Optional[int] = None,
|
|
147
|
+
month: Optional[int] = None,
|
|
148
|
+
repo: Optional[str] = None,
|
|
149
|
+
) -> dict[str, Any]:
|
|
150
|
+
"""Generate a monthly report."""
|
|
151
|
+
now = datetime.utcnow()
|
|
152
|
+
year = year or now.year
|
|
153
|
+
month = month or now.month
|
|
154
|
+
since = f"{year}-{month:02d}-01T00:00:00Z"
|
|
155
|
+
|
|
156
|
+
# Compute end of month
|
|
157
|
+
if month == 12:
|
|
158
|
+
until_dt = datetime(year + 1, 1, 1)
|
|
159
|
+
else:
|
|
160
|
+
until_dt = datetime(year, month + 1, 1)
|
|
161
|
+
until = until_dt.isoformat()
|
|
162
|
+
|
|
163
|
+
commits = self.db.get_commits(repo=repo, since=since, until=until, limit=5000)
|
|
164
|
+
prs = self.db.get_pull_requests(repo=repo, since=since, limit=1000)
|
|
165
|
+
issues = self.db.get_issues(repo=repo, since=since, limit=1000)
|
|
166
|
+
|
|
167
|
+
# Team health
|
|
168
|
+
health = self.engine.team_health(days=30, repo=repo)
|
|
169
|
+
insights = self.engine.generate_insights(days=30, repo=repo)
|
|
170
|
+
|
|
171
|
+
author_counts: dict[str, int] = {}
|
|
172
|
+
for c in commits:
|
|
173
|
+
a = c.get("author", "unknown")
|
|
174
|
+
author_counts[a] = author_counts.get(a, 0) + 1
|
|
175
|
+
top = sorted(author_counts.items(), key=lambda x: x[1], reverse=True)[:10]
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
"period": f"{year}-{month:02d}",
|
|
179
|
+
"total_commits": len(commits),
|
|
180
|
+
"total_prs": len(prs),
|
|
181
|
+
"total_issues_closed": len([i for i in issues if i.get("state") == "closed"]),
|
|
182
|
+
"team_health_score": health.overall_score,
|
|
183
|
+
"top_contributors": [{"author": a, "commits": c} for a, c in top],
|
|
184
|
+
"insights": [i["message"] for i in insights],
|
|
185
|
+
"recommendations": [i["recommendation"] for i in insights],
|
|
186
|
+
"velocity_trend": health.velocity_trend,
|
|
187
|
+
"burnout_risks": health.burnout_risk,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
# ── Output Formats ───────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
def to_markdown(self, data: dict[str, Any], report_type: str = "daily") -> str:
|
|
193
|
+
"""Convert report data to Markdown."""
|
|
194
|
+
lines: list[str] = []
|
|
195
|
+
|
|
196
|
+
if report_type == "daily":
|
|
197
|
+
lines.append(f"# Daily Report — {data['date']}")
|
|
198
|
+
lines.append("")
|
|
199
|
+
lines.append(f"**Author**: {data['author']}")
|
|
200
|
+
lines.append(f"**Summary**: {data['summary']}")
|
|
201
|
+
lines.append("")
|
|
202
|
+
lines.append("## Metrics")
|
|
203
|
+
lines.append("")
|
|
204
|
+
lines.append(f"| Metric | Value |")
|
|
205
|
+
lines.append(f"|--------|-------|")
|
|
206
|
+
lines.append(f"| Commits | {data['commits']} |")
|
|
207
|
+
lines.append(f"| PRs Opened | {data['prs_opened']} |")
|
|
208
|
+
lines.append(f"| PRs Merged | {data['prs_merged']} |")
|
|
209
|
+
lines.append(f"| Issues Opened | {data['issues_opened']} |")
|
|
210
|
+
lines.append(f"| Issues Closed | {data['issues_closed']} |")
|
|
211
|
+
lines.append(f"| Reviews Given | {data['reviews_given']} |")
|
|
212
|
+
lines.append(f"| Lines Changed | {data['lines_changed']} |")
|
|
213
|
+
lines.append("")
|
|
214
|
+
if data.get("commit_details"):
|
|
215
|
+
lines.append("## Recent Commits")
|
|
216
|
+
lines.append("")
|
|
217
|
+
for c in data["commit_details"]:
|
|
218
|
+
lines.append(f"- `[{c['sha']}]` {c['message']} ({c['repo']})")
|
|
219
|
+
lines.append("")
|
|
220
|
+
|
|
221
|
+
elif report_type == "weekly":
|
|
222
|
+
lines.append(f"# Weekly Report — {data['week_start']} to {data['week_end']}")
|
|
223
|
+
lines.append("")
|
|
224
|
+
lines.append("## Overview")
|
|
225
|
+
lines.append("")
|
|
226
|
+
lines.append(f"- **Total Commits**: {data['total_commits']}")
|
|
227
|
+
lines.append(f"- **Total PRs**: {data['total_prs']}")
|
|
228
|
+
lines.append(f"- **Issues Closed**: {data['total_issues_closed']}")
|
|
229
|
+
lines.append(f"- **Avg Merge Time**: {data['avg_merge_time_hours']}h")
|
|
230
|
+
lines.append("")
|
|
231
|
+
lines.append("## Top Contributors")
|
|
232
|
+
lines.append("")
|
|
233
|
+
for tc in data["top_contributors"]:
|
|
234
|
+
lines.append(f"- **{tc['author']}**: {tc['commits']} commits")
|
|
235
|
+
lines.append("")
|
|
236
|
+
if data.get("insights"):
|
|
237
|
+
lines.append("## AI Insights")
|
|
238
|
+
lines.append("")
|
|
239
|
+
for ins in data["insights"]:
|
|
240
|
+
lines.append(f"- {ins}")
|
|
241
|
+
lines.append("")
|
|
242
|
+
if data.get("recommendations"):
|
|
243
|
+
lines.append("## Recommendations")
|
|
244
|
+
lines.append("")
|
|
245
|
+
for rec in data["recommendations"]:
|
|
246
|
+
lines.append(f"- {rec}")
|
|
247
|
+
lines.append("")
|
|
248
|
+
|
|
249
|
+
elif report_type == "monthly":
|
|
250
|
+
lines.append(f"# Monthly Report — {data['period']}")
|
|
251
|
+
lines.append("")
|
|
252
|
+
lines.append("## Overview")
|
|
253
|
+
lines.append("")
|
|
254
|
+
lines.append(f"- **Total Commits**: {data['total_commits']}")
|
|
255
|
+
lines.append(f"- **Total PRs**: {data['total_prs']}")
|
|
256
|
+
lines.append(f"- **Issues Closed**: {data['total_issues_closed']}")
|
|
257
|
+
lines.append(f"- **Team Health Score**: {data['team_health_score']}/100")
|
|
258
|
+
lines.append(f"- **Velocity Trend**: {data['velocity_trend']}")
|
|
259
|
+
lines.append("")
|
|
260
|
+
lines.append("## Top Contributors")
|
|
261
|
+
lines.append("")
|
|
262
|
+
for tc in data["top_contributors"]:
|
|
263
|
+
lines.append(f"- **{tc['author']}**: {tc['commits']} commits")
|
|
264
|
+
lines.append("")
|
|
265
|
+
if data.get("burnout_risks"):
|
|
266
|
+
lines.append("## Burnout Risk")
|
|
267
|
+
lines.append("")
|
|
268
|
+
for name, risk in data["burnout_risks"].items():
|
|
269
|
+
emoji = ":red_circle:" if risk >= 0.6 else ":yellow_circle:" if risk >= 0.3 else ":green_circle:"
|
|
270
|
+
lines.append(f"- {emoji} **{name}**: {risk:.0%}")
|
|
271
|
+
lines.append("")
|
|
272
|
+
if data.get("insights"):
|
|
273
|
+
lines.append("## AI Insights")
|
|
274
|
+
lines.append("")
|
|
275
|
+
for ins in data["insights"]:
|
|
276
|
+
lines.append(f"- {ins}")
|
|
277
|
+
lines.append("")
|
|
278
|
+
|
|
279
|
+
return "\n".join(lines)
|
|
280
|
+
|
|
281
|
+
def to_json(self, data: dict[str, Any]) -> str:
|
|
282
|
+
"""Convert report data to JSON."""
|
|
283
|
+
return json.dumps(data, indent=2, default=str)
|
|
284
|
+
|
|
285
|
+
def to_html(self, data: dict[str, Any], report_type: str = "daily") -> str:
|
|
286
|
+
"""Convert report data to a standalone HTML page."""
|
|
287
|
+
md_content = self.to_markdown(data, report_type)
|
|
288
|
+
# Simple markdown-to-HTML conversion for key elements
|
|
289
|
+
html_body = _md_to_html(md_content)
|
|
290
|
+
|
|
291
|
+
html = f"""<!DOCTYPE html>
|
|
292
|
+
<html lang="en">
|
|
293
|
+
<head>
|
|
294
|
+
<meta charset="UTF-8">
|
|
295
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
296
|
+
<title>DevPulse Report</title>
|
|
297
|
+
<style>
|
|
298
|
+
:root {{
|
|
299
|
+
--bg: #0d1117;
|
|
300
|
+
--card: #161b22;
|
|
301
|
+
--border: #30363d;
|
|
302
|
+
--text: #c9d1d9;
|
|
303
|
+
--heading: #f0f6fc;
|
|
304
|
+
--accent: #58a6ff;
|
|
305
|
+
--green: #3fb950;
|
|
306
|
+
--yellow: #d29922;
|
|
307
|
+
--red: #f85149;
|
|
308
|
+
}}
|
|
309
|
+
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|
310
|
+
body {{
|
|
311
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
|
312
|
+
background: var(--bg);
|
|
313
|
+
color: var(--text);
|
|
314
|
+
line-height: 1.6;
|
|
315
|
+
padding: 2rem;
|
|
316
|
+
max-width: 960px;
|
|
317
|
+
margin: 0 auto;
|
|
318
|
+
}}
|
|
319
|
+
h1 {{ color: var(--heading); margin-bottom: 1rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; }}
|
|
320
|
+
h2 {{ color: var(--accent); margin-top: 1.5rem; margin-bottom: 0.5rem; }}
|
|
321
|
+
table {{ border-collapse: collapse; width: 100%; margin: 1rem 0; }}
|
|
322
|
+
th, td {{ border: 1px solid var(--border); padding: 0.5rem 1rem; text-align: left; }}
|
|
323
|
+
th {{ background: var(--card); color: var(--heading); }}
|
|
324
|
+
td {{ background: var(--card); }}
|
|
325
|
+
ul {{ padding-left: 1.5rem; }}
|
|
326
|
+
li {{ margin: 0.25rem 0; }}
|
|
327
|
+
strong {{ color: var(--heading); }}
|
|
328
|
+
code {{ background: var(--card); padding: 0.15rem 0.4rem; border-radius: 3px; font-size: 0.9em; }}
|
|
329
|
+
.header {{ text-align: center; margin-bottom: 2rem; }}
|
|
330
|
+
.header p {{ color: var(--accent); }}
|
|
331
|
+
.metric-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin: 1rem 0; }}
|
|
332
|
+
.metric-card {{ background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; text-align: center; }}
|
|
333
|
+
.metric-card .value {{ font-size: 2rem; font-weight: bold; color: var(--heading); }}
|
|
334
|
+
.metric-card .label {{ font-size: 0.85rem; color: var(--text); }}
|
|
335
|
+
.badge {{ display: inline-block; padding: 0.2rem 0.6rem; border-radius: 12px; font-size: 0.8rem; font-weight: 600; }}
|
|
336
|
+
.badge-green {{ background: #1a3a2a; color: var(--green); }}
|
|
337
|
+
.badge-yellow {{ background: #3a2a1a; color: var(--yellow); }}
|
|
338
|
+
.badge-red {{ background: #3a1a1a; color: var(--red); }}
|
|
339
|
+
</style>
|
|
340
|
+
</head>
|
|
341
|
+
<body>
|
|
342
|
+
<div class="header">
|
|
343
|
+
<h1>DevPulse Report</h1>
|
|
344
|
+
<p>AI-Powered Developer Productivity Dashboard</p>
|
|
345
|
+
</div>
|
|
346
|
+
{html_body}
|
|
347
|
+
</body>
|
|
348
|
+
</html>"""
|
|
349
|
+
return html
|
|
350
|
+
|
|
351
|
+
# ── Save to File ─────────────────────────────────────────────────
|
|
352
|
+
|
|
353
|
+
def save_report(
|
|
354
|
+
self,
|
|
355
|
+
data: dict[str, Any],
|
|
356
|
+
report_type: str,
|
|
357
|
+
fmt: str = "markdown",
|
|
358
|
+
) -> str:
|
|
359
|
+
"""Save report to file and return the path."""
|
|
360
|
+
period = data.get("date", data.get("week_start", data.get("period", "unknown")))
|
|
361
|
+
|
|
362
|
+
if fmt == "json":
|
|
363
|
+
content = self.to_json(data)
|
|
364
|
+
ext = "json"
|
|
365
|
+
elif fmt == "html":
|
|
366
|
+
content = self.to_html(data, report_type)
|
|
367
|
+
ext = "html"
|
|
368
|
+
else:
|
|
369
|
+
content = self.to_markdown(data, report_type)
|
|
370
|
+
ext = "md"
|
|
371
|
+
|
|
372
|
+
filename = f"{report_type}_report_{period}.{ext}"
|
|
373
|
+
filepath = os.path.join(self.reports_dir, filename)
|
|
374
|
+
|
|
375
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
376
|
+
f.write(content)
|
|
377
|
+
|
|
378
|
+
# Cache in database
|
|
379
|
+
self.db.save_report(report_type, period, content)
|
|
380
|
+
|
|
381
|
+
return filepath
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _md_to_html(md: str) -> str:
|
|
385
|
+
"""Minimal Markdown to HTML converter."""
|
|
386
|
+
lines = md.split("\n")
|
|
387
|
+
html_parts: list[str] = []
|
|
388
|
+
in_list = False
|
|
389
|
+
in_table = False
|
|
390
|
+
|
|
391
|
+
for line in lines:
|
|
392
|
+
stripped = line.strip()
|
|
393
|
+
|
|
394
|
+
if stripped.startswith("# "):
|
|
395
|
+
if in_list:
|
|
396
|
+
html_parts.append("</ul>")
|
|
397
|
+
in_list = False
|
|
398
|
+
html_parts.append(f"<h1>{stripped[2:]}</h1>")
|
|
399
|
+
elif stripped.startswith("## "):
|
|
400
|
+
if in_list:
|
|
401
|
+
html_parts.append("</ul>")
|
|
402
|
+
in_list = False
|
|
403
|
+
html_parts.append(f"<h2>{stripped[3:]}</h2>")
|
|
404
|
+
elif stripped.startswith("| ") and "---" not in stripped:
|
|
405
|
+
if not in_table:
|
|
406
|
+
html_parts.append("<table>")
|
|
407
|
+
in_table = True
|
|
408
|
+
cells = [c.strip() for c in stripped.split("|")[1:-1]]
|
|
409
|
+
tag = "th" if not html_parts or not any("<td>" in p for p in html_parts[-5:]) else "td"
|
|
410
|
+
row = "".join(f"<{tag}>{c}</{tag}>" for c in cells)
|
|
411
|
+
html_parts.append(f"<tr>{row}</tr>")
|
|
412
|
+
elif stripped.startswith("- "):
|
|
413
|
+
if in_table:
|
|
414
|
+
html_parts.append("</table>")
|
|
415
|
+
in_table = False
|
|
416
|
+
if not in_list:
|
|
417
|
+
html_parts.append("<ul>")
|
|
418
|
+
in_list = True
|
|
419
|
+
content = stripped[2:]
|
|
420
|
+
# Bold
|
|
421
|
+
content = content.replace("**", "<strong>", 1).replace("**", "</strong>", 1)
|
|
422
|
+
# Code
|
|
423
|
+
if "`" in content:
|
|
424
|
+
parts = content.split("`")
|
|
425
|
+
content = parts[0] + "".join(
|
|
426
|
+
f"<code>{parts[i]}</code>" + parts[i + 1] if i + 1 < len(parts) else ""
|
|
427
|
+
for i in range(1, len(parts), 2)
|
|
428
|
+
)
|
|
429
|
+
html_parts.append(f"<li>{content}</li>")
|
|
430
|
+
elif stripped == "":
|
|
431
|
+
if in_list:
|
|
432
|
+
html_parts.append("</ul>")
|
|
433
|
+
in_list = False
|
|
434
|
+
if in_table:
|
|
435
|
+
html_parts.append("</table>")
|
|
436
|
+
in_table = False
|
|
437
|
+
html_parts.append("<br>")
|
|
438
|
+
else:
|
|
439
|
+
if in_list:
|
|
440
|
+
html_parts.append("</ul>")
|
|
441
|
+
in_list = False
|
|
442
|
+
if in_table:
|
|
443
|
+
html_parts.append("</table>")
|
|
444
|
+
in_table = False
|
|
445
|
+
content = stripped
|
|
446
|
+
content = content.replace("**", "<strong>", 1).replace("**", "</strong>", 1)
|
|
447
|
+
html_parts.append(f"<p>{content}</p>")
|
|
448
|
+
|
|
449
|
+
if in_list:
|
|
450
|
+
html_parts.append("</ul>")
|
|
451
|
+
if in_table:
|
|
452
|
+
html_parts.append("</table>")
|
|
453
|
+
|
|
454
|
+
return "\n".join(html_parts)
|