33from __future__ import annotations
44
55from contextlib import nullcontext
6+ import io
67import logging
78import os
89from pathlib import Path
910import 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
1116import typer
1217from rich .console import Console
1318from rich .panel import Panel
1419from rich .progress import Progress , SpinnerColumn , TextColumn
1520
1621from 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
2324from roast .scanner import scan_repo
2425
2526app = typer .Typer (
3031console = Console ()
3132LOGGER = logging .getLogger (__name__ )
3233VALID_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+
84161def _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
203311if __name__ == "__main__" :
204- app ()
312+ app ()
0 commit comments