@theihtisham/dev-pulse 1.0.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.
@@ -0,0 +1,678 @@
1
+ """Main CLI entry point for DevPulse."""
2
+
3
+ import click
4
+
5
+ from devpulse.cli.dashboard import dashboard_cmd
6
+
7
+
8
+ @click.group()
9
+ @click.version_option(version="1.0.0", prog_name="devpulse")
10
+ def cli() -> None:
11
+ """DevPulse -- AI-powered developer productivity dashboard.
12
+
13
+ Track commits, PRs, issues, and reviews. Get AI-powered insights
14
+ on team health, sprint velocity, and developer productivity.
15
+
16
+ Set DEVPULSE_GITHUB_TOKEN environment variable for GitHub access.
17
+ """
18
+ pass
19
+
20
+
21
+ # ── Sync command ─────────────────────────────────────────────────────
22
+
23
+ @cli.command()
24
+ @click.option("--repos", "-r", multiple=True, help="Specific repos to sync (owner/name).")
25
+ @click.option("--since", "-s", default=None, help="Sync data since this date (ISO format).")
26
+ @click.option("--all", "sync_all", is_flag=True, help="Sync all accessible repos.")
27
+ def sync(repos: tuple[str, ...], since: str | None, sync_all: bool) -> None:
28
+ """Sync data from GitHub."""
29
+ from rich.console import Console
30
+ from rich.progress import Progress, SpinnerColumn, TextColumn
31
+
32
+ from devpulse.core.config import get_settings
33
+ from devpulse.core.github_client import GitHubClient
34
+ from devpulse.core.database import Database
35
+
36
+ console = Console()
37
+ settings = get_settings()
38
+
39
+ if not settings.github_token:
40
+ console.print("[red]Error: DEVPULSE_GITHUB_TOKEN not set.[/red]")
41
+ console.print("Run: export DEVPULSE_GITHUB_TOKEN=your_token_here")
42
+ raise SystemExit(1)
43
+
44
+ client = GitHubClient()
45
+ db = Database()
46
+
47
+ repo_list = list(repos) if repos else None
48
+ if not repo_list and not sync_all:
49
+ if settings.github_repos:
50
+ repo_list = settings.github_repos
51
+ else:
52
+ console.print("[yellow]Discovering repositories...[/yellow]")
53
+ repo_list = client.get_repos()
54
+ if not repo_list:
55
+ console.print("[red]No repositories found. Use --repos or set DEVPULSE_GITHUB_ORG.[/red]")
56
+ raise SystemExit(1)
57
+
58
+ with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), console=console) as progress:
59
+ task = progress.add_task("Syncing GitHub data...", total=None)
60
+ counts = client.sync_all(repos=repo_list, since=since, db=db)
61
+ progress.update(task, completed=True)
62
+
63
+ console.print("\n[green]Sync complete![/green]")
64
+ console.print(f" Commits: {counts['commits']}")
65
+ console.print(f" PRs: {counts['prs']}")
66
+ console.print(f" Issues: {counts['issues']}")
67
+ console.print(f" Reviews: {counts['reviews']}")
68
+
69
+
70
+ # ── Report commands ──────────────────────────────────────────────────
71
+
72
+ @cli.command()
73
+ @click.option("--date", "-d", default=None, help="Date for report (YYYY-MM-DD).")
74
+ @click.option("--author", "-a", default=None, help="Filter by author.")
75
+ @click.option("--repo", "-r", default=None, help="Filter by repo.")
76
+ @click.option("--format", "-f", "fmt", type=click.Choice(["terminal", "markdown", "json", "html"]), default="terminal", help="Output format.")
77
+ @click.option("--save", is_flag=True, help="Save report to file.")
78
+ def daily(date: str | None, author: str | None, repo: str | None, fmt: str, save: bool) -> None:
79
+ """Generate a daily report."""
80
+ from rich.console import Console
81
+ from rich.table import Table
82
+ from rich.panel import Panel
83
+
84
+ from devpulse.core.report_generator import ReportGenerator
85
+
86
+ console = Console()
87
+ gen = ReportGenerator()
88
+ data = gen.daily_report(target_date=date, author=author, repo=repo)
89
+
90
+ if fmt == "terminal":
91
+ console.print(Panel(f"[bold]Daily Report — {data['date']}[/bold]", style="blue"))
92
+ console.print(f"[dim]Author: {data['author']}[/dim]")
93
+ console.print(f"\n{data['summary']}\n")
94
+
95
+ table = Table(title="Metrics", show_header=True, header_style="bold cyan")
96
+ table.add_column("Metric", style="white")
97
+ table.add_column("Value", style="green", justify="right")
98
+ for key in ["commits", "prs_opened", "prs_merged", "issues_opened", "issues_closed", "reviews_given", "lines_changed"]:
99
+ table.add_row(key.replace("_", " ").title(), str(data.get(key, 0)))
100
+ console.print(table)
101
+
102
+ if data.get("commit_details"):
103
+ console.print("\n[bold]Recent Commits[/bold]")
104
+ for c in data["commit_details"]:
105
+ console.print(f" [dim][{c['sha']}][/dim] {c['message']} [dim]({c['repo']})[/dim]")
106
+ elif fmt == "json":
107
+ console.print(gen.to_json(data))
108
+ elif fmt == "html":
109
+ html = gen.to_html(data, "daily")
110
+ if save:
111
+ path = gen.save_report(data, "daily", "html")
112
+ console.print(f"[green]Report saved to {path}[/green]")
113
+ else:
114
+ console.print(html)
115
+ else:
116
+ md = gen.to_markdown(data, "daily")
117
+ if save:
118
+ path = gen.save_report(data, "daily", "markdown")
119
+ console.print(f"[green]Report saved to {path}[/green]")
120
+ else:
121
+ console.print(md)
122
+
123
+ if save and fmt in ("terminal", "json"):
124
+ path = gen.save_report(data, "daily", fmt if fmt != "terminal" else "markdown")
125
+ console.print(f"[green]Report saved to {path}[/green]")
126
+
127
+
128
+ @cli.command()
129
+ @click.option("--week-start", "-w", default=None, help="Week start date (YYYY-MM-DD).")
130
+ @click.option("--repo", "-r", default=None, help="Filter by repo.")
131
+ @click.option("--format", "-f", "fmt", type=click.Choice(["terminal", "markdown", "json", "html"]), default="terminal")
132
+ @click.option("--save", is_flag=True, help="Save report to file.")
133
+ def weekly(week_start: str | None, repo: str | None, fmt: str, save: bool) -> None:
134
+ """Generate a weekly report."""
135
+ from rich.console import Console
136
+ from rich.table import Table
137
+ from rich.panel import Panel
138
+
139
+ from devpulse.core.report_generator import ReportGenerator
140
+
141
+ console = Console()
142
+ gen = ReportGenerator()
143
+ data = gen.weekly_report(week_start=week_start, repo=repo)
144
+
145
+ if fmt == "terminal":
146
+ console.print(Panel(f"[bold]Weekly Report — {data['week_start']} to {data['week_end']}[/bold]", style="blue"))
147
+
148
+ console.print(f"\n[bold]Overview[/bold]")
149
+ console.print(f" Commits: {data['total_commits']}")
150
+ console.print(f" PRs: {data['total_prs']}")
151
+ console.print(f" Issues Closed: {data['total_issues_closed']}")
152
+ console.print(f" Avg Merge: {data['avg_merge_time_hours']}h")
153
+
154
+ if data["top_contributors"]:
155
+ table = Table(title="Top Contributors", show_header=True, header_style="bold cyan")
156
+ table.add_column("Author", style="white")
157
+ table.add_column("Commits", style="green", justify="right")
158
+ for tc in data["top_contributors"]:
159
+ table.add_row(tc["author"], str(tc["commits"]))
160
+ console.print(table)
161
+
162
+ if data.get("insights"):
163
+ console.print("\n[bold yellow]AI Insights[/bold yellow]")
164
+ for ins in data["insights"]:
165
+ console.print(f" - {ins}")
166
+ if data.get("recommendations"):
167
+ console.print("\n[bold green]Recommendations[/bold green]")
168
+ for rec in data["recommendations"]:
169
+ console.print(f" - {rec}")
170
+ elif fmt == "json":
171
+ console.print(gen.to_json(data))
172
+ elif fmt == "html":
173
+ html = gen.to_html(data, "weekly")
174
+ if save:
175
+ path = gen.save_report(data, "weekly", "html")
176
+ console.print(f"[green]Saved to {path}[/green]")
177
+ else:
178
+ console.print(html)
179
+ else:
180
+ md = gen.to_markdown(data, "weekly")
181
+ if save:
182
+ path = gen.save_report(data, "weekly", "markdown")
183
+ console.print(f"[green]Saved to {path}[/green]")
184
+ else:
185
+ console.print(md)
186
+
187
+ if save and fmt == "terminal":
188
+ path = gen.save_report(data, "weekly", "markdown")
189
+ console.print(f"[green]Report saved to {path}[/green]")
190
+
191
+
192
+ @cli.command()
193
+ @click.option("--year", "-y", default=None, type=int, help="Year.")
194
+ @click.option("--month", "-m", default=None, type=int, help="Month (1-12).")
195
+ @click.option("--repo", "-r", default=None, help="Filter by repo.")
196
+ @click.option("--format", "-f", "fmt", type=click.Choice(["terminal", "markdown", "json", "html"]), default="terminal")
197
+ @click.option("--save", is_flag=True, help="Save report to file.")
198
+ def monthly(year: int | None, month: int | None, repo: str | None, fmt: str, save: bool) -> None:
199
+ """Generate a monthly report."""
200
+ from rich.console import Console
201
+ from rich.table import Table
202
+ from rich.panel import Panel
203
+
204
+ from devpulse.core.report_generator import ReportGenerator
205
+
206
+ console = Console()
207
+ gen = ReportGenerator()
208
+ data = gen.monthly_report(year=year, month=month, repo=repo)
209
+
210
+ if fmt == "terminal":
211
+ console.print(Panel(f"[bold]Monthly Report — {data['period']}[/bold]", style="blue"))
212
+
213
+ console.print(f"\n[bold]Overview[/bold]")
214
+ console.print(f" Commits: {data['total_commits']}")
215
+ console.print(f" PRs: {data['total_prs']}")
216
+ console.print(f" Issues Closed: {data['total_issues_closed']}")
217
+ console.print(f" Team Health: {data['team_health_score']}/100")
218
+ console.print(f" Velocity: {data['velocity_trend']}")
219
+
220
+ if data.get("burnout_risks"):
221
+ console.print("\n[bold]Burnout Risk[/bold]")
222
+ for name, risk in data["burnout_risks"].items():
223
+ color = "red" if risk >= 0.6 else "yellow" if risk >= 0.3 else "green"
224
+ console.print(f" [{color}]{name}: {risk:.0%}[/{color}]")
225
+
226
+ if data["top_contributors"]:
227
+ table = Table(title="Top Contributors", show_header=True, header_style="bold cyan")
228
+ table.add_column("Author", style="white")
229
+ table.add_column("Commits", style="green", justify="right")
230
+ for tc in data["top_contributors"]:
231
+ table.add_row(tc["author"], str(tc["commits"]))
232
+ console.print(table)
233
+
234
+ if data.get("insights"):
235
+ console.print("\n[bold yellow]AI Insights[/bold yellow]")
236
+ for ins in data["insights"]:
237
+ console.print(f" - {ins}")
238
+ elif fmt == "json":
239
+ console.print(gen.to_json(data))
240
+ elif fmt == "html":
241
+ html = gen.to_html(data, "monthly")
242
+ if save:
243
+ path = gen.save_report(data, "monthly", "html")
244
+ console.print(f"[green]Saved to {path}[/green]")
245
+ else:
246
+ console.print(html)
247
+ else:
248
+ md = gen.to_markdown(data, "monthly")
249
+ if save:
250
+ path = gen.save_report(data, "monthly", "markdown")
251
+ console.print(f"[green]Saved to {path}[/green]")
252
+ else:
253
+ console.print(md)
254
+
255
+ if save and fmt == "terminal":
256
+ path = gen.save_report(data, "monthly", "markdown")
257
+ console.print(f"[green]Report saved to {path}[/green]")
258
+
259
+
260
+ # ── Metrics command ──────────────────────────────────────────────────
261
+
262
+ @cli.command()
263
+ @click.option("--author", "-a", default=None, help="Developer name or 'all' for team.")
264
+ @click.option("--days", "-d", default=30, help="Number of days to analyze.")
265
+ @click.option("--repo", "-r", default=None, help="Filter by repo.")
266
+ @click.option("--format", "-f", "fmt", type=click.Choice(["terminal", "json"]), default="terminal")
267
+ def metrics(author: str | None, days: int, repo: str | None, fmt: str) -> None:
268
+ """View developer or team metrics."""
269
+ from rich.console import Console
270
+ from rich.table import Table
271
+ from rich.panel import Panel
272
+
273
+ from devpulse.core.analytics import AnalyticsEngine
274
+
275
+ console = Console()
276
+ engine = AnalyticsEngine()
277
+
278
+ if author and author != "all":
279
+ m = engine.developer_metrics(author=author, days=days, repo=repo)
280
+ if fmt == "json":
281
+ console.print(m.model_dump_json(indent=2))
282
+ return
283
+ console.print(Panel(f"[bold]Metrics: {m.author}[/bold] ({m.period_days}d)", style="blue"))
284
+
285
+ table = Table(show_header=True, header_style="bold cyan", grid=True)
286
+ table.add_column("Metric", style="white")
287
+ table.add_column("Value", style="green", justify="right")
288
+ table.add_row("Commits", str(m.commits_count))
289
+ table.add_row("Active Days", str(m.active_days))
290
+ table.add_row("Commits/Day", str(m.commits_per_day))
291
+ table.add_row("PRs Created", str(m.prs_created))
292
+ table.add_row("PRs Merged", str(m.prs_merged))
293
+ table.add_row("Avg PR Merge Time", f"{m.avg_pr_merge_time_hours}h")
294
+ table.add_row("Reviews Given", str(m.reviews_given))
295
+ table.add_row("Avg Review Turnaround", f"{m.avg_review_turnaround_hours}h")
296
+ table.add_row("Issues Opened", str(m.issues_opened))
297
+ table.add_row("Issues Closed", str(m.issues_closed))
298
+ table.add_row("Lines Added", f"+{m.lines_added}")
299
+ table.add_row("Lines Removed", f"-{m.lines_removed}")
300
+ console.print(table)
301
+ else:
302
+ team = engine.team_metrics(days=days, repo=repo)
303
+ if fmt == "json":
304
+ import json
305
+ console.print(json.dumps([m.model_dump() for m in team], indent=2, default=str))
306
+ return
307
+ if not team:
308
+ console.print("[yellow]No data. Run 'devpulse sync' first.[/yellow]")
309
+ return
310
+ console.print(Panel(f"[bold]Team Metrics[/bold] ({days}d)", style="blue"))
311
+ table = Table(show_header=True, header_style="bold cyan")
312
+ table.add_column("Author", style="white")
313
+ table.add_column("Commits", justify="right")
314
+ table.add_column("C/D", justify="right", style="dim")
315
+ table.add_column("PRs", justify="right")
316
+ table.add_column("Merged", justify="right")
317
+ table.add_column("Reviews", justify="right")
318
+ table.add_column("Merge(h)", justify="right")
319
+ table.add_column("Lines +/-", justify="right")
320
+ for m in team:
321
+ table.add_row(
322
+ m.author,
323
+ str(m.commits_count),
324
+ str(m.commits_per_day),
325
+ str(m.prs_created),
326
+ str(m.prs_merged),
327
+ str(m.reviews_given),
328
+ str(m.avg_pr_merge_time_hours),
329
+ f"+{m.lines_added}/-{m.lines_removed}",
330
+ )
331
+ console.print(table)
332
+
333
+
334
+ # ── Insights command ─────────────────────────────────────────────────
335
+
336
+ @cli.command()
337
+ @click.option("--days", "-d", default=30, help="Analysis window in days.")
338
+ @click.option("--repo", "-r", default=None, help="Filter by repo.")
339
+ def insights(days: int, repo: str | None) -> None:
340
+ """Get AI-powered insights and recommendations."""
341
+ from rich.console import Console
342
+ from rich.panel import Panel
343
+
344
+ from devpulse.core.analytics import AnalyticsEngine
345
+
346
+ console = Console()
347
+ engine = AnalyticsEngine()
348
+
349
+ results = engine.generate_insights(days=days, repo=repo)
350
+
351
+ console.print(Panel("[bold]AI-Powered Insights[/bold]", style="blue"))
352
+ for i, insight in enumerate(results, 1):
353
+ severity = insight["severity"]
354
+ color = "red" if severity == "high" else "yellow" if severity == "medium" else "green"
355
+ console.print(f"\n[{color}] {i}. {insight['message']}[/{color}]")
356
+ console.print(f"[dim] Recommendation: {insight['recommendation']}[/dim]")
357
+
358
+
359
+ # ── Team Health command ──────────────────────────────────────────────
360
+
361
+ @cli.command()
362
+ @click.option("--days", "-d", default=30, help="Analysis window.")
363
+ @click.option("--repo", "-r", default=None, help="Filter by repo.")
364
+ def health(days: int, repo: str | None) -> None:
365
+ """Analyze team health and burnout risk."""
366
+ from rich.console import Console
367
+ from rich.table import Table
368
+ from rich.panel import Panel
369
+ from rich.progress import BarColumn, Progress
370
+
371
+ from devpulse.core.analytics import AnalyticsEngine
372
+
373
+ console = Console()
374
+ engine = AnalyticsEngine()
375
+
376
+ th = engine.team_health(days=days, repo=repo)
377
+
378
+ console.print(Panel("[bold]Team Health Report[/bold]", style="blue"))
379
+
380
+ score_color = "green" if th.overall_score >= 70 else "yellow" if th.overall_score >= 40 else "red"
381
+ console.print(f"\n Overall Score: [{score_color}]{th.overall_score}/100[/{score_color}]")
382
+ console.print(f" Workload Balance: {th.workload_balance:.0%}")
383
+ console.print(f" Collaboration: {th.collaboration_score:.0%}")
384
+ console.print(f" Velocity Trend: {th.velocity_trend}")
385
+
386
+ if th.burnout_risk:
387
+ console.print("\n[bold]Burnout Risk[/bold]")
388
+ table = Table(show_header=True, header_style="bold")
389
+ table.add_column("Developer", style="white")
390
+ table.add_column("Risk", justify="right")
391
+ for name, risk in sorted(th.burnout_risk.items(), key=lambda x: x[1], reverse=True):
392
+ color = "red" if risk >= 0.6 else "yellow" if risk >= 0.3 else "green"
393
+ table.add_row(name, f"[{color}]{risk:.0%}[/{color}]")
394
+ console.print(table)
395
+
396
+ if th.recommendations:
397
+ console.print("\n[bold green]Recommendations[/bold green]")
398
+ for rec in th.recommendations:
399
+ console.print(f" - {rec}")
400
+
401
+
402
+ # ── Goals commands ───────────────────────────────────────────────────
403
+
404
+ @cli.group()
405
+ def goals() -> None:
406
+ """Manage goals and track progress."""
407
+ pass
408
+
409
+
410
+ @goals.command("add")
411
+ @click.option("--title", "-t", required=True, help="Goal title.")
412
+ @click.option("--target", required=True, type=float, help="Target value.")
413
+ @click.option("--metric", "-m", required=True, help="Metric name (e.g., commits_per_day).")
414
+ @click.option("--deadline", "-d", default=None, help="Deadline (YYYY-MM-DD).")
415
+ @click.option("--description", default="", help="Goal description.")
416
+ def goals_add(title: str, target: float, metric: str, deadline: str | None, description: str) -> None:
417
+ """Add a new goal."""
418
+ from rich.console import Console
419
+ from devpulse.core.database import Database
420
+
421
+ console = Console()
422
+ db = Database()
423
+ gid = db.upsert_goal({
424
+ "title": title,
425
+ "target_value": target,
426
+ "metric": metric,
427
+ "deadline": deadline,
428
+ "description": description,
429
+ })
430
+ console.print(f"[green]Goal created (ID: {gid}): {title}[/green]")
431
+
432
+
433
+ @goals.command("list")
434
+ def goals_list() -> None:
435
+ """List all goals."""
436
+ from rich.console import Console
437
+ from rich.table import Table
438
+ from devpulse.core.database import Database
439
+
440
+ console = Console()
441
+ db = Database()
442
+ all_goals = db.get_goals()
443
+
444
+ if not all_goals:
445
+ console.print("[yellow]No goals found. Use 'devpulse goals add' to create one.[/yellow]")
446
+ return
447
+
448
+ table = Table(title="Goals", show_header=True, header_style="bold cyan")
449
+ table.add_column("ID", justify="right")
450
+ table.add_column("Title", style="white")
451
+ table.add_column("Metric")
452
+ table.add_column("Progress", justify="right")
453
+ table.add_column("Target", justify="right")
454
+ table.add_column("Deadline")
455
+ table.add_column("Status")
456
+ for g in all_goals:
457
+ pct = (g["current_value"] / g["target_value"] * 100) if g["target_value"] > 0 else 0
458
+ color = "green" if pct >= 75 else "yellow" if pct >= 25 else "red"
459
+ table.add_row(
460
+ str(g["id"]),
461
+ g["title"],
462
+ g["metric"],
463
+ f"[{color}]{pct:.0%}[/{color}]",
464
+ str(g["target_value"]),
465
+ g.get("deadline", "-"),
466
+ g["status"],
467
+ )
468
+ console.print(table)
469
+
470
+
471
+ @goals.command("coach")
472
+ @click.option("--goal-id", "-g", default=None, type=int, help="Specific goal ID.")
473
+ def goals_coach(goal_id: int | None) -> None:
474
+ """Get AI coaching on your goals."""
475
+ from rich.console import Console
476
+ from rich.panel import Panel
477
+ from devpulse.core.analytics import AnalyticsEngine
478
+
479
+ console = Console()
480
+ engine = AnalyticsEngine()
481
+ coaching = engine.goal_coaching(goal_id=goal_id)
482
+
483
+ console.print(Panel("[bold]AI Goal Coaching[/bold]", style="blue"))
484
+ for c in coaching:
485
+ color = "green" if c["status"] == "achieved" else "yellow" if c["status"] == "on_track" else "red"
486
+ console.print(f"\n[{color}] {c['goal']}[/{color}]")
487
+ console.print(f" Progress: {c['progress_pct']:.0f}% ({c['current']}/{c['target']} {c['metric']})")
488
+ console.print(f" [dim]Advice: {c['advice']}[/dim]")
489
+
490
+
491
+ @goals.command("delete")
492
+ @click.argument("goal_id", type=int)
493
+ def goals_delete(goal_id: int) -> None:
494
+ """Delete a goal."""
495
+ from rich.console import Console
496
+ from devpulse.core.database import Database
497
+
498
+ console = Console()
499
+ db = Database()
500
+ if db.delete_goal(goal_id):
501
+ console.print(f"[green]Goal {goal_id} deleted.[/green]")
502
+ else:
503
+ console.print(f"[red]Goal {goal_id} not found.[/red]")
504
+
505
+
506
+ # ── Sprint commands ──────────────────────────────────────────────────
507
+
508
+ @cli.group()
509
+ def sprint() -> None:
510
+ """Sprint analytics and tracking."""
511
+ pass
512
+
513
+
514
+ @sprint.command("snapshot")
515
+ @click.option("--name", "-n", required=True, help="Sprint name.")
516
+ @click.option("--total", required=True, type=float, help="Total story points.")
517
+ @click.option("--completed", required=True, type=float, help="Completed story points.")
518
+ @click.option("--added", default=0.0, type=float, help="Points added mid-sprint.")
519
+ def sprint_snapshot(name: str, total: float, completed: float, added: float) -> None:
520
+ """Record a sprint snapshot for burndown tracking."""
521
+ from rich.console import Console
522
+ from devpulse.core.database import Database
523
+
524
+ console = Console()
525
+ db = Database()
526
+ db.save_sprint_snapshot({
527
+ "sprint_name": name,
528
+ "total_points": total,
529
+ "completed_points": completed,
530
+ "remaining_points": total - completed,
531
+ "added_points": added,
532
+ })
533
+ console.print(f"[green]Sprint snapshot recorded for '{name}'.[/green]")
534
+
535
+
536
+ @sprint.command("burndown")
537
+ @click.option("--name", "-n", required=True, help="Sprint name.")
538
+ def sprint_burndown(name: str) -> None:
539
+ """View sprint burndown chart."""
540
+ from rich.console import Console
541
+ from devpulse.core.analytics import AnalyticsEngine
542
+
543
+ console = Console()
544
+ engine = AnalyticsEngine()
545
+
546
+ data = engine.sprint_burndown(name)
547
+ if not data:
548
+ console.print(f"[yellow]No data for sprint '{name}'.[/yellow]")
549
+ return
550
+
551
+ console.print(f"\n[bold]Sprint Burndown: {name}[/bold]\n")
552
+ max_pts = max(d["remaining"] for d in data) if data else 1
553
+ chart_width = 50
554
+
555
+ for d in data:
556
+ actual_bar = int(d["remaining"] / max(max_pts, 1) * chart_width)
557
+ ideal_bar = int(d["ideal"] / max(max_pts, 1) * chart_width)
558
+ actual_str = "[green]" + "#" * actual_bar + "[/green]"
559
+ ideal_str = "[dim]" + "." * ideal_bar + "[/dim]"
560
+ console.print(f" Day {d['day']:2d} | {actual_str} {d['remaining']:.1f}")
561
+ console.print(f" | {ideal_str}")
562
+
563
+ console.print(f"\n Scale: 0{' ' * (chart_width - 8)}{max_pts:.0f} pts")
564
+
565
+
566
+ @sprint.command("predict")
567
+ @click.option("--name", "-n", required=True, help="Sprint name.")
568
+ @click.option("--total", required=True, type=float, help="Total story points.")
569
+ @click.option("--elapsed", required=True, type=int, help="Days elapsed.")
570
+ @click.option("--duration", required=True, type=int, help="Total sprint duration (days).")
571
+ def sprint_predict(name: str, total: float, elapsed: int, duration: int) -> None:
572
+ """Predict sprint completion."""
573
+ from rich.console import Console
574
+ from devpulse.core.analytics import AnalyticsEngine
575
+
576
+ console = Console()
577
+ engine = AnalyticsEngine()
578
+
579
+ result = engine.predict_sprint_completion(name, total, elapsed, duration)
580
+ color = "green" if result["prediction"] == "on_track" else "red"
581
+ console.print(f"\n[{color}]{result['message']}[/{color}]")
582
+ console.print(f" Prediction: {result['prediction']} | Confidence: {result['confidence']}%")
583
+
584
+
585
+ # ── Quality command ──────────────────────────────────────────────────
586
+
587
+ @cli.command()
588
+ @click.option("--repo", "-r", default=None, help="Repository to analyze.")
589
+ @click.option("--days", "-d", default=30, type=int, help="Analysis window.")
590
+ @click.option("--format", "-f", "fmt", type=click.Choice(["terminal", "json"]), default="terminal")
591
+ def quality(repo: str | None, days: int, fmt: str) -> None:
592
+ """View code quality trends."""
593
+ from rich.console import Console
594
+ from rich.table import Table
595
+ from rich.panel import Panel
596
+ from devpulse.core.analytics import AnalyticsEngine
597
+
598
+ console = Console()
599
+ engine = AnalyticsEngine()
600
+
601
+ if repo:
602
+ score = engine.compute_quality_score(repo=repo, days=days)
603
+ if fmt == "json":
604
+ import json
605
+ console.print(json.dumps(score, indent=2))
606
+ return
607
+ console.print(Panel(f"[bold]Code Quality: {repo}[/bold] ({days}d)", style="blue"))
608
+ table = Table(show_header=True, header_style="bold cyan", grid=True)
609
+ table.add_column("Metric", style="white")
610
+ table.add_column("Value", style="green", justify="right")
611
+ for k, v in score.items():
612
+ table.add_row(k.replace("_", " ").title(), str(v))
613
+ console.print(table)
614
+ else:
615
+ trends = engine.code_quality_trend(days=days)
616
+ if fmt == "json":
617
+ import json
618
+ console.print(json.dumps([t.model_dump() for t in trends], indent=2, default=str))
619
+ return
620
+ if not trends:
621
+ console.print("[yellow]No quality data. Record snapshots with 'devpulse sprint snapshot'.[/yellow]")
622
+ return
623
+ console.print(Panel("[bold]Code Quality Trends[/bold]", style="blue"))
624
+ table = Table(show_header=True, header_style="bold cyan")
625
+ table.add_column("Date")
626
+ table.add_column("Repo")
627
+ table.add_column("Coverage", justify="right")
628
+ table.add_column("Bugs", justify="right")
629
+ table.add_column("Tech Debt", justify="right")
630
+ for t in trends[-20:]:
631
+ table.add_row(t.date, t.repo, f"{t.test_coverage:.0%}", str(t.open_bugs), f"{t.tech_debt_score:.1f}")
632
+ console.print(table)
633
+
634
+
635
+ # ── Heatmap command ──────────────────────────────────────────────────
636
+
637
+ @cli.command()
638
+ @click.option("--author", "-a", default=None, help="Filter by author.")
639
+ @click.option("--days", "-d", default=365, type=int, help="Number of days to show.")
640
+ def heatmap(author: str | None, days: int) -> None:
641
+ """Show GitHub-style activity heatmap."""
642
+ from rich.console import Console
643
+ from devpulse.core.analytics import AnalyticsEngine
644
+ from devpulse.cli.render import render_heatmap
645
+
646
+ console = Console()
647
+ engine = AnalyticsEngine()
648
+
649
+ data = engine.activity_heatmap(author=author, days=days)
650
+ render_heatmap(console, data, days)
651
+
652
+
653
+ # ── Dashboard command ────────────────────────────────────────────────
654
+
655
+ @cli.command()
656
+ @click.option("--author", "-a", default=None, help="Focus on specific developer.")
657
+ @click.option("--days", "-d", default=30, type=int, help="Dashboard window.")
658
+ @click.option("--repo", "-r", default=None, help="Focus repo.")
659
+ def dashboard(author: str | None, days: int, repo: str | None) -> None:
660
+ """Launch the interactive terminal dashboard."""
661
+ dashboard_cmd(author=author, days=days, repo=repo)
662
+
663
+
664
+ # ── Serve command ────────────────────────────────────────────────────
665
+
666
+ @cli.command()
667
+ @click.option("--host", default="127.0.0.1", help="Bind host.")
668
+ @click.option("--port", "-p", default=8742, type=int, help="Bind port.")
669
+ def serve(host: str, port: int) -> None:
670
+ """Start the DevPulse web API server."""
671
+ import uvicorn
672
+ console_import_msg = "Starting DevPulse API server..."
673
+ print(console_import_msg)
674
+ uvicorn.run("devpulse.api.app:app", host=host, port=port, reload=False)
675
+
676
+
677
+ if __name__ == "__main__":
678
+ cli()