diff --git a/README.md b/README.md index 684e5bb..aca2445 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/backend/app/ai/chunker.py b/backend/app/ai/chunker.py index 27e1b34..5a0f53e 100644 --- a/backend/app/ai/chunker.py +++ b/backend/app/ai/chunker.py @@ -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: diff --git a/backend/app/ai/llm.py b/backend/app/ai/llm.py index a91a31d..a0aa19e 100644 --- a/backend/app/ai/llm.py +++ b/backend/app/ai/llm.py @@ -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", @@ -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 \ No newline at end of file diff --git a/backend/app/database/connection.py b/backend/app/database/connection.py index c1645a7..1211f58 100644 --- a/backend/app/database/connection.py +++ b/backend/app/database/connection.py @@ -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() @@ -114,7 +115,7 @@ def get_connection(): """ ) connection.commit() - return _PostgresConnection(connection) + return _PostgresConnection(connection, dict_row) except Exception: return _sqlite_connection() diff --git a/backend/app/main.py b/backend/app/main.py index 3ebfed8..41fa323 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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( @@ -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=["*"], diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index 57ad4f9..c3fe602 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -11,6 +11,7 @@ get_github_user, get_user_repositories ) +from app.config import FRONTEND_URL router = APIRouter( @@ -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="/" ) diff --git a/backend/app/routes/github.py b/backend/app/routes/github.py index b12619f..a33076d 100644 --- a/backend/app/routes/github.py +++ b/backend/app/routes/github.py @@ -73,11 +73,19 @@ async def analyze_repo( 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( + status_code=502, + detail=f"GitHub repository analysis failed: {error}" + ) from error return {**result, "cached": cached} diff --git a/backend/app/services/repo_analyzer.py b/backend/app/services/repo_analyzer.py index eecfbda..fddbdab 100644 --- a/backend/app/services/repo_analyzer.py +++ b/backend/app/services/repo_analyzer.py @@ -46,24 +46,30 @@ async def analyze_repository( 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 @@ -75,7 +81,7 @@ async def analyze_repository( access_token, owner, repo, - repository["default_branch"] + repository.get("default_branch") or "main" ) structure_analysis = analyze_structure(tree) @@ -100,13 +106,19 @@ async def fetch_source(path: str): 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 @@ -125,13 +137,13 @@ async def fetch_source(path: str): 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, diff --git a/backend/requirements.txt b/backend/requirements.txt index 0e918e3..de38789 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/backend/tests/test_llm_provider_fallback.py b/backend/tests/test_llm_provider_fallback.py new file mode 100644 index 0000000..6436ae5 --- /dev/null +++ b/backend/tests/test_llm_provider_fallback.py @@ -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() diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index ea36dd3..ea944c7 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -18,4 +18,10 @@ export default defineConfig([ parserOptions: { ecmaFeatures: { jsx: true } }, }, }, + { + files: ['src/**/*.test.js'], + languageOptions: { + globals: globals.node, + }, + }, ]) diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 288c553..73b54be 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -1,4 +1,4 @@ -const API_URL = "http://localhost:8000"; +const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000"; export async function signOut() { const response = await fetch(`${API_URL}/auth/logout`, { @@ -55,12 +55,29 @@ export async function getActivitySummary() { } export async function analyzeRepository(owner, repo) { - const response = await fetch( - `${API_URL}/github/repositories/${owner}/${repo}/analysis`, - { credentials: "include" } - ); + let response; + try { + response = await fetch( + `${API_URL}/github/repositories/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/analysis`, + { credentials: "include" } + ); + } catch { + throw new Error( + "Could not reach the backend. Make sure the API is running on port 8000." + ); + } + + if (!response.ok) { + let message = "Unable to analyze repository"; + try { + const errorData = await response.json(); + if (errorData?.detail) message = errorData.detail; + } catch { + // Keep the fallback when the server does not return JSON. + } + throw new Error(message); + } - if (!response.ok) throw new Error("Unable to analyze repository"); return response.json(); } @@ -75,14 +92,40 @@ export async function analyzeAllRepositories() { } export async function askAI(question, scope = {}) { - const response = await fetch(`${API_URL}/ai/chat`, { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ question, ...scope }) - }); + let response; + + try { + response = await fetch(`${API_URL}/ai/chat`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ question, ...scope }) + }); + } catch (error) { + throw new Error( + "Unable to reach the backend AI API. Make sure the API server is running on http://localhost:8000.", + { cause: error } + ); + } + + let data; + try { + data = await response.json(); + } catch { + data = undefined; + } + + if (!response.ok) { + if (response.status === 401) { + throw new Error("Your session has expired. Please log in again to use the AI assistant."); + } + + if (response.status === 403) { + throw new Error("AI access is not allowed for this session."); + } + + throw new Error(data?.detail || "AI request failed"); + } - const data = await response.json(); - if (!response.ok) throw new Error(data.detail || "AI request failed"); return data; } \ No newline at end of file diff --git a/frontend/src/services/api.test.js b/frontend/src/services/api.test.js new file mode 100644 index 0000000..57283c4 --- /dev/null +++ b/frontend/src/services/api.test.js @@ -0,0 +1,54 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +const originalFetch = global.fetch; + +const { askAI } = await import('./api.js'); + +test('askAI surfaces backend error details when the error body is not JSON', async () => { + global.fetch = async () => ({ + ok: false, + json: async () => { + throw new Error('Unexpected end of JSON input'); + }, + }); + + await assert.rejects( + askAI('hello'), + { message: 'AI request failed' } + ); + + global.fetch = originalFetch; +}); + +test('askAI returns a helpful error when the backend is unreachable', async () => { + global.fetch = async () => { + throw new TypeError('Failed to fetch'); + }; + + await assert.rejects( + askAI('hello'), + { message: /backend|configured|running/i } + ); + + global.fetch = originalFetch; +}); + +test('askAI turns a 401 into a login prompt instead of a network error', async () => { + global.fetch = async () => ({ + ok: false, + status: 401, + json: async () => ({ detail: 'Not authenticated' }), + }); + + await assert.rejects( + askAI('hello'), + { message: /log in again|session has expired/i } + ); + + global.fetch = originalFetch; +}); + +process.on('exit', () => { + global.fetch = originalFetch; +}); diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..456b26f --- /dev/null +++ b/render.yaml @@ -0,0 +1,25 @@ +services: + - type: web + name: github-ai-repo-analyzer-api + runtime: python + rootDir: backend + buildCommand: pip install -r requirements.txt + startCommand: uvicorn app.main:app --host 0.0.0.0 --port $PORT + healthCheckPath: / + envVars: + - key: GITHUB_CLIENT_ID + sync: false + - key: GITHUB_CLIENT_SECRET + sync: false + - key: GITHUB_REDIRECT_URI + sync: false + - key: FRONTEND_URL + sync: false + - key: DATABASE_URL + sync: false + - key: GROQ_API_KEY + sync: false + - key: MISTRAL_API_KEY + sync: false + - key: AI_PROVIDER + value: groq