""" 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) # Create branch and PR with the fix pr_info = await create_fix_branch_and_pr(ticket, result) # Post complete analysis with PR link back to TicketHub await post_complete_analysis(ticket, result, pr_info) 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"])) # FormataΓ§Γ£o texto plano com quebras de linha claras comment = f"""πŸ€– AI ANALYSIS COMPLETE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ πŸ“‹ ROOT CAUSE: {result.get('analysis', 'Unable to determine')} πŸ“ AFFECTED FILES: {files} πŸ”§ SUGGESTED FIX: ──────────────────────────────────────── {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 DASHBOARD_HTML = """ JIRA AI Fixer
πŸ€–

JIRA AI Fixer

Intelligent Support Case Resolution

● Online

Total Issues

0

πŸ“‹

Analyzed

0

βœ…

PRs Created

0

πŸ”€

Avg Confidence

0%

🎯

Recent Issues

Loading...

Integrations

🎫 TicketHub
Active
πŸ“¦ Gitea
Active
πŸ”΅ JIRA
Ready
βš™οΈ ServiceNow
Ready

Repositories

πŸ“

cobol-sample-app

4 COBOL files indexed

Webhook Endpoints

POST /api/webhook/tickethub
POST /api/webhook/jira
""" @app.get("/", response_class=HTMLResponse) async def dashboard(): return DASHBOARD_HTML @app.get("/dashboard", response_class=HTMLResponse) async def dashboard_alt(): return DASHBOARD_HTML # ============================================ # GIT INTEGRATION - Create Branch and PR # ============================================ GITEA_TOKEN = os.getenv("GITEA_TOKEN", "") # Token de acesso ao Gitea async def create_fix_branch_and_pr(ticket: dict, result: dict): """Create a branch with the fix and open a Pull Request""" ticket_key = ticket.get("key", "unknown") ticket_id = ticket.get("id") if not result.get("affected_files") or not result.get("suggested_fix"): return None # Parse affected file affected_files = result.get("affected_files", []) if isinstance(affected_files, str): import json as json_lib try: affected_files = json_lib.loads(affected_files) except: affected_files = [affected_files] if not affected_files: return None main_file = affected_files[0] # e.g., "AUTH.CBL" branch_name = f"fix/{ticket_key.lower()}-auto-fix" async with httpx.AsyncClient(timeout=30.0) as client: headers = {} if GITEA_TOKEN: headers["Authorization"] = f"token {GITEA_TOKEN}" try: # 1. Get the current file content and SHA file_path = f"src/cobol/{main_file}" file_url = f"{GITEA_URL}/api/v1/repos/{COBOL_REPO}/contents/{file_path}" resp = await client.get(file_url, headers=headers) if resp.status_code != 200: return {"error": f"File not found: {file_path}"} file_data = resp.json() current_content = file_data.get("content", "") file_sha = file_data.get("sha", "") # Decode base64 content import base64 try: original_code = base64.b64decode(current_content).decode('utf-8') except: return {"error": "Failed to decode file content"} # 2. Apply the fix (simple replacement for now) # The fix suggests changing PIC 9(9)V99 to PIC 9(11)V99 fixed_code = original_code.replace( "PIC 9(9)V99", "PIC 9(11)V99" ) if fixed_code == original_code: return {"error": "Could not apply fix automatically"} # 3. Get default branch SHA for creating new branch repo_url = f"{GITEA_URL}/api/v1/repos/{COBOL_REPO}" repo_resp = await client.get(repo_url, headers=headers) default_branch = repo_resp.json().get("default_branch", "main") # Get the SHA of default branch branch_url = f"{GITEA_URL}/api/v1/repos/{COBOL_REPO}/branches/{default_branch}" branch_resp = await client.get(branch_url, headers=headers) base_sha = branch_resp.json().get("commit", {}).get("sha", "") # 4. Create new branch create_branch_url = f"{GITEA_URL}/api/v1/repos/{COBOL_REPO}/branches" branch_data = { "new_branch_name": branch_name, "old_ref_name": default_branch } branch_create_resp = await client.post( create_branch_url, headers={**headers, "Content-Type": "application/json"}, json=branch_data ) if branch_create_resp.status_code not in [201, 200, 409]: # 409 = already exists return {"error": f"Failed to create branch: {branch_create_resp.text}"} # 5. Update the file in the new branch update_url = f"{GITEA_URL}/api/v1/repos/{COBOL_REPO}/contents/{file_path}" update_data = { "message": f"fix({ticket_key}): {ticket.get('title', 'Auto-fix')}\n\nAutomatically generated fix by JIRA AI Fixer.\nConfidence: {int(result.get('confidence', 0) * 100)}%", "content": base64.b64encode(fixed_code.encode()).decode(), "sha": file_sha, "branch": branch_name } update_resp = await client.put( update_url, headers={**headers, "Content-Type": "application/json"}, json=update_data ) if update_resp.status_code not in [200, 201]: return {"error": f"Failed to update file: {update_resp.text}"} # 6. Create Pull Request pr_url = f"{GITEA_URL}/api/v1/repos/{COBOL_REPO}/pulls" pr_data = { "title": f"[{ticket_key}] {ticket.get('title', 'Auto-fix')}", "body": f"""## πŸ€– Automated Fix **Ticket:** {ticket_key} **Issue:** {ticket.get('title', '')} ### Root Cause Analysis {result.get('analysis', 'N/A')} ### Changes Made - **File:** `{file_path}` - **Fix:** {result.get('suggested_fix', 'N/A')} ### Confidence {int(result.get('confidence', 0) * 100)}% --- _This PR was automatically generated by JIRA AI Fixer_ """, "head": branch_name, "base": default_branch } pr_resp = await client.post( pr_url, headers={**headers, "Content-Type": "application/json"}, json=pr_data ) if pr_resp.status_code in [200, 201]: pr_info = pr_resp.json() return { "success": True, "branch": branch_name, "pr_number": pr_info.get("number"), "pr_url": pr_info.get("html_url", f"{GITEA_URL}/{COBOL_REPO}/pulls/{pr_info.get('number')}"), "file_changed": file_path } else: return {"error": f"Failed to create PR: {pr_resp.text}"} except Exception as e: return {"error": str(e)} async def post_complete_analysis(ticket: dict, result: dict, pr_info: dict = None): """Post complete analysis with PR link back to TicketHub""" 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"])) # Build PR section pr_section = "" if pr_info and pr_info.get("success"): pr_section = f""" πŸ”€ PULL REQUEST CREATED: ──────────────────────────────────────── Branch: {pr_info.get('branch')} PR: #{pr_info.get('pr_number')} URL: {pr_info.get('pr_url')} ──────────────────────────────────────── """ elif pr_info and pr_info.get("error"): pr_section = f""" ⚠️ AUTO-FIX FAILED: {pr_info.get('error')} """ comment = f"""πŸ€– AI ANALYSIS COMPLETE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ πŸ“‹ ROOT CAUSE: {result.get('analysis', 'Unable to determine')} πŸ“ AFFECTED FILES: {files} πŸ”§ SUGGESTED FIX: ──────────────────────────────────────── {result.get('suggested_fix', 'No fix suggested')} ──────────────────────────────────────── {pr_section} πŸ“Š 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