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/__init__.py +3 -0
- diffly_cli/__main__.py +6 -0
- diffly_cli/astmap.py +121 -0
- diffly_cli/cli.py +770 -0
- diffly_cli/diffparse.py +117 -0
- diffly_cli/explainer.py +227 -0
- diffly_cli/github.py +181 -0
- diffly_cli/local.py +151 -0
- diffly_cli/models.py +68 -0
- diffly_cli/redact.py +59 -0
- diffly_cli/triage.py +142 -0
- diffly_cli/update.py +161 -0
- diffly_cli-0.4.0.dist-info/METADATA +341 -0
- diffly_cli-0.4.0.dist-info/RECORD +17 -0
- diffly_cli-0.4.0.dist-info/WHEEL +4 -0
- diffly_cli-0.4.0.dist-info/entry_points.txt +3 -0
- diffly_cli-0.4.0.dist-info/licenses/LICENSE +107 -0
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())
|