@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
package/devpulse/cli/main.py
CHANGED
|
@@ -1,678 +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()
|
|
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()
|