diff --git a/api/main_v2.py b/api/main_v2.py new file mode 100644 index 0000000..188d2c7 --- /dev/null +++ b/api/main_v2.py @@ -0,0 +1,387 @@ +""" +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 = """ +
+