Skip to content

Commit 2e877fc

Browse files
committed
feat: upgrade remote scanning and reporting (roast/cli.py)
1 parent abd09d9 commit 2e877fc

1 file changed

Lines changed: 130 additions & 22 deletions

File tree

roast/cli.py

Lines changed: 130 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,24 @@
33
from __future__ import annotations
44

55
from contextlib import nullcontext
6+
import io
67
import logging
78
import os
89
from pathlib import Path
910
import tempfile
11+
from urllib.error import HTTPError, URLError
12+
from urllib.parse import urlparse
13+
from urllib.request import Request, urlopen
14+
import zipfile
1015

1116
import typer
1217
from rich.console import Console
1318
from rich.panel import Panel
1419
from rich.progress import Progress, SpinnerColumn, TextColumn
1520

1621
from roast.analyzer import analyze
17-
from roast.reporter import export_html_report, render_terminal_report
18-
from roast.roaster import (
19-
DEFAULT_GROQ_MODEL,
20-
DEFAULT_NIM_MODEL,
21-
generate_roast,
22-
)
22+
from roast.reporter import export_html_report, export_json_report, render_terminal_report
23+
from roast.roaster import DEFAULT_GROQ_MODEL, DEFAULT_NIM_MODEL, generate_roast
2324
from roast.scanner import scan_repo
2425

2526
app = typer.Typer(
@@ -30,24 +31,88 @@
3031
console = Console()
3132
LOGGER = logging.getLogger(__name__)
3233
VALID_PROVIDERS = {"auto", "groq", "nim", "openai", "none"}
34+
GITHUB_API_BASE = "https://api.github.com"
35+
36+
37+
def _parse_github_target(value: str) -> tuple[str, str, str | None] | None:
38+
parsed = urlparse(value)
39+
if parsed.scheme != "https" or parsed.netloc != "github.com":
40+
return None
41+
42+
parts = [part for part in parsed.path.split("/") if part]
43+
if len(parts) < 2:
44+
raise RuntimeError("GitHub URL must point to a repository, e.g. https://github.com/owner/repo")
45+
46+
owner = parts[0]
47+
repo = parts[1].removesuffix(".git")
48+
if len(parts) == 2:
49+
return owner, repo, None
50+
if len(parts) >= 4 and parts[2] == "tree":
51+
ref = "/".join(parts[3:]).strip() or None
52+
return owner, repo, ref
53+
raise RuntimeError("GitHub URL must point to a repository root or tree ref.")
54+
55+
56+
def _github_headers() -> dict[str, str]:
57+
headers = {
58+
"Accept": "application/vnd.github+json",
59+
"User-Agent": "roast-my-code",
60+
}
61+
github_token = os.getenv("GITHUB_TOKEN")
62+
if github_token:
63+
headers["Authorization"] = f"Bearer {github_token}"
64+
return headers
65+
66+
67+
def _extract_archive_root(temp_dir_path: Path) -> Path:
68+
extracted_dirs = [child for child in temp_dir_path.iterdir() if child.is_dir()]
69+
if len(extracted_dirs) != 1:
70+
raise RuntimeError("GitHub archive had an unexpected layout.")
71+
return extracted_dirs[0]
72+
73+
74+
def _download_github_archive(
75+
owner: str,
76+
repo: str,
77+
ref: str | None,
78+
temp_dir: tempfile.TemporaryDirectory[str],
79+
) -> Path:
80+
archive_suffix = f"/{ref}" if ref else ""
81+
archive_url = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/zipball{archive_suffix}"
82+
request = Request(archive_url, headers=_github_headers())
3383

84+
try:
85+
with urlopen(request) as response:
86+
archive_bytes = response.read()
87+
except HTTPError as exc:
88+
detail = exc.read().decode("utf-8", errors="ignore")
89+
if exc.code == 404:
90+
hint = " If this repo is private, set GITHUB_TOKEN before running the CLI."
91+
else:
92+
hint = ""
93+
raise RuntimeError(f"Failed to download GitHub archive ({exc.code}).{hint} {detail}".strip()) from exc
94+
except URLError as exc:
95+
raise RuntimeError(f"Failed to reach GitHub archive endpoint: {exc.reason}") from exc
3496

35-
def _is_github_url(value: str) -> bool:
36-
return value.startswith("https://github.com")
97+
try:
98+
with zipfile.ZipFile(io.BytesIO(archive_bytes)) as archive:
99+
archive.extractall(temp_dir.name)
100+
except zipfile.BadZipFile as exc:
101+
raise RuntimeError("GitHub archive download was not a valid zip file.") from exc
37102

103+
return _extract_archive_root(Path(temp_dir.name))
38104

39-
def _resolve_scan_target(path_or_url: str) -> tuple[Path, tempfile.TemporaryDirectory[str] | None]:
40-
if _is_github_url(path_or_url):
41-
from git import Repo
42-
from git.exc import GitCommandError
43105

106+
def _resolve_scan_target(path_or_url: str) -> tuple[Path, tempfile.TemporaryDirectory[str] | None]:
107+
github_target = _parse_github_target(path_or_url)
108+
if github_target:
44109
temp_dir = tempfile.TemporaryDirectory(prefix="roast-my-code-")
45110
try:
46-
Repo.clone_from(path_or_url, temp_dir.name)
47-
except GitCommandError as exc:
111+
target_path = _download_github_archive(*github_target, temp_dir=temp_dir)
112+
except RuntimeError:
48113
temp_dir.cleanup()
49-
raise RuntimeError(f"Failed to clone GitHub URL: {exc}") from exc
50-
return Path(temp_dir.name), temp_dir
114+
raise
115+
return target_path, temp_dir
51116

52117
local_path = Path(path_or_url).expanduser().resolve()
53118
if not local_path.exists():
@@ -81,6 +146,18 @@ def _validate_provider(value: str, option_name: str) -> str:
81146
return normalized
82147

83148

149+
def _validate_fail_under(value: int | None) -> int | None:
150+
if value is None:
151+
return None
152+
if not 0 <= value <= 100:
153+
raise RuntimeError("fail-under must be between 0 and 100.")
154+
return value
155+
156+
157+
def _should_fail_quality_gate(overall_score: int, fail_under: int | None) -> bool:
158+
return fail_under is not None and overall_score < fail_under
159+
160+
84161
def _has_any_configured_llm_key(provider: str, backup_provider: str) -> bool:
85162
if provider == "auto":
86163
return any(_provider_has_key(name) for name in ("groq", "nim", "openai"))
@@ -99,6 +176,11 @@ def roast(
99176
"-o",
100177
help="Save HTML report to this path.",
101178
),
179+
json_output: str | None = typer.Option(
180+
None,
181+
"--json-output",
182+
help="Save JSON report to this path.",
183+
),
102184
model: str = typer.Option(
103185
DEFAULT_GROQ_MODEL,
104186
"--model",
@@ -107,7 +189,7 @@ def roast(
107189
provider: str = typer.Option(
108190
"auto",
109191
"--provider",
110-
help="Primary provider: auto, groq, nim, openai.",
192+
help="Primary provider: auto, groq, nim, openai, none.",
111193
),
112194
backup_provider: str = typer.Option(
113195
"nim",
@@ -125,7 +207,17 @@ def roast(
125207
"--extensions",
126208
help="Comma-separated file extensions to scan.",
127209
),
210+
include_config: bool = typer.Option(
211+
False,
212+
"--include-config",
213+
help="Include config and documentation files like .toml, .yml, and .md.",
214+
),
128215
max_files: int = typer.Option(50, "--max-files", help="Max files to scan."),
216+
fail_under: int | None = typer.Option(
217+
None,
218+
"--fail-under",
219+
help="Exit with code 1 if the overall score falls below this threshold.",
220+
),
129221
) -> None:
130222
"""Roast a local repository path or GitHub URL."""
131223
logging.basicConfig(level=logging.WARNING, format="%(levelname)s: %(message)s")
@@ -134,6 +226,7 @@ def roast(
134226
try:
135227
provider = _validate_provider(provider, "provider")
136228
backup_provider = _validate_provider(backup_provider, "backup_provider")
229+
fail_under = _validate_fail_under(fail_under)
137230
except RuntimeError as exc:
138231
console.print(Panel(str(exc), title="Configuration Error", border_style="red"))
139232
raise typer.Exit(code=1)
@@ -146,9 +239,9 @@ def roast(
146239
Panel(
147240
"[bold red]No LLM API keys found.[/]\n"
148241
"Set at least one:\n"
149-
"[cyan]export GROQ_API_KEY='...[/cyan]' (recommended free primary)\n"
150-
"[cyan]export NVIDIA_NIM_API_KEY='...[/cyan]' (recommended backup)\n"
151-
"[cyan]export OPENAI_API_KEY='...[/cyan]' (optional)\n"
242+
"[cyan]export GROQ_API_KEY='...'[/cyan] (recommended free primary)\n"
243+
"[cyan]export NVIDIA_NIM_API_KEY='...'[/cyan] (recommended backup)\n"
244+
"[cyan]export OPENAI_API_KEY='...'[/cyan] (optional)\n"
152245
"Or run with [cyan]--no-llm[/] to skip AI roast generation.",
153246
title="Configuration Error",
154247
border_style="red",
@@ -167,7 +260,7 @@ def roast(
167260
with context:
168261
with Progress(SpinnerColumn(), TextColumn("[bold cyan]{task.description}"), transient=True) as progress:
169262
task_id = progress.add_task("Scanning repository...", total=None)
170-
files = scan_repo(target_path, ext_list, max_files=max_files)
263+
files = scan_repo(target_path, ext_list, max_files=max_files, include_config=include_config)
171264
progress.update(task_id, description=f"Running static analysis on {len(files)} files...")
172265
report = analyze(files)
173266

@@ -197,8 +290,23 @@ def roast(
197290
roast_result = generate_roast(report, files, no_llm=True)
198291

199292
export_html_report(report, roast_result, output_path=output)
293+
if json_output:
294+
export_json_report(report, roast_result, output_path=json_output)
200295
render_terminal_report(report, roast_result, output_path=output, console=console)
296+
if json_output:
297+
console.print(f"[bold cyan]JSON report saved to: {Path(json_output).expanduser()}[/]")
298+
299+
overall_score = report.scores.get("Overall", 0)
300+
if _should_fail_quality_gate(overall_score, fail_under):
301+
console.print(
302+
Panel(
303+
f"Overall score {overall_score} is below required threshold {fail_under}.",
304+
title="Quality Gate Failed",
305+
border_style="red",
306+
)
307+
)
308+
raise typer.Exit(code=1)
201309

202310

203311
if __name__ == "__main__":
204-
app()
312+
app()

0 commit comments

Comments
 (0)