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.
- demo/api/index.py +14 -0
- demo/gitradar/__init__.py +22 -0
- demo/gitradar/cli.py +214 -0
- demo/gitradar/config.py +117 -0
- demo/gitradar/models.py +67 -0
- demo/gitradar/prompts/__init__.py +18 -0
- demo/gitradar/services/github.py +179 -0
- demo/gitradar/services/llm.py +342 -0
- demo/gitradar/utils/ui.py +196 -0
- demo/gitradar/web/__init__.py +7 -0
- demo/gitradar/web/app.py +173 -0
- examples/python_sdk_usage.py +48 -0
- gitradar/__init__.py +22 -0
- gitradar/cli.py +214 -0
- gitradar/config.py +117 -0
- gitradar/models.py +67 -0
- gitradar/prompts/__init__.py +18 -0
- gitradar/prompts/gap_analysis_system.j2 +36 -0
- gitradar/prompts/gap_analysis_user.j2 +21 -0
- gitradar/prompts/query_expansion_system.j2 +19 -0
- gitradar/prompts/query_expansion_user.j2 +1 -0
- gitradar/prompts/relevance_evaluation_system.j2 +22 -0
- gitradar/prompts/relevance_evaluation_user.j2 +14 -0
- gitradar/services/github.py +179 -0
- gitradar/services/llm.py +342 -0
- gitradar/utils/ui.py +196 -0
- gitradar/web/__init__.py +7 -0
- gitradar/web/app.py +173 -0
- gitradar/web/static/apple-touch-icon.png +0 -0
- gitradar/web/static/css/style.css +1430 -0
- gitradar/web/static/favicon-96x96.png +0 -0
- gitradar/web/static/favicon.ico +0 -0
- gitradar/web/static/favicon.svg +1 -0
- gitradar/web/static/js/app.js +1206 -0
- gitradar/web/static/site.webmanifest +21 -0
- gitradar/web/static/web-app-manifest-192x192.png +0 -0
- gitradar/web/static/web-app-manifest-512x512.png +0 -0
- gitradar/web/templates/index.html +521 -0
- gitradar-0.1.5.dist-info/METADATA +251 -0
- gitradar-0.1.5.dist-info/RECORD +51 -0
- gitradar-0.1.5.dist-info/WHEEL +5 -0
- gitradar-0.1.5.dist-info/entry_points.txt +2 -0
- gitradar-0.1.5.dist-info/licenses/LICENSE +21 -0
- gitradar-0.1.5.dist-info/top_level.txt +4 -0
- tests/conftest.py +27 -0
- tests/test_cli.py +29 -0
- tests/test_llm.py +163 -0
- tests/test_models.py +90 -0
- tests/test_prompts.py +58 -0
- tests/test_relevance.py +75 -0
- tests/test_web.py +81 -0
demo/gitradar/web/app.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import concurrent.futures
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from flask import Flask, render_template, request, jsonify, send_from_directory
|
|
5
|
+
from gitradar.config import settings
|
|
6
|
+
from gitradar.services.github import GitHubService
|
|
7
|
+
from gitradar.services.llm import LLMService
|
|
8
|
+
|
|
9
|
+
BASE_DIR = Path(__file__).parent
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def safe_run_async(coro):
|
|
13
|
+
"""Run an async coroutine safely, handling cases where an asyncio event loop is already running (e.g. Vercel / ASGI)."""
|
|
14
|
+
try:
|
|
15
|
+
loop = asyncio.get_running_loop()
|
|
16
|
+
except RuntimeError:
|
|
17
|
+
loop = None
|
|
18
|
+
|
|
19
|
+
if loop and loop.is_running():
|
|
20
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
|
21
|
+
return executor.submit(asyncio.run, coro).result()
|
|
22
|
+
else:
|
|
23
|
+
return asyncio.run(coro)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def create_app() -> Flask:
|
|
27
|
+
"""Create and configure the Flask web application instance."""
|
|
28
|
+
app = Flask(
|
|
29
|
+
__name__,
|
|
30
|
+
template_folder=str(BASE_DIR / "templates"),
|
|
31
|
+
static_folder=str(BASE_DIR / "static"),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
@app.route("/")
|
|
35
|
+
def index():
|
|
36
|
+
return render_template("index.html")
|
|
37
|
+
|
|
38
|
+
@app.route("/<path:filename>")
|
|
39
|
+
def serve_static_root(filename):
|
|
40
|
+
target = BASE_DIR / "static" / filename
|
|
41
|
+
if target.is_file():
|
|
42
|
+
return send_from_directory(BASE_DIR / "static", filename)
|
|
43
|
+
return jsonify({"error": "Not found"}), 404
|
|
44
|
+
|
|
45
|
+
@app.route("/api/config", methods=["GET"])
|
|
46
|
+
def get_config():
|
|
47
|
+
return jsonify({
|
|
48
|
+
"openai_configured": bool(settings.openai_api_key or settings.groq_api_key),
|
|
49
|
+
"groq_configured": bool(settings.groq_api_key),
|
|
50
|
+
"github_configured": bool(settings.github_token),
|
|
51
|
+
"default_model": settings.default_model,
|
|
52
|
+
"default_language": settings.default_language,
|
|
53
|
+
"max_repos_to_analyze": settings.max_repos_to_analyze,
|
|
54
|
+
"base_url": settings.openai_base_url,
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
@app.route("/api/analyze", methods=["POST"])
|
|
58
|
+
def analyze_idea():
|
|
59
|
+
data = request.get_json() or {}
|
|
60
|
+
idea = data.get("idea", "").strip()
|
|
61
|
+
limit = int(data.get("limit", 10))
|
|
62
|
+
model = data.get("model") or settings.default_model
|
|
63
|
+
language = data.get("language") or settings.default_language
|
|
64
|
+
|
|
65
|
+
def clean_key(v):
|
|
66
|
+
if not v:
|
|
67
|
+
return None
|
|
68
|
+
cleaned = str(v).replace("\r", "").replace("\n", "").replace("\t", "").strip().strip("'\"")
|
|
69
|
+
return cleaned if cleaned else None
|
|
70
|
+
|
|
71
|
+
# Extract user credentials and OpenAI-compatible endpoint from headers or JSON payload
|
|
72
|
+
api_key = clean_key(
|
|
73
|
+
request.headers.get("X-OpenAI-Api-Key")
|
|
74
|
+
or request.headers.get("X-Groq-Api-Key")
|
|
75
|
+
or data.get("openai_key")
|
|
76
|
+
or data.get("openaiKey")
|
|
77
|
+
or data.get("api_key")
|
|
78
|
+
or data.get("groq_key")
|
|
79
|
+
or data.get("groqKey")
|
|
80
|
+
or settings.openai_api_key
|
|
81
|
+
or settings.groq_api_key
|
|
82
|
+
)
|
|
83
|
+
base_url = clean_key(
|
|
84
|
+
request.headers.get("X-OpenAI-Base-Url")
|
|
85
|
+
or data.get("base_url")
|
|
86
|
+
or data.get("baseUrl")
|
|
87
|
+
or settings.openai_base_url
|
|
88
|
+
)
|
|
89
|
+
github_token = clean_key(
|
|
90
|
+
request.headers.get("X-Github-Token")
|
|
91
|
+
or data.get("github_token")
|
|
92
|
+
or data.get("githubToken")
|
|
93
|
+
or settings.github_token
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if not idea:
|
|
97
|
+
return jsonify({"error": "Project idea parameter is required."}), 400
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
llm_service = LLMService(api_key=api_key, base_url=base_url, model=model, language=language)
|
|
101
|
+
github_service = GitHubService(token=github_token)
|
|
102
|
+
|
|
103
|
+
# 1. Expand Queries
|
|
104
|
+
queries = llm_service.expand_idea_to_queries(idea, language=language)
|
|
105
|
+
|
|
106
|
+
# 2. Search GitHub Repositories
|
|
107
|
+
raw_repos = safe_run_async(
|
|
108
|
+
github_service.search_and_enrich(
|
|
109
|
+
keywords=queries.search_keywords,
|
|
110
|
+
topics=queries.github_topics,
|
|
111
|
+
limit=max(limit * 2, 15),
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
min_relevance = int(data.get("min_relevance") or data.get("minRelevance") or settings.min_relevance_threshold)
|
|
117
|
+
except (ValueError, TypeError):
|
|
118
|
+
min_relevance = settings.min_relevance_threshold
|
|
119
|
+
|
|
120
|
+
# 3. Relevance Evaluation & Hybrid Ranking
|
|
121
|
+
eval_repos = llm_service.evaluate_repository_relevance(idea, raw_repos, language=language)
|
|
122
|
+
repos = github_service.rank_and_sort_by_relevance(eval_repos, limit=limit, min_relevance=min_relevance)
|
|
123
|
+
|
|
124
|
+
# 4. Perform Gap Analysis
|
|
125
|
+
report = llm_service.analyze_market_and_gaps(idea, repos, language=language)
|
|
126
|
+
|
|
127
|
+
return jsonify({
|
|
128
|
+
"queries": queries.model_dump(),
|
|
129
|
+
"repositories": [r.model_dump() for r in repos],
|
|
130
|
+
"report": report.model_dump(),
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
except ValueError as ve:
|
|
134
|
+
return jsonify({"error": str(ve)}), 400
|
|
135
|
+
except Exception as e:
|
|
136
|
+
err_msg = str(e)
|
|
137
|
+
if any(k in err_msg.lower() for k in ["invalid_api_key", "invalid api key", "authentication", "unauthorized"]):
|
|
138
|
+
err_msg = "Invalid AI API Key! Please click the Settings Modal (⚙️) in the top-right corner and enter a valid API Key."
|
|
139
|
+
return jsonify({"error": err_msg}), 400
|
|
140
|
+
|
|
141
|
+
@app.route("/api/search", methods=["POST"])
|
|
142
|
+
def search_repos():
|
|
143
|
+
data = request.get_json() or {}
|
|
144
|
+
query = data.get("query", "").strip()
|
|
145
|
+
limit = int(data.get("limit", 10))
|
|
146
|
+
sort = data.get("sort", "stars")
|
|
147
|
+
|
|
148
|
+
def clean_key(v):
|
|
149
|
+
if not v:
|
|
150
|
+
return None
|
|
151
|
+
cleaned = str(v).replace("\r", "").replace("\n", "").replace("\t", "").strip().strip("'\"")
|
|
152
|
+
return cleaned if cleaned else None
|
|
153
|
+
|
|
154
|
+
github_token = clean_key(
|
|
155
|
+
request.headers.get("X-Github-Token")
|
|
156
|
+
or data.get("github_token")
|
|
157
|
+
or data.get("githubToken")
|
|
158
|
+
or settings.github_token
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
if not query:
|
|
162
|
+
return jsonify({"error": "Search query parameter is required."}), 400
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
github_service = GitHubService(token=github_token)
|
|
166
|
+
repos = safe_run_async(github_service.search_repositories(query, limit=limit, sort=sort))
|
|
167
|
+
return jsonify({
|
|
168
|
+
"repositories": [r.model_dump() for r in repos]
|
|
169
|
+
})
|
|
170
|
+
except Exception as e:
|
|
171
|
+
return jsonify({"error": str(e)}), 500
|
|
172
|
+
|
|
173
|
+
return app
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Example script demonstrating programmatic usage of GitRadar Python SDK services.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from gitradar.services.github import GitHubService
|
|
7
|
+
from gitradar.services.llm import LLMService
|
|
8
|
+
from gitradar.utils import ui
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def main():
|
|
12
|
+
idea = "AI powered automated documentation generator for Python repositories"
|
|
13
|
+
target_language = "Turkish"
|
|
14
|
+
|
|
15
|
+
print(f"📡 Analyzing Project Idea via GitRadar SDK: '{idea}' (Language: {target_language})\n")
|
|
16
|
+
|
|
17
|
+
# 1. Initialize Services with custom response language
|
|
18
|
+
github_service = GitHubService()
|
|
19
|
+
llm_service = LLMService(language=target_language)
|
|
20
|
+
|
|
21
|
+
# 2. Expand Idea into Search Strategy
|
|
22
|
+
print("1️⃣ Generating search queries with LLM...")
|
|
23
|
+
queries = llm_service.expand_idea_to_queries(idea, language=target_language)
|
|
24
|
+
print(f" Keywords: {queries.search_keywords}")
|
|
25
|
+
print(f" Topics: {queries.github_topics}\n")
|
|
26
|
+
|
|
27
|
+
# 3. Search & Enrich Repositories
|
|
28
|
+
print("2️⃣ Fetching GitHub repositories asynchronously...")
|
|
29
|
+
repos = await github_service.search_and_enrich(
|
|
30
|
+
keywords=queries.search_keywords,
|
|
31
|
+
topics=queries.github_topics,
|
|
32
|
+
limit=5,
|
|
33
|
+
)
|
|
34
|
+
print(f" Found {len(repos)} relevant repositories.\n")
|
|
35
|
+
|
|
36
|
+
# 4. Perform Gap Analysis & Implementation Guidance
|
|
37
|
+
print("3️⃣ Running Market & Gap Analysis...")
|
|
38
|
+
report = llm_service.analyze_market_and_gaps(idea, repos, language=target_language)
|
|
39
|
+
|
|
40
|
+
if report.implementation_guide:
|
|
41
|
+
print(f" Recommended Stack: {report.implementation_guide.recommended_tech_stack}")
|
|
42
|
+
|
|
43
|
+
# 5. Display with GitRadar UI
|
|
44
|
+
ui.display_gap_report(report)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
asyncio.run(main())
|
gitradar/__init__.py
ADDED
|
@@ -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.1.5"
|
|
21
|
+
__author__ = "GitRadar Developer"
|
|
22
|
+
|
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()
|
gitradar/config.py
ADDED
|
@@ -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()
|
gitradar/models.py
ADDED
|
@@ -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)
|