diffly-cli 0.4.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.
diffly_cli/cli.py ADDED
@@ -0,0 +1,770 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from contextlib import nullcontext
6
+ from dataclasses import asdict, dataclass
7
+ import os
8
+ import re
9
+ import select
10
+ import shutil
11
+ import sys
12
+ from typing import Any
13
+
14
+ from rich.align import Align
15
+ from rich.console import Console
16
+ from rich.markdown import Markdown
17
+ from rich.panel import Panel
18
+ from rich.prompt import Confirm, Prompt
19
+ from rich.table import Table
20
+ from rich.text import Text
21
+
22
+ from . import __version__
23
+ from . import update
24
+ from .astmap import analyze_files
25
+ from .explainer import ExplanationResult, generate_explanation
26
+ from .diffparse import files_from_unified_diff
27
+ from .github import GitHubClient, GitHubError
28
+ from .local import LocalAnalysisError, build_local_result, resolve_repository_root
29
+ from .models import ChangedFile, PRMetadata, TriageResult
30
+ from .triage import compute_flags, verdict_for
31
+
32
+ console = Console()
33
+ VERDICT_STYLES = {"PASS": "bold green", "SHIP": "bold green", "QUARANTINE": "bold yellow", "BLOCK": "bold red"}
34
+ CONTENT_WIDTH = 100
35
+
36
+
37
+ def center(renderable: Any) -> Any:
38
+ """Horizontally center a renderable so screens feel composed, not left-hugging."""
39
+ return Align.center(renderable, style="")
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class RepoRef:
44
+ """A repository reference, optionally carrying a PR number from a URL."""
45
+
46
+ owner: str
47
+ repo: str
48
+ pr_number: int | None = None
49
+
50
+ @property
51
+ def slug(self) -> str:
52
+ return f"{self.owner}/{self.repo}"
53
+
54
+
55
+ def positive_pr_number(value: str) -> int:
56
+ """Parse a GitHub pull-request number, rejecting impossible values early."""
57
+ if not value.isdigit():
58
+ raise argparse.ArgumentTypeError("PR number must be an integer greater than zero")
59
+ number = int(value)
60
+ if number < 1:
61
+ raise argparse.ArgumentTypeError("PR number must be greater than zero")
62
+ return number
63
+
64
+
65
+ def _normalize_github_path(value: str) -> str:
66
+ value = value.strip().rstrip("/")
67
+ for prefix in ("https://github.com/", "github.com/"):
68
+ if value.startswith(prefix):
69
+ return value.split(prefix, 1)[1]
70
+ return value
71
+
72
+
73
+ def _split_owner_repo(value: str) -> tuple[str, str]:
74
+ parts = value.split("/")
75
+ if len(parts) != 2 or not all(parts) or any(not re.fullmatch(r"[A-Za-z0-9_.-]+", part) for part in parts):
76
+ raise argparse.ArgumentTypeError("repository must look like owner/repo")
77
+ return parts[0], parts[1]
78
+
79
+
80
+ def parse_repo(value: str) -> RepoRef:
81
+ """Accept `owner/repo`, GitHub URLs, or full pull-request URLs.
82
+
83
+ A trailing `/pull/<number>` populates `pr_number`, letting reviewers paste
84
+ a complete pull-request link as the only positional argument.
85
+ """
86
+ path = _normalize_github_path(value)
87
+ url_match = re.fullmatch(r"(.+?)/pull/(\d+)", path)
88
+ pr_number: int | None = None
89
+ if url_match:
90
+ path, raw_number = url_match.groups()
91
+ try:
92
+ pr_number = positive_pr_number(raw_number)
93
+ except argparse.ArgumentTypeError as exc:
94
+ raise argparse.ArgumentTypeError(f"invalid pull-request URL: {exc}") from exc
95
+ owner, repo = _split_owner_repo(path)
96
+ return RepoRef(owner, repo, pr_number)
97
+
98
+
99
+ def summarize_checks(check_runs: dict[str, Any], status: dict[str, Any]) -> dict[str, Any]:
100
+ runs = check_runs.get("check_runs", [])
101
+ failed = []
102
+ pending = []
103
+ for run in runs:
104
+ conclusion = run.get("conclusion")
105
+ name = run.get("name", "unnamed check")
106
+ if conclusion in {"failure", "cancelled", "timed_out", "action_required", "startup_failure"}:
107
+ failed.append(name)
108
+ elif conclusion not in {"success", "skipped", "neutral"}:
109
+ pending.append(name)
110
+ combined_state = status.get("state")
111
+ if combined_state in {"failure", "error"}:
112
+ failed.append("combined commit status")
113
+ elif combined_state == "pending":
114
+ pending.append("combined commit status")
115
+ statuses = status.get("statuses", [])
116
+ for item in statuses:
117
+ state = item.get("state")
118
+ context = item.get("context", "unnamed status")
119
+ if state in {"failure", "error"}:
120
+ failed.append(context)
121
+ elif state == "pending":
122
+ pending.append(context)
123
+ if failed:
124
+ state = "failure"
125
+ elif pending:
126
+ state = "pending"
127
+ elif runs or statuses:
128
+ state = "success"
129
+ else:
130
+ state = "unknown"
131
+ return {"state": state, "failed": sorted(dict.fromkeys(failed)), "pending": sorted(dict.fromkeys(pending)), "count": len(runs) + len(statuses)}
132
+
133
+
134
+ def _risk_badge(severity: str) -> str:
135
+ return {"critical": "CRITICAL", "high": "HIGH", "medium": "MEDIUM", "low": "LOW"}.get(severity, severity.upper())
136
+
137
+
138
+ def render_explanation(explanation: ExplanationResult) -> list[str]:
139
+ lines = ["## Literate diff — generated explanation", ""]
140
+ if explanation.error:
141
+ lines.extend([
142
+ f"> Generated explanation unavailable: {explanation.error}",
143
+ f"> Redactions applied before any model call: {explanation.redactions}",
144
+ "",
145
+ ])
146
+ return lines
147
+ lines.extend([
148
+ f"> Generated by `{explanation.model}` from bounded, redacted context. This prose cannot change the deterministic verdict.",
149
+ f"> Redactions applied before the model call: {explanation.redactions}",
150
+ "",
151
+ "### Background",
152
+ "",
153
+ explanation.explanation["background"],
154
+ "",
155
+ "### Intent in plain language",
156
+ "",
157
+ explanation.explanation["intent"],
158
+ "",
159
+ "### Narrative",
160
+ "",
161
+ ])
162
+ for index, step in enumerate(explanation.explanation["narrative"], start=1):
163
+ lines.append(f"#### {index}. {step['title']}")
164
+ lines.append("")
165
+ lines.append(step["explanation"])
166
+ if step["files"]:
167
+ lines.append(f"- Files: {', '.join(f'`{path}`' for path in step['files'])}")
168
+ if step["evidence"]:
169
+ lines.append(f"- Evidence: {'; '.join(step['evidence'])}")
170
+ if step["snippet"]:
171
+ lines.extend(["", "```text", step["snippet"], "```"])
172
+ lines.append("")
173
+ if explanation.explanation["review_questions"]:
174
+ lines.extend(["### Review questions", ""])
175
+ lines.extend(f"- {question}" for question in explanation.explanation["review_questions"])
176
+ lines.append("")
177
+ if explanation.explanation["uncertainties"]:
178
+ lines.extend(["### Uncertainties", ""])
179
+ lines.extend(f"- {item}" for item in explanation.explanation["uncertainties"])
180
+ lines.append("")
181
+ return lines
182
+
183
+
184
+ def render_markdown(result: TriageResult, explanation: ExplanationResult | None = None) -> str:
185
+ m = result.metadata
186
+ lines = [
187
+ f"# PR triage: [{m.owner}/{m.repo}#{m.number}]({m.html_url})",
188
+ "",
189
+ f"**Title:** {m.title}",
190
+ f"**Author:** @{m.author} ",
191
+ f"**Refs:** `{m.base_ref}` ← `{m.head_ref}` ",
192
+ f"**Commits:** {m.commits} · **Files:** {m.changed_files} · **Lines:** +{m.additions} / -{m.deletions}",
193
+ "",
194
+ "## Verdict",
195
+ "",
196
+ f"# **{result.verdict}**",
197
+ "",
198
+ *[f"- {reason}" for reason in result.reasoning],
199
+ "",
200
+ "## Checks",
201
+ "",
202
+ f"- State: **{result.checks.get('state', 'unknown').upper()}**",
203
+ f"- Observed checks: {result.checks.get('count', 0)}",
204
+ ]
205
+ if result.checks.get("failed"):
206
+ lines.append(f"- Failed: {', '.join(f'`{x}`' for x in result.checks['failed'])}")
207
+ if result.checks.get("pending"):
208
+ lines.append(f"- Pending: {', '.join(f'`{x}`' for x in result.checks['pending'])}")
209
+
210
+ lines += ["", "## Risk flags", ""]
211
+ if result.flags:
212
+ for flag in result.flags:
213
+ lines.append(f"### `{flag.code}` — {_risk_badge(flag.severity)}")
214
+ lines.append(flag.message)
215
+ for evidence in flag.evidence[:20]:
216
+ lines.append(f"- `{evidence}`")
217
+ lines.append("")
218
+ else:
219
+ lines.append("No deterministic risk flags fired.")
220
+
221
+ lines += ["", "## Blast-radius map", "", "The Phase 1 map is conservative: it identifies changed files, changed symbols, and direct call sites visible in changed hunks. A full repository-wide call graph is a Phase 2 enhancement.", ""]
222
+ for file in result.files:
223
+ lines.append(f"### `{file.path}` ({file.status}, +{file.additions}/-{file.deletions})")
224
+ symbols = ", ".join(f"`{x}`" for x in file.touched_symbols) if file.touched_symbols else "none detected"
225
+ callers = ", ".join(f"`{x}`" for x in file.callers) if file.callers else "none detected in changed hunks"
226
+ tests = ", ".join(f"`{x}`" for x in file.tests_found) if file.tests_found else "none detected"
227
+ lines.append(f"- Touched symbols: {symbols}")
228
+ lines.append(f"- Direct callers: {callers}")
229
+ lines.append(f"- Related tests: {tests}")
230
+ lines.append("")
231
+
232
+ if explanation is not None:
233
+ lines += [""] + render_explanation(explanation)
234
+
235
+ lines += ["## Changed-file inventory", "", "| File | Status | Additions | Deletions | Symbols |", "| --- | --- | ---: | ---: | --- |"]
236
+ for file in result.files:
237
+ symbols = ", ".join(file.touched_symbols) if file.touched_symbols else "—"
238
+ lines.append(f"| `{file.path}` | {file.status} | {file.additions} | {file.deletions} | {symbols} |")
239
+ lines += ["", "## Deterministic policy", "", "- `BLOCK`: failed checks or authentication/secrets/security-sensitive changes.", "- `QUARANTINE`: database changes, dependency changes, missing obvious production test coverage, or unavailable/pending checks.", "- `PASS`: no blocking or quarantine rule fired and observed checks passed.", "", "_Generated by diffly. Deterministic triage is authoritative; any literate-diff prose is optional generated explanation._"]
240
+ return "\n".join(lines) + "\n"
241
+
242
+
243
+ def _progress(message: str):
244
+ """Spinner in terminals; silent no-op for pipes and CI."""
245
+ if sys.stdout.isatty():
246
+ return console.status(message, spinner="dots")
247
+ return nullcontext()
248
+
249
+
250
+ def _explanation_lines(explanation: ExplanationResult) -> list[str]:
251
+ """Render the literate-diff explanation as compact lines for the interactive view."""
252
+ if explanation.error:
253
+ hint = ""
254
+ if "API key" in explanation.error:
255
+ hint = "\n[dim]Enable it: export DIFFLY_LLM_API_KEY=... (optional — deterministic triage is unaffected)[/]"
256
+ return [f"[yellow]Generated explanation unavailable:[/] {explanation.error}", hint or "[dim]Deterministic triage above remains authoritative.[/]", ""]
257
+ data = explanation.explanation
258
+ lines = [f"[dim]Generated by {explanation.model} from bounded, redacted context · cannot change the verdict[/]", "", f"[bold]Background.[/] {data['background']}", f"[bold]Intent.[/] {data['intent']}", "", "[bold]Narrative steps[/]"]
259
+ for index, step in enumerate(data["narrative"], start=1):
260
+ lines.append(f" {index}. [cyan]{step['title']}[/] — {step['explanation']}")
261
+ if step["files"]:
262
+ lines.append(f" [dim]files:[/] {', '.join(step['files'])}")
263
+ if data["review_questions"]:
264
+ lines += ["", "[bold]Review questions[/]"] + [f" • {question}" for question in data["review_questions"]]
265
+ if data["uncertainties"]:
266
+ lines += ["", "[bold]Uncertainties[/]"] + [f" ? {item}" for item in data["uncertainties"]]
267
+ return lines
268
+
269
+
270
+ def _section_lines(result: TriageResult, section: str, explanation: ExplanationResult | None = None) -> list[str]:
271
+ """Build a compact Rich-friendly section for the interactive view."""
272
+ if section == "verdict":
273
+ style = VERDICT_STYLES.get(result.verdict, "bold")
274
+ return [f"[{style}]{result.verdict}[/]", *result.reasoning]
275
+ if section == "checks":
276
+ checks = result.checks
277
+ state = checks.get("state", "unknown").upper()
278
+ if state == "NOT_APPLICABLE":
279
+ return ["CI checks do not apply to local analysis."]
280
+ return [f"State: {state}", f"Observed: {checks.get('count', 0)}", *[f"Failed: {x}" for x in checks.get("failed", [])], *[f"Pending: {x}" for x in checks.get("pending", [])]]
281
+ if section == "risks":
282
+ if not result.flags:
283
+ return ["No deterministic risk flags fired."]
284
+ return [f"{flag.code} ({flag.severity}): {flag.message}" for flag in result.flags]
285
+ if section == "explain":
286
+ if explanation is None:
287
+ return ["No generated explanation requested."]
288
+ return _explanation_lines(explanation)
289
+ return [f"{item.path} +{item.additions}/-{item.deletions}" for item in result.files[:80]] or ["No changed files returned."]
290
+
291
+
292
+ def interactive_view(result: TriageResult, explanation: ExplanationResult | None = None) -> None:
293
+ """Let a reviewer toggle report sections with up/down arrows and space."""
294
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
295
+ console.print("[yellow]Interactive mode needs a terminal; showing the standard report instead.[/]")
296
+ console.print(Markdown(render_markdown(result, explanation)))
297
+ return
298
+ try:
299
+ import termios
300
+ import tty
301
+ except ImportError:
302
+ console.print("[yellow]Interactive mode is unavailable on this platform; showing the standard report instead.[/]")
303
+ console.print(Markdown(render_markdown(result, explanation)))
304
+ return
305
+ labels = [("verdict", "Verdict"), ("checks", "Checks"), ("risks", "Risk flags"), ("files", "Changed files")]
306
+ if explanation is not None:
307
+ labels.append(("explain", "Explanation"))
308
+ try:
309
+ import termios
310
+ import tty
311
+ except ImportError:
312
+ console.print("[yellow]Interactive mode is unavailable on this platform; showing the standard report instead.[/]")
313
+ console.print(Markdown(render_markdown(result)))
314
+ return
315
+ labels = [("verdict", "Verdict"), ("checks", "Checks"), ("risks", "Risk flags"), ("files", "Changed files")]
316
+ enabled = {key: True for key, _ in labels}
317
+ cursor = 0
318
+ fd = sys.stdin.fileno()
319
+ old = termios.tcgetattr(fd)
320
+ try:
321
+ tty.setcbreak(fd)
322
+ while True:
323
+ console.clear()
324
+ console.print(center(Panel.fit("[bold cyan]diffly interactive review[/] [dim]↑/↓ move · space toggle · enter apply · q quit[/]", border_style="cyan")))
325
+ menu = Table(show_header=False, box=None, padding=(0, 2))
326
+ menu.add_column("", width=3)
327
+ menu.add_column("Section")
328
+ for index, (key, label) in enumerate(labels):
329
+ marker = "[cyan]›[/]" if index == cursor else " "
330
+ state = "[green]on[/]" if enabled[key] else "[dim]off[/]"
331
+ menu.add_row(marker, f"{label:<18} {state}")
332
+ console.print(center(menu))
333
+ console.print(center(Text.from_markup("[dim]Toggle sections to keep the review focused.[/]")))
334
+ key = sys.stdin.read(1)
335
+ if key in {"q", "Q"}:
336
+ return
337
+ if key in {"\r", "\n"}:
338
+ break
339
+ if key == " ":
340
+ selected = labels[cursor][0]
341
+ enabled[selected] = not enabled[selected]
342
+ elif key == "\x1b":
343
+ sequence = _read_escape_sequence()
344
+ if sequence == "[A":
345
+ cursor = (cursor - 1) % len(labels)
346
+ elif sequence == "[B":
347
+ cursor = (cursor + 1) % len(labels)
348
+ console.clear()
349
+ result_table = Table(title=f"{result.metadata.owner}/{result.metadata.repo}#{result.metadata.number}", box=None, padding=(0, 1))
350
+ result_table.add_column("Section", style="cyan")
351
+ result_table.add_column("Details")
352
+ for key, label in labels:
353
+ if enabled[key]:
354
+ details = "\n".join(_section_lines(result, key, explanation))
355
+ result_table.add_row(label, details)
356
+ console.print(center(result_table))
357
+ console.print()
358
+ finally:
359
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
360
+
361
+
362
+ def build_result(client: GitHubClient, owner: str, repo: str, number: int) -> TriageResult:
363
+ metadata = client.pull_request(owner, repo, number)
364
+ files = client.pull_request_files(owner, repo, number)
365
+ try:
366
+ diff = client.pull_request_diff(owner, repo, number)
367
+ except GitHubError:
368
+ # GitHub refuses raw diffs above its size limit, while the paginated
369
+ # list-files endpoint can still provide useful metadata and patches.
370
+ diff = ""
371
+ if diff and any(not file.patch for file in files):
372
+ fallback_patches = {file.path: file.patch for file in files_from_unified_diff(diff)}
373
+ for file in files:
374
+ if not file.patch and file.path in fallback_patches:
375
+ file.patch = fallback_patches[file.path]
376
+ files = analyze_files(files)
377
+ check_runs = client.check_runs(owner, repo, metadata.head_sha)
378
+ status = client.commit_status(owner, repo, metadata.head_sha)
379
+ checks = summarize_checks(check_runs, status)
380
+ try:
381
+ tree_result = client.repository_tree(owner, repo, metadata.head_sha)
382
+ repo_paths = tree_result.paths
383
+ tree_truncated = tree_result.truncated
384
+ except GitHubError:
385
+ repo_paths = []
386
+ tree_truncated = True
387
+ checks = dict(checks)
388
+ checks["repository_tree_complete"] = not tree_truncated
389
+ if tree_truncated:
390
+ checks["repository_tree_truncated"] = True
391
+ flags = compute_flags(metadata, files, checks, repo_paths)
392
+ verdict, reasoning = verdict_for(flags, checks)
393
+ return TriageResult(metadata, files, flags, verdict, reasoning, checks, "GitHub REST API")
394
+
395
+
396
+ def result_payload(result: TriageResult, explanation: ExplanationResult | None) -> dict[str, Any]:
397
+ return {
398
+ "metadata": asdict(result.metadata),
399
+ "files": [asdict(file) for file in result.files],
400
+ "flags": [asdict(flag) for flag in result.flags],
401
+ "verdict": result.verdict,
402
+ "legacy_verdict": "SHIP" if result.verdict == "PASS" else result.verdict,
403
+ "reasoning": result.reasoning,
404
+ "checks": result.checks,
405
+ "source": result.source,
406
+ "literate_diff": ({
407
+ "explanation": explanation.explanation,
408
+ "redactions": explanation.redactions,
409
+ "model": explanation.model,
410
+ "error": explanation.error,
411
+ } if explanation is not None else None),
412
+ }
413
+
414
+
415
+ def _emit_report(result: TriageResult, explanation: ExplanationResult | None, args: argparse.Namespace) -> int:
416
+ """Shared output tail: interactive screen, JSON, file, or terminal Markdown."""
417
+ wants_interactive = getattr(args, "interactive", False)
418
+ if wants_interactive and not args.json and not args.output:
419
+ interactive_view(result, explanation)
420
+ return 0
421
+ output = render_markdown(result, explanation)
422
+ if args.json:
423
+ print(json.dumps(result_payload(result, explanation), indent=2, sort_keys=True))
424
+ elif args.output:
425
+ with open(args.output, "w", encoding="utf-8") as handle:
426
+ handle.write(output)
427
+ console.print(f"Wrote {args.output}")
428
+ else:
429
+ console.print(Markdown(output))
430
+ return 0
431
+
432
+
433
+ def run_pr(args: argparse.Namespace) -> int:
434
+ ref: RepoRef = args.repository
435
+ number = args.number if args.number is not None else ref.pr_number
436
+ if number is None:
437
+ console.print("[red]Error:[/red] a pull-request number is required, or pass a full pull-request URL as the repository.")
438
+ return 2
439
+ owner, repo = ref.owner, ref.repo
440
+ client = GitHubClient(token=args.token)
441
+ try:
442
+ with _progress(f"[cyan]Analyzing [bold]{ref.slug}#{number}[/] — fetching metadata, diffs, symbols, and checks…[/]"):
443
+ result = build_result(client, owner, repo, number)
444
+ except GitHubError as exc:
445
+ console.print(center(Panel.fit(f"[bold red]Unable to inspect pull request[/]\n{exc}\n\n[dim]Check GITHUB_TOKEN, repository access, and network connectivity. Run `diffly doctor` for diagnostics.[/]", border_style="red")))
446
+ return 2
447
+ if args.explain:
448
+ with _progress("[cyan]Generating literate-diff explanation…[/]"):
449
+ explanation = generate_explanation(
450
+ result,
451
+ model=args.llm_model,
452
+ base_url=args.llm_base_url,
453
+ )
454
+ else:
455
+ explanation = None
456
+ if not getattr(args, "json", False):
457
+ console.print()
458
+ console.print(center(Text.from_markup("[dim]Analysis complete — rendering your focused review.[/]")))
459
+ console.print()
460
+ return _emit_report(result, explanation, args)
461
+
462
+
463
+ def run_local(args: argparse.Namespace) -> int:
464
+ """Triage local git changes entirely offline — no GitHub, no token."""
465
+ scope = f"vs {args.base}" if args.base else "working tree"
466
+ try:
467
+ root = resolve_repository_root(args.path)
468
+ except LocalAnalysisError as exc:
469
+ console.print(center(Panel.fit(f"[bold red]Cannot analyze folder[/]\n{exc}\n\n[dim]Local mode works on any git checkout — including private or deleted repositories you still have on disk.[/]", border_style="red")))
470
+ return 2
471
+ try:
472
+ with _progress(f"[cyan]Analyzing [bold]{root.name}[/] ({scope}) — reading diffs and symbols locally…[/]"):
473
+ result = build_local_result(str(root), base=args.base)
474
+ except LocalAnalysisError as exc:
475
+ console.print(center(Panel.fit(f"[bold red]Local analysis failed[/]\n{exc}", border_style="red")))
476
+ return 2
477
+ if args.explain:
478
+ with _progress("[cyan]Generating literate-diff explanation…[/]"):
479
+ explanation = generate_explanation(
480
+ result,
481
+ model=args.llm_model,
482
+ base_url=args.llm_base_url,
483
+ )
484
+ else:
485
+ explanation = None
486
+ if not getattr(args, "json", False):
487
+ console.print()
488
+ console.print(center(Text.from_markup("[dim]Analysis complete — rendering your focused review.[/]")))
489
+ console.print()
490
+ return _emit_report(result, explanation, args)
491
+
492
+
493
+ def run_doctor(_: argparse.Namespace) -> int:
494
+ """Print actionable local diagnostics without making a GitHub request."""
495
+ table = Table(title="diffly doctor", box=None, padding=(0, 2))
496
+ table.add_column("Check", style="cyan")
497
+ table.add_column("Status")
498
+ pref = update.get_update_preference()
499
+ update_status = {"auto": "auto-update enabled", "manual": "manual updates"}.get(pref, "not set (will prompt on startup)")
500
+ checks = [("Python", f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"), ("GitHub token", "configured" if os.environ.get("GITHUB_TOKEN") else "not set (public API only)"), ("Terminal", "interactive" if sys.stdin.isatty() else "non-interactive"), ("diffly executable", shutil.which("diffly") or "not on PATH"), ("Update preference", update_status)]
501
+ for name, value in checks:
502
+ table.add_row(name, value)
503
+ console.print()
504
+ console.print(center(table))
505
+ console.print()
506
+ return 0
507
+
508
+
509
+ def run_help(args: argparse.Namespace) -> int:
510
+ args.root_parser.print_help()
511
+ return 0
512
+
513
+
514
+ def run_version(_: argparse.Namespace) -> int:
515
+ console.print()
516
+ console.print(center(Text.from_markup(f"[bold cyan]diffly[/] {__version__}")))
517
+ console.print()
518
+ return 0
519
+
520
+
521
+ def _prompt_update(latest_version: str) -> None:
522
+ """Ask the user whether to update now and whether to enable auto-updates.
523
+
524
+ Called when a newer version is available and the user has not opted into
525
+ automatic updates. The function handles the full interactive flow:
526
+ download-now prompt, installation, and the follow-up auto-update question.
527
+ """
528
+ console.print()
529
+ console.print(
530
+ center(
531
+ Panel.fit(
532
+ f"[bold cyan]diffly[/] [bold yellow]{latest_version}[/] is available "
533
+ f"([dim]installed: {__version__}[/])\n\n"
534
+ "[dim]Release notes: https://github.com/VIVAAN-DHAWAN/diffly-cli/releases[/]",
535
+ border_style="yellow",
536
+ padding=(1, 3),
537
+ )
538
+ )
539
+ )
540
+ if not Confirm.ask(
541
+ "[cyan]Download and install the update now?[/]",
542
+ default=True,
543
+ ):
544
+ console.print("[dim]Skipping update — you can run [bold]diffly update[/] later.[/]")
545
+ return
546
+
547
+ with _progress(f"[cyan]Updating diffly to {latest_version}…[/]"):
548
+ success = update.install_update()
549
+
550
+ if success:
551
+ console.print(
552
+ center(
553
+ Panel.fit(
554
+ f"[bold green]Updated to diffly {latest_version}[/]\n\n"
555
+ "[dim]Restart diffly to use the new version.[/]",
556
+ border_style="green",
557
+ padding=(1, 2),
558
+ )
559
+ )
560
+ )
561
+ if Confirm.ask(
562
+ "[cyan]Would you like diffly to automatically update in the future?[/]",
563
+ default=False,
564
+ ):
565
+ update.set_update_preference("auto")
566
+ console.print("[dim]Auto-update enabled — diffly will update itself when new versions are released.[/]")
567
+ else:
568
+ update.set_update_preference("manual")
569
+ console.print("[dim]Manual updates — diffly will prompt you when a new version is available.[/]")
570
+ else:
571
+ console.print(
572
+ center(
573
+ Panel.fit(
574
+ "[bold red]Update failed[/]\n\n"
575
+ "[dim]Try running [bold]diffly update[/] or re-run the install script manually:\n"
576
+ "curl -fsSL https://raw.githubusercontent.com/VIVAAN-DHAWAN/diffly-cli/main/install.sh | sh[/]",
577
+ border_style="red",
578
+ padding=(1, 2),
579
+ )
580
+ )
581
+ )
582
+
583
+
584
+ def _check_and_prompt_update(*, skip_auto: bool = False) -> None:
585
+ """Check for updates and act according to the user's stored preference.
586
+
587
+ - ``auto``: silently install the update (unless *skip_auto* is true).
588
+ - ``manual`` or unset: prompt the user interactively.
589
+ - Non-interactive terminals: skip entirely.
590
+ """
591
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
592
+ return
593
+
594
+ latest = update.check_for_update()
595
+ if latest is None:
596
+ return
597
+
598
+ preference = update.get_update_preference()
599
+ if preference == "auto" and not skip_auto:
600
+ with _progress(f"[cyan]Auto-updating diffly to {latest}…[/]"):
601
+ if update.install_update():
602
+ console.print(
603
+ center(
604
+ Panel.fit(
605
+ f"[bold green]Auto-updated to diffly {latest}[/]",
606
+ border_style="green",
607
+ padding=(1, 2),
608
+ )
609
+ )
610
+ )
611
+ console.print()
612
+ sys.exit(0)
613
+ return
614
+
615
+ _prompt_update(latest)
616
+
617
+
618
+ def run_update(_: argparse.Namespace) -> int:
619
+ """Manually check for and install the latest diffly release."""
620
+ console.print()
621
+ latest = update.check_for_update()
622
+ if latest is None:
623
+ console.print(
624
+ center(
625
+ Panel.fit(
626
+ f"[bold green]diffly {__version__} is up to date[/]\n\n"
627
+ "[dim]No newer release was found on PyPI.[/]",
628
+ border_style="green",
629
+ padding=(1, 2),
630
+ )
631
+ )
632
+ )
633
+ return 0
634
+ _prompt_update(latest)
635
+ return 0
636
+
637
+
638
+ def run_wizard(parser: argparse.ArgumentParser) -> int:
639
+ """Run the zero-argument first-use flow for human reviewers."""
640
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
641
+ parser.print_help()
642
+ return 2
643
+ console.clear()
644
+ _check_and_prompt_update()
645
+ console.print()
646
+ console.print(center(Panel.fit(
647
+ "[bold cyan]diffly[/] [dim]Deterministic pull-request triage[/]\n"
648
+ "[dim]Paste a repository or pull-request URL, answer two prompts, get a focused review.[/]",
649
+ border_style="cyan",
650
+ padding=(1, 4),
651
+ )))
652
+ console.print(center(Text.from_markup("[dim]Tip: scripted usage stays available with `diffly pr OWNER/REPO NUMBER --json`.\n[/]")))
653
+ console.print(center(Text.from_markup("[dim]Format: owner/repo · or paste any GitHub repository / pull-request URL[/]\n")))
654
+ while True:
655
+ raw_repository = Prompt.ask(
656
+ "[cyan]Repository URL[/]",
657
+ default=os.environ.get("DIFFLY_REPOSITORY", ""),
658
+ show_default=False,
659
+ )
660
+ try:
661
+ reference = parse_repo(raw_repository)
662
+ break
663
+ except argparse.ArgumentTypeError as exc:
664
+ console.print(f"[red]Invalid repository:[/] {exc} — use owner/repo or paste a GitHub URL.")
665
+ if reference.pr_number is not None:
666
+ number = reference.pr_number
667
+ console.print(f"[dim]Using pull-request #{number} from the URL you entered.[/]")
668
+ else:
669
+ while True:
670
+ raw_number = Prompt.ask("[cyan]Pull request number[/]")
671
+ try:
672
+ number = positive_pr_number(raw_number)
673
+ break
674
+ except argparse.ArgumentTypeError as exc:
675
+ console.print(f"[red]Invalid PR number:[/] {exc}")
676
+ token = os.environ.get("GITHUB_TOKEN")
677
+ if not token:
678
+ console.print("[dim]No GITHUB_TOKEN found. Public repositories still work with lower API limits.[/]")
679
+ explain = Confirm.ask("[cyan]Add an optional AI-generated explanation?[/]", default=False)
680
+ if not explain:
681
+ console.print("[dim]Deterministic mode — nothing leaves your machine.[/]")
682
+ console.print(center(Panel.fit(
683
+ f"[bold]Ready[/] {reference.slug}#{number}\n"
684
+ f"[dim]Mode: {'deterministic + explanation' if explain else 'deterministic'} · output: interactive[/]",
685
+ border_style="green",
686
+ )))
687
+ console.print(center(Text.from_markup("[dim]Crunching the diff — your focused review will appear shortly…\n[/]")))
688
+ args = parser.parse_args(["pr", reference.slug, str(number)])
689
+ args.token = token
690
+ args.explain = explain
691
+ args.interactive = True
692
+ return run_pr(args)
693
+
694
+
695
+ def run_setup(args: argparse.Namespace) -> int:
696
+ """Teach the core Diffly workflow through a short terminal walkthrough."""
697
+ pages = [
698
+ ("Welcome", "[bold cyan]diffly[/] turns a pull request — or your local git changes — into a deterministic review gate.\n\nRun [bold]diffly[/] with no arguments whenever you want the guided PR wizard."),
699
+ ("Review a PR", "[bold]diffly pr OWNER/REPO NUMBER[/]\n\nPaste a full pull-request URL and the number is inferred. Add [cyan]--interactive[/] for the keyboard view, [cyan]--explain[/] for optional generated context, or [cyan]--output report.md[/] to save Markdown."),
700
+ ("Analyze a local folder", "[bold]diffly local ~/code/my-project[/]\n\nTriage uncommitted working-tree changes — or compare a branch with [cyan]--base main[/]. Works fully offline, even for repositories that are private or no longer exist on GitHub."),
701
+ ("Interactive controls", "[cyan]↑ / ↓[/] move between report sections\n[cyan]Space[/] enables or disables a section\n[cyan]Enter[/] renders the focused report\n[cyan]q[/] exits"),
702
+ ("Automation", "Use [bold]diffly pr OWNER/REPO NUMBER --json[/] in scripts and CI.\n\nThe command exits 0 after successful analysis; enforce policy by reading the JSON [cyan]verdict[/] field."),
703
+ ("Troubleshooting", "Run [bold]diffly doctor[/] to check Python, terminal support, token configuration, and PATH setup.\n\nRun [bold]diffly help[/] for the complete command list and [bold]diffly version[/] when reporting an issue."),
704
+ ("Credentials and privacy", "Set [bold]GITHUB_TOKEN[/] for private repositories and higher API limits. Tokens are never shown by the wizard. Local mode never touches the network at all.\n\nDeterministic mode sends no code to an LLM. [cyan]--explain[/] uses your configured model endpoint."),
705
+ ]
706
+ for index, (title, body) in enumerate(pages, start=1):
707
+ console.clear()
708
+ console.print()
709
+ console.print(center(Panel(body, title=f"[bold]{index}/{len(pages)} · {title}[/]", subtitle="[dim]Enter next · q quit[/]", border_style="cyan", padding=(1, 2))))
710
+ choice = Prompt.ask("[dim]Press Enter to continue[/]", default="", show_default=False)
711
+ if choice.strip().lower() == "q":
712
+ return 0
713
+ console.clear()
714
+ console.print(center(Panel.fit("[bold green]Setup complete[/]\n[dim]You can rerun this guide anytime with `diffly setup`.[/]", border_style="green", padding=(1, 2))))
715
+ if Confirm.ask("[cyan]Try the guided PR wizard now?[/]", default=True):
716
+ return run_wizard(args.root_parser)
717
+ return 0
718
+
719
+
720
+ def build_parser() -> argparse.ArgumentParser:
721
+ parser = argparse.ArgumentParser(prog="diffly", description="Deterministic triage for large GitHub pull requests", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="Examples:\n diffly pr astral-sh/ruff 27808\n diffly pr https://github.com/astral-sh/ruff/pull/27808\n diffly pr astral-sh/ruff 27808 --interactive\n diffly local ~/code/my-repo --base main\n diffly doctor")
722
+ parser.add_argument("-V", "--version", action="version", version=f"diffly {__version__}", help="Show the diffly version and exit")
723
+ subparsers = parser.add_subparsers(dest="command", required=True)
724
+ pr = subparsers.add_parser("pr", help="Analyze one GitHub pull request")
725
+ pr.add_argument("repository", type=parse_repo, metavar="OWNER/REPO", help="owner/repo, a GitHub URL, or a full pull-request URL")
726
+ pr.add_argument("number", type=positive_pr_number, nargs="?", default=None, metavar="PR-NUMBER", help="Required unless the repository argument is a pull-request URL")
727
+ pr.add_argument("--token", default=None, help="GitHub token; defaults to GITHUB_TOKEN")
728
+ pr.add_argument("--output", help="Write terminal Markdown to a file")
729
+ pr.add_argument("--json", action="store_true", help="Emit structured JSON instead of Markdown")
730
+ pr.add_argument("--explain", action="store_true", help="Add an optional LLM-generated literate-diff explanation")
731
+ pr.add_argument("--llm-model", default=None, help="Override DIFFLY_LLM_MODEL for --explain")
732
+ pr.add_argument("--llm-base-url", default=None, help="Override DIFFLY_LLM_BASE_URL for --explain")
733
+ pr.add_argument("--interactive", action="store_true", help="Open a keyboard-driven report view (arrows, space, enter)")
734
+ pr.set_defaults(func=run_pr)
735
+ local = subparsers.add_parser("local", help="Analyze local git changes in a folder — no GitHub needed (works for private or removed repositories)")
736
+ local.add_argument("path", nargs="?", default=".", metavar="FOLDER", help="Path to a local git repository (default: current directory)")
737
+ local.add_argument("--base", default=None, metavar="REF", help="Compare against this git ref (e.g. main) instead of the working tree")
738
+ local.add_argument("--output", help="Write terminal Markdown to a file")
739
+ local.add_argument("--json", action="store_true", help="Emit structured JSON instead of Markdown")
740
+ local.add_argument("--explain", action="store_true", help="Add an optional LLM-generated literate-diff explanation")
741
+ local.add_argument("--llm-model", default=None, help="Override DIFFLY_LLM_MODEL for --explain")
742
+ local.add_argument("--llm-base-url", default=None, help="Override DIFFLY_LLM_BASE_URL for --explain")
743
+ local.add_argument("--interactive", action="store_true", help="Open a keyboard-driven report view (arrows, space, enter)")
744
+ local.set_defaults(func=run_local)
745
+ doctor = subparsers.add_parser("doctor", help="Diagnose local installation and environment")
746
+ doctor.set_defaults(func=run_doctor)
747
+ version = subparsers.add_parser("version", help="Print the installed diffly version")
748
+ version.set_defaults(func=run_version)
749
+ help_command = subparsers.add_parser("help", help="Show commands, options, and examples")
750
+ help_command.set_defaults(func=run_help, root_parser=parser)
751
+ setup = subparsers.add_parser("setup", help="Learn Diffly through a guided terminal tutorial")
752
+ setup.set_defaults(func=run_setup, root_parser=parser)
753
+ update_cmd = subparsers.add_parser("update", help="Check for and install the latest diffly release")
754
+ update_cmd.set_defaults(func=run_update)
755
+ return parser
756
+
757
+
758
+ def main(argv: list[str] | None = None) -> int:
759
+ parser = build_parser()
760
+ effective_argv = sys.argv[1:] if argv is None else argv
761
+ if not effective_argv:
762
+ return run_wizard(parser)
763
+ args = parser.parse_args(effective_argv)
764
+ if args.command not in ("update", "version", "help", "doctor"):
765
+ _check_and_prompt_update()
766
+ return args.func(args)
767
+
768
+
769
+ if __name__ == "__main__":
770
+ sys.exit(main())