debtpilot 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- debtpilot-0.1.0.dist-info/METADATA +143 -0
- debtpilot-0.1.0.dist-info/RECORD +20 -0
- debtpilot-0.1.0.dist-info/WHEEL +5 -0
- debtpilot-0.1.0.dist-info/entry_points.txt +2 -0
- debtpilot-0.1.0.dist-info/top_level.txt +1 -0
- debtpilot_core/__init__.py +6 -0
- debtpilot_core/ai_advisor.py +206 -0
- debtpilot_core/autostart.py +255 -0
- debtpilot_core/cli.py +682 -0
- debtpilot_core/daemon.py +263 -0
- debtpilot_core/dashboard.py +289 -0
- debtpilot_core/database.py +271 -0
- debtpilot_core/digest.py +227 -0
- debtpilot_core/hooks.py +149 -0
- debtpilot_core/linter.py +153 -0
- debtpilot_core/notifier.py +247 -0
- debtpilot_core/predictor.py +223 -0
- debtpilot_core/reporter.py +210 -0
- debtpilot_core/scanner.py +177 -0
- debtpilot_core/scorer.py +138 -0
debtpilot_core/cli.py
ADDED
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DebtPilot CLI
|
|
3
|
+
Usage: debtpilot <command> [options]
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import List
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
from rich.panel import Panel
|
|
17
|
+
from rich import box
|
|
18
|
+
HAS_RICH = True
|
|
19
|
+
except ImportError:
|
|
20
|
+
HAS_RICH = False
|
|
21
|
+
|
|
22
|
+
from debtpilot_core import database as db
|
|
23
|
+
from debtpilot_core import linter, scanner, scorer
|
|
24
|
+
|
|
25
|
+
console = Console() if HAS_RICH else None
|
|
26
|
+
|
|
27
|
+
GRADE_COLORS = {"A": "green", "B": "yellow", "C": "orange3", "D": "red", "F": "bold red"}
|
|
28
|
+
GRADE_EMOJI = {"A": "ā
", "B": "ā ļø ", "C": "š ", "D": "š“", "F": "š"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _cprint(msg, style=""):
|
|
32
|
+
if console:
|
|
33
|
+
console.print(msg, style=style)
|
|
34
|
+
else:
|
|
35
|
+
print(msg)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _bar(score: float, width: int = 20) -> str:
|
|
39
|
+
filled = int(score / 100 * width)
|
|
40
|
+
return "ā" * filled + "ā" * (width - filled)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _grade_style(grade: str) -> str:
|
|
44
|
+
return GRADE_COLORS.get(grade, "white")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def cmd_scan(args) -> int:
|
|
48
|
+
db.ensure_db()
|
|
49
|
+
target = Path(args.path).resolve()
|
|
50
|
+
|
|
51
|
+
if target.is_file():
|
|
52
|
+
files = [str(target)]
|
|
53
|
+
elif target.is_dir():
|
|
54
|
+
exts = {".py", ".js", ".jsx", ".ts", ".tsx", ".mjs"}
|
|
55
|
+
ignored = {
|
|
56
|
+
"node_modules", ".git", "__pycache__", ".venv", "venv",
|
|
57
|
+
"dist", "build", ".mypy_cache", ".pytest_cache", ".tox",
|
|
58
|
+
"site-packages", "migrations",
|
|
59
|
+
}
|
|
60
|
+
files = []
|
|
61
|
+
import re
|
|
62
|
+
backup_pattern = re.compile(r'_\d{14}\.py$')
|
|
63
|
+
for f in target.rglob("*"):
|
|
64
|
+
if f.suffix in exts and not any(p in ignored for p in f.parts):
|
|
65
|
+
if not backup_pattern.search(f.name):
|
|
66
|
+
files.append(str(f))
|
|
67
|
+
else:
|
|
68
|
+
_cprint(f"Path not found: {args.path}")
|
|
69
|
+
return 1
|
|
70
|
+
|
|
71
|
+
if not files:
|
|
72
|
+
_cprint("No supported files found.")
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
_cprint(f"\n[bold]DebtPilot scan[/bold] ā {len(files)} file(s)\n" if HAS_RICH
|
|
76
|
+
else f"\nDebtPilot scan ā {len(files)} file(s)\n")
|
|
77
|
+
|
|
78
|
+
results = []
|
|
79
|
+
for fp in files:
|
|
80
|
+
lint_data = linter.lint_file(fp)
|
|
81
|
+
all_issues = []
|
|
82
|
+
for issues in lint_data.values():
|
|
83
|
+
if isinstance(issues, list) and issues and isinstance(issues[0], dict) and "line" in issues[0]:
|
|
84
|
+
all_issues.extend(issues)
|
|
85
|
+
todos = lint_data.get("todos", [])
|
|
86
|
+
scan_data = scanner.scan_file(fp, todos)
|
|
87
|
+
score_data = scorer.compute_score(
|
|
88
|
+
churn=scan_data["churn"], complexity=scan_data["complexity"],
|
|
89
|
+
lint_issues=all_issues, todos=scan_data["todos"],
|
|
90
|
+
staleness_days=scan_data["staleness_days"],
|
|
91
|
+
)
|
|
92
|
+
db.upsert_score(fp, score_data)
|
|
93
|
+
if scan_data["churn"]["commits"] > 0:
|
|
94
|
+
db.upsert_churn(fp, scan_data["churn"]["commits"],
|
|
95
|
+
scan_data["churn"]["authors"], scan_data["churn"]["last_commit"])
|
|
96
|
+
for tool, issues in lint_data.items():
|
|
97
|
+
if tool != "todos" and issues:
|
|
98
|
+
db.upsert_lint(fp, tool, issues)
|
|
99
|
+
if scan_data["todos"]:
|
|
100
|
+
db.upsert_todos(fp, scan_data["todos"])
|
|
101
|
+
|
|
102
|
+
results.append((fp, score_data))
|
|
103
|
+
grade = score_data["grade"]
|
|
104
|
+
total = score_data["total"]
|
|
105
|
+
|
|
106
|
+
if HAS_RICH:
|
|
107
|
+
console.print(
|
|
108
|
+
f" {GRADE_EMOJI[grade]} [bold]{Path(fp).name:<40}[/bold] "
|
|
109
|
+
f"[{_grade_style(grade)}]{total:>5.1f}[/{_grade_style(grade)}] "
|
|
110
|
+
f"[dim]{score_data['label']}[/dim]"
|
|
111
|
+
)
|
|
112
|
+
else:
|
|
113
|
+
print(f" [{grade}] {Path(fp).name:<40} {total:>5.1f} {score_data['label']}")
|
|
114
|
+
|
|
115
|
+
if len(results) > 1:
|
|
116
|
+
avg = sum(s["total"] for _, s in results) / len(results)
|
|
117
|
+
worst = max(results, key=lambda x: x[1]["total"])
|
|
118
|
+
_cprint(f"\n Average debt score : {avg:.1f}")
|
|
119
|
+
_cprint(f" Highest risk file : {Path(worst[0]).name} ({worst[1]['total']:.1f})\n")
|
|
120
|
+
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def cmd_report(args) -> int:
|
|
125
|
+
db.ensure_db()
|
|
126
|
+
scores = db.get_all_scores(limit=args.limit or 20, min_score=args.min_score or 0)
|
|
127
|
+
|
|
128
|
+
if not scores:
|
|
129
|
+
_cprint("No data yet. Run: debtpilot scan .")
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
if HAS_RICH:
|
|
133
|
+
table = Table(title="ā” Debt Report", box=box.ROUNDED, highlight=True)
|
|
134
|
+
table.add_column("Grade", justify="center", width=6)
|
|
135
|
+
table.add_column("File", style="cyan", min_width=35)
|
|
136
|
+
table.add_column("Score", justify="right", width=7)
|
|
137
|
+
table.add_column("Churn", justify="right", width=7)
|
|
138
|
+
table.add_column("Complexity", justify="right", width=11)
|
|
139
|
+
table.add_column("Lint", justify="right", width=6)
|
|
140
|
+
table.add_column("TODOs", justify="right", width=7)
|
|
141
|
+
for row in scores:
|
|
142
|
+
g = scorer.score_to_grade(row["score"])[0]
|
|
143
|
+
table.add_row(
|
|
144
|
+
f"[{_grade_style(g)}]{g}[/{_grade_style(g)}]",
|
|
145
|
+
Path(row["filepath"]).name,
|
|
146
|
+
f"[{_grade_style(g)}]{row['score']:.1f}[/{_grade_style(g)}]",
|
|
147
|
+
f"{row.get('churn_score', 0):.0f}",
|
|
148
|
+
f"{row.get('complexity_score', 0):.0f}",
|
|
149
|
+
f"{row.get('lint_score', 0):.0f}",
|
|
150
|
+
f"{row.get('todo_score', 0):.0f}",
|
|
151
|
+
)
|
|
152
|
+
console.print(table)
|
|
153
|
+
else:
|
|
154
|
+
print(f"\n{'Grade':<6} {'File':<40} {'Score':>6}")
|
|
155
|
+
print("-" * 55)
|
|
156
|
+
for row in scores:
|
|
157
|
+
g = scorer.score_to_grade(row["score"])[0]
|
|
158
|
+
print(f" [{g}] {Path(row['filepath']).name:<38} {row['score']:>6.1f}")
|
|
159
|
+
|
|
160
|
+
stats = db.get_stats()
|
|
161
|
+
_cprint(f"\n š {stats['files_tracked']} files tracked | avg score {stats['avg_debt_score']:.1f}"
|
|
162
|
+
f" | {stats['active_alerts']} active alert(s) | {stats['stale_todos']} stale TODOs")
|
|
163
|
+
return 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def cmd_file(args) -> int:
|
|
167
|
+
db.ensure_db()
|
|
168
|
+
fp = str(Path(args.filepath).resolve())
|
|
169
|
+
score = db.get_latest_score(fp)
|
|
170
|
+
|
|
171
|
+
if not score:
|
|
172
|
+
_cprint(f"No data for {fp}. Run: debtpilot scan {args.filepath}")
|
|
173
|
+
return 1
|
|
174
|
+
|
|
175
|
+
g, label = scorer.score_to_grade(score["score"])
|
|
176
|
+
todos = db.get_todos(fp)
|
|
177
|
+
|
|
178
|
+
if HAS_RICH:
|
|
179
|
+
lines = ["[bold]Sub-scores[/bold]"]
|
|
180
|
+
for key, lbl in [("churn_score", "Churn"), ("complexity_score", "Complexity"),
|
|
181
|
+
("lint_score", "Lint"), ("todo_score", "TODOs"), ("staleness_score", "Staleness")]:
|
|
182
|
+
v = score.get(key, 0)
|
|
183
|
+
lines.append(f" {lbl:<12} {_bar(v, 18)} {v:>5.1f}")
|
|
184
|
+
if todos:
|
|
185
|
+
lines.append(f"\n[bold]TODOs ({len(todos)})[/bold]")
|
|
186
|
+
for t in sorted(todos, key=lambda x: -x.get("age_days", 0))[:5]:
|
|
187
|
+
age = t.get("age_days", 0)
|
|
188
|
+
color = "red" if age > 90 else "yellow" if age > 30 else "white"
|
|
189
|
+
lines.append(f" [{color}]L{t['line']:<5} {age:>3}d {t.get('tag','TODO')}: {t.get('text','')[:60]}[/{color}]")
|
|
190
|
+
fake = {f"{k}_score": score.get(f"{k}_score", 0)
|
|
191
|
+
for k in ["churn","complexity","lint","todo","staleness"]}
|
|
192
|
+
recs = scorer.top_recommendations(fake)
|
|
193
|
+
if recs:
|
|
194
|
+
lines.append("\n[bold]Recommendations[/bold]")
|
|
195
|
+
for r in recs:
|
|
196
|
+
lines.append(f" ⢠{r}")
|
|
197
|
+
title = f"{GRADE_EMOJI[g]} [bold]{Path(fp).name}[/bold] [{_grade_style(g)}]{label} ({score['score']:.1f})[/{_grade_style(g)}]"
|
|
198
|
+
console.print(Panel("\n".join(lines), title=title, border_style=_grade_style(g)))
|
|
199
|
+
else:
|
|
200
|
+
print(f"\n[{g}] {Path(fp).name} score={score['score']:.1f} {label}")
|
|
201
|
+
for key, lbl in [("churn_score","Churn"),("complexity_score","Complexity"),
|
|
202
|
+
("lint_score","Lint"),("todo_score","TODOs")]:
|
|
203
|
+
print(f" {lbl}: {score.get(key,0):.0f}")
|
|
204
|
+
return 0
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def cmd_alerts(args) -> int:
|
|
208
|
+
db.ensure_db()
|
|
209
|
+
alerts = db.get_alerts(dismissed=False)
|
|
210
|
+
if not alerts:
|
|
211
|
+
_cprint("ā
No active alerts.")
|
|
212
|
+
return 0
|
|
213
|
+
_cprint(f"\nš {len(alerts)} active alert(s)\n")
|
|
214
|
+
for a in alerts:
|
|
215
|
+
ts = time.strftime("%Y-%m-%d %H:%M", time.localtime(a["created_at"]))
|
|
216
|
+
print(f" #{a['id']} {a['alert_type']:<16} {Path(a['filepath']).name:<30} score={a['score']:.1f} {ts}")
|
|
217
|
+
print(f" {a['message']}")
|
|
218
|
+
return 0
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def cmd_dismiss(args) -> int:
|
|
222
|
+
db.ensure_db()
|
|
223
|
+
db.dismiss_alert(args.alert_id)
|
|
224
|
+
_cprint(f"Alert #{args.alert_id} dismissed.")
|
|
225
|
+
return 0
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def cmd_start(args) -> int:
|
|
229
|
+
try:
|
|
230
|
+
from debtpilot_core.daemon import main_daemon
|
|
231
|
+
except ImportError as e:
|
|
232
|
+
_cprint(f"Daemon dependencies missing: {e}")
|
|
233
|
+
_cprint("Install with: pip install \"debtpilot[full]\"")
|
|
234
|
+
return 1
|
|
235
|
+
project = str(Path(args.dir or ".").resolve())
|
|
236
|
+
port = args.port or 57017
|
|
237
|
+
_cprint(f"š Starting DebtPilot daemon ā watching {project} (ws://127.0.0.1:{port})")
|
|
238
|
+
_cprint(" Press Ctrl+C to stop.\n")
|
|
239
|
+
main_daemon(project, port)
|
|
240
|
+
return 0
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def cmd_todos(args) -> int:
|
|
244
|
+
db.ensure_db()
|
|
245
|
+
todos = db.get_todos(min_age_days=args.min_age or 0)
|
|
246
|
+
if not todos:
|
|
247
|
+
_cprint("No TODOs found.")
|
|
248
|
+
return 0
|
|
249
|
+
_cprint(f"\nš {len(todos)} TODO(s)\n")
|
|
250
|
+
for t in todos:
|
|
251
|
+
age = t.get("age_days", 0)
|
|
252
|
+
print(f" {age:>4}d {t.get('tag','TODO'):<6} {Path(t['filepath']).name}:{t['line']} {t.get('text','')[:70]}")
|
|
253
|
+
return 0
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def cmd_clean(args) -> int:
|
|
257
|
+
days = args.days or 90
|
|
258
|
+
db.clear_old_data(days)
|
|
259
|
+
_cprint(f"Cleaned data older than {days} days.")
|
|
260
|
+
return 0
|
|
261
|
+
|
|
262
|
+
def cmd_dashboard(args) -> int:
|
|
263
|
+
from debtpilot_core.dashboard import generate_dashboard
|
|
264
|
+
db.ensure_db()
|
|
265
|
+
output = generate_dashboard(args.output)
|
|
266
|
+
_cprint(f"ā
Dashboard generated: [bold]{output}[/bold]" if HAS_RICH else f"Dashboard generated: {output}")
|
|
267
|
+
_cprint(" Open in your browser to view.")
|
|
268
|
+
return 0
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def cmd_ai(args) -> int:
|
|
272
|
+
from debtpilot_core.ai_advisor import analyze_file
|
|
273
|
+
db.ensure_db()
|
|
274
|
+
fp = str(Path(args.filepath).resolve())
|
|
275
|
+
score = db.get_latest_score(fp)
|
|
276
|
+
if not score:
|
|
277
|
+
_cprint(f"No scan data for {fp}. Run: debtpilot scan {args.filepath}")
|
|
278
|
+
return 1
|
|
279
|
+
|
|
280
|
+
_cprint(f"\nš¤ AI analysing [bold]{Path(fp).name}[/bold]...\n" if HAS_RICH
|
|
281
|
+
else f"\nAI analysing {Path(fp).name}...")
|
|
282
|
+
|
|
283
|
+
lint = db.get_lint(fp)
|
|
284
|
+
all_issues = []
|
|
285
|
+
for l in lint[:1]:
|
|
286
|
+
issues = l.get("issues", [])
|
|
287
|
+
if isinstance(issues, str):
|
|
288
|
+
import json as _json
|
|
289
|
+
issues = _json.loads(issues)
|
|
290
|
+
all_issues.extend(issues)
|
|
291
|
+
|
|
292
|
+
todos = db.get_todos(fp)
|
|
293
|
+
from debtpilot_core.scanner import analyze_complexity
|
|
294
|
+
complexity = analyze_complexity(fp)
|
|
295
|
+
|
|
296
|
+
result = analyze_file(fp, score, all_issues, todos, complexity)
|
|
297
|
+
|
|
298
|
+
if "error" in result and not result.get("fixes"):
|
|
299
|
+
_cprint(f"[red]{result['error']}[/red]" if HAS_RICH else result["error"])
|
|
300
|
+
return 1
|
|
301
|
+
|
|
302
|
+
if HAS_RICH:
|
|
303
|
+
from rich.panel import Panel
|
|
304
|
+
risk_colors = {"LOW":"green","MEDIUM":"yellow","HIGH":"orange3","CRITICAL":"bold red"}
|
|
305
|
+
risk = result.get("risk_level","UNKNOWN")
|
|
306
|
+
console.print(Panel(
|
|
307
|
+
f"[bold]Risk Level:[/bold] [{risk_colors.get(risk,'white')}]{risk}[/{risk_colors.get(risk,'white')}]\n\n"
|
|
308
|
+
f"{result.get('summary','')}",
|
|
309
|
+
title="š¤ AI Summary", border_style="blue"
|
|
310
|
+
))
|
|
311
|
+
fixes = result.get("fixes", [])
|
|
312
|
+
if fixes:
|
|
313
|
+
console.print("\n[bold]Suggested Fixes:[/bold]\n")
|
|
314
|
+
for i, fix in enumerate(fixes, 1):
|
|
315
|
+
console.print(f" [bold cyan]{i}. {fix['title']}[/bold cyan] [dim]({fix.get('effort','?')})[/dim]")
|
|
316
|
+
console.print(f" [yellow]Problem:[/yellow] {fix['problem']}")
|
|
317
|
+
console.print(f" [green]Solution:[/green] {fix['solution']}")
|
|
318
|
+
if fix.get("code_example"):
|
|
319
|
+
console.print(f" [dim]{fix['code_example'][:200]}[/dim]")
|
|
320
|
+
console.print()
|
|
321
|
+
actions = result.get("priority_actions", [])
|
|
322
|
+
if actions:
|
|
323
|
+
console.print("[bold]Priority Actions:[/bold]")
|
|
324
|
+
for a in actions:
|
|
325
|
+
console.print(f" ⢠{a}")
|
|
326
|
+
eta = result.get("estimated_fix_time")
|
|
327
|
+
if eta:
|
|
328
|
+
console.print(f"\n [dim]Estimated fix time: {eta}[/dim]")
|
|
329
|
+
else:
|
|
330
|
+
print(f"\nRisk: {result.get('risk_level')}")
|
|
331
|
+
print(f"Summary: {result.get('summary')}\n")
|
|
332
|
+
for i, fix in enumerate(result.get("fixes", []), 1):
|
|
333
|
+
print(f"{i}. {fix['title']} ({fix.get('effort')})")
|
|
334
|
+
print(f" Problem: {fix['problem']}")
|
|
335
|
+
print(f" Solution: {fix['solution']}\n")
|
|
336
|
+
return 0
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def cmd_predict(args) -> int:
|
|
340
|
+
from debtpilot_core.predictor import predict_all, team_risk_summary
|
|
341
|
+
db.ensure_db()
|
|
342
|
+
predictions = predict_all(limit=args.limit)
|
|
343
|
+
summary = team_risk_summary()
|
|
344
|
+
|
|
345
|
+
if not predictions:
|
|
346
|
+
_cprint("No data yet. Run: debtpilot scan .")
|
|
347
|
+
return 0
|
|
348
|
+
|
|
349
|
+
_cprint(f"\nš® [bold]Bug Prediction Report[/bold] ā {summary['total_files']} files analysed\n"
|
|
350
|
+
if HAS_RICH else f"\nBug Prediction ā {summary['total_files']} files\n")
|
|
351
|
+
_cprint(f" Critical: {summary['critical']} High: {summary['high']} "
|
|
352
|
+
f"Medium: {summary['medium']} Low: {summary['low']}\n")
|
|
353
|
+
|
|
354
|
+
for p in predictions:
|
|
355
|
+
colors = {"CRITICAL":"bold red","HIGH":"red","MEDIUM":"orange3","LOW":"green"}
|
|
356
|
+
risk = p["risk_level"]
|
|
357
|
+
prob = p["probability"]
|
|
358
|
+
name = Path(p["filepath"]).name
|
|
359
|
+
drivers = " Ā· ".join(p["drivers"][:2])
|
|
360
|
+
if HAS_RICH:
|
|
361
|
+
console.print(f" [{colors.get(risk,'white')}]{risk:<10}[/{colors.get(risk,'white')}]"
|
|
362
|
+
f" {prob:>4}% [cyan]{name:<40}[/cyan] [dim]{drivers}[/dim]")
|
|
363
|
+
else:
|
|
364
|
+
print(f" {risk:<10} {prob:>4}% {name:<40} {drivers}")
|
|
365
|
+
return 0
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def cmd_digest(args) -> int:
|
|
369
|
+
from debtpilot_core.digest import send_digest, preview_digest
|
|
370
|
+
db.ensure_db()
|
|
371
|
+
project = args.project
|
|
372
|
+
|
|
373
|
+
if args.preview:
|
|
374
|
+
output = preview_digest(project_name=project)
|
|
375
|
+
_cprint(f"ā
Digest preview saved: [bold]{output}[/bold]" if HAS_RICH else f"Preview saved: {output}")
|
|
376
|
+
_cprint(" Open in your browser to view.")
|
|
377
|
+
return 0
|
|
378
|
+
|
|
379
|
+
if args.send:
|
|
380
|
+
_cprint("š§ Sending weekly digest...")
|
|
381
|
+
result = send_digest(project_name=project)
|
|
382
|
+
if result["success"]:
|
|
383
|
+
_cprint(f"ā
Sent to: {', '.join(result['sent_to'])}")
|
|
384
|
+
else:
|
|
385
|
+
_cprint(f"ā Failed: {result['error']}")
|
|
386
|
+
return 0 if result.get("success") else 1
|
|
387
|
+
|
|
388
|
+
_cprint("Use --preview to generate a preview or --send to send the email.")
|
|
389
|
+
return 0
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def cmd_report_pdf(args) -> int:
|
|
393
|
+
from debtpilot_core.reporter import generate_pdf_report
|
|
394
|
+
db.ensure_db()
|
|
395
|
+
output = generate_pdf_report(output_path=args.output, project_name=args.project)
|
|
396
|
+
_cprint(f"ā
Report generated: [bold]{output}[/bold]" if HAS_RICH else f"Report saved: {output}")
|
|
397
|
+
_cprint(" Open in browser ā Ctrl+P ā Save as PDF")
|
|
398
|
+
return 0
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def cmd_hook(args) -> int:
|
|
402
|
+
from debtpilot_core.hooks import install_hook, uninstall_hook, hook_status
|
|
403
|
+
project = str(Path(".").resolve())
|
|
404
|
+
|
|
405
|
+
if args.install:
|
|
406
|
+
result = install_hook(project, threshold=args.threshold)
|
|
407
|
+
if result["success"]:
|
|
408
|
+
_cprint(f"ā
{result['message']}")
|
|
409
|
+
else:
|
|
410
|
+
_cprint(f"ā {result['error']}")
|
|
411
|
+
return 0 if result["success"] else 1
|
|
412
|
+
|
|
413
|
+
if args.uninstall:
|
|
414
|
+
result = uninstall_hook(project)
|
|
415
|
+
_cprint(f"ā
{result.get('message','Done')}" if result["success"] else f"ā {result['error']}")
|
|
416
|
+
return 0 if result["success"] else 1
|
|
417
|
+
|
|
418
|
+
if args.status:
|
|
419
|
+
status = hook_status(project)
|
|
420
|
+
if status["installed"]:
|
|
421
|
+
_cprint(f"ā
Hook installed ā blocks commits with score > {status['threshold']}")
|
|
422
|
+
else:
|
|
423
|
+
_cprint("ā Hook not installed. Run: debtpilot hook --install")
|
|
424
|
+
return 0
|
|
425
|
+
|
|
426
|
+
_cprint("Use --install, --uninstall, or --status")
|
|
427
|
+
return 0
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def cmd_config(args) -> int:
|
|
431
|
+
from debtpilot_core.ai_advisor import save_api_key
|
|
432
|
+
from debtpilot_core.digest import save_email_config, get_email_config
|
|
433
|
+
|
|
434
|
+
if args.gemini_key:
|
|
435
|
+
save_api_key(args.gemini_key)
|
|
436
|
+
_cprint("ā
Gemini API key saved.")
|
|
437
|
+
|
|
438
|
+
if args.gmail and args.gmail_password:
|
|
439
|
+
recipients = [r.strip() for r in args.recipients.split(",")] if args.recipients else []
|
|
440
|
+
save_email_config(args.gmail, args.gmail_password, recipients)
|
|
441
|
+
_cprint("ā
Gmail config saved.")
|
|
442
|
+
elif args.gmail or args.gmail_password:
|
|
443
|
+
_cprint("Provide both --gmail and --gmail-password together.")
|
|
444
|
+
return 1
|
|
445
|
+
|
|
446
|
+
if not any([args.gemini_key, args.gmail, args.gmail_password]):
|
|
447
|
+
config = get_email_config()
|
|
448
|
+
_cprint("\n[bold]Current Config:[/bold]" if HAS_RICH else "\nCurrent Config:")
|
|
449
|
+
_cprint(f" Gemini key : {'ā
set' if config.get('gemini_api_key') else 'ā not set'}")
|
|
450
|
+
_cprint(f" Gmail : {config.get('gmail_address', 'ā not set')}")
|
|
451
|
+
_cprint(f" Recipients : {', '.join(config.get('digest_recipients', [])) or 'ā not set'}")
|
|
452
|
+
_cprint("\nTo set:")
|
|
453
|
+
_cprint(" debtpilot config --gemini-key YOUR_KEY")
|
|
454
|
+
_cprint(" debtpilot config --gmail you@gmail.com --gmail-password YOUR_APP_PASSWORD --recipients team@company.com")
|
|
455
|
+
|
|
456
|
+
return 0
|
|
457
|
+
|
|
458
|
+
def cmd_autostart(args) -> int:
|
|
459
|
+
from debtpilot_core.autostart import install_autostart, uninstall_autostart, autostart_status
|
|
460
|
+
|
|
461
|
+
if args.install:
|
|
462
|
+
project = str(Path(args.dir or ".").resolve())
|
|
463
|
+
result = install_autostart(project_dir=project, port=args.port)
|
|
464
|
+
_cprint(result["message"] if result["success"] else f"ā {result['error']}")
|
|
465
|
+
return 0 if result["success"] else 1
|
|
466
|
+
|
|
467
|
+
if args.uninstall:
|
|
468
|
+
result = uninstall_autostart()
|
|
469
|
+
_cprint(result.get("message", "") if result["success"] else f"ā {result['error']}")
|
|
470
|
+
return 0 if result["success"] else 1
|
|
471
|
+
|
|
472
|
+
if args.status:
|
|
473
|
+
status = autostart_status()
|
|
474
|
+
if status["installed"]:
|
|
475
|
+
_cprint("ā
Autostart installed")
|
|
476
|
+
_cprint(f" Watching : {status.get('project_dir')}")
|
|
477
|
+
_cprint(f" Port : {status.get('port')}")
|
|
478
|
+
_cprint(f" Next run : {status.get('next_run')}")
|
|
479
|
+
else:
|
|
480
|
+
_cprint("ā Autostart not installed")
|
|
481
|
+
_cprint(" Run: debtpilot autostart --install")
|
|
482
|
+
return 0
|
|
483
|
+
|
|
484
|
+
_cprint("Use --install, --uninstall, or --status")
|
|
485
|
+
return 0
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def cmd_status(args) -> int:
|
|
489
|
+
from debtpilot_core.autostart import autostart_status
|
|
490
|
+
from debtpilot_core.hooks import hook_status
|
|
491
|
+
from debtpilot_core.ai_advisor import get_api_key
|
|
492
|
+
from debtpilot_core.digest import get_email_config
|
|
493
|
+
import socket
|
|
494
|
+
|
|
495
|
+
db.ensure_db()
|
|
496
|
+
stats = db.get_stats()
|
|
497
|
+
project = str(Path(".").resolve())
|
|
498
|
+
|
|
499
|
+
# Check if daemon is running
|
|
500
|
+
daemon_running = False
|
|
501
|
+
try:
|
|
502
|
+
s = socket.create_connection(("127.0.0.1", 57017), timeout=1)
|
|
503
|
+
s.close()
|
|
504
|
+
daemon_running = True
|
|
505
|
+
except Exception:
|
|
506
|
+
pass
|
|
507
|
+
|
|
508
|
+
auto = autostart_status()
|
|
509
|
+
hook = hook_status(project)
|
|
510
|
+
api_key = get_api_key()
|
|
511
|
+
email_config = get_email_config()
|
|
512
|
+
|
|
513
|
+
if HAS_RICH:
|
|
514
|
+
from rich.panel import Panel
|
|
515
|
+
from rich.table import Table
|
|
516
|
+
from rich import box
|
|
517
|
+
|
|
518
|
+
table = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))
|
|
519
|
+
table.add_column("Item", style="bold")
|
|
520
|
+
table.add_column("Status")
|
|
521
|
+
table.add_column("Info", style="dim")
|
|
522
|
+
|
|
523
|
+
table.add_row(
|
|
524
|
+
"Daemon",
|
|
525
|
+
"[green]ā Running[/green]" if daemon_running else "[red]ā Stopped[/red]",
|
|
526
|
+
"ws://127.0.0.1:57017" if daemon_running else "Run: debtpilot start"
|
|
527
|
+
)
|
|
528
|
+
table.add_row(
|
|
529
|
+
"Autostart",
|
|
530
|
+
"[green]ā
Enabled[/green]" if auto.get("installed") else "[yellow]ā Disabled[/yellow]",
|
|
531
|
+
auto.get("project_dir", "Run: debtpilot autostart --install")
|
|
532
|
+
)
|
|
533
|
+
table.add_row(
|
|
534
|
+
"Git Hook",
|
|
535
|
+
"[green]ā
Installed[/green]" if hook.get("installed") else "[yellow]ā Not installed[/yellow]",
|
|
536
|
+
f"Threshold: {hook.get('threshold', 'N/A')}" if hook.get("installed") else "Run: debtpilot hook --install"
|
|
537
|
+
)
|
|
538
|
+
table.add_row(
|
|
539
|
+
"AI Advisor",
|
|
540
|
+
"[green]ā
Configured[/green]" if api_key else "[yellow]ā Not configured[/yellow]",
|
|
541
|
+
"Gemini API key set" if api_key else "Run: debtpilot config --gemini-key KEY"
|
|
542
|
+
)
|
|
543
|
+
table.add_row(
|
|
544
|
+
"Email Digest",
|
|
545
|
+
"[green]ā
Configured[/green]" if email_config.get("gmail_address") else "[yellow]ā Not configured[/yellow]",
|
|
546
|
+
email_config.get("gmail_address", "Run: debtpilot config --gmail")
|
|
547
|
+
)
|
|
548
|
+
table.add_row(
|
|
549
|
+
"Files Tracked",
|
|
550
|
+
f"[cyan]{stats['files_tracked']}[/cyan]",
|
|
551
|
+
f"avg score {stats['avg_debt_score']:.1f}"
|
|
552
|
+
)
|
|
553
|
+
table.add_row(
|
|
554
|
+
"Active Alerts",
|
|
555
|
+
f"[{'red' if stats['active_alerts'] > 0 else 'green'}]{stats['active_alerts']}[/{'red' if stats['active_alerts'] > 0 else 'green'}]",
|
|
556
|
+
"Run: debtpilot alerts" if stats["active_alerts"] > 0 else "All clear"
|
|
557
|
+
)
|
|
558
|
+
table.add_row(
|
|
559
|
+
"Stale TODOs",
|
|
560
|
+
f"[{'orange3' if stats['stale_todos'] > 0 else 'green'}]{stats['stale_todos']}[/{'orange3' if stats['stale_todos'] > 0 else 'green'}]",
|
|
561
|
+
"Run: debtpilot todos --min-age 30" if stats["stale_todos"] > 0 else "All clear"
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
console.print(Panel(table, title="ā” DebtPilot Status", border_style="blue"))
|
|
565
|
+
else:
|
|
566
|
+
print("\nā” DebtPilot Status")
|
|
567
|
+
print("ā" * 45)
|
|
568
|
+
print(f" Daemon : {'ā Running' if daemon_running else 'ā Stopped'}")
|
|
569
|
+
print(f" Autostart : {'ā
Enabled' if auto.get('installed') else 'ā Disabled'}")
|
|
570
|
+
print(f" Git Hook : {'ā
Installed' if hook.get('installed') else 'ā Not installed'}")
|
|
571
|
+
print(f" AI Advisor : {'ā
Configured' if api_key else 'ā Not configured'}")
|
|
572
|
+
print(f" Email : {'ā
Configured' if email_config.get('gmail_address') else 'ā Not configured'}")
|
|
573
|
+
print(f" Files : {stats['files_tracked']} (avg score {stats['avg_debt_score']:.1f})")
|
|
574
|
+
print(f" Alerts : {stats['active_alerts']}")
|
|
575
|
+
print(f" Stale TODOs : {stats['stale_todos']}\n")
|
|
576
|
+
return 0
|
|
577
|
+
|
|
578
|
+
def main() -> None:
|
|
579
|
+
parser = argparse.ArgumentParser(
|
|
580
|
+
prog="debtpilot",
|
|
581
|
+
description="DebtPilot ā track technical debt before it blows up in production",
|
|
582
|
+
)
|
|
583
|
+
sub = parser.add_subparsers(dest="command")
|
|
584
|
+
|
|
585
|
+
p_scan = sub.add_parser("scan")
|
|
586
|
+
p_scan.add_argument("path", nargs="?", default=".")
|
|
587
|
+
|
|
588
|
+
p_report = sub.add_parser("report")
|
|
589
|
+
p_report.add_argument("--limit", "-n", type=int, default=20)
|
|
590
|
+
p_report.add_argument("--min-score", type=float, default=0)
|
|
591
|
+
|
|
592
|
+
p_file = sub.add_parser("file")
|
|
593
|
+
p_file.add_argument("filepath")
|
|
594
|
+
|
|
595
|
+
sub.add_parser("alerts")
|
|
596
|
+
|
|
597
|
+
p_dismiss = sub.add_parser("dismiss")
|
|
598
|
+
p_dismiss.add_argument("alert_id", type=int)
|
|
599
|
+
|
|
600
|
+
p_todos = sub.add_parser("todos")
|
|
601
|
+
p_todos.add_argument("--min-age", type=int, default=0)
|
|
602
|
+
|
|
603
|
+
p_start = sub.add_parser("start")
|
|
604
|
+
p_start.add_argument("--dir", default=".")
|
|
605
|
+
p_start.add_argument("--port", type=int, default=57017)
|
|
606
|
+
|
|
607
|
+
p_clean = sub.add_parser("clean")
|
|
608
|
+
p_clean.add_argument("--days", type=int, default=90)
|
|
609
|
+
# dashboard
|
|
610
|
+
p_dash = sub.add_parser("dashboard", help="Generate HTML dashboard")
|
|
611
|
+
p_dash.add_argument("--output", default="debtpilot_dashboard.html")
|
|
612
|
+
|
|
613
|
+
# ai
|
|
614
|
+
p_ai = sub.add_parser("ai", help="AI fix suggestions for a file")
|
|
615
|
+
p_ai.add_argument("filepath")
|
|
616
|
+
|
|
617
|
+
# predict
|
|
618
|
+
p_predict = sub.add_parser("predict", help="Bug prediction for all files")
|
|
619
|
+
p_predict.add_argument("--limit", type=int, default=10)
|
|
620
|
+
|
|
621
|
+
# digest
|
|
622
|
+
p_digest = sub.add_parser("digest", help="Weekly email digest")
|
|
623
|
+
p_digest.add_argument("--send", action="store_true")
|
|
624
|
+
p_digest.add_argument("--preview", action="store_true")
|
|
625
|
+
p_digest.add_argument("--project", default="My Project")
|
|
626
|
+
|
|
627
|
+
# report
|
|
628
|
+
p_pdf = sub.add_parser("report-pdf", help="Generate PDF report")
|
|
629
|
+
p_pdf.add_argument("--output", default="debtpilot_report.html")
|
|
630
|
+
p_pdf.add_argument("--project", default="My Project")
|
|
631
|
+
|
|
632
|
+
# hook
|
|
633
|
+
p_hook = sub.add_parser("hook", help="Install git pre-commit hook")
|
|
634
|
+
p_hook.add_argument("--install", action="store_true")
|
|
635
|
+
p_hook.add_argument("--uninstall", action="store_true")
|
|
636
|
+
p_hook.add_argument("--status", action="store_true")
|
|
637
|
+
p_hook.add_argument("--threshold", type=int, default=70)
|
|
638
|
+
|
|
639
|
+
# config
|
|
640
|
+
p_config = sub.add_parser("config", help="Configure API keys and email")
|
|
641
|
+
p_config.add_argument("--gemini-key", type=str)
|
|
642
|
+
p_config.add_argument("--gmail", type=str)
|
|
643
|
+
p_config.add_argument("--gmail-password", type=str)
|
|
644
|
+
p_config.add_argument("--recipients", type=str, help="Comma-separated emails")
|
|
645
|
+
|
|
646
|
+
# autostart
|
|
647
|
+
p_auto = sub.add_parser("autostart", help="Auto-start daemon on login")
|
|
648
|
+
p_auto.add_argument("--install", action="store_true")
|
|
649
|
+
p_auto.add_argument("--uninstall", action="store_true")
|
|
650
|
+
p_auto.add_argument("--status", action="store_true")
|
|
651
|
+
p_auto.add_argument("--dir", default=None)
|
|
652
|
+
p_auto.add_argument("--port", type=int, default=57017)
|
|
653
|
+
|
|
654
|
+
# status
|
|
655
|
+
sub.add_parser("status", help="Full health check of DebtPilot")
|
|
656
|
+
|
|
657
|
+
args = parser.parse_args()
|
|
658
|
+
|
|
659
|
+
if not args.command:
|
|
660
|
+
parser.print_help()
|
|
661
|
+
sys.exit(0)
|
|
662
|
+
|
|
663
|
+
handlers = {
|
|
664
|
+
"scan": cmd_scan, "report": cmd_report, "file": cmd_file,
|
|
665
|
+
"alerts": cmd_alerts, "dismiss": cmd_dismiss, "todos": cmd_todos,
|
|
666
|
+
"start": cmd_start, "clean": cmd_clean,
|
|
667
|
+
"dashboard": cmd_dashboard, "ai": cmd_ai, "predict": cmd_predict,
|
|
668
|
+
"digest": cmd_digest, "report-pdf": cmd_report_pdf,
|
|
669
|
+
"hook": cmd_hook, "config": cmd_config,
|
|
670
|
+
"autostart": cmd_autostart,
|
|
671
|
+
"status": cmd_status,
|
|
672
|
+
}
|
|
673
|
+
fn = handlers.get(args.command)
|
|
674
|
+
if fn:
|
|
675
|
+
sys.exit(fn(args))
|
|
676
|
+
else:
|
|
677
|
+
parser.print_help()
|
|
678
|
+
sys.exit(1)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
if __name__ == "__main__":
|
|
682
|
+
main()
|