feat: Add endpoint to create Jira analysis records and update existing endpoints for consistency
Some checks are pending
CI/CD Pipeline / test (push) Waiting to run

This commit is contained in:
Ireneusz Bachanowicz 2025-07-18 00:57:28 +02:00
parent 935a8a49ae
commit 1de9f46517
8 changed files with 107 additions and 81 deletions

View File

@ -2,10 +2,10 @@ from fastapi import APIRouter, Request, HTTPException, Depends
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from typing import Dict, Any from typing import Dict, Any
import config import config
from llm.models import LLMResponse from llm.models import LLMResponse, JiraWebhookPayload
from database.database import get_db_session # Removed Session import here from database.database import get_db_session # Removed Session import here
from sqlalchemy.orm import Session # Added correct SQLAlchemy import from sqlalchemy.orm import Session # Added correct SQLAlchemy import
from database.crud import get_all_analysis_records, delete_all_analysis_records, get_analysis_by_id from database.crud import get_all_analysis_records, delete_all_analysis_records, get_analysis_by_id, create_analysis_record
router = APIRouter( router = APIRouter(
prefix="/api", prefix="/api",
@ -13,7 +13,7 @@ router = APIRouter(
) )
@router.get("/requests") @router.get("/request")
async def get_analysis_records_endpoint(db: Session = Depends(get_db_session)): async def get_analysis_records_endpoint(db: Session = Depends(get_db_session)):
"""Get analysis records""" """Get analysis records"""
try: try:
@ -28,6 +28,21 @@ async def get_analysis_records_endpoint(db: Session = Depends(get_db_session)):
content={"error": str(e)} content={"error": str(e)}
) )
@router.post("/request", status_code=201)
async def create_analysis_record_endpoint(
payload: JiraWebhookPayload,
db: Session = Depends(get_db_session)
):
"""Create a new Jira analysis record"""
try:
db_record = create_analysis_record(db, payload)
return JSONResponse(
status_code=201,
content={"message": "Record created successfully", "record_id": db_record.id}
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to create record: {str(e)}")
@router.post("/test-llm") @router.post("/test-llm")
async def test_llm_endpoint(db: Session = Depends(get_db_session)): async def test_llm_endpoint(db: Session = Depends(get_db_session)):
"""Test endpoint for LLM integration""" """Test endpoint for LLM integration"""
@ -50,7 +65,7 @@ async def test_llm_endpoint(db: Session = Depends(get_db_session)):
} }
) )
@router.delete("/requests") @router.delete("/request")
async def delete_analysis_records_endpoint(db: Session = Depends(get_db_session)): async def delete_analysis_records_endpoint(db: Session = Depends(get_db_session)):
"""Delete analysis records""" """Delete analysis records"""
try: try:
@ -63,7 +78,9 @@ async def delete_analysis_records_endpoint(db: Session = Depends(get_db_session)
return JSONResponse( return JSONResponse(
status_code=500, status_code=500,
content={"error": str(e)}) content={"error": str(e)})
@router.get("/requests/{record_id}")
@router.get("/request/{record_id}")
async def get_analysis_record_endpoint(record_id: int, db: Session = Depends(get_db_session)): async def get_analysis_record_endpoint(record_id: int, db: Session = Depends(get_db_session)):
"""Get specific analysis record by ID""" """Get specific analysis record by ID"""
record = get_analysis_by_id(db, record_id) record = get_analysis_by_id(db, record_id)

View File

@ -1,6 +1,6 @@
from loguru import logger from loguru import logger
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from datetime import datetime from datetime import datetime, timezone
import json import json
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
@ -11,12 +11,11 @@ def create_analysis_record(db: Session, payload: JiraWebhookPayload) -> JiraAnal
"""Creates a new Jira analysis record in the database.""" """Creates a new Jira analysis record in the database."""
db_analysis = JiraAnalysis( db_analysis = JiraAnalysis(
issue_key=payload.issueKey, issue_key=payload.issueKey,
project_key=payload.projectKey,
status="pending", status="pending",
issue_summary=payload.summary, issue_summary=payload.summary,
request_payload=payload.model_dump(), request_payload=payload.model_dump(),
created_at=datetime.utcnow(), created_at=datetime.now(timezone.utc),
updated_at=datetime.utcnow() updated_at=datetime.now(timezone.utc)
) )
db.add(db_analysis) db.add(db_analysis)
db.commit() db.commit()
@ -45,7 +44,7 @@ def update_analysis_record(
db_analysis = db.query(JiraAnalysis).filter(JiraAnalysis.id == record_id).first() db_analysis = db.query(JiraAnalysis).filter(JiraAnalysis.id == record_id).first()
if db_analysis: if db_analysis:
db_analysis.status = status db_analysis.status = status
db_analysis.updated_at = datetime.utcnow() db_analysis.updated_at = datetime.now(timezone.utc)
if analysis_result: if analysis_result:
db_analysis.analysis_result = analysis_result db_analysis.analysis_result = analysis_result
if error_message: if error_message:

View File

@ -9,7 +9,6 @@ class JiraAnalysis(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
issue_key = Column(String, index=True, nullable=False) issue_key = Column(String, index=True, nullable=False)
project_key = Column(String, index=True, nullable=False)
status = Column(String, default="pending", nullable=False) # pending, processing, completed, failed status = Column(String, default="pending", nullable=False) # pending, processing, completed, failed
issue_summary = Column(Text, nullable=False) issue_summary = Column(Text, nullable=False)
request_payload = Column(JSON, nullable=False) # Store the original Jira webhook payload request_payload = Column(JSON, nullable=False) # Store the original Jira webhook payload

Binary file not shown.

View File

@ -56,72 +56,57 @@ async def lifespan(app: FastAPI):
executor = ThreadPoolExecutor(max_workers=settings.thread_pool_max_workers) executor = ThreadPoolExecutor(max_workers=settings.thread_pool_max_workers)
app.state.executor = executor app.state.executor = executor
# Flag to track if initialization succeeded
init_success = False
try: try:
logger.info("Initializing application...") logger.info("Initializing application...")
init_db() # Initialize the database init_db() # Initialize the database
# Initialize event loop
loop = asyncio.get_running_loop()
logger.debug("Event loop initialized")
# Setup signal handlers # Setup signal handlers
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT): for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, partial(handle_shutdown_signal, sig, loop)) loop.add_signal_handler(sig, partial(handle_shutdown_signal, sig, loop))
logger.info("Signal handlers configured successfully") logger.info("Signal handlers configured successfully")
# Verify critical components # Verify critical components
if not hasattr(settings, 'langfuse_handler'): if not hasattr(settings, 'langfuse_handler'):
logger.error("Langfuse handler not found in settings") logger.error("Langfuse handler not found in settings")
raise RuntimeError("Langfuse handler not initialized") raise RuntimeError("Langfuse handler not initialized")
logger.info("Application initialized successfully") logger.info("Application initialized successfully")
yield init_success = True
# Check shutdown flag before cleanup
loop = asyncio.get_running_loop()
if hasattr(loop, '_shutdown'):
logger.info("Shutdown initiated, starting cleanup...")
except Exception as e: except Exception as e:
logger.critical(f"Application initialization failed: {str(e)}. Exiting.") logger.critical(f"Application initialization failed: {str(e)}. Exiting.")
# Do not re-raise here, allow finally block to execute cleanup # Don't re-raise to allow finally block to execute cleanup
try:
# Yield control to the application
yield
finally: finally:
# Ensure async context for cleanup # Cleanup logic runs after application finishes
async def perform_shutdown(): if init_success:
try: # Check shutdown flag before cleanup
await execute_cleanup() loop = asyncio.get_running_loop()
except Exception as e: if hasattr(loop, '_shutdown'):
logger.error(f"Cleanup failed: {str(e)}") logger.info("Shutdown initiated, starting cleanup...")
finally:
loop.stop() # Close langfuse with retry
# Run within event loop
asyncio.get_event_loop().run_until_complete(perform_shutdown())
# Close langfuse with retry
if hasattr(settings, 'langfuse_handler') and hasattr(settings.langfuse_handler, 'close'): if hasattr(settings, 'langfuse_handler') and hasattr(settings.langfuse_handler, 'close'):
async def close_langfuse(): try:
try: await asyncio.wait_for(settings.langfuse_handler.close(), timeout=5.0)
await asyncio.wait_for(settings.langfuse_handler.close(), timeout=5.0) logger.info("Langfuse client closed successfully")
logger.info("Langfuse client closed successfully") except asyncio.TimeoutError:
except asyncio.TimeoutError: logger.warning("Timeout while closing Langfuse client")
logger.warning("Timeout while closing Langfuse client") except Exception as e:
except Exception as e: logger.error(f"Error closing Langfuse client: {str(e)}")
logger.error(f"Error closing Langfuse client: {str(e)}")
cleanup_tasks.append(close_langfuse) # Execute any other cleanup tasks
if cleanup_tasks:
# Wrap in async function and structure properly try:
async def execute_cleanup(): await asyncio.gather(*cleanup_tasks)
try: except Exception as e:
await asyncio.wait_for(asyncio.gather(*cleanup_tasks), timeout=10.0) logger.error(f"Error during additional cleanup tasks: {str(e)}")
except asyncio.TimeoutError:
logger.warning("Timeout during cleanup sequence")
finally:
loop.stop()
# Wrap in try/except and ensure async context
# The following lines were causing syntax errors due to incorrect indentation and placement.
# The cleanup logic is already handled by `execute_cleanup` and `perform_shutdown`.
# Removing redundant and misplaced code.
# Ensure proper top-level placement after async blocks
def create_app(): def create_app():
"""Factory function to create FastAPI app instance""" """Factory function to create FastAPI app instance"""
configure_logging(log_level="DEBUG") configure_logging(log_level="DEBUG")
@ -177,7 +162,10 @@ def create_app():
return _app return _app
from api.handlers import test_llm_endpoint
app = create_app() app = create_app()
async def process_jira_webhook_background(record_id: int, payload: JiraWebhookPayload): async def process_jira_webhook_background(record_id: int, payload: JiraWebhookPayload):
""" """

View File

@ -11,7 +11,6 @@ class JiraWebhookPayload(BaseModel):
model_config = ConfigDict(alias_generator=lambda x: ''.join(word.capitalize() if i > 0 else word for i, word in enumerate(x.split('_'))), populate_by_name=True) model_config = ConfigDict(alias_generator=lambda x: ''.join(word.capitalize() if i > 0 else word for i, word in enumerate(x.split('_'))), populate_by_name=True)
issueKey: str issueKey: str
projectKey: Optional[str] = None # Added missing field
summary: str summary: str
description: Optional[str] = None description: Optional[str] = None
comment: Optional[str] = None comment: Optional[str] = None

View File

@ -1,7 +1,7 @@
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from jira_webhook_llm import create_app, app # Assuming app is created via factory from jira_webhook_llm import create_app
from database.database import engine, Base # Add import from database.database import get_db_session, Base # Import get_db_session and Base
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
import os import os
@ -33,7 +33,6 @@ def setup_db(monkeypatch):
def mock_full_jira_payload(setup_db): def mock_full_jira_payload(setup_db):
mock_data = { mock_data = {
"issueKey": "PROJ-123", "issueKey": "PROJ-123",
"projectKey": "PROJ",
"summary": "Test Issue", "summary": "Test Issue",
"description": "Test Description", "description": "Test Description",
"comment": "Test Comment", "comment": "Test Comment",
@ -45,26 +44,32 @@ def mock_full_jira_payload(setup_db):
} }
return mock_data return mock_data
@pytest.fixture @pytest.fixture(scope="function")
def test_client(setup_db): def test_client(setup_db, monkeypatch):
# Ensure the database is set up before creating the app # Prevent signal handling in tests to avoid "set_wakeup_fd" error
test_engine = setup_db monkeypatch.setattr("jira_webhook_llm.handle_shutdown_signal", lambda *args: None)
# Import and patch the database module to use test database # Create a test app instance
from database import database as db
db.engine = test_engine
db.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=test_engine)
# Create fresh app instance with test DB
from jira_webhook_llm import create_app
app = create_app() app = create_app()
return TestClient(app)
# Override the get_db_session dependency to use the test database
def override_get_db_session():
with setup_db.connect() as connection:
with sessionmaker(autocommit=False, autoflush=False, bind=connection)() as session:
yield session
app.dependency_overrides[get_db_session] = override_get_db_session
with TestClient(app) as client:
yield client
# Clean up dependency override
app.dependency_overrides.clear()
@pytest.fixture @pytest.fixture
def mock_jira_payload(): def mock_jira_payload():
return { return {
"projectKey": "TEST-PROJECT",
"issueKey": "TEST-123", "issueKey": "TEST-123",
"summary": "Test Issue", "summary": "Test Issue",
"description": "Test Description", "description": "Test Description",

View File

@ -2,6 +2,9 @@ import pytest
from fastapi import HTTPException from fastapi import HTTPException
from jira_webhook_llm import app from jira_webhook_llm import app
from llm.models import JiraWebhookPayload from llm.models import JiraWebhookPayload
from database.crud import create_analysis_record, get_analysis_by_id
from database.models import JiraAnalysis
from database.database import get_db
def test_error_handling_middleware(test_client, mock_jira_payload): def test_error_handling_middleware(test_client, mock_jira_payload):
# Test 404 error handling # Test 404 error handling
@ -12,13 +15,13 @@ def test_error_handling_middleware(test_client, mock_jira_payload):
# Test validation error handling # Test validation error handling
invalid_payload = mock_jira_payload.copy() invalid_payload = mock_jira_payload.copy()
invalid_payload.pop("issueKey") invalid_payload.pop("issueKey")
response = test_client.post("/jira-webhook", json=invalid_payload) response = test_client.post("/api/jira-webhook", json=invalid_payload)
assert response.status_code == 422 assert response.status_code == 422
assert "details" in response.json() assert "details" in response.json()
def test_webhook_handler(setup_db, test_client, mock_full_jira_payload): def test_webhook_handler(setup_db, test_client, mock_full_jira_payload):
# Test successful webhook handling with full payload # Test successful webhook handling with full payload
response = test_client.post("/jira-webhook", json=mock_full_jira_payload) response = test_client.post("/api/jira-webhook", json=mock_full_jira_payload)
assert response.status_code == 200 assert response.status_code == 200
response_data = response.json() response_data = response.json()
assert "status" in response_data assert "status" in response_data
@ -34,14 +37,30 @@ def test_webhook_handler(setup_db, test_client, mock_full_jira_payload):
assert record is not None assert record is not None
assert record.issue_summary == mock_full_jira_payload["summary"] assert record.issue_summary == mock_full_jira_payload["summary"]
assert record.request_payload == mock_full_jira_payload assert record.request_payload == mock_full_jira_payload
assert record.project_key == mock_full_jira_payload["projectKey"]
def test_llm_test_endpoint(test_client): def test_llm_test_endpoint(test_client):
# Test LLM test endpoint # Test LLM test endpoint
response = test_client.post("/test-llm") response = test_client.post("/api/test-llm")
assert response.status_code == 200 assert response.status_code == 200
assert "response" in response.json() assert "response" in response.json()
def test_create_analysis_record_endpoint(setup_db, test_client, mock_full_jira_payload):
# Test successful creation of a new analysis record via API
response = test_client.post("/api/request", json=mock_full_jira_payload)
assert response.status_code == 201
response_data = response.json()
assert "message" in response_data
assert response_data["message"] == "Record created successfully"
assert "record_id" in response_data
# Verify the record exists in the database
with get_db() as db:
record = get_analysis_by_id(db, response_data["record_id"])
assert record is not None
assert record.issue_key == mock_full_jira_payload["issueKey"]
assert record.issue_summary == mock_full_jira_payload["summary"]
assert record.request_payload == mock_full_jira_payload
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_retry_decorator(): async def test_retry_decorator():
# Test retry decorator functionality # Test retry decorator functionality