Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,30 @@ FastAPI's interactive documentation is available at:
- `http://localhost:8000/docs`
- `http://localhost:8000/redoc`

## Deploying the Backend to Render

The repository includes `render.yaml` for a Render Python web service. Create a new Blueprint from the repository, or configure a web service with:

- **Root directory:** `backend`
- **Build command:** `pip install -r requirements.txt`
- **Start command:** `uvicorn app.main:app --host 0.0.0.0 --port $PORT`
- **Health check path:** `/`

Set these environment variables in Render:

```env
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
GITHUB_REDIRECT_URI=https://your-backend.onrender.com/auth/github/callback
FRONTEND_URL=https://your-frontend-host.example
DATABASE_URL=your_postgres_connection_string
GROQ_API_KEY=your_groq_api_key
MISTRAL_API_KEY=your_mistral_api_key
AI_PROVIDER=groq
```

For a deployed frontend, set `VITE_API_URL` to the Render backend URL before building. Update the GitHub OAuth App homepage and callback URLs to use the deployed frontend and backend URLs.

## Frontend Setup

Open a second terminal from the repository root:
Expand Down
2 changes: 0 additions & 2 deletions backend/app/ai/chunker.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
from pathlib import PurePosixPath


SUPPORTED_EXTENSIONS = {"py", "js", "jsx", "ts", "tsx", "json", "md", "toml", "txt"}


def chunk_file(path: str, content: str, max_chars: int = 3500):
extension = PurePosixPath(path).suffix.lower().lstrip(".")
if extension not in SUPPORTED_EXTENSIONS:
Expand Down
28 changes: 17 additions & 11 deletions backend/app/ai/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,16 @@
from app.config import (
AI_MODEL,
AI_PROVIDER,
GROK_API_KEY,
GROQ_API_KEY,
MISTRAL_API_KEY
)


PROVIDERS = {
"grok": {
"url": "https://api.x.ai/v1/chat/completions",
"default_model": "grok-3-mini",
"key": GROK_API_KEY
},
"groq": {
"url": "https://api.groq.com/openai/v1/chat/completions",
"default_model": "openai/gpt-oss-120b",
"key": GROQ_API_KEY or (GROK_API_KEY if GROK_API_KEY.startswith("gsk_") else "")
"key": GROQ_API_KEY
},
"mistral": {
"url": "https://api.mistral.ai/v1/chat/completions",
Expand Down Expand Up @@ -84,17 +78,29 @@ async def _request_provider(messages: list[dict], provider_name: str):

async def ask_llm(messages: list[dict], provider: str | None = None):
provider_name = (provider or AI_PROVIDER).lower()

if provider_name == "grok" and GROK_API_KEY.startswith("gsk_"):
provider_name = "groq"

try:
return await _request_provider(messages, provider_name)
except AIProviderError as error:
except Exception as error:
error_text = str(error).lower()
if (
should_fallback_to_mistral = (
provider is None
and provider_name == "groq"
and ("context_length" in error_text or "rate_limit" in error_text)
and MISTRAL_API_KEY
):
and (
"context_length" in error_text
or "rate_limit" in error_text
or "unauthorized" in error_text
or "forbidden" in error_text
or "invalid" in error_text
or "authentication" in error_text
or "bad request" in error_text
or "not authorized" in error_text
)
)
if should_fallback_to_mistral:
return await _request_provider(messages, "mistral")
raise
11 changes: 6 additions & 5 deletions backend/app/database/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,18 @@ def __getattr__(self, name):


class _PostgresConnection:
def __init__(self, connection):
def __init__(self, connection, row_factory):
self._connection = connection
self._row_factory = row_factory

def execute(self, query, params=None):
cursor = self._connection.cursor(row_factory=None)
cursor.execute(query, params or ())
cursor = self._connection.cursor(row_factory=self._row_factory)
cursor.execute(query.replace("?", "%s"), params or ())
return _PostgresCursorProxy(cursor)

def executemany(self, query, params_seq):
with self._connection.cursor() as cursor:
cursor.executemany(query, params_seq)
cursor.executemany(query.replace("?", "%s"), params_seq)

def commit(self):
self._connection.commit()
Expand Down Expand Up @@ -114,7 +115,7 @@ def get_connection():
"""
)
connection.commit()
return _PostgresConnection(connection)
return _PostgresConnection(connection, dict_row)
except Exception:
return _sqlite_connection()

Expand Down
9 changes: 6 additions & 3 deletions backend/app/main.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from app.routes import auth
from app.routes import auth
from app.routes import github
from app.routes import ai

from app.config import FRONTEND_URL


app = FastAPI(
Expand All @@ -15,7 +14,11 @@

app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_origins=[
origin.strip()
for origin in (FRONTEND_URL or "http://localhost:5173").split(",")
if origin.strip()
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
Expand Down
5 changes: 3 additions & 2 deletions backend/app/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
get_github_user,
get_user_repositories
)
from app.config import FRONTEND_URL


router = APIRouter(
Expand Down Expand Up @@ -70,14 +71,14 @@ async def github_callback(
session = create_session(access_token)

response = RedirectResponse(
url="http://localhost:5173/dashboard"
url=f"{(FRONTEND_URL or 'http://localhost:5173').rstrip('/')}/dashboard"
)

response.set_cookie(
key="session",
value=session,
httponly=True,
secure=False, # True in production HTTPS
secure=(FRONTEND_URL or "").startswith("https://"),
samesite="lax",
path="/"
)
Expand Down
18 changes: 13 additions & 5 deletions backend/app/routes/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,19 @@

access_token = get_access_token(session)

result, cached = await analyze_repository_cached(
access_token,
owner,
repo
)
try:
result, cached = await analyze_repository_cached(
access_token,
owner,
repo
)
except HTTPException:
raise
except Exception as error:
raise HTTPException(

Check failure on line 85 in backend/app/routes/github.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Document this HTTPException with status code 502 in the "responses" parameter.

See more on https://sonarcloud.io/project/issues?id=kunal-yelgate_GithubAI2&issues=AaDHYfJjUDJ0UtnKRj2C&open=AaDHYfJjUDJ0UtnKRj2C&pullRequest=5
status_code=502,
detail=f"GitHub repository analysis failed: {error}"
) from error

return {**result, "cached": cached}

Expand Down
72 changes: 42 additions & 30 deletions backend/app/services/repo_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
from app.analyzers.architecture_analyzer import analyze_architecture


async def analyze_repository(

Check failure on line 36 in backend/app/services/repo_analyzer.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 21 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=kunal-yelgate_GithubAI2&issues=AaDHYfD5UDJ0UtnKRj2B&open=AaDHYfD5UDJ0UtnKRj2B&pullRequest=5
access_token: str,
owner: str,
repo: str
Expand All @@ -46,24 +46,30 @@
repo
)

# Languages
language_data = await get_repository_languages(
access_token,
owner,
repo
)

language_analysis = analyze_languages(
language_data
)
try:
language_data = await get_repository_languages(
access_token,
owner,
repo
)
language_analysis = analyze_languages(language_data)
except Exception:
language_analysis = {
"primary_language": None,
"languages": {},
"total_bytes": 0
}

# Commits
commits = await get_repository_commits(
access_token,
owner,
repo,
max_pages=10
)
try:
commits = await get_repository_commits(
access_token,
owner,
repo,
max_pages=10
)
except Exception:
commits = []

commit_analysis = analyze_commits(
commits
Expand All @@ -75,7 +81,7 @@
access_token,
owner,
repo,
repository["default_branch"]
repository.get("default_branch") or "main"
)

structure_analysis = analyze_structure(tree)
Expand All @@ -100,13 +106,19 @@
continue

if path.endswith("package.json"):
package_analysis = analyze_package_json(content)
dependencies[path] = package_analysis["dependencies"]
technologies.extend(package_analysis["technologies"])
try:
package_analysis = analyze_package_json(content)
dependencies[path] = package_analysis["dependencies"]
technologies.extend(package_analysis["technologies"])
except Exception:
pass
elif path.endswith("requirements.txt"):
requirements_analysis = analyze_requirements(content)
dependencies[path] = requirements_analysis["dependencies"]
technologies.extend(requirements_analysis["technologies"])
try:
requirements_analysis = analyze_requirements(content)
dependencies[path] = requirements_analysis["dependencies"]
technologies.extend(requirements_analysis["technologies"])
except Exception:
pass

if path.rsplit(".", 1)[-1].lower() in {"py", "js", "jsx", "ts", "tsx"}:
source_contents[path] = content
Expand All @@ -125,13 +137,13 @@

return {
"repository": {
"name": repository["name"],
"full_name": repository["full_name"],
"description": repository["description"],
"stars": repository["stargazers_count"],
"forks": repository["forks_count"],
"open_issues": repository["open_issues_count"],
"default_branch": repository["default_branch"]
"name": repository.get("name"),
"full_name": repository.get("full_name"),
"description": repository.get("description"),
"stars": repository.get("stargazers_count", 0),
"forks": repository.get("forks_count", 0),
"open_issues": repository.get("open_issues_count", 0),
"default_branch": repository.get("default_branch") or "main"
},

"languages": language_analysis,
Expand Down
1 change: 1 addition & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ idna==3.19
pydantic==2.13.5
pydantic_core==2.46.5
python-dotenv==1.2.3
itsdangerous==2.2.0
psycopg[binary]==3.2.7
starlette==1.6.0
typing-inspection==0.4.4
Expand Down
29 changes: 29 additions & 0 deletions backend/tests/test_llm_provider_fallback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import asyncio
import unittest
from unittest.mock import AsyncMock, patch

from app.ai.llm import AIProviderError, ask_llm


class AskLLMProviderFallbackTests(unittest.TestCase):
def test_falls_back_to_mistral_on_groq_auth_failure(self):
async def run_test():
with patch("app.ai.llm._request_provider", new_callable=AsyncMock) as mock_request, \
patch("app.ai.llm.MISTRAL_API_KEY", "mistral-key"), \
patch("app.ai.llm.GROQ_API_KEY", "gsk_test_key"), \
patch("app.ai.llm.AI_PROVIDER", "groq"):
mock_request.side_effect = [
AIProviderError("groq request failed: unauthorized"),
{"answer": "ok", "provider": "mistral", "model": "mistral-small-latest"},
]

result = await ask_llm([{"role": "user", "content": "hello"}])

self.assertEqual(result["provider"], "mistral")
self.assertEqual(mock_request.call_count, 2)

asyncio.run(run_test())


if __name__ == "__main__":
unittest.main()
6 changes: 6 additions & 0 deletions frontend/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,10 @@ export default defineConfig([
parserOptions: { ecmaFeatures: { jsx: true } },
},
},
{
files: ['src/**/*.test.js'],
languageOptions: {
globals: globals.node,
},
},
])
Loading
Loading