Spaces:
Sleeping
Sleeping
fix
Browse files- .github/workflows/ci.yml +3 -0
- app/api/dependencies.py +1 -1
- app/api/main.py +1 -1
- app/api/routes.py +5 -4
- app/core/config.py +2 -3
- app/core/llm.py +3 -2
- app/core/metrics.py +7 -4
- app/models/issue.py +4 -4
- app/services/.gitkeep +0 -0
- app/services/__init__.py +0 -0
- app/tools/ast_parser.py +10 -32
- app/tools/file_scanner.py +2 -2
- app/tools/github_tool.py +2 -2
- app/tools/pytest_runner.py +6 -22
- app/tools/static_analyzer.py +10 -32
- pyproject.toml +11 -1
- requirements.txt +1 -0
.github/workflows/ci.yml
CHANGED
|
@@ -22,6 +22,9 @@ jobs:
|
|
| 22 |
- name: Lint
|
| 23 |
run: ruff check app/ tests/
|
| 24 |
|
|
|
|
|
|
|
|
|
|
| 25 |
- name: Security scan
|
| 26 |
run: bandit -r app/ -q
|
| 27 |
|
|
|
|
| 22 |
- name: Lint
|
| 23 |
run: ruff check app/ tests/
|
| 24 |
|
| 25 |
+
- name: Type check
|
| 26 |
+
run: mypy app/
|
| 27 |
+
|
| 28 |
- name: Security scan
|
| 29 |
run: bandit -r app/ -q
|
| 30 |
|
app/api/dependencies.py
CHANGED
|
@@ -5,7 +5,7 @@ from app.core.config import get_settings
|
|
| 5 |
settings = get_settings()
|
| 6 |
|
| 7 |
|
| 8 |
-
def verify_api_key(x_api_key: Optional[str] = Header(default=None)):
|
| 9 |
if not settings.api_key:
|
| 10 |
# API_KEY not configured — open access (dev/HF Spaces mode)
|
| 11 |
return
|
|
|
|
| 5 |
settings = get_settings()
|
| 6 |
|
| 7 |
|
| 8 |
+
def verify_api_key(x_api_key: Optional[str] = Header(default=None)) -> None:
|
| 9 |
if not settings.api_key:
|
| 10 |
# API_KEY not configured — open access (dev/HF Spaces mode)
|
| 11 |
return
|
app/api/main.py
CHANGED
|
@@ -29,7 +29,7 @@ app.include_router(router, prefix="/api/v1")
|
|
| 29 |
|
| 30 |
|
| 31 |
@app.get("/")
|
| 32 |
-
def root():
|
| 33 |
return {
|
| 34 |
"message": "AI Code Review Agent",
|
| 35 |
"version": "0.1.0",
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
@app.get("/")
|
| 32 |
+
def root() -> dict[str, str]:
|
| 33 |
return {
|
| 34 |
"message": "AI Code Review Agent",
|
| 35 |
"version": "0.1.0",
|
app/api/routes.py
CHANGED
|
@@ -4,6 +4,7 @@ from app.core.logger import get_logger
|
|
| 4 |
from fastapi import APIRouter, HTTPException, Depends
|
| 5 |
from app.api.dependencies import verify_api_key
|
| 6 |
from app.api.limiter import limiter
|
|
|
|
| 7 |
from app.core.metrics import metrics
|
| 8 |
from app.models.repository import RepositoryRequest, RepositoryResponse
|
| 9 |
from app.models.report import EngineeringReport
|
|
@@ -22,12 +23,12 @@ logger = get_logger(__name__)
|
|
| 22 |
|
| 23 |
|
| 24 |
@router.get("/health")
|
| 25 |
-
def health_check():
|
| 26 |
return {"status": "ok", "message": "AI Code Review Agent is running"}
|
| 27 |
|
| 28 |
@limiter.limit("5/minute")
|
| 29 |
@router.post("/analyze", response_model=EngineeringReport, dependencies=[Depends(verify_api_key)])
|
| 30 |
-
async def analyze_repository(request: RepositoryRequest):
|
| 31 |
"""
|
| 32 |
Full pipeline:
|
| 33 |
1. Clone repository
|
|
@@ -75,7 +76,7 @@ async def analyze_repository(request: RepositoryRequest):
|
|
| 75 |
|
| 76 |
@limiter.limit("10/minute")
|
| 77 |
@router.post("/quick-analyze", response_model=RepositoryResponse, dependencies=[Depends(verify_api_key)])
|
| 78 |
-
async def quick_analyze(request: RepositoryRequest):
|
| 79 |
"""
|
| 80 |
Only run repository analysis agent.
|
| 81 |
Faster, for testing purposes.
|
|
@@ -100,6 +101,6 @@ async def quick_analyze(request: RepositoryRequest):
|
|
| 100 |
delete_repository(local_path)
|
| 101 |
|
| 102 |
@router.get("/metrics")
|
| 103 |
-
def get_metrics():
|
| 104 |
"""Observability endpoint: agent run counts, error rates, latencies."""
|
| 105 |
return metrics.snapshot()
|
|
|
|
| 4 |
from fastapi import APIRouter, HTTPException, Depends
|
| 5 |
from app.api.dependencies import verify_api_key
|
| 6 |
from app.api.limiter import limiter
|
| 7 |
+
from typing import Any
|
| 8 |
from app.core.metrics import metrics
|
| 9 |
from app.models.repository import RepositoryRequest, RepositoryResponse
|
| 10 |
from app.models.report import EngineeringReport
|
|
|
|
| 23 |
|
| 24 |
|
| 25 |
@router.get("/health")
|
| 26 |
+
def health_check() -> dict[str, str]:
|
| 27 |
return {"status": "ok", "message": "AI Code Review Agent is running"}
|
| 28 |
|
| 29 |
@limiter.limit("5/minute")
|
| 30 |
@router.post("/analyze", response_model=EngineeringReport, dependencies=[Depends(verify_api_key)])
|
| 31 |
+
async def analyze_repository(request: RepositoryRequest) -> EngineeringReport:
|
| 32 |
"""
|
| 33 |
Full pipeline:
|
| 34 |
1. Clone repository
|
|
|
|
| 76 |
|
| 77 |
@limiter.limit("10/minute")
|
| 78 |
@router.post("/quick-analyze", response_model=RepositoryResponse, dependencies=[Depends(verify_api_key)])
|
| 79 |
+
async def quick_analyze(request: RepositoryRequest) -> RepositoryResponse:
|
| 80 |
"""
|
| 81 |
Only run repository analysis agent.
|
| 82 |
Faster, for testing purposes.
|
|
|
|
| 101 |
delete_repository(local_path)
|
| 102 |
|
| 103 |
@router.get("/metrics")
|
| 104 |
+
def get_metrics() -> dict[str, Any]:
|
| 105 |
"""Observability endpoint: agent run counts, error rates, latencies."""
|
| 106 |
return metrics.snapshot()
|
app/core/config.py
CHANGED
|
@@ -1,12 +1,11 @@
|
|
| 1 |
import tempfile
|
| 2 |
import os
|
| 3 |
-
from pydantic_settings import BaseSettings
|
| 4 |
-
from pydantic import ConfigDict
|
| 5 |
from functools import lru_cache
|
| 6 |
|
| 7 |
|
| 8 |
class Settings(BaseSettings):
|
| 9 |
-
model_config =
|
| 10 |
|
| 11 |
# Set OPENROUTER_API_KEY as a HF Space Secret
|
| 12 |
openrouter_api_key: str = ""
|
|
|
|
| 1 |
import tempfile
|
| 2 |
import os
|
| 3 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
|
|
| 4 |
from functools import lru_cache
|
| 5 |
|
| 6 |
|
| 7 |
class Settings(BaseSettings):
|
| 8 |
+
model_config = SettingsConfigDict(env_file=".env", case_sensitive=False, extra="ignore")
|
| 9 |
|
| 10 |
# Set OPENROUTER_API_KEY as a HF Space Secret
|
| 11 |
openrouter_api_key: str = ""
|
app/core/llm.py
CHANGED
|
@@ -2,6 +2,7 @@ import httpx
|
|
| 2 |
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
|
| 3 |
from app.core.config import get_settings
|
| 4 |
from app.core.logger import get_logger
|
|
|
|
| 5 |
|
| 6 |
settings = get_settings()
|
| 7 |
logger = get_logger(__name__)
|
|
@@ -18,7 +19,7 @@ OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions"
|
|
| 18 |
extra={"attempt": rs.attempt_number, "error": str(rs.outcome.exception())},
|
| 19 |
),
|
| 20 |
)
|
| 21 |
-
async def call_llm(prompt: str, system_prompt: str = None, max_tokens: int = 2000) -> str:
|
| 22 |
"""Async call to OpenRouter API with retry logic."""
|
| 23 |
messages = []
|
| 24 |
if system_prompt:
|
|
@@ -45,4 +46,4 @@ async def call_llm(prompt: str, system_prompt: str = None, max_tokens: int = 200
|
|
| 45 |
response.raise_for_status()
|
| 46 |
|
| 47 |
data = response.json()
|
| 48 |
-
return data["choices"][0]["message"]["content"]
|
|
|
|
| 2 |
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
|
| 3 |
from app.core.config import get_settings
|
| 4 |
from app.core.logger import get_logger
|
| 5 |
+
from typing import Optional
|
| 6 |
|
| 7 |
settings = get_settings()
|
| 8 |
logger = get_logger(__name__)
|
|
|
|
| 19 |
extra={"attempt": rs.attempt_number, "error": str(rs.outcome.exception())},
|
| 20 |
),
|
| 21 |
)
|
| 22 |
+
async def call_llm(prompt: str, system_prompt: Optional[str] = None, max_tokens: int = 2000) -> str:
|
| 23 |
"""Async call to OpenRouter API with retry logic."""
|
| 24 |
messages = []
|
| 25 |
if system_prompt:
|
|
|
|
| 46 |
response.raise_for_status()
|
| 47 |
|
| 48 |
data = response.json()
|
| 49 |
+
return str(data["choices"][0]["message"]["content"])
|
app/core/metrics.py
CHANGED
|
@@ -1,25 +1,27 @@
|
|
| 1 |
import time
|
| 2 |
from collections import defaultdict
|
| 3 |
from threading import Lock
|
|
|
|
|
|
|
| 4 |
|
| 5 |
class MetricsCollector:
|
| 6 |
-
def __init__(self):
|
| 7 |
self._lock = Lock()
|
| 8 |
self._counts: dict[str, int] = defaultdict(int)
|
| 9 |
self._latencies: dict[str, list[float]] = defaultdict(list)
|
| 10 |
self._errors: dict[str, int] = defaultdict(int)
|
| 11 |
self._started_at = time.time()
|
| 12 |
|
| 13 |
-
def record_run(self, agent: str, duration_seconds: float, success: bool):
|
| 14 |
with self._lock:
|
| 15 |
self._counts[agent] += 1
|
| 16 |
self._latencies[agent].append(round(duration_seconds, 3))
|
| 17 |
if not success:
|
| 18 |
self._errors[agent] += 1
|
| 19 |
|
| 20 |
-
def snapshot(self) -> dict:
|
| 21 |
with self._lock:
|
| 22 |
-
agents = {}
|
| 23 |
for agent, count in self._counts.items():
|
| 24 |
lats = self._latencies[agent]
|
| 25 |
agents[agent] = {
|
|
@@ -33,4 +35,5 @@ class MetricsCollector:
|
|
| 33 |
"agents": agents,
|
| 34 |
}
|
| 35 |
|
|
|
|
| 36 |
metrics = MetricsCollector()
|
|
|
|
| 1 |
import time
|
| 2 |
from collections import defaultdict
|
| 3 |
from threading import Lock
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
|
| 7 |
class MetricsCollector:
|
| 8 |
+
def __init__(self) -> None:
|
| 9 |
self._lock = Lock()
|
| 10 |
self._counts: dict[str, int] = defaultdict(int)
|
| 11 |
self._latencies: dict[str, list[float]] = defaultdict(list)
|
| 12 |
self._errors: dict[str, int] = defaultdict(int)
|
| 13 |
self._started_at = time.time()
|
| 14 |
|
| 15 |
+
def record_run(self, agent: str, duration_seconds: float, success: bool) -> None:
|
| 16 |
with self._lock:
|
| 17 |
self._counts[agent] += 1
|
| 18 |
self._latencies[agent].append(round(duration_seconds, 3))
|
| 19 |
if not success:
|
| 20 |
self._errors[agent] += 1
|
| 21 |
|
| 22 |
+
def snapshot(self) -> dict[str, Any]:
|
| 23 |
with self._lock:
|
| 24 |
+
agents: dict[str, Any] = {}
|
| 25 |
for agent, count in self._counts.items():
|
| 26 |
lats = self._latencies[agent]
|
| 27 |
agents[agent] = {
|
|
|
|
| 35 |
"agents": agents,
|
| 36 |
}
|
| 37 |
|
| 38 |
+
|
| 39 |
metrics = MetricsCollector()
|
app/models/issue.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
from pydantic import BaseModel, model_validator, field_validator
|
| 2 |
-
from typing import List, Optional
|
| 3 |
|
| 4 |
|
| 5 |
class Bug(BaseModel):
|
|
@@ -10,7 +10,7 @@ class Bug(BaseModel):
|
|
| 10 |
|
| 11 |
@field_validator("file", mode="before")
|
| 12 |
@classmethod
|
| 13 |
-
def coerce_file(cls, v):
|
| 14 |
if isinstance(v, list):
|
| 15 |
return ", ".join(str(f) for f in v) if v else "unknown"
|
| 16 |
return v or "unknown"
|
|
@@ -23,7 +23,7 @@ class Warning(BaseModel):
|
|
| 23 |
|
| 24 |
@field_validator("file", mode="before")
|
| 25 |
@classmethod
|
| 26 |
-
def coerce_file(cls, v):
|
| 27 |
if isinstance(v, list):
|
| 28 |
return ", ".join(str(f) for f in v) if v else "unknown"
|
| 29 |
return v or "unknown"
|
|
@@ -35,7 +35,7 @@ class Suggestion(BaseModel):
|
|
| 35 |
|
| 36 |
@field_validator("file", mode="before")
|
| 37 |
@classmethod
|
| 38 |
-
def coerce_file(cls, v):
|
| 39 |
if isinstance(v, list):
|
| 40 |
return ", ".join(str(f) for f in v) if v else "unknown"
|
| 41 |
return v or "unknown"
|
|
|
|
| 1 |
from pydantic import BaseModel, model_validator, field_validator
|
| 2 |
+
from typing import List, Optional, Any
|
| 3 |
|
| 4 |
|
| 5 |
class Bug(BaseModel):
|
|
|
|
| 10 |
|
| 11 |
@field_validator("file", mode="before")
|
| 12 |
@classmethod
|
| 13 |
+
def coerce_file(cls, v: Any) -> str:
|
| 14 |
if isinstance(v, list):
|
| 15 |
return ", ".join(str(f) for f in v) if v else "unknown"
|
| 16 |
return v or "unknown"
|
|
|
|
| 23 |
|
| 24 |
@field_validator("file", mode="before")
|
| 25 |
@classmethod
|
| 26 |
+
def coerce_file(cls, v: Any) -> str:
|
| 27 |
if isinstance(v, list):
|
| 28 |
return ", ".join(str(f) for f in v) if v else "unknown"
|
| 29 |
return v or "unknown"
|
|
|
|
| 35 |
|
| 36 |
@field_validator("file", mode="before")
|
| 37 |
@classmethod
|
| 38 |
+
def coerce_file(cls, v: Any) -> str:
|
| 39 |
if isinstance(v, list):
|
| 40 |
return ", ".join(str(f) for f in v) if v else "unknown"
|
| 41 |
return v or "unknown"
|
app/services/.gitkeep
DELETED
|
File without changes
|
app/services/__init__.py
DELETED
|
File without changes
|
app/tools/ast_parser.py
CHANGED
|
@@ -1,75 +1,53 @@
|
|
| 1 |
import ast
|
| 2 |
import os
|
| 3 |
-
from typing import
|
| 4 |
|
| 5 |
|
| 6 |
-
def parse_python_file(file_path: str) ->
|
| 7 |
-
|
| 8 |
-
Parse a Python file using AST.
|
| 9 |
-
Returns functions, classes, and imports found.
|
| 10 |
-
"""
|
| 11 |
-
result = {
|
| 12 |
"file": file_path,
|
| 13 |
"functions": [],
|
| 14 |
"classes": [],
|
| 15 |
"imports": [],
|
| 16 |
-
"errors": []
|
| 17 |
}
|
| 18 |
-
|
| 19 |
try:
|
| 20 |
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
| 21 |
source = f.read()
|
| 22 |
-
|
| 23 |
tree = ast.parse(source)
|
| 24 |
-
|
| 25 |
for node in ast.walk(tree):
|
| 26 |
-
# Extract functions
|
| 27 |
if isinstance(node, ast.FunctionDef):
|
| 28 |
result["functions"].append({
|
| 29 |
"name": node.name,
|
| 30 |
"line": node.lineno,
|
| 31 |
"args": [arg.arg for arg in node.args.args],
|
| 32 |
-
"docstring": ast.get_docstring(node) or ""
|
| 33 |
})
|
| 34 |
-
|
| 35 |
-
# Extract classes
|
| 36 |
elif isinstance(node, ast.ClassDef):
|
| 37 |
result["classes"].append({
|
| 38 |
"name": node.name,
|
| 39 |
"line": node.lineno,
|
| 40 |
-
"docstring": ast.get_docstring(node) or ""
|
| 41 |
})
|
| 42 |
-
|
| 43 |
-
# Extract imports
|
| 44 |
elif isinstance(node, ast.Import):
|
| 45 |
for alias in node.names:
|
| 46 |
result["imports"].append(alias.name)
|
| 47 |
-
|
| 48 |
elif isinstance(node, ast.ImportFrom):
|
| 49 |
-
|
| 50 |
-
result["imports"].append(module)
|
| 51 |
-
|
| 52 |
except SyntaxError as e:
|
| 53 |
result["errors"].append(f"SyntaxError: {str(e)}")
|
| 54 |
except Exception as e:
|
| 55 |
result["errors"].append(f"Error: {str(e)}")
|
| 56 |
-
|
| 57 |
return result
|
| 58 |
|
| 59 |
|
| 60 |
-
def parse_repository(local_path: str) ->
|
| 61 |
-
|
| 62 |
-
Parse all Python files in a repository.
|
| 63 |
-
"""
|
| 64 |
-
results = []
|
| 65 |
-
|
| 66 |
for root, dirs, files in os.walk(local_path):
|
| 67 |
dirs[:] = [d for d in dirs if d not in {
|
| 68 |
".git", "__pycache__", ".venv", "venv", "node_modules"
|
| 69 |
}]
|
| 70 |
for file in files:
|
| 71 |
if file.endswith(".py"):
|
| 72 |
-
|
| 73 |
-
results.append(parse_python_file(file_path))
|
| 74 |
-
|
| 75 |
return results
|
|
|
|
| 1 |
import ast
|
| 2 |
import os
|
| 3 |
+
from typing import Any
|
| 4 |
|
| 5 |
|
| 6 |
+
def parse_python_file(file_path: str) -> dict[str, Any]:
|
| 7 |
+
result: dict[str, Any] = {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
"file": file_path,
|
| 9 |
"functions": [],
|
| 10 |
"classes": [],
|
| 11 |
"imports": [],
|
| 12 |
+
"errors": [],
|
| 13 |
}
|
|
|
|
| 14 |
try:
|
| 15 |
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
| 16 |
source = f.read()
|
|
|
|
| 17 |
tree = ast.parse(source)
|
|
|
|
| 18 |
for node in ast.walk(tree):
|
|
|
|
| 19 |
if isinstance(node, ast.FunctionDef):
|
| 20 |
result["functions"].append({
|
| 21 |
"name": node.name,
|
| 22 |
"line": node.lineno,
|
| 23 |
"args": [arg.arg for arg in node.args.args],
|
| 24 |
+
"docstring": ast.get_docstring(node) or "",
|
| 25 |
})
|
|
|
|
|
|
|
| 26 |
elif isinstance(node, ast.ClassDef):
|
| 27 |
result["classes"].append({
|
| 28 |
"name": node.name,
|
| 29 |
"line": node.lineno,
|
| 30 |
+
"docstring": ast.get_docstring(node) or "",
|
| 31 |
})
|
|
|
|
|
|
|
| 32 |
elif isinstance(node, ast.Import):
|
| 33 |
for alias in node.names:
|
| 34 |
result["imports"].append(alias.name)
|
|
|
|
| 35 |
elif isinstance(node, ast.ImportFrom):
|
| 36 |
+
result["imports"].append(node.module or "")
|
|
|
|
|
|
|
| 37 |
except SyntaxError as e:
|
| 38 |
result["errors"].append(f"SyntaxError: {str(e)}")
|
| 39 |
except Exception as e:
|
| 40 |
result["errors"].append(f"Error: {str(e)}")
|
|
|
|
| 41 |
return result
|
| 42 |
|
| 43 |
|
| 44 |
+
def parse_repository(local_path: str) -> list[dict[str, Any]]:
|
| 45 |
+
results: list[dict[str, Any]] = []
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
for root, dirs, files in os.walk(local_path):
|
| 47 |
dirs[:] = [d for d in dirs if d not in {
|
| 48 |
".git", "__pycache__", ".venv", "venv", "node_modules"
|
| 49 |
}]
|
| 50 |
for file in files:
|
| 51 |
if file.endswith(".py"):
|
| 52 |
+
results.append(parse_python_file(os.path.join(root, file)))
|
|
|
|
|
|
|
| 53 |
return results
|
app/tools/file_scanner.py
CHANGED
|
@@ -40,7 +40,7 @@ def scan_repository(local_path: str) -> Dict:
|
|
| 40 |
Scan a repository and return metadata.
|
| 41 |
"""
|
| 42 |
all_files = []
|
| 43 |
-
language_counts = {}
|
| 44 |
total_lines = 0
|
| 45 |
frameworks_found = set()
|
| 46 |
|
|
@@ -74,7 +74,7 @@ def scan_repository(local_path: str) -> Dict:
|
|
| 74 |
# Detect primary language
|
| 75 |
primary_language = "Unknown"
|
| 76 |
if language_counts:
|
| 77 |
-
primary_language = max(language_counts, key=language_counts
|
| 78 |
|
| 79 |
return {
|
| 80 |
"all_files": all_files,
|
|
|
|
| 40 |
Scan a repository and return metadata.
|
| 41 |
"""
|
| 42 |
all_files = []
|
| 43 |
+
language_counts: dict[str, int] = {}
|
| 44 |
total_lines = 0
|
| 45 |
frameworks_found = set()
|
| 46 |
|
|
|
|
| 74 |
# Detect primary language
|
| 75 |
primary_language = "Unknown"
|
| 76 |
if language_counts:
|
| 77 |
+
primary_language = max(language_counts, key=lambda k: language_counts[k])
|
| 78 |
|
| 79 |
return {
|
| 80 |
"all_files": all_files,
|
app/tools/github_tool.py
CHANGED
|
@@ -41,8 +41,8 @@ def clone_repository(github_url: str) -> str:
|
|
| 41 |
|
| 42 |
logger.info("Clone complete", extra={"path": str(local_path), "size_mb": round(size_mb, 1)})
|
| 43 |
return str(local_path)
|
| 44 |
-
|
| 45 |
-
def delete_repository(local_path: str):
|
| 46 |
path = Path(local_path)
|
| 47 |
if path.exists():
|
| 48 |
shutil.rmtree(path, ignore_errors=True)
|
|
|
|
| 41 |
|
| 42 |
logger.info("Clone complete", extra={"path": str(local_path), "size_mb": round(size_mb, 1)})
|
| 43 |
return str(local_path)
|
| 44 |
+
|
| 45 |
+
def delete_repository(local_path: str) -> None:
|
| 46 |
path = Path(local_path)
|
| 47 |
if path.exists():
|
| 48 |
shutil.rmtree(path, ignore_errors=True)
|
app/tools/pytest_runner.py
CHANGED
|
@@ -1,32 +1,18 @@
|
|
| 1 |
import subprocess # nosec B404
|
| 2 |
-
from typing import
|
| 3 |
|
| 4 |
|
| 5 |
-
def run_tests(local_path: str) ->
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
"""
|
| 9 |
-
result = {
|
| 10 |
-
"passed": 0,
|
| 11 |
-
"failed": 0,
|
| 12 |
-
"errors": 0,
|
| 13 |
-
"output": "",
|
| 14 |
-
"success": False
|
| 15 |
}
|
| 16 |
-
|
| 17 |
try:
|
| 18 |
process = subprocess.run( # nosec
|
| 19 |
["pytest", "--tb=short", "-q", "--no-header"],
|
| 20 |
-
cwd=local_path,
|
| 21 |
-
capture_output=True,
|
| 22 |
-
text=True,
|
| 23 |
-
timeout=120
|
| 24 |
)
|
| 25 |
-
|
| 26 |
result["output"] = process.stdout + process.stderr
|
| 27 |
-
|
| 28 |
-
# Parse basic counts from output
|
| 29 |
-
output = result["output"]
|
| 30 |
if "passed" in output:
|
| 31 |
result["success"] = True
|
| 32 |
for line in output.splitlines():
|
|
@@ -39,12 +25,10 @@ def run_tests(local_path: str) -> Dict:
|
|
| 39 |
result["failed"] = int(parts[i - 1])
|
| 40 |
elif part == "error" and i > 0:
|
| 41 |
result["errors"] = int(parts[i - 1])
|
| 42 |
-
|
| 43 |
except subprocess.TimeoutExpired:
|
| 44 |
result["output"] = "Tests timed out after 120 seconds"
|
| 45 |
except FileNotFoundError:
|
| 46 |
result["output"] = "pytest not found in repository"
|
| 47 |
except Exception as e:
|
| 48 |
result["output"] = f"Error running tests: {str(e)}"
|
| 49 |
-
|
| 50 |
return result
|
|
|
|
| 1 |
import subprocess # nosec B404
|
| 2 |
+
from typing import Any
|
| 3 |
|
| 4 |
|
| 5 |
+
def run_tests(local_path: str) -> dict[str, Any]:
|
| 6 |
+
result: dict[str, Any] = {
|
| 7 |
+
"passed": 0, "failed": 0, "errors": 0, "output": "", "success": False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
}
|
|
|
|
| 9 |
try:
|
| 10 |
process = subprocess.run( # nosec
|
| 11 |
["pytest", "--tb=short", "-q", "--no-header"],
|
| 12 |
+
cwd=local_path, capture_output=True, text=True, timeout=120
|
|
|
|
|
|
|
|
|
|
| 13 |
)
|
|
|
|
| 14 |
result["output"] = process.stdout + process.stderr
|
| 15 |
+
output: str = str(result["output"])
|
|
|
|
|
|
|
| 16 |
if "passed" in output:
|
| 17 |
result["success"] = True
|
| 18 |
for line in output.splitlines():
|
|
|
|
| 25 |
result["failed"] = int(parts[i - 1])
|
| 26 |
elif part == "error" and i > 0:
|
| 27 |
result["errors"] = int(parts[i - 1])
|
|
|
|
| 28 |
except subprocess.TimeoutExpired:
|
| 29 |
result["output"] = "Tests timed out after 120 seconds"
|
| 30 |
except FileNotFoundError:
|
| 31 |
result["output"] = "pytest not found in repository"
|
| 32 |
except Exception as e:
|
| 33 |
result["output"] = f"Error running tests: {str(e)}"
|
|
|
|
| 34 |
return result
|
app/tools/static_analyzer.py
CHANGED
|
@@ -1,66 +1,44 @@
|
|
| 1 |
import subprocess # nosec B404
|
| 2 |
-
from typing import
|
| 3 |
|
| 4 |
|
| 5 |
-
def run_ruff(local_path: str) ->
|
| 6 |
-
"""
|
| 7 |
-
Run Ruff linter on repository.
|
| 8 |
-
"""
|
| 9 |
-
result = {"output": "", "issues": [], "success": False}
|
| 10 |
-
|
| 11 |
try:
|
| 12 |
process = subprocess.run( # nosec
|
| 13 |
["ruff", "check", ".", "--output-format=text"],
|
| 14 |
-
cwd=local_path,
|
| 15 |
-
capture_output=True,
|
| 16 |
-
text=True,
|
| 17 |
-
timeout=60
|
| 18 |
)
|
| 19 |
result["output"] = process.stdout + process.stderr
|
| 20 |
result["issues"] = [
|
| 21 |
-
line for line in result["output"].splitlines() if line.strip()
|
| 22 |
]
|
| 23 |
result["success"] = True
|
| 24 |
-
|
| 25 |
except Exception as e:
|
| 26 |
result["output"] = f"Ruff error: {str(e)}"
|
| 27 |
-
|
| 28 |
return result
|
| 29 |
|
| 30 |
|
| 31 |
-
def run_bandit(local_path: str) ->
|
| 32 |
-
"""
|
| 33 |
-
Run Bandit security checker on repository.
|
| 34 |
-
"""
|
| 35 |
-
result = {"output": "", "issues": [], "success": False}
|
| 36 |
-
|
| 37 |
try:
|
| 38 |
process = subprocess.run( # nosec
|
| 39 |
["bandit", "-r", ".", "-f", "txt", "-q"],
|
| 40 |
-
cwd=local_path,
|
| 41 |
-
capture_output=True,
|
| 42 |
-
text=True,
|
| 43 |
-
timeout=60
|
| 44 |
)
|
| 45 |
result["output"] = process.stdout + process.stderr
|
| 46 |
result["issues"] = [
|
| 47 |
-
line for line in result["output"].splitlines() if line.strip()
|
| 48 |
]
|
| 49 |
result["success"] = True
|
| 50 |
-
|
| 51 |
except Exception as e:
|
| 52 |
result["output"] = f"Bandit error: {str(e)}"
|
| 53 |
-
|
| 54 |
return result
|
| 55 |
|
| 56 |
|
| 57 |
-
def run_full_analysis(local_path: str) ->
|
| 58 |
-
"""
|
| 59 |
-
Run all static analysis tools.
|
| 60 |
-
"""
|
| 61 |
ruff_results = run_ruff(local_path)
|
| 62 |
bandit_results = run_bandit(local_path)
|
| 63 |
-
|
| 64 |
return {
|
| 65 |
"ruff": ruff_results,
|
| 66 |
"bandit": bandit_results,
|
|
|
|
| 1 |
import subprocess # nosec B404
|
| 2 |
+
from typing import Any
|
| 3 |
|
| 4 |
|
| 5 |
+
def run_ruff(local_path: str) -> dict[str, Any]:
|
| 6 |
+
result: dict[str, Any] = {"output": "", "issues": [], "success": False}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
try:
|
| 8 |
process = subprocess.run( # nosec
|
| 9 |
["ruff", "check", ".", "--output-format=text"],
|
| 10 |
+
cwd=local_path, capture_output=True, text=True, timeout=60
|
|
|
|
|
|
|
|
|
|
| 11 |
)
|
| 12 |
result["output"] = process.stdout + process.stderr
|
| 13 |
result["issues"] = [
|
| 14 |
+
line for line in str(result["output"]).splitlines() if line.strip()
|
| 15 |
]
|
| 16 |
result["success"] = True
|
|
|
|
| 17 |
except Exception as e:
|
| 18 |
result["output"] = f"Ruff error: {str(e)}"
|
|
|
|
| 19 |
return result
|
| 20 |
|
| 21 |
|
| 22 |
+
def run_bandit(local_path: str) -> dict[str, Any]:
|
| 23 |
+
result: dict[str, Any] = {"output": "", "issues": [], "success": False}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
try:
|
| 25 |
process = subprocess.run( # nosec
|
| 26 |
["bandit", "-r", ".", "-f", "txt", "-q"],
|
| 27 |
+
cwd=local_path, capture_output=True, text=True, timeout=60
|
|
|
|
|
|
|
|
|
|
| 28 |
)
|
| 29 |
result["output"] = process.stdout + process.stderr
|
| 30 |
result["issues"] = [
|
| 31 |
+
line for line in str(result["output"]).splitlines() if line.strip()
|
| 32 |
]
|
| 33 |
result["success"] = True
|
|
|
|
| 34 |
except Exception as e:
|
| 35 |
result["output"] = f"Bandit error: {str(e)}"
|
|
|
|
| 36 |
return result
|
| 37 |
|
| 38 |
|
| 39 |
+
def run_full_analysis(local_path: str) -> dict[str, Any]:
|
|
|
|
|
|
|
|
|
|
| 40 |
ruff_results = run_ruff(local_path)
|
| 41 |
bandit_results = run_bandit(local_path)
|
|
|
|
| 42 |
return {
|
| 43 |
"ruff": ruff_results,
|
| 44 |
"bandit": bandit_results,
|
pyproject.toml
CHANGED
|
@@ -19,4 +19,14 @@ asyncio_mode = "auto"
|
|
| 19 |
asyncio_default_fixture_loop_scope = "function"
|
| 20 |
|
| 21 |
[tool.bandit]
|
| 22 |
-
exclude_dirs = ["tests", ".venv"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
asyncio_default_fixture_loop_scope = "function"
|
| 20 |
|
| 21 |
[tool.bandit]
|
| 22 |
+
exclude_dirs = ["tests", ".venv"]
|
| 23 |
+
|
| 24 |
+
[tool.mypy]
|
| 25 |
+
python_version = "3.11"
|
| 26 |
+
strict = false
|
| 27 |
+
ignore_missing_imports = true
|
| 28 |
+
disallow_untyped_defs = true
|
| 29 |
+
disallow_untyped_calls = false
|
| 30 |
+
warn_return_any = true
|
| 31 |
+
warn_unused_ignores = true
|
| 32 |
+
exclude = ["tests/", "gradio_app/"]
|
requirements.txt
CHANGED
|
@@ -27,3 +27,4 @@ pytest-asyncio
|
|
| 27 |
fastapi==0.115.12
|
| 28 |
uvicorn==0.34.3
|
| 29 |
tenacity==8.3.0
|
|
|
|
|
|
| 27 |
fastapi==0.115.12
|
| 28 |
uvicorn==0.34.3
|
| 29 |
tenacity==8.3.0
|
| 30 |
+
mypy==1.10.0
|