Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
16 changes: 16 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Run RepoSense Extension",
"type": "extensionHost",
"request": "launch",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}/vscode-extension"
],
"outFiles": [
"${workspaceFolder}/vscode-extension/out/**/*.js"
]
}
]
}
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"reposense.backendUrl": "https://bits-and-bobs-deployment-1.onrender.com"
}
1 change: 1 addition & 0 deletions Procfile
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
web: pip install -r backend/requirements.txt && cd backend && gunicorn -k uvicorn.workers.UvicornWorker src.main:app --bind 0.0.0.0:$PORT --workers 1
26 changes: 26 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
FROM python:3.12-slim

WORKDIR /app

# Ensure git is available for repository cloning in production images
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates \
&& rm -rf /var/lib/apt/lists/*

# Copy requirements (Render's root is set to backend/)
COPY requirements.txt .

# Install dependencies
RUN pip install --no-cache-dir -r requirements.txt

# Copy the source
COPY src ./src
COPY .env* ./

# Expose port
EXPOSE 8000

# Run the app (Render sets PORT, defaults to 8000)
# Use a single worker because job state is stored in memory.
CMD ["sh", "-c", "gunicorn -k uvicorn.workers.UvicornWorker src.main:app --bind 0.0.0.0:${PORT:-8000} --workers 1"]
1 change: 1 addition & 0 deletions backend/Procfile
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
web: cd backend && gunicorn -k uvicorn.workers.UvicornWorker src.main:app --bind 0.0.0.0:$PORT --workers 1
8 changes: 8 additions & 0 deletions backend/render.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# This file should be moved to repo root or use web service dashboard
# services:
# - type: web
# name: reposense-backend
# root: backend
# env: python
# buildCommand: pip install -r requirements.txt
# startCommand: gunicorn -k uvicorn.workers.UvicornWorker src.main:app --bind 0.0.0.0:$PORT --workers 1
3 changes: 3 additions & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@ python-dotenv==1.2.2

# Async Support
asyncio==4.0.0

# Production WSGI server for Render
gunicorn==22.0.0
1 change: 1 addition & 0 deletions backend/runtime.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
python-3.12.0
25 changes: 23 additions & 2 deletions backend/src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class Settings(BaseSettings):
watsonx_api_key: str = ""
watsonx_project_id: str = ""
watsonx_url: str = "https://us-south.ml.cloud.ibm.com"
watsonx_model_id: str = "meta-llama/llama-3-3-70b-instruct"
watsonx_model_id: str = "ibm/granite-8b-code-instruct"

# WatsonX Orchestrate Configuration
orchestrate_api_key: str = ""
Expand Down Expand Up @@ -94,8 +94,10 @@ def validate_log_level(cls, v: str) -> str:
raise ValueError(f'log_level must be one of {valid_levels}')
return v_upper

# Only use .env file if it exists (for local dev), otherwise rely on OS env vars (for Render/production)
_env_file_path = Path(__file__).parent.parent / ".env"
model_config = SettingsConfigDict(
env_file=str(Path(__file__).parent.parent / ".env"),
env_file=str(_env_file_path) if _env_file_path.exists() else None,
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore"
Expand Down Expand Up @@ -133,11 +135,30 @@ def is_configured(self) -> bool:
return len(self.missing_fields()) == 0


# Initialize settings and log configuration status (for debugging)
import logging
logger = logging.getLogger(__name__)

# Global settings instance
# Note: This will fail at import time if required env vars are missing.
# For testing or tooling that needs to import without loading config,
# consider mocking or setting dummy env vars.
settings = Settings() # type: ignore[call-arg]
logger.info(f"Config loaded. Orchestrate API key present: {bool(settings.orchestrate_api_key)}")
logger.info(f"Missing fields: {settings.missing_fields()}")


def reload_settings() -> Settings:
"""
Reload configuration from environment and update the shared settings object in place.

This preserves object identity so modules that imported `settings` continue
to see fresh values after `/config/setup` updates.
"""
new_settings = Settings() # type: ignore[call-arg]
for key, value in new_settings.model_dump().items():
setattr(settings, key, value)
return settings


# Constants
Expand Down
107 changes: 102 additions & 5 deletions backend/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@

import asyncio
import os
import shutil
import subprocess
import tempfile
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Optional
Expand Down Expand Up @@ -46,6 +49,7 @@ class ConfigSetupRequest(BaseModel):
delete_job as delete_job_from_store
)
from .orchestrate import orchestrate_analysis
from fastapi.responses import JSONResponse


def validate_local_path(local_path: str) -> str:
Expand Down Expand Up @@ -109,6 +113,59 @@ def validate_local_path(local_path: str) -> str:
)


def validate_repo_url(repo_url: str) -> str:
"""Validate repository URL format for server-side clone analysis."""
normalized = (repo_url or "").strip()
if not normalized:
raise HTTPException(status_code=400, detail="repo_url cannot be empty")

valid_prefixes = ("https://", "http://", "git@")
if not normalized.startswith(valid_prefixes):
raise HTTPException(
status_code=400,
detail="repo_url must start with https://, http://, or git@"
)

return normalized


def clone_repo_to_temp(repo_url: str) -> tuple[str, str]:
"""Clone repository to a temporary directory and return (repo_path, temp_root)."""
temp_root = tempfile.mkdtemp(prefix="reposense-")
repo_path = str(Path(temp_root) / "repo")

try:
subprocess.run(
["git", "clone", "--depth", "1", repo_url, repo_path],
check=True,
capture_output=True,
text=True,
timeout=180,
)
except FileNotFoundError as e:
shutil.rmtree(temp_root, ignore_errors=True)
raise HTTPException(status_code=500, detail="git is not available on the backend server") from e
except subprocess.TimeoutExpired as e:
shutil.rmtree(temp_root, ignore_errors=True)
raise HTTPException(status_code=408, detail="Timed out while cloning repository") from e
except subprocess.CalledProcessError as e:
shutil.rmtree(temp_root, ignore_errors=True)
stderr = (e.stderr or "").strip()
message = stderr[:300] if stderr else "Unknown git clone error"
raise HTTPException(status_code=400, detail=f"Failed to clone repository: {message}") from e

return repo_path, temp_root
Comment on lines +116 to +157


async def run_analysis_with_optional_cleanup(job_id: str, local_path: str, temp_root: Optional[str] = None) -> None:
"""Run analysis and cleanup temporary clone directory when applicable."""
try:
await orchestrate_analysis(job_id=job_id, local_path=local_path)
finally:
if temp_root:
shutil.rmtree(temp_root, ignore_errors=True)


@asynccontextmanager
async def lifespan(app: FastAPI):
"""
Expand Down Expand Up @@ -171,8 +228,16 @@ async def analyze_codebase(
Raises:
HTTPException: If max concurrent jobs exceeded
"""
# Validate and sanitize the local path
validated_path = validate_local_path(request.local_path)
validated_path: Optional[str] = None
temp_clone_root: Optional[str] = None

if request.repo_url:
validated_repo_url = validate_repo_url(request.repo_url)
validated_path, temp_clone_root = clone_repo_to_temp(validated_repo_url)
elif request.local_path:
validated_path = validate_local_path(request.local_path)
else:
raise HTTPException(status_code=400, detail="Either local_path or repo_url is required")

# Check concurrent job limit
active_jobs = get_active_job_count()
Expand All @@ -192,9 +257,10 @@ async def analyze_codebase(

# Start background analysis with validated path
background_tasks.add_task(
orchestrate_analysis,
run_analysis_with_optional_cleanup,
job_id=job_id,
local_path=validated_path
local_path=validated_path,
temp_root=temp_clone_root,
)
Comment on lines 231 to 264

# Return immediate response with job_id and initial progress
Expand Down Expand Up @@ -292,6 +358,37 @@ async def config_status():
}


@app.get("/config/test-orchestrate")
async def config_test_orchestrate():
"""Perform an IAM token exchange using the configured ORCHESTRATE_API_KEY.

Returns the first bytes of the token on success, or a 400 with the error message.
"""
try:
from .orchestrate_client import get_orchestrate_client
client = get_orchestrate_client()
token = await client._get_iam_token()
return {"ok": True, "message": "IAM token obtained", "token_excerpt": (token[:16] + "...") if token else None}
except Exception as e:
return JSONResponse(status_code=400, content={"ok": False, "error": str(e)})


@app.get("/config/test-watsonx")
async def config_test_watsonx():
"""Perform a minimal WatsonX call to validate WatsonX credentials and project.

Returns an excerpt of the response on success, or a 400 with the error message.
"""
try:
from .watsonx import _call_watsonx
# Use a short prompt that should always succeed if credentials and project are valid
resp = await _call_watsonx("Say hello in one short sentence.")
excerpt = resp[:200] if isinstance(resp, str) else str(resp)
return {"ok": True, "message": "WatsonX responded", "response_excerpt": excerpt}
except Exception as e:
return JSONResponse(status_code=400, content={"ok": False, "error": str(e)})
Comment on lines +361 to +389


@app.post("/config/setup")
async def config_setup(body: ConfigSetupRequest):
"""
Expand Down Expand Up @@ -336,7 +433,7 @@ async def config_setup(body: ConfigSetupRequest):
encoding="utf-8"
)

config_module.settings = config_module.Settings()
config_module.reload_settings()

missing = config_module.settings.missing_fields()
return {
Expand Down
15 changes: 12 additions & 3 deletions backend/src/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from enum import Enum
from typing import Optional, Union
from pydantic import BaseModel
from pydantic import BaseModel, model_validator


# ============================================================================
Expand Down Expand Up @@ -87,8 +87,17 @@ class ErrorCode(str, Enum):
# ============================================================================

class AnalyzeRequest(BaseModel):
"""Request to analyze a local codebase"""
local_path: str
"""Request to analyze a local codebase path or remote repository URL."""
local_path: Optional[str] = None
repo_url: Optional[str] = None

@model_validator(mode="after")
def validate_source(self) -> "AnalyzeRequest":
has_local = bool(self.local_path and self.local_path.strip())
has_repo = bool(self.repo_url and self.repo_url.strip())
if not has_local and not has_repo:
raise ValueError("Either local_path or repo_url must be provided")
return self


# ============================================================================
Expand Down
28 changes: 23 additions & 5 deletions backend/src/orchestrate_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ def __init__(self):

async def _get_iam_token(self) -> str:
"""Exchange IBM Cloud API key for an IAM Bearer token, refreshing when near expiry."""
if not self.api_key or not self.api_key.strip():
raise ValueError(
"ORCHESTRATE_API_KEY is missing or empty. Open RepoSense setup and save your Orchestrate API key."
)
if self._iam_token and time.time() < self._iam_token_expiry - 60:
return self._iam_token
async with httpx.AsyncClient(timeout=30) as client:
Expand Down Expand Up @@ -260,15 +264,29 @@ async def call_hardener_agent(self, prompt: str) -> Dict[str, Any]:
_orchestrate_client: Optional[OrchestrateClient] = None


def _client_matches_settings(client: OrchestrateClient) -> bool:
"""Return True when the cached client reflects current runtime settings."""
return (
client.api_key == settings.orchestrate_api_key
and client.base_url == settings.orchestrate_url.rstrip('/')
and client.architect_agent_id == settings.orchestrate_agent_architect_id
and client.reviewer_agent_id == settings.orchestrate_agent_reviewer_id
and client.documenter_agent_id == settings.orchestrate_agent_documenter_id
and client.hardener_agent_id == settings.orchestrate_agent_hardener_id
and client.environment_id == settings.orchestrate_environment_id
and client.instance_id == settings.orchestrate_instance_id
and client.timeout == settings.orchestrate_timeout
)


def get_orchestrate_client() -> OrchestrateClient:
"""
Get or create the global Orchestrate client instance.

Returns:
OrchestrateClient instance
Get the Orchestrate client, recreating it if settings changed at runtime.

This avoids stale credentials after /config/setup updates.
"""
global _orchestrate_client
if _orchestrate_client is None:
if _orchestrate_client is None or not _client_matches_settings(_orchestrate_client):
_orchestrate_client = OrchestrateClient()
return _orchestrate_client

Expand Down
7 changes: 7 additions & 0 deletions render.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
services:
- type: web
name: reposense-backend
env: docker
root: backend
dockerfilePath: ./Dockerfile
envVars: []
16 changes: 16 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Core Framework
fastapi
uvicorn[standard]
pydantic
pydantic-settings
# IBM WatsonX AI
ibm-watsonx-ai

# HTTP Client
httpx

# Environment Variables
python-dotenv

# Async Support
asyncio
1 change: 1 addition & 0 deletions runtime.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
python-3.11.9
Loading