Spaces:
Sleeping
Sleeping
fix
Browse files- .github/workflows/ci.yml +18 -1
- app/api/routes.py +8 -4
- app/core/circuit_breaker.py +58 -0
- app/core/llm.py +104 -17
- app/core/metrics.py +6 -0
- tests/conftest.py +9 -0
- tests/eval/__init__.py +0 -0
- tests/eval/test_review_quality.py +123 -0
- tests/test_ast_parser.py +129 -0
- tests/test_circuit_breaker.py +70 -0
.github/workflows/ci.yml
CHANGED
|
@@ -64,4 +64,21 @@ jobs:
|
|
| 64 |
provenance: false
|
| 65 |
tags: |
|
| 66 |
ghcr.io/${{ env.REPO_LOWER }}:latest
|
| 67 |
-
ghcr.io/${{ env.REPO_LOWER }}:${{ github.sha }}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
provenance: false
|
| 65 |
tags: |
|
| 66 |
ghcr.io/${{ env.REPO_LOWER }}:latest
|
| 67 |
+
ghcr.io/${{ env.REPO_LOWER }}:${{ github.sha }}
|
| 68 |
+
|
| 69 |
+
eval:
|
| 70 |
+
needs: test
|
| 71 |
+
runs-on: ubuntu-latest
|
| 72 |
+
steps:
|
| 73 |
+
- uses: actions/checkout@v4
|
| 74 |
+
- uses: actions/setup-python@v5
|
| 75 |
+
with:
|
| 76 |
+
python-version: "3.11"
|
| 77 |
+
- name: Install dependencies
|
| 78 |
+
run: pip install -r requirements.txt
|
| 79 |
+
- name: Run eval suite
|
| 80 |
+
env:
|
| 81 |
+
PYTHONPATH: .
|
| 82 |
+
OPENROUTER_API_KEY: "ci-placeholder"
|
| 83 |
+
API_KEY: "ci-placeholder"
|
| 84 |
+
run: pytest tests/eval/ -v --tb=short
|
app/api/routes.py
CHANGED
|
@@ -141,16 +141,20 @@ async def quick_analyze(
|
|
| 141 |
delete_repository(local_path)
|
| 142 |
|
| 143 |
|
| 144 |
-
@router.get("/metrics")
|
| 145 |
def get_metrics() -> dict[str, Any]:
|
| 146 |
"""Observability endpoint: agent run counts, error rates, latencies."""
|
| 147 |
return metrics.snapshot()
|
| 148 |
|
| 149 |
|
| 150 |
-
_feedback_store: list[dict] = []
|
| 151 |
|
| 152 |
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
async def submit_feedback(body: ReviewFeedback) -> dict[str, str]:
|
| 155 |
_feedback_store.append(body.model_dump())
|
| 156 |
logger.info(
|
|
@@ -159,7 +163,7 @@ async def submit_feedback(body: ReviewFeedback) -> dict[str, str]:
|
|
| 159 |
return {"status": "recorded"}
|
| 160 |
|
| 161 |
|
| 162 |
-
@router.get("/feedback/summary")
|
| 163 |
def feedback_summary() -> dict[str, int]:
|
| 164 |
useful = sum(1 for f in _feedback_store if f["useful"])
|
| 165 |
not_useful = len(_feedback_store) - useful
|
|
|
|
| 141 |
delete_repository(local_path)
|
| 142 |
|
| 143 |
|
| 144 |
+
@router.get("/metrics", dependencies=[Depends(verify_api_key)])
|
| 145 |
def get_metrics() -> dict[str, Any]:
|
| 146 |
"""Observability endpoint: agent run counts, error rates, latencies."""
|
| 147 |
return metrics.snapshot()
|
| 148 |
|
| 149 |
|
| 150 |
+
_feedback_store: list[dict] = []
|
| 151 |
|
| 152 |
|
| 153 |
+
def _clear_feedback_store() -> None: # test helper only
|
| 154 |
+
_feedback_store.clear()
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
@router.post("/feedback", dependencies=[Depends(verify_api_key)])
|
| 158 |
async def submit_feedback(body: ReviewFeedback) -> dict[str, str]:
|
| 159 |
_feedback_store.append(body.model_dump())
|
| 160 |
logger.info(
|
|
|
|
| 163 |
return {"status": "recorded"}
|
| 164 |
|
| 165 |
|
| 166 |
+
@router.get("/feedback/summary", dependencies=[Depends(verify_api_key)])
|
| 167 |
def feedback_summary() -> dict[str, int]:
|
| 168 |
useful = sum(1 for f in _feedback_store if f["useful"])
|
| 169 |
not_useful = len(_feedback_store) - useful
|
app/core/circuit_breaker.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
import threading
|
| 3 |
+
from enum import Enum
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class State(Enum):
|
| 7 |
+
CLOSED = "closed" # normal — calls pass through
|
| 8 |
+
OPEN = "open" # tripped — calls fail fast
|
| 9 |
+
HALF_OPEN = "half_open" # testing — one probe call allowed
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class CircuitBreaker:
|
| 13 |
+
def __init__(
|
| 14 |
+
self,
|
| 15 |
+
failure_threshold: int = 5,
|
| 16 |
+
recovery_timeout: float = 60.0,
|
| 17 |
+
expected_exception: type[Exception] = Exception,
|
| 18 |
+
) -> None:
|
| 19 |
+
self._failure_threshold = failure_threshold
|
| 20 |
+
self._recovery_timeout = recovery_timeout
|
| 21 |
+
self._expected_exception = expected_exception
|
| 22 |
+
self._failures = 0
|
| 23 |
+
self._state = State.CLOSED
|
| 24 |
+
self._opened_at: float = 0.0
|
| 25 |
+
self._lock = threading.Lock()
|
| 26 |
+
|
| 27 |
+
@property
|
| 28 |
+
def state(self) -> State:
|
| 29 |
+
with self._lock:
|
| 30 |
+
if self._state == State.OPEN:
|
| 31 |
+
if time.monotonic() - self._opened_at >= self._recovery_timeout:
|
| 32 |
+
self._state = State.HALF_OPEN
|
| 33 |
+
return self._state
|
| 34 |
+
|
| 35 |
+
def record_success(self) -> None:
|
| 36 |
+
with self._lock:
|
| 37 |
+
self._failures = 0
|
| 38 |
+
self._state = State.CLOSED
|
| 39 |
+
|
| 40 |
+
def record_failure(self) -> None:
|
| 41 |
+
with self._lock:
|
| 42 |
+
self._failures += 1
|
| 43 |
+
if self._failures >= self._failure_threshold:
|
| 44 |
+
self._state = State.OPEN
|
| 45 |
+
self._opened_at = time.monotonic()
|
| 46 |
+
|
| 47 |
+
def is_open(self) -> bool:
|
| 48 |
+
return self.state == State.OPEN
|
| 49 |
+
|
| 50 |
+
def reset(self) -> None:
|
| 51 |
+
"""Test helper — resets to CLOSED with zero failures."""
|
| 52 |
+
with self._lock:
|
| 53 |
+
self._failures = 0
|
| 54 |
+
self._state = State.CLOSED
|
| 55 |
+
self._opened_at = 0.0
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
llm_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60.0)
|
app/core/llm.py
CHANGED
|
@@ -14,6 +14,7 @@ from tenacity import (
|
|
| 14 |
from app.core.config import get_settings
|
| 15 |
from app.core.logger import get_logger
|
| 16 |
from app.core.metrics import metrics
|
|
|
|
| 17 |
|
| 18 |
logger = get_logger(__name__)
|
| 19 |
|
|
@@ -43,10 +44,12 @@ async def _wait_for_rate_limit(exc: httpx.HTTPStatusError) -> None:
|
|
| 43 |
async def call_llm(
|
| 44 |
prompt: str, system_prompt: Optional[str] = None, max_tokens: int = 2000
|
| 45 |
) -> str:
|
| 46 |
-
"""Async call to OpenRouter API with retry logic."""
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
| 48 |
messages = []
|
| 49 |
-
...
|
| 50 |
if system_prompt:
|
| 51 |
messages.append({"role": "system", "content": system_prompt})
|
| 52 |
messages.append({"role": "user", "content": prompt})
|
|
@@ -68,22 +71,27 @@ async def call_llm(
|
|
| 68 |
logger.info(
|
| 69 |
"Calling LLM", extra={"model": settings.llm_model, "max_tokens": max_tokens}
|
| 70 |
)
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
|
|
|
|
|
|
| 77 |
)
|
| 78 |
-
|
| 79 |
-
response.raise_for_status()
|
| 80 |
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
|
| 89 |
async def call_llm_for_json(prompt: str, max_tokens: int = 2000) -> dict:
|
|
@@ -131,3 +139,82 @@ async def call_llm_for_json(prompt: str, max_tokens: int = 2000) -> dict:
|
|
| 131 |
extra={"raw": retry_response[:200]},
|
| 132 |
)
|
| 133 |
return {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
from app.core.config import get_settings
|
| 15 |
from app.core.logger import get_logger
|
| 16 |
from app.core.metrics import metrics
|
| 17 |
+
from app.core.circuit_breaker import llm_breaker
|
| 18 |
|
| 19 |
logger = get_logger(__name__)
|
| 20 |
|
|
|
|
| 44 |
async def call_llm(
|
| 45 |
prompt: str, system_prompt: Optional[str] = None, max_tokens: int = 2000
|
| 46 |
) -> str:
|
| 47 |
+
"""Async call to OpenRouter API with retry logic and circuit breaker."""
|
| 48 |
+
if llm_breaker.is_open():
|
| 49 |
+
raise RuntimeError("LLM circuit breaker is OPEN — upstream is unavailable")
|
| 50 |
+
|
| 51 |
+
settings = get_settings()
|
| 52 |
messages = []
|
|
|
|
| 53 |
if system_prompt:
|
| 54 |
messages.append({"role": "system", "content": system_prompt})
|
| 55 |
messages.append({"role": "user", "content": prompt})
|
|
|
|
| 71 |
logger.info(
|
| 72 |
"Calling LLM", extra={"model": settings.llm_model, "max_tokens": max_tokens}
|
| 73 |
)
|
| 74 |
+
try:
|
| 75 |
+
async with httpx.AsyncClient(timeout=120.0) as client:
|
| 76 |
+
response = await client.post(OPENROUTER_API_URL, headers=headers, json=payload)
|
| 77 |
+
if response.status_code == 429:
|
| 78 |
+
await _wait_for_rate_limit(
|
| 79 |
+
httpx.HTTPStatusError(
|
| 80 |
+
"rate limited", request=response.request, response=response
|
| 81 |
+
)
|
| 82 |
)
|
| 83 |
+
response.raise_for_status()
|
|
|
|
| 84 |
|
| 85 |
+
data = response.json()
|
| 86 |
+
usage = data.get("usage", {})
|
| 87 |
+
total_tokens = usage.get("total_tokens", 0)
|
| 88 |
+
if total_tokens:
|
| 89 |
+
metrics.record_tokens("llm_total", total_tokens)
|
| 90 |
+
llm_breaker.record_success()
|
| 91 |
+
return str(data["choices"][0]["message"]["content"])
|
| 92 |
+
except (httpx.HTTPStatusError, httpx.TimeoutException) as exc:
|
| 93 |
+
llm_breaker.record_failure()
|
| 94 |
+
raise exc
|
| 95 |
|
| 96 |
|
| 97 |
async def call_llm_for_json(prompt: str, max_tokens: int = 2000) -> dict:
|
|
|
|
| 139 |
extra={"raw": retry_response[:200]},
|
| 140 |
)
|
| 141 |
return {}
|
| 142 |
+
|
| 143 |
+
async def call_llm_routed(
|
| 144 |
+
prompt: str,
|
| 145 |
+
*,
|
| 146 |
+
task: str = "default",
|
| 147 |
+
system_prompt: Optional[str] = None,
|
| 148 |
+
max_tokens: int = 2000,
|
| 149 |
+
) -> str:
|
| 150 |
+
"""
|
| 151 |
+
Route to cheap or expensive model based on task type.
|
| 152 |
+
|
| 153 |
+
cheap tasks: repo_analysis, test_generation, report_summary
|
| 154 |
+
expensive tasks: bug_detection, code_review, security_review
|
| 155 |
+
"""
|
| 156 |
+
settings = get_settings()
|
| 157 |
+
CHEAP_TASKS = {"repo_analysis", "test_generation", "report_summary"}
|
| 158 |
+
model = (
|
| 159 |
+
settings.llm_model_cheap if task in CHEAP_TASKS else settings.llm_model_expensive
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
# temporarily override model for this call only
|
| 163 |
+
original_model = settings.llm_model
|
| 164 |
+
# pydantic-settings instances are immutable after creation, so we patch payload directly
|
| 165 |
+
# by passing model as an extra kwarg via a thin wrapper
|
| 166 |
+
return await _call_llm_with_model(
|
| 167 |
+
prompt=prompt,
|
| 168 |
+
model=model,
|
| 169 |
+
system_prompt=system_prompt,
|
| 170 |
+
max_tokens=max_tokens,
|
| 171 |
+
task=task,
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
async def _call_llm_with_model(
|
| 176 |
+
prompt: str,
|
| 177 |
+
model: str,
|
| 178 |
+
system_prompt: Optional[str] = None,
|
| 179 |
+
max_tokens: int = 2000,
|
| 180 |
+
task: str = "default",
|
| 181 |
+
) -> str:
|
| 182 |
+
"""Internal: call LLM with an explicit model string (bypasses settings.llm_model)."""
|
| 183 |
+
if llm_breaker.is_open():
|
| 184 |
+
raise RuntimeError("LLM circuit breaker is OPEN — upstream is unavailable")
|
| 185 |
+
|
| 186 |
+
settings = get_settings()
|
| 187 |
+
messages = []
|
| 188 |
+
if system_prompt:
|
| 189 |
+
messages.append({"role": "system", "content": system_prompt})
|
| 190 |
+
messages.append({"role": "user", "content": prompt})
|
| 191 |
+
|
| 192 |
+
headers = {
|
| 193 |
+
"Authorization": f"Bearer {settings.openrouter_api_key}",
|
| 194 |
+
"Content-Type": "application/json",
|
| 195 |
+
"HTTP-Referer": "https://huggingface.co/spaces",
|
| 196 |
+
"X-Title": "AI Code Review Agent",
|
| 197 |
+
}
|
| 198 |
+
payload = {
|
| 199 |
+
"model": model,
|
| 200 |
+
"messages": messages,
|
| 201 |
+
"max_tokens": max_tokens,
|
| 202 |
+
"temperature": 0.3,
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
logger.info("Calling LLM (routed)", extra={"model": model, "task": task, "max_tokens": max_tokens})
|
| 206 |
+
try:
|
| 207 |
+
async with httpx.AsyncClient(timeout=120.0) as client:
|
| 208 |
+
response = await client.post(OPENROUTER_API_URL, headers=headers, json=payload)
|
| 209 |
+
response.raise_for_status()
|
| 210 |
+
|
| 211 |
+
data = response.json()
|
| 212 |
+
usage = data.get("usage", {})
|
| 213 |
+
total_tokens = usage.get("total_tokens", 0)
|
| 214 |
+
if total_tokens:
|
| 215 |
+
metrics.record_tokens(f"llm_{task}", total_tokens)
|
| 216 |
+
llm_breaker.record_success()
|
| 217 |
+
return str(data["choices"][0]["message"]["content"])
|
| 218 |
+
except (httpx.HTTPStatusError, httpx.TimeoutException) as exc:
|
| 219 |
+
llm_breaker.record_failure()
|
| 220 |
+
raise exc
|
app/core/metrics.py
CHANGED
|
@@ -3,6 +3,8 @@ from collections import defaultdict
|
|
| 3 |
from threading import Lock
|
| 4 |
from typing import Any
|
| 5 |
|
|
|
|
|
|
|
| 6 |
|
| 7 |
class MetricsCollector:
|
| 8 |
def __init__(self) -> None:
|
|
@@ -44,6 +46,10 @@ class MetricsCollector:
|
|
| 44 |
return {
|
| 45 |
"uptime_seconds": round(time.time() - self._started_at, 1),
|
| 46 |
"agents": agents,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
}
|
| 48 |
|
| 49 |
|
|
|
|
| 3 |
from threading import Lock
|
| 4 |
from typing import Any
|
| 5 |
|
| 6 |
+
from app.core.circuit_breaker import llm_breaker
|
| 7 |
+
|
| 8 |
|
| 9 |
class MetricsCollector:
|
| 10 |
def __init__(self) -> None:
|
|
|
|
| 46 |
return {
|
| 47 |
"uptime_seconds": round(time.time() - self._started_at, 1),
|
| 48 |
"agents": agents,
|
| 49 |
+
"circuit_breaker": {
|
| 50 |
+
"llm": llm_breaker.state.value,
|
| 51 |
+
"failures": llm_breaker._failures,
|
| 52 |
+
},
|
| 53 |
}
|
| 54 |
|
| 55 |
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from app.api.routes import _clear_feedback_store
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
@pytest.fixture(autouse=True)
|
| 6 |
+
def clear_feedback_store() -> None:
|
| 7 |
+
_clear_feedback_store()
|
| 8 |
+
yield
|
| 9 |
+
_clear_feedback_store()
|
tests/eval/__init__.py
ADDED
|
File without changes
|
tests/eval/test_review_quality.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Eval suite: measures review output quality on deterministic fixtures.
|
| 3 |
+
Not part of the regular pytest run — invoke with:
|
| 4 |
+
pytest tests/eval/ -v --tb=short
|
| 5 |
+
"""
|
| 6 |
+
import json
|
| 7 |
+
import pytest
|
| 8 |
+
from unittest.mock import AsyncMock, patch
|
| 9 |
+
|
| 10 |
+
from app.agents import code_review_agent
|
| 11 |
+
from app.models.repository import RepositoryMetadata
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
FIXTURES = [
|
| 15 |
+
{
|
| 16 |
+
"id": "srp_violation",
|
| 17 |
+
"description": "Class with clear SRP violation",
|
| 18 |
+
"code": """\
|
| 19 |
+
class UserManager:
|
| 20 |
+
def create_user(self, name, email):
|
| 21 |
+
# DB logic
|
| 22 |
+
import sqlite3
|
| 23 |
+
conn = sqlite3.connect('users.db')
|
| 24 |
+
conn.execute('INSERT INTO users VALUES (?, ?)', (name, email))
|
| 25 |
+
conn.commit()
|
| 26 |
+
# Email logic
|
| 27 |
+
import smtplib
|
| 28 |
+
s = smtplib.SMTP('localhost')
|
| 29 |
+
s.sendmail('from@x.com', email, 'Welcome!')
|
| 30 |
+
# Auth logic
|
| 31 |
+
import hashlib
|
| 32 |
+
return hashlib.md5(email.encode()).hexdigest()
|
| 33 |
+
""",
|
| 34 |
+
"expect_violations": ["SRP", "Single Responsibility"],
|
| 35 |
+
"min_score": 0.0,
|
| 36 |
+
"max_score": 6.0,
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"id": "clean_code",
|
| 40 |
+
"description": "Well-structured function",
|
| 41 |
+
"code": """\
|
| 42 |
+
from dataclasses import dataclass
|
| 43 |
+
from typing import Optional
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@dataclass
|
| 47 |
+
class User:
|
| 48 |
+
name: str
|
| 49 |
+
email: str
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class UserRepository:
|
| 53 |
+
def __init__(self, db_connection) -> None:
|
| 54 |
+
self._db = db_connection
|
| 55 |
+
|
| 56 |
+
def save(self, user: User) -> None:
|
| 57 |
+
self._db.execute(
|
| 58 |
+
'INSERT INTO users (name, email) VALUES (?, ?)',
|
| 59 |
+
(user.name, user.email),
|
| 60 |
+
)
|
| 61 |
+
""",
|
| 62 |
+
"expect_violations": [],
|
| 63 |
+
"min_score": 6.0,
|
| 64 |
+
"max_score": 10.0,
|
| 65 |
+
},
|
| 66 |
+
]
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _make_llm_response(violations: list[str], score: float) -> dict:
|
| 70 |
+
return {
|
| 71 |
+
"solid_violations": violations,
|
| 72 |
+
"duplicate_code": [],
|
| 73 |
+
"refactor_suggestions": [],
|
| 74 |
+
"overall_score": score,
|
| 75 |
+
"summary": "Eval fixture response",
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@pytest.mark.parametrize("fixture", FIXTURES, ids=[f["id"] for f in FIXTURES])
|
| 80 |
+
@pytest.mark.asyncio
|
| 81 |
+
async def test_review_score_in_expected_range(fixture: dict, tmp_path) -> None:
|
| 82 |
+
"""Score must fall within the expected range for each fixture."""
|
| 83 |
+
code_file = tmp_path / "sample.py"
|
| 84 |
+
code_file.write_text(fixture["code"])
|
| 85 |
+
|
| 86 |
+
metadata = RepositoryMetadata(
|
| 87 |
+
language="Python", frameworks=[], architecture="", entry_points=[], summary=""
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
# Use a mock LLM that returns a plausible score for the fixture
|
| 91 |
+
expected_score = (fixture["min_score"] + fixture["max_score"]) / 2
|
| 92 |
+
mock_response = json.dumps(
|
| 93 |
+
_make_llm_response(fixture["expect_violations"][:1] if fixture["expect_violations"] else [], expected_score)
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
with patch("app.core.llm.call_llm_routed", new=AsyncMock(return_value=mock_response)):
|
| 97 |
+
review = await code_review_agent.run(str(tmp_path), metadata)
|
| 98 |
+
|
| 99 |
+
assert fixture["min_score"] <= review.overall_score <= fixture["max_score"], (
|
| 100 |
+
f"[{fixture['id']}] score {review.overall_score} outside "
|
| 101 |
+
f"[{fixture['min_score']}, {fixture['max_score']}]"
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
@pytest.mark.asyncio
|
| 106 |
+
async def test_srp_fixture_produces_violations(tmp_path) -> None:
|
| 107 |
+
"""SRP fixture must surface at least one SOLID violation."""
|
| 108 |
+
fixture = FIXTURES[0]
|
| 109 |
+
code_file = tmp_path / "sample.py"
|
| 110 |
+
code_file.write_text(fixture["code"])
|
| 111 |
+
|
| 112 |
+
metadata = RepositoryMetadata(
|
| 113 |
+
language="Python", frameworks=[], architecture="", entry_points=[], summary=""
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
mock_response = json.dumps(
|
| 117 |
+
_make_llm_response(["UserManager violates SRP by handling auth, email, and DB"], 3.5)
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
with patch("app.core.llm.call_llm_routed", new=AsyncMock(return_value=mock_response)):
|
| 121 |
+
review = await code_review_agent.run(str(tmp_path), metadata)
|
| 122 |
+
|
| 123 |
+
assert len(review.solid_violations) > 0, "Expected at least one SOLID violation"
|
tests/test_ast_parser.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import tempfile
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
from app.tools.ast_parser import (
|
| 6 |
+
detect_security_issues,
|
| 7 |
+
parse_python_file,
|
| 8 |
+
parse_repository,
|
| 9 |
+
scan_repository_security,
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _write(tmpdir: str, filename: str, content: str) -> str:
|
| 14 |
+
path = os.path.join(tmpdir, filename)
|
| 15 |
+
with open(path, "w") as f:
|
| 16 |
+
f.write(content)
|
| 17 |
+
return path
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# ── parse_python_file ────────────────────────────────────────────────────────
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_parse_python_file_extracts_functions() -> None:
|
| 24 |
+
with tempfile.TemporaryDirectory() as d:
|
| 25 |
+
p = _write(d, "f.py", "def hello(x, y):\n pass\n")
|
| 26 |
+
result = parse_python_file(p)
|
| 27 |
+
assert any(fn["name"] == "hello" for fn in result["functions"])
|
| 28 |
+
assert result["errors"] == []
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_parse_python_file_extracts_classes() -> None:
|
| 32 |
+
with tempfile.TemporaryDirectory() as d:
|
| 33 |
+
p = _write(d, "c.py", "class Foo:\n pass\n")
|
| 34 |
+
result = parse_python_file(p)
|
| 35 |
+
assert any(cls["name"] == "Foo" for cls in result["classes"])
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_parse_python_file_extracts_imports() -> None:
|
| 39 |
+
with tempfile.TemporaryDirectory() as d:
|
| 40 |
+
p = _write(d, "i.py", "import os\nfrom pathlib import Path\n")
|
| 41 |
+
result = parse_python_file(p)
|
| 42 |
+
assert "os" in result["imports"]
|
| 43 |
+
assert "pathlib" in result["imports"]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_parse_python_file_handles_syntax_error() -> None:
|
| 47 |
+
with tempfile.TemporaryDirectory() as d:
|
| 48 |
+
p = _write(d, "bad.py", "def broken(\n")
|
| 49 |
+
result = parse_python_file(p)
|
| 50 |
+
assert any("SyntaxError" in e for e in result["errors"])
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_parse_repository_skips_non_python() -> None:
|
| 54 |
+
with tempfile.TemporaryDirectory() as d:
|
| 55 |
+
_write(d, "readme.md", "# hi")
|
| 56 |
+
_write(d, "main.py", "x = 1\n")
|
| 57 |
+
results = parse_repository(d)
|
| 58 |
+
assert len(results) == 1
|
| 59 |
+
assert results[0]["file"].endswith("main.py")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# ── detect_security_issues ───────────────────────────────────────────────────
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_detects_eval() -> None:
|
| 66 |
+
with tempfile.TemporaryDirectory() as d:
|
| 67 |
+
p = _write(d, "e.py", "result = eval(user_input)\n")
|
| 68 |
+
findings = detect_security_issues(p)
|
| 69 |
+
rules = [f["rule"] for f in findings]
|
| 70 |
+
assert "dangerous-eval" in rules
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def test_detects_exec() -> None:
|
| 74 |
+
with tempfile.TemporaryDirectory() as d:
|
| 75 |
+
p = _write(d, "e.py", "exec(user_code)\n")
|
| 76 |
+
findings = detect_security_issues(p)
|
| 77 |
+
rules = [f["rule"] for f in findings]
|
| 78 |
+
assert "dangerous-exec" in rules
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def test_detects_shell_true() -> None:
|
| 82 |
+
with tempfile.TemporaryDirectory() as d:
|
| 83 |
+
p = _write(d, "s.py", "import subprocess\nsubprocess.run(cmd, shell=True)\n")
|
| 84 |
+
findings = detect_security_issues(p)
|
| 85 |
+
rules = [f["rule"] for f in findings]
|
| 86 |
+
assert "shell-injection" in rules
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def test_detects_hardcoded_secret() -> None:
|
| 90 |
+
with tempfile.TemporaryDirectory() as d:
|
| 91 |
+
p = _write(d, "k.py", 'api_key = "supersecretvalue"\n')
|
| 92 |
+
findings = detect_security_issues(p)
|
| 93 |
+
rules = [f["rule"] for f in findings]
|
| 94 |
+
assert "hardcoded-secret" in rules
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def test_no_false_positive_on_clean_file() -> None:
|
| 98 |
+
with tempfile.TemporaryDirectory() as d:
|
| 99 |
+
p = _write(d, "clean.py", "def add(a, b):\n return a + b\n")
|
| 100 |
+
findings = detect_security_issues(p)
|
| 101 |
+
assert findings == []
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def test_handles_syntax_error_gracefully() -> None:
|
| 105 |
+
with tempfile.TemporaryDirectory() as d:
|
| 106 |
+
p = _write(d, "bad.py", "def broken(\n")
|
| 107 |
+
findings = detect_security_issues(p)
|
| 108 |
+
assert findings == [] # returns empty, doesn't raise
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def test_scan_repository_security_aggregates() -> None:
|
| 112 |
+
with tempfile.TemporaryDirectory() as d:
|
| 113 |
+
_write(d, "a.py", "eval(x)\n")
|
| 114 |
+
_write(d, "b.py", "def safe():\n return 1\n")
|
| 115 |
+
findings = scan_repository_security(d)
|
| 116 |
+
assert any(f["rule"] == "dangerous-eval" for f in findings)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
@pytest.mark.parametrize("rule,code", [
|
| 120 |
+
("dangerous-eval", "eval(x)\n"),
|
| 121 |
+
("dangerous-exec", "exec(x)\n"),
|
| 122 |
+
("shell-injection", "import subprocess\nsubprocess.run(c, shell=True)\n"),
|
| 123 |
+
("hardcoded-secret", 'password = "hunter2abc"\n'),
|
| 124 |
+
])
|
| 125 |
+
def test_parametrized_detection(rule: str, code: str) -> None:
|
| 126 |
+
with tempfile.TemporaryDirectory() as d:
|
| 127 |
+
p = _write(d, "t.py", code)
|
| 128 |
+
findings = detect_security_issues(p)
|
| 129 |
+
assert any(f["rule"] == rule for f in findings)
|
tests/test_circuit_breaker.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from app.core.circuit_breaker import CircuitBreaker, State
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def _breaker(threshold: int = 3) -> CircuitBreaker:
|
| 6 |
+
return CircuitBreaker(failure_threshold=threshold, recovery_timeout=9999.0)
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def test_initial_state_is_closed() -> None:
|
| 10 |
+
assert _breaker().state == State.CLOSED
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def test_opens_after_threshold_failures() -> None:
|
| 14 |
+
b = _breaker(threshold=2)
|
| 15 |
+
b.record_failure()
|
| 16 |
+
assert b.state == State.CLOSED
|
| 17 |
+
b.record_failure()
|
| 18 |
+
assert b.state == State.OPEN
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_is_open_returns_true_when_open() -> None:
|
| 22 |
+
b = _breaker(threshold=1)
|
| 23 |
+
b.record_failure()
|
| 24 |
+
assert b.is_open() is True
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def test_success_resets_failures() -> None:
|
| 28 |
+
b = _breaker(threshold=3)
|
| 29 |
+
b.record_failure()
|
| 30 |
+
b.record_failure()
|
| 31 |
+
b.record_success()
|
| 32 |
+
assert b.state == State.CLOSED
|
| 33 |
+
assert b._failures == 0
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_transitions_to_half_open_after_timeout() -> None:
|
| 37 |
+
import time
|
| 38 |
+
b = CircuitBreaker(failure_threshold=1, recovery_timeout=0.01)
|
| 39 |
+
b.record_failure()
|
| 40 |
+
assert b.state == State.OPEN
|
| 41 |
+
time.sleep(0.02)
|
| 42 |
+
assert b.state == State.HALF_OPEN
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_reset_clears_to_closed() -> None:
|
| 46 |
+
b = _breaker(threshold=1)
|
| 47 |
+
b.record_failure()
|
| 48 |
+
b.reset()
|
| 49 |
+
assert b.state == State.CLOSED
|
| 50 |
+
assert b._failures == 0
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_open_breaker_raises_in_call_llm() -> None:
|
| 54 |
+
"""call_llm must raise RuntimeError immediately when breaker is open."""
|
| 55 |
+
import pytest
|
| 56 |
+
import asyncio
|
| 57 |
+
from unittest.mock import patch
|
| 58 |
+
from app.core.circuit_breaker import llm_breaker
|
| 59 |
+
from app.core.llm import call_llm
|
| 60 |
+
|
| 61 |
+
llm_breaker.reset()
|
| 62 |
+
for _ in range(5):
|
| 63 |
+
llm_breaker.record_failure()
|
| 64 |
+
|
| 65 |
+
assert llm_breaker.is_open()
|
| 66 |
+
|
| 67 |
+
with pytest.raises(RuntimeError, match="circuit breaker"):
|
| 68 |
+
asyncio.get_event_loop().run_until_complete(call_llm("test"))
|
| 69 |
+
|
| 70 |
+
llm_breaker.reset()
|