gitradar 0.1.5__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.
Files changed (51) hide show
  1. demo/api/index.py +14 -0
  2. demo/gitradar/__init__.py +22 -0
  3. demo/gitradar/cli.py +214 -0
  4. demo/gitradar/config.py +117 -0
  5. demo/gitradar/models.py +67 -0
  6. demo/gitradar/prompts/__init__.py +18 -0
  7. demo/gitradar/services/github.py +179 -0
  8. demo/gitradar/services/llm.py +342 -0
  9. demo/gitradar/utils/ui.py +196 -0
  10. demo/gitradar/web/__init__.py +7 -0
  11. demo/gitradar/web/app.py +173 -0
  12. examples/python_sdk_usage.py +48 -0
  13. gitradar/__init__.py +22 -0
  14. gitradar/cli.py +214 -0
  15. gitradar/config.py +117 -0
  16. gitradar/models.py +67 -0
  17. gitradar/prompts/__init__.py +18 -0
  18. gitradar/prompts/gap_analysis_system.j2 +36 -0
  19. gitradar/prompts/gap_analysis_user.j2 +21 -0
  20. gitradar/prompts/query_expansion_system.j2 +19 -0
  21. gitradar/prompts/query_expansion_user.j2 +1 -0
  22. gitradar/prompts/relevance_evaluation_system.j2 +22 -0
  23. gitradar/prompts/relevance_evaluation_user.j2 +14 -0
  24. gitradar/services/github.py +179 -0
  25. gitradar/services/llm.py +342 -0
  26. gitradar/utils/ui.py +196 -0
  27. gitradar/web/__init__.py +7 -0
  28. gitradar/web/app.py +173 -0
  29. gitradar/web/static/apple-touch-icon.png +0 -0
  30. gitradar/web/static/css/style.css +1430 -0
  31. gitradar/web/static/favicon-96x96.png +0 -0
  32. gitradar/web/static/favicon.ico +0 -0
  33. gitradar/web/static/favicon.svg +1 -0
  34. gitradar/web/static/js/app.js +1206 -0
  35. gitradar/web/static/site.webmanifest +21 -0
  36. gitradar/web/static/web-app-manifest-192x192.png +0 -0
  37. gitradar/web/static/web-app-manifest-512x512.png +0 -0
  38. gitradar/web/templates/index.html +521 -0
  39. gitradar-0.1.5.dist-info/METADATA +251 -0
  40. gitradar-0.1.5.dist-info/RECORD +51 -0
  41. gitradar-0.1.5.dist-info/WHEEL +5 -0
  42. gitradar-0.1.5.dist-info/entry_points.txt +2 -0
  43. gitradar-0.1.5.dist-info/licenses/LICENSE +21 -0
  44. gitradar-0.1.5.dist-info/top_level.txt +4 -0
  45. tests/conftest.py +27 -0
  46. tests/test_cli.py +29 -0
  47. tests/test_llm.py +163 -0
  48. tests/test_models.py +90 -0
  49. tests/test_prompts.py +58 -0
  50. tests/test_relevance.py +75 -0
  51. tests/test_web.py +81 -0
demo/api/index.py ADDED
@@ -0,0 +1,14 @@
1
+ import os
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ # Add demo directory to sys.path so bundled gitradar package is importable on Vercel
6
+ DEMO_DIR = Path(__file__).parent.parent
7
+ if str(DEMO_DIR) not in sys.path:
8
+ sys.path.insert(0, str(DEMO_DIR))
9
+
10
+ # Import monkeypatch for Python compatibility
11
+ import gitradar # noqa: F401
12
+ from gitradar.web.app import create_app
13
+
14
+ app = create_app()
@@ -0,0 +1,22 @@
1
+ """
2
+ GitRadar: Smart GitHub Market & Gap Analysis CLI tool.
3
+ """
4
+
5
+ import sys
6
+ import typing
7
+
8
+ # Fix compatibility issue on Python < 3.11 where third-party packages (e.g. litellm)
9
+ # attempt to import 'NotRequired' or other PEP 655/673 types directly from 'typing'.
10
+ if sys.version_info < (3, 11):
11
+ try:
12
+ import typing_extensions
13
+
14
+ for _name in ("NotRequired", "Required", "Self", "Never", "LiteralString", "TypeAlias", "Override", "dataclass_transform"):
15
+ if not hasattr(typing, _name) and hasattr(typing_extensions, _name):
16
+ setattr(typing, _name, getattr(typing_extensions, _name))
17
+ except ImportError:
18
+ pass
19
+
20
+ __version__ = "0.2.0"
21
+ __author__ = "GitRadar Developer"
22
+
demo/gitradar/cli.py ADDED
@@ -0,0 +1,214 @@
1
+ import asyncio
2
+ import webbrowser
3
+ from typing import Optional
4
+ import typer
5
+ from rich.console import Console
6
+ from rich.status import Status
7
+
8
+ from gitradar import __version__
9
+ from gitradar.config import settings, save_setting
10
+ from gitradar.services.github import GitHubService
11
+ from gitradar.services.llm import LLMService
12
+ from gitradar.utils import ui
13
+ from gitradar.web import create_app
14
+
15
+ app = typer.Typer(
16
+ name="gitradar",
17
+ help="📡 GitRadar: GitHub Market & Gap Analysis CLI tool driven by AI & GitHub REST API.",
18
+ add_completion=False,
19
+ )
20
+ console = Console()
21
+
22
+
23
+ @app.command(name="analyze", help="💡 Analyze a project idea, scan candidate repos, and generate a Market & Gap Report.")
24
+ def analyze(
25
+ idea: str = typer.Argument(..., help="Project idea to analyze (e.g. 'AI code review tool for git hooks')"),
26
+ limit: int = typer.Option(10, "--limit", "-l", help="Maximum repositories to fetch and evaluate"),
27
+ min_relevance: int = typer.Option(50, "--min-relevance", "-r", help="Minimum relevance score threshold (0-100%)"),
28
+ model: Optional[str] = typer.Option(None, "--model", "-m", help="Override AI model (e.g. 'gpt-4o-mini', 'llama-3.3-70b-versatile')"),
29
+ api_key: Optional[str] = typer.Option(None, "--api-key", "-k", help="Override OpenAI or compatible API Key"),
30
+ base_url: Optional[str] = typer.Option(None, "--base-url", "-u", help="Override OpenAI-compatible base URL"),
31
+ language: Optional[str] = typer.Option(None, "--lang", "--language", help="Output language for AI report (e.g. 'Turkish', 'English', 'Spanish')"),
32
+ ):
33
+ """Run full AI-driven market and gap analysis workflow on a project idea."""
34
+ ui.display_banner()
35
+
36
+ try:
37
+ llm_service = LLMService(api_key=api_key, base_url=base_url, model=model, language=language)
38
+ github_service = GitHubService()
39
+
40
+ # Step 1: Query Expansion with LLM
41
+ with Status("[bold cyan]🧠 Analyzing project idea with LLM & deriving search strategy...[/bold cyan]", spinner="dots"):
42
+ queries = llm_service.expand_idea_to_queries(idea, language=language)
43
+
44
+ ui.display_query_strategy(queries)
45
+
46
+ # Step 2: GitHub Search & Enrichment
47
+ with Status(f"[bold cyan]🔍 Scanning GitHub repositories via REST API (Max: {limit})...[/bold cyan]", spinner="earth"):
48
+ candidate_repos = asyncio.run(
49
+ github_service.search_and_enrich(
50
+ keywords=queries.search_keywords,
51
+ topics=queries.github_topics,
52
+ limit=max(limit * 2, 15),
53
+ )
54
+ )
55
+
56
+ if not candidate_repos:
57
+ ui.display_error("No relevant repositories found. Try phrasing your project idea with different terms.")
58
+ raise typer.Exit(code=1)
59
+
60
+ # Step 2b: Relevance Evaluation & Hybrid Ranking
61
+ with Status(f"[bold cyan]⚖️ Evaluating relevance match & filtering by threshold (≥{min_relevance}%)...[/bold cyan]", spinner="dots"):
62
+ evaluated_repos = llm_service.evaluate_repository_relevance(idea, candidate_repos, language=language)
63
+ repos = github_service.rank_and_sort_by_relevance(evaluated_repos, limit=limit, min_relevance=min_relevance)
64
+
65
+ ui.display_repo_table(repos, title=f"Repositories Related to '{idea}' (Threshold: ≥{min_relevance}%)")
66
+
67
+ # Step 3: Semantic Gap Analysis with LLM
68
+ with Status("[bold cyan]🎯 Semantically evaluating candidate repos & synthesizing Market & Gap Report...[/bold cyan]", spinner="bouncingBar"):
69
+ report = llm_service.analyze_market_and_gaps(idea, repos, language=language)
70
+
71
+ # Step 4: Display Final Report
72
+ ui.display_gap_report(report)
73
+
74
+ except ValueError as ve:
75
+ ui.display_error(str(ve))
76
+ raise typer.Exit(code=1)
77
+ except Exception as e:
78
+ ui.display_error(f"An unexpected error occurred: {str(e)}")
79
+ raise typer.Exit(code=1)
80
+
81
+
82
+ @app.command(name="search", help="🔍 Perform a quick standalone GitHub repository search.")
83
+ def search(
84
+ query: str = typer.Argument(..., help="Search query string or keywords"),
85
+ limit: int = typer.Option(10, "--limit", "-l", help="Maximum repositories to return"),
86
+ sort: str = typer.Option("stars", "--sort", "-s", help="Sort field (stars, forks, updated)"),
87
+ ):
88
+ """Fast standalone repository search without LLM synthesis."""
89
+ ui.display_banner()
90
+ github_service = GitHubService()
91
+
92
+ try:
93
+ with Status(f"[bold cyan]🔍 Searching GitHub for '{query}'...[/bold cyan]", spinner="dots"):
94
+ repos = asyncio.run(github_service.search_repositories(query, limit=limit, sort=sort))
95
+
96
+ ui.display_repo_table(repos, title=f"Search Results: '{query}'")
97
+
98
+ except Exception as e:
99
+ ui.display_error(f"GitHub Search Failed: {str(e)}")
100
+ raise typer.Exit(code=1)
101
+
102
+
103
+ @app.command(name="ui", help="🌐 Launches local GitRadar web dashboard in your browser.")
104
+ def ui_command(
105
+ port: int = typer.Option(5000, "--port", "-p", help="Port to run the local web server on"),
106
+ host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host address to bind the web server"),
107
+ open_browser: bool = typer.Option(True, "--open/--no-open", help="Automatically open browser tab"),
108
+ ):
109
+ """Launch local Flask web application dashboard."""
110
+ ui.display_banner()
111
+ url = f"http://{host}:{port}"
112
+ console.print(f"[bold green]🚀 Launching GitRadar Web Dashboard at:[/bold green] [bold cyan]{url}[/bold cyan]")
113
+
114
+ if open_browser:
115
+ try:
116
+ webbrowser.open(url)
117
+ except Exception:
118
+ pass
119
+
120
+ flask_app = create_app()
121
+ flask_app.run(host=host, port=port, debug=False)
122
+
123
+
124
+ @app.command(name="config", help="⚙️ View or configure GitRadar API credentials and preferences.")
125
+ def config_command(
126
+ show: bool = typer.Option(False, "--show", help="Display current configuration"),
127
+ openai_api_key: Optional[str] = typer.Option(None, "--openai-api-key", "-k", help="Save OpenAI or compatible API Key credential"),
128
+ base_url: Optional[str] = typer.Option(None, "--base-url", "-u", help="Save custom OpenAI-compatible Base URL (e.g. https://api.groq.com/openai/v1, http://localhost:11434/v1)"),
129
+ groq_api_key: Optional[str] = typer.Option(None, "--groq-api-key", help="Save Groq API Key credential (legacy / fallback)"),
130
+ github_token: Optional[str] = typer.Option(None, "--github-token", help="Save GitHub Access Token credential"),
131
+ default_model: Optional[str] = typer.Option(None, "--model", help="Save default AI model identifier (e.g. gpt-4o-mini, llama-3.3-70b-versatile)"),
132
+ default_language: Optional[str] = typer.Option(None, "--lang", "--language", help="Save default response language for AI reports"),
133
+ ):
134
+ """Manage GitRadar configuration."""
135
+ ui.display_banner()
136
+
137
+ updated = False
138
+ if openai_api_key:
139
+ save_setting("OPENAI_API_KEY", openai_api_key)
140
+ ui.display_info("OPENAI_API_KEY successfully saved! 🔑")
141
+ updated = True
142
+
143
+ if base_url:
144
+ save_setting("OPENAI_BASE_URL", base_url)
145
+ ui.display_info(f"OPENAI_BASE_URL saved as '{base_url}'! 🌐")
146
+ updated = True
147
+
148
+ if groq_api_key:
149
+ save_setting("GROQ_API_KEY", groq_api_key)
150
+ ui.display_info("GROQ_API_KEY successfully saved! 🔑")
151
+ updated = True
152
+
153
+ if github_token:
154
+ save_setting("GITHUB_TOKEN", github_token)
155
+ ui.display_info("GITHUB_TOKEN successfully saved! 🐙")
156
+ updated = True
157
+
158
+ if default_model:
159
+ save_setting("DEFAULT_MODEL", default_model)
160
+ ui.display_info(f"Default model saved as '{default_model}'! 🤖")
161
+ updated = True
162
+
163
+ if default_language:
164
+ save_setting("DEFAULT_LANGUAGE", default_language)
165
+ ui.display_info(f"Default language saved as '{default_language}'! 🌐")
166
+ updated = True
167
+
168
+ if show or not updated:
169
+ curr_settings = settings
170
+ grid = ui.Table(title="⚙️ Current GitRadar Configuration", header_style="bold yellow", expand=True)
171
+ grid.add_column("Parameter", style="bold cyan")
172
+ grid.add_column("Value", style="white")
173
+
174
+ openai_val = curr_settings.openai_api_key
175
+ masked_openai = f"{openai_val[:6]}...{openai_val[-4:]}" if openai_val and len(openai_val) > 10 else ("Configured" if openai_val else "[red]Not Configured[/red]")
176
+
177
+ groq_val = curr_settings.groq_api_key
178
+ masked_groq = f"{groq_val[:6]}...{groq_val[-4:]}" if groq_val and len(groq_val) > 10 else ("Configured" if groq_val else "[dim]Not Configured[/dim]")
179
+
180
+ base_url_val = curr_settings.openai_base_url or (
181
+ "https://api.groq.com/openai/v1 (Auto-routed from Groq Key)"
182
+ if not curr_settings.openai_api_key and curr_settings.groq_api_key
183
+ else "https://api.openai.com/v1 (Default)"
184
+ )
185
+
186
+ gh_val = curr_settings.github_token
187
+ masked_gh = f"{gh_val[:4]}...{gh_val[-4:]}" if gh_val and len(gh_val) > 8 else ("Configured (Anonymous Mode)" if not gh_val else "Configured")
188
+
189
+ grid.add_row("OPENAI_API_KEY", masked_openai)
190
+ if groq_val:
191
+ grid.add_row("GROQ_API_KEY (Legacy)", masked_groq)
192
+ grid.add_row("OPENAI_BASE_URL", base_url_val)
193
+ grid.add_row("GITHUB_TOKEN", masked_gh)
194
+ grid.add_row("DEFAULT_MODEL", curr_settings.default_model)
195
+ grid.add_row("DEFAULT_LANGUAGE", curr_settings.default_language)
196
+ grid.add_row("MAX_REPOS_TO_ANALYZE", str(curr_settings.max_repos_to_analyze))
197
+ grid.add_row("MIN_RELEVANCE_THRESHOLD", f"{curr_settings.min_relevance_threshold}%")
198
+
199
+ console.print(grid)
200
+
201
+
202
+ @app.command(name="version", help="ℹ️ Display GitRadar version information.")
203
+ def version():
204
+ """Display CLI version."""
205
+ ui.display_banner()
206
+ console.print(f"[bold cyan]GitRadar CLI Version:[/bold cyan] [bold white]v{__version__}[/bold white]")
207
+
208
+
209
+ def main():
210
+ app()
211
+
212
+
213
+ if __name__ == "__main__":
214
+ main()
@@ -0,0 +1,117 @@
1
+ import os
2
+ from pathlib import Path
3
+ from typing import Optional
4
+ from pydantic import Field, field_validator
5
+ from pydantic_settings import BaseSettings, SettingsConfigDict
6
+
7
+ CONFIG_DIR = Path.home() / ".config" / "gitradar"
8
+ CONFIG_FILE = CONFIG_DIR / "config.env"
9
+
10
+
11
+ class Settings(BaseSettings):
12
+ openai_api_key: Optional[str] = None
13
+ openai_base_url: Optional[str] = None
14
+ groq_api_key: Optional[str] = None
15
+ github_token: Optional[str] = None
16
+ default_model: str = "gpt-4o-mini"
17
+ default_language: str = "English"
18
+ max_repos_to_analyze: int = 50
19
+ min_relevance_threshold: int = 50
20
+
21
+ model_config = SettingsConfigDict(
22
+ env_file=(str(CONFIG_FILE), ".env"),
23
+ env_file_encoding="utf-8",
24
+ extra="ignore",
25
+ )
26
+
27
+ @property
28
+ def effective_api_key(self) -> Optional[str]:
29
+ return self.openai_api_key or self.groq_api_key
30
+
31
+ @property
32
+ def effective_base_url(self) -> Optional[str]:
33
+ if self.openai_base_url:
34
+ return self.openai_base_url
35
+ if not self.openai_api_key and self.groq_api_key:
36
+ return "https://api.groq.com/openai/v1"
37
+ return None
38
+
39
+ @field_validator("max_repos_to_analyze", "min_relevance_threshold", mode="before")
40
+ @classmethod
41
+ def validate_int_settings(cls, v):
42
+ if v is None or (isinstance(v, str) and v.strip() == ""):
43
+ return 50 if "min_relevance" in str(v) else 10
44
+ try:
45
+ return int(v)
46
+ except (ValueError, TypeError):
47
+ return 50
48
+
49
+ @field_validator("openai_api_key", "openai_base_url", "groq_api_key", "github_token", "default_model", "default_language", mode="before")
50
+ @classmethod
51
+ def validate_empty_strings(cls, v):
52
+ if isinstance(v, str) and v.strip() == "":
53
+ return None
54
+ return v
55
+
56
+
57
+ def load_settings() -> Settings:
58
+ """Load settings from env vars or config files safely."""
59
+ try:
60
+ s = Settings()
61
+ except Exception:
62
+ try:
63
+ s = Settings(_env_file=None)
64
+ except Exception:
65
+ s = Settings.model_construct(
66
+ openai_api_key=None,
67
+ openai_base_url=None,
68
+ groq_api_key=None,
69
+ github_token=None,
70
+ default_model="gpt-4o-mini",
71
+ default_language="English",
72
+ max_repos_to_analyze=50,
73
+ min_relevance_threshold=50,
74
+ )
75
+
76
+ if not s.openai_base_url:
77
+ s.openai_base_url = os.environ.get("OPENAI_BASE_URL") or os.environ.get("OPENAI_API_BASE")
78
+ if not s.openai_api_key:
79
+ s.openai_api_key = os.environ.get("OPENAI_API_KEY")
80
+ if s.default_model == "gpt-4o-mini" and os.environ.get("OPENAI_MODEL"):
81
+ s.default_model = os.environ["OPENAI_MODEL"]
82
+ return s
83
+
84
+
85
+ def save_setting(key: str, value: str) -> None:
86
+ """Save a setting key-value pair to user config file (~/.config/gitradar/config.env)."""
87
+ try:
88
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
89
+
90
+ existing_lines = []
91
+ if CONFIG_FILE.exists():
92
+ with open(CONFIG_FILE, "r", encoding="utf-8") as f:
93
+ existing_lines = f.readlines()
94
+
95
+ key_upper = key.upper()
96
+ found = False
97
+ new_lines = []
98
+
99
+ for line in existing_lines:
100
+ if line.strip().startswith(f"{key_upper}="):
101
+ new_lines.append(f"{key_upper}={value}\n")
102
+ found = True
103
+ else:
104
+ new_lines.append(line)
105
+
106
+ if not found:
107
+ if new_lines and not new_lines[-1].endswith("\n"):
108
+ new_lines.append("\n")
109
+ new_lines.append(f"{key_upper}={value}\n")
110
+
111
+ with open(CONFIG_FILE, "w", encoding="utf-8") as f:
112
+ f.writelines(new_lines)
113
+ except Exception:
114
+ pass
115
+
116
+
117
+ settings = load_settings()
@@ -0,0 +1,67 @@
1
+ from typing import Optional, List
2
+ from pydantic import BaseModel, Field
3
+
4
+
5
+ class RepositoryInfo(BaseModel):
6
+ """Model representing a GitHub repository summary."""
7
+ full_name: str
8
+ name: str
9
+ owner: str
10
+ html_url: str
11
+ description: Optional[str] = "No description provided"
12
+ stars: int = 0
13
+ forks: int = 0
14
+ language: Optional[str] = "Unspecified"
15
+ topics: List[str] = Field(default_factory=list)
16
+ updated_at: Optional[str] = ""
17
+ open_issues: int = 0
18
+ readme_snippet: Optional[str] = None
19
+ relevance_score: Optional[int] = Field(default=None, description="LLM evaluated relevance score 0-100%")
20
+ is_direct_competitor: Optional[bool] = Field(default=True, description="Whether repo directly addresses user idea concept")
21
+ relevance_reason: Optional[str] = Field(default=None, description="Brief explanation of relevance fit")
22
+ hybrid_score: Optional[float] = Field(default=0.0, description="Calculated hybrid ranking score")
23
+
24
+
25
+ class ExpandedQueries(BaseModel):
26
+ """Model for LLM generated search strategy."""
27
+ search_keywords: List[str] = Field(default_factory=list, description="Optimized search keywords for GitHub REST API")
28
+ github_topics: List[str] = Field(default_factory=list, description="Relevant GitHub topics/tags")
29
+ target_languages: List[str] = Field(default_factory=list, description="Suggested or target programming languages")
30
+ search_explanation: str = Field(default="Search strategy generated.", description="Explanation of the search strategy")
31
+
32
+
33
+ class CompetitorSummary(BaseModel):
34
+ """Model for a key competitor identified in gap analysis."""
35
+ repo_name: str = Field(default="Unknown Repository")
36
+ key_strengths: List[str] = Field(default_factory=list)
37
+ weaknesses_or_gaps: List[str] = Field(default_factory=list)
38
+
39
+
40
+ class OpenSourceTool(BaseModel):
41
+ """Model representing a recommended open-source library or tool to build the project."""
42
+ name: str = Field(default="Open-Source Tool", description="Name of the open-source tool or library (e.g. Typer, Qdrant, Tree-sitter)")
43
+ category: str = Field(default="General", description="Category (e.g. CLI Framework, Vector DB, LLM SDK, Parser)")
44
+ description_and_usage: str = Field(default="", description="How to leverage this tool in building the project idea")
45
+ repo_url: Optional[str] = Field(default=None, description="GitHub repository or URL link if applicable")
46
+
47
+
48
+ class ImplementationGuide(BaseModel):
49
+ """Model for architecture and technical implementation guidance."""
50
+ recommended_tech_stack: List[str] = Field(default_factory=list, description="Recommended technology stack (languages, frameworks, DBs)")
51
+ architecture_overview: str = Field(default="", description="High-level architecture and how the system should be structured")
52
+ open_source_building_blocks: List[OpenSourceTool] = Field(default_factory=list, description="Key open-source tools to use during development")
53
+
54
+
55
+ class GapAnalysisReport(BaseModel):
56
+ """Model for LLM generated Market and Gap Analysis Report."""
57
+ idea_summary: str = Field(default="Project Idea Summary", description="Summary of the analyzed project idea")
58
+ market_saturation: str = Field(default="Moderate", description="Market Saturation level: Low, Moderate, or High")
59
+ saturation_score: int = Field(default=50, description="Market saturation score from 1-100")
60
+ market_summary: str = Field(default="Market analysis completed.", description="Overview of the current market and ecosystem")
61
+ top_competitors: List[CompetitorSummary] = Field(default_factory=list, description="Top competitor repositories and analysis")
62
+ unmet_needs: List[str] = Field(default_factory=list, description="Unmet needs and open gaps identified in existing repos")
63
+ differentiators: List[str] = Field(default_factory=list, description="Key differentiators to make your project stand out")
64
+ actionable_recommendations: List[str] = Field(default_factory=list, description="Actionable recommendations for the developer")
65
+ opportunity_score: int = Field(default=80, description="Opportunity potential score from 1-100")
66
+ implementation_guide: Optional[ImplementationGuide] = Field(default=None, description="Architecture, tech stack, and open source building blocks")
67
+
@@ -0,0 +1,18 @@
1
+ from pathlib import Path
2
+ from jinja2 import Environment, FileSystemLoader
3
+
4
+ PROMPTS_DIR = Path(__file__).parent
5
+
6
+ env = Environment(
7
+ loader=FileSystemLoader(PROMPTS_DIR),
8
+ trim_blocks=True,
9
+ lstrip_blocks=True,
10
+ )
11
+
12
+
13
+ def render_prompt(template_name: str, **kwargs) -> str:
14
+ """Render a Jinja2 prompt template with the provided context variables."""
15
+ if not template_name.endswith(".j2"):
16
+ template_name += ".j2"
17
+ template = env.get_template(template_name)
18
+ return template.render(**kwargs)
@@ -0,0 +1,179 @@
1
+ import asyncio
2
+ from typing import List, Optional, Dict, Any
3
+ import httpx
4
+ from gitradar.config import settings
5
+ from gitradar.models import RepositoryInfo
6
+
7
+ GITHUB_API_BASE = "https://api.github.com"
8
+
9
+
10
+ class GitHubService:
11
+ """Asynchronous client for interacting with the GitHub REST API."""
12
+
13
+ def __init__(self, token: Optional[str] = None):
14
+ self.token = token or settings.github_token
15
+ self.headers = {
16
+ "Accept": "application/vnd.github.v3+json",
17
+ "User-Agent": "GitRadar-CLI/0.1.0",
18
+ }
19
+ if self.token:
20
+ self.headers["Authorization"] = f"Bearer {self.token}"
21
+
22
+ async def search_repositories(
23
+ self,
24
+ query: str,
25
+ limit: int = 10,
26
+ sort: str = "stars",
27
+ order: str = "desc"
28
+ ) -> List[RepositoryInfo]:
29
+ """Search GitHub repositories based on query string."""
30
+ url = f"{GITHUB_API_BASE}/search/repositories"
31
+ params = {
32
+ "q": query,
33
+ "sort": sort,
34
+ "order": order,
35
+ "per_page": min(limit, 100),
36
+ }
37
+
38
+ async with httpx.AsyncClient(timeout=15.0) as client:
39
+ response = await client.get(url, headers=self.headers, params=params)
40
+
41
+ if response.status_code == 403:
42
+ raise RuntimeError(
43
+ "GitHub API Rate Limit Exceeded. "
44
+ "Please configure a GITHUB_TOKEN (`gitradar config --github-token YOUR_TOKEN`)."
45
+ )
46
+ elif response.status_code != 200:
47
+ raise RuntimeError(f"GitHub API Error ({response.status_code}): {response.text}")
48
+
49
+ data = response.json()
50
+ items = data.get("items", [])
51
+
52
+ results: List[RepositoryInfo] = []
53
+ for item in items[:limit]:
54
+ repo = RepositoryInfo(
55
+ full_name=item.get("full_name", ""),
56
+ name=item.get("name", ""),
57
+ owner=item.get("owner", {}).get("login", ""),
58
+ html_url=item.get("html_url", ""),
59
+ description=item.get("description") or "No description provided",
60
+ stars=item.get("stargazers_count", 0),
61
+ forks=item.get("forks_count", 0),
62
+ language=item.get("language") or "Unspecified",
63
+ topics=item.get("topics", []),
64
+ updated_at=item.get("updated_at", "")[:10] if item.get("updated_at") else "",
65
+ open_issues=item.get("open_issues_count", 0),
66
+ )
67
+ results.append(repo)
68
+
69
+ return results
70
+
71
+ async def fetch_readme_snippet(self, owner: str, repo: str, max_chars: int = 800) -> Optional[str]:
72
+ """Fetch README content snippet for a repository."""
73
+ url = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/readme"
74
+ headers = {**self.headers, "Accept": "application/vnd.github.v3.raw"}
75
+
76
+ async with httpx.AsyncClient(timeout=10.0) as client:
77
+ try:
78
+ response = await client.get(url, headers=headers)
79
+ if response.status_code == 200:
80
+ text = response.text
81
+ cleaned = text.strip()
82
+ return cleaned[:max_chars] + "..." if len(cleaned) > max_chars else cleaned
83
+ except Exception:
84
+ pass
85
+ return None
86
+
87
+ async def search_and_enrich(
88
+ self,
89
+ keywords: List[str],
90
+ topics: List[str] = None,
91
+ limit: int = 10,
92
+ fetch_readmes: bool = True
93
+ ) -> List[RepositoryInfo]:
94
+ """
95
+ Execute smart combined search using keywords & topics, deduplicate results,
96
+ and enrich top results with README snippets asynchronously.
97
+ """
98
+ all_repos: Dict[str, RepositoryInfo] = {}
99
+
100
+ queries = []
101
+ if keywords:
102
+ for kw in keywords:
103
+ kw_clean = kw.strip()
104
+ if kw_clean and kw_clean not in queries:
105
+ queries.append(kw_clean)
106
+
107
+ if topics:
108
+ for topic in topics[:3]:
109
+ topic_clean = topic.strip().replace(" ", "-").lower()
110
+ if topic_clean and topic_clean not in ["python", "machine-learning", "deep-learning", "ai", "artificial-intelligence"]:
111
+ t_query = f"topic:{topic_clean}"
112
+ if t_query not in queries:
113
+ queries.append(t_query)
114
+
115
+ tasks = [self.search_repositories(q, limit=limit) for q in queries]
116
+ search_results = await asyncio.gather(*tasks, return_exceptions=True)
117
+
118
+ for res in search_results:
119
+ if isinstance(res, list):
120
+ for repo in res:
121
+ if repo.full_name not in all_repos:
122
+ all_repos[repo.full_name] = repo
123
+
124
+ sorted_repos = sorted(all_repos.values(), key=lambda r: (r.stars, r.forks, r.full_name), reverse=True)[:limit]
125
+
126
+ if fetch_readmes and sorted_repos:
127
+ readme_tasks = [
128
+ self.fetch_readme_snippet(repo.owner, repo.name)
129
+ for repo in sorted_repos[:5]
130
+ ]
131
+ readmes = await asyncio.gather(*readme_tasks, return_exceptions=True)
132
+
133
+ for i, readme in enumerate(readmes):
134
+ if isinstance(readme, str) and readme:
135
+ sorted_repos[i].readme_snippet = readme
136
+
137
+ return sorted_repos
138
+
139
+ def rank_and_sort_by_relevance(
140
+ self,
141
+ repos: List[RepositoryInfo],
142
+ limit: int = 10,
143
+ min_relevance: int = 50,
144
+ ) -> List[RepositoryInfo]:
145
+ """
146
+ Calculate hybrid score for each repository, filter out repos below min_relevance threshold,
147
+ and sort by hybrid score desc.
148
+ Hybrid Score = (Relevance Score * 0.7) + (Normalized Star Score * 0.3)
149
+ """
150
+ if not repos:
151
+ return repos
152
+
153
+ import math
154
+ max_stars = max((r.stars for r in repos), default=1)
155
+
156
+ for r in repos:
157
+ rel_score = r.relevance_score if r.relevance_score is not None else 50
158
+ star_score = min(100.0, (math.log10(r.stars + 1) / math.log10(max(max_stars, 10) + 1)) * 100.0) if max_stars > 0 else 50.0
159
+ r.hybrid_score = round((rel_score * 0.7) + (star_score * 0.3), 1)
160
+
161
+ # Enforce minimum relevance threshold filtering
162
+ filtered_repos = [
163
+ r for r in repos
164
+ if r.relevance_score is not None and r.relevance_score >= min_relevance
165
+ ]
166
+
167
+ if not filtered_repos:
168
+ fallback_threshold = max(30, min_relevance - 20)
169
+ filtered_repos = [
170
+ r for r in repos
171
+ if r.relevance_score is not None and r.relevance_score >= fallback_threshold
172
+ ]
173
+
174
+ sorted_repos = sorted(
175
+ filtered_repos if filtered_repos else repos,
176
+ key=lambda r: (r.hybrid_score, r.stars, r.forks, r.full_name),
177
+ reverse=True
178
+ )
179
+ return sorted_repos[:limit]