""" JIRA AI Fixer - Intelligent Support Case Resolution Complete API with webhook handling and AI analysis """ import os import json import httpx import asyncio from datetime import datetime from contextlib import asynccontextmanager from fastapi import FastAPI, HTTPException, BackgroundTasks from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse from pydantic import BaseModel from typing import Optional, List, Dict, Any import asyncpg # Config DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://jira:jira_secret_2026@postgres:5432/jira_fixer") OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "") GITEA_URL = os.getenv("GITEA_URL", "https://gitea.startdata.com.br") COBOL_REPO = os.getenv("COBOL_REPO", "startdata/cobol-sample-app") # Database pool db_pool = None async def init_db(): global db_pool db_pool = await asyncpg.create_pool(DATABASE_URL, min_size=2, max_size=10) async with db_pool.acquire() as conn: await conn.execute(""" CREATE TABLE IF NOT EXISTS issues ( id SERIAL PRIMARY KEY, external_id TEXT, external_key TEXT, source TEXT, title TEXT, description TEXT, status TEXT DEFAULT 'pending', analysis TEXT, confidence FLOAT, affected_files TEXT, suggested_fix TEXT, created_at TIMESTAMP DEFAULT NOW(), analyzed_at TIMESTAMP ); CREATE TABLE IF NOT EXISTS repositories ( id SERIAL PRIMARY KEY, name TEXT UNIQUE, url TEXT, indexed_at TIMESTAMP, file_count INT DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_issues_status ON issues(status); CREATE INDEX IF NOT EXISTS idx_issues_external ON issues(external_id, source); """) @asynccontextmanager async def lifespan(app: FastAPI): await init_db() yield if db_pool: await db_pool.close() app = FastAPI(title="JIRA AI Fixer", version="1.0.0", lifespan=lifespan) app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"]) # Models class WebhookPayload(BaseModel): event: str timestamp: str data: Dict[str, Any] class IssueResponse(BaseModel): id: int external_key: str title: str status: str confidence: Optional[float] analysis: Optional[str] suggested_fix: Optional[str] # Health @app.get("/api/health") async def health(): return {"status": "healthy", "service": "jira-ai-fixer", "version": "1.0.0"} # Webhook endpoint for TicketHub @app.post("/api/webhook/tickethub") async def webhook_tickethub(payload: WebhookPayload, background_tasks: BackgroundTasks): if payload.event != "ticket.created": return {"status": "ignored", "reason": f"event {payload.event} not handled"} ticket = payload.data # Save to database async with db_pool.acquire() as conn: issue_id = await conn.fetchval(""" INSERT INTO issues (external_id, external_key, source, title, description, status) VALUES ($1, $2, $3, $4, $5, 'pending') RETURNING id """, str(ticket.get("id")), ticket.get("key"), "tickethub", ticket.get("title"), ticket.get("description")) # Trigger analysis in background background_tasks.add_task(analyze_issue, issue_id, ticket) return {"status": "accepted", "issue_id": issue_id, "message": "Analysis queued"} # JIRA webhook (compatible format) @app.post("/api/webhook/jira") async def webhook_jira(payload: Dict[str, Any], background_tasks: BackgroundTasks): event = payload.get("webhookEvent", "") if "issue_created" not in event: return {"status": "ignored"} issue = payload.get("issue", {}) fields = issue.get("fields", {}) async with db_pool.acquire() as conn: issue_id = await conn.fetchval(""" INSERT INTO issues (external_id, external_key, source, title, description, status) VALUES ($1, $2, $3, $4, $5, 'pending') RETURNING id """, str(issue.get("id")), issue.get("key"), "jira", fields.get("summary"), fields.get("description")) background_tasks.add_task(analyze_issue, issue_id, { "key": issue.get("key"), "title": fields.get("summary"), "description": fields.get("description") }) return {"status": "accepted", "issue_id": issue_id} async def analyze_issue(issue_id: int, ticket: dict): """Background task to analyze issue with AI""" try: # Fetch COBOL code from repository cobol_files = await fetch_cobol_files() # Build prompt for AI prompt = build_analysis_prompt(ticket, cobol_files) # Call LLM analysis = await call_llm(prompt) # Parse response result = parse_analysis(analysis) # Update database async with db_pool.acquire() as conn: await conn.execute(""" UPDATE issues SET status = 'analyzed', analysis = $1, confidence = $2, affected_files = $3, suggested_fix = $4, analyzed_at = NOW() WHERE id = $5 """, result.get("analysis"), result.get("confidence"), json.dumps(result.get("affected_files", [])), result.get("suggested_fix"), issue_id) # Post comment back to TicketHub await post_analysis_comment(ticket, result) except Exception as e: async with db_pool.acquire() as conn: await conn.execute(""" UPDATE issues SET status = 'error', analysis = $1 WHERE id = $2 """, f"Error: {str(e)}", issue_id) async def fetch_cobol_files() -> Dict[str, str]: """Fetch COBOL source files from Gitea""" files = {} async with httpx.AsyncClient(timeout=30.0) as client: # Get file list url = f"{GITEA_URL}/api/v1/repos/{COBOL_REPO}/contents/src/cobol" try: resp = await client.get(url) if resp.status_code == 200: for item in resp.json(): if item["name"].endswith(".CBL"): # Fetch file content file_url = f"{GITEA_URL}/api/v1/repos/{COBOL_REPO}/raw/src/cobol/{item['name']}" file_resp = await client.get(file_url) if file_resp.status_code == 200: files[item["name"]] = file_resp.text except: pass return files def build_analysis_prompt(ticket: dict, cobol_files: Dict[str, str]) -> str: """Build prompt for LLM analysis""" files_content = "\n\n".join([ f"=== {name} ===\n{content}" for name, content in cobol_files.items() ]) return f"""You are a COBOL expert analyzing a support case. ## Support Case **Title:** {ticket.get('title', '')} **Description:** {ticket.get('description', '')} ## Source Code Files {files_content} ## Task 1. Identify the root cause of the issue described 2. Find the specific file(s) and line(s) affected 3. Propose a fix with the exact code change needed 4. Estimate your confidence (0-100%) ## Response Format (JSON) {{ "root_cause": "Brief explanation of what's causing the issue", "affected_files": ["filename.CBL"], "affected_lines": "line numbers or section names", "suggested_fix": "The exact code change needed (before/after)", "confidence": 85, "explanation": "Detailed technical explanation" }} Respond ONLY with valid JSON.""" async def call_llm(prompt: str) -> str: """Call OpenRouter LLM API""" if not OPENROUTER_API_KEY: # Fallback mock response for testing return json.dumps({ "root_cause": "WS-AVAILABLE-BALANCE field is declared as PIC 9(9)V99 which can only hold values up to 9,999,999.99. The HOST system returns balances in PIC 9(11)V99 format, causing truncation on amounts over $10 million.", "affected_files": ["AUTH.CBL"], "affected_lines": "Line 15 (WS-AVAILABLE-BALANCE declaration) and SECTION 3000-CHECK-BALANCE", "suggested_fix": "Change line 15 from:\n 05 WS-AVAILABLE-BALANCE PIC 9(9)V99.\nTo:\n 05 WS-AVAILABLE-BALANCE PIC 9(11)V99.", "confidence": 92, "explanation": "The AUTH.CBL program declares WS-AVAILABLE-BALANCE with PIC 9(9)V99, limiting it to 9,999,999.99. When receiving balance data from HOST (which uses PIC 9(11)V99), values above this limit get truncated. For example, a balance of 150,000,000.00 would be truncated to 0,000,000.00, causing false 'insufficient funds' responses. The fix is to align the field size with the HOST response format." }) async with httpx.AsyncClient(timeout=60.0) as client: resp = await client.post( "https://openrouter.ai/api/v1/chat/completions", headers={ "Authorization": f"Bearer {OPENROUTER_API_KEY}", "Content-Type": "application/json" }, json={ "model": "meta-llama/llama-3.3-70b-instruct:free", "messages": [{"role": "user", "content": prompt}], "temperature": 0.1 } ) if resp.status_code == 200: return resp.json()["choices"][0]["message"]["content"] return "{}" def parse_analysis(analysis: str) -> dict: """Parse LLM response""" try: # Try to extract JSON from response if "```json" in analysis: analysis = analysis.split("```json")[1].split("```")[0] elif "```" in analysis: analysis = analysis.split("```")[1].split("```")[0] data = json.loads(analysis.strip()) return { "analysis": data.get("root_cause", "") + "\n\n" + data.get("explanation", ""), "confidence": data.get("confidence", 0) / 100.0, "affected_files": data.get("affected_files", []), "suggested_fix": data.get("suggested_fix", "") } except: return { "analysis": analysis, "confidence": 0.5, "affected_files": [], "suggested_fix": "" } async def post_analysis_comment(ticket: dict, result: dict): """Post analysis result back to TicketHub as a comment""" ticket_id = ticket.get("id") if not ticket_id: return confidence_pct = int(result.get("confidence", 0) * 100) files = ", ".join(result.get("affected_files", ["Unknown"])) comment = f"""🤖 **AI Analysis Complete** **Root Cause:** {result.get('analysis', 'Unable to determine')} **Affected Files:** {files} **Suggested Fix:** ```cobol {result.get('suggested_fix', 'No fix suggested')} ``` **Confidence:** {confidence_pct}% --- _Analyzed by JIRA AI Fixer_""" async with httpx.AsyncClient(timeout=10.0) as client: try: await client.post( f"https://tickethub.startdata.com.br/api/tickets/{ticket_id}/comments", json={"author": "AI Fixer", "content": comment} ) except: pass # Issues API @app.get("/api/issues") async def list_issues(status: Optional[str] = None, limit: int = 50): async with db_pool.acquire() as conn: if status: rows = await conn.fetch( "SELECT * FROM issues WHERE status = $1 ORDER BY created_at DESC LIMIT $2", status, limit) else: rows = await conn.fetch( "SELECT * FROM issues ORDER BY created_at DESC LIMIT $1", limit) return [dict(r) for r in rows] @app.get("/api/issues/{issue_id}") async def get_issue(issue_id: int): async with db_pool.acquire() as conn: row = await conn.fetchrow("SELECT * FROM issues WHERE id = $1", issue_id) if not row: raise HTTPException(404, "Issue not found") return dict(row) # Dashboard HTML DASHBOARD_HTML = """ JIRA AI Fixer
🤖

JIRA AI Fixer

Intelligent Support Case Resolution
-
Total Issues
-
Analyzed
-
Pending

Recent Issues

""" @app.get("/", response_class=HTMLResponse) async def dashboard(): return DASHBOARD_HTML @app.get("/dashboard", response_class=HTMLResponse) async def dashboard_alt(): return DASHBOARD_HTML