jira-ai-fixer/api/main_v2.py

603 lines
23 KiB
Python

"""
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 = """<!DOCTYPE html>
<html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>JIRA AI Fixer</title><script src="https://cdn.tailwindcss.com"></script></head>
<body class="bg-gray-900 text-white">
<div class="min-h-screen">
<header class="bg-gray-800 border-b border-gray-700 p-4">
<div class="max-w-6xl mx-auto flex items-center justify-between">
<div class="flex items-center gap-3"><span class="text-3xl">🤖</span><h1 class="text-xl font-bold">JIRA AI Fixer</h1></div>
<span class="text-sm text-gray-400">Intelligent Support Case Resolution</span>
</div></header>
<main class="max-w-6xl mx-auto p-6">
<div class="grid grid-cols-3 gap-4 mb-6">
<div class="bg-gray-800 rounded-lg p-4"><div class="text-3xl font-bold" id="total">-</div><div class="text-gray-400">Total Issues</div></div>
<div class="bg-gray-800 rounded-lg p-4"><div class="text-3xl font-bold text-green-400" id="analyzed">-</div><div class="text-gray-400">Analyzed</div></div>
<div class="bg-gray-800 rounded-lg p-4"><div class="text-3xl font-bold text-yellow-400" id="pending">-</div><div class="text-gray-400">Pending</div></div>
</div>
<div class="bg-gray-800 rounded-lg">
<div class="p-4 border-b border-gray-700"><h2 class="font-semibold">Recent Issues</h2></div>
<div id="issues" class="divide-y divide-gray-700"></div>
</div>
</main></div>
<script>
async function load(){
const r=await fetch('/api/issues');const issues=await r.json();
document.getElementById('total').textContent=issues.length;
document.getElementById('analyzed').textContent=issues.filter(i=>i.status==='analyzed').length;
document.getElementById('pending').textContent=issues.filter(i=>i.status==='pending').length;
document.getElementById('issues').innerHTML=issues.length?issues.map(i=>`
<div class="p-4 hover:bg-gray-700/50">
<div class="flex justify-between items-start">
<div><span class="text-blue-400 font-mono">${i.external_key||'#'+i.id}</span>
<span class="ml-2">${i.title}</span></div>
<span class="px-2 py-1 rounded text-xs ${i.status==='analyzed'?'bg-green-500/20 text-green-400':i.status==='error'?'bg-red-500/20 text-red-400':'bg-yellow-500/20 text-yellow-400'}">${i.status}</span>
</div>
${i.confidence?`<div class="mt-2 text-sm text-gray-400">Confidence: ${Math.round(i.confidence*100)}%</div>`:''}
${i.analysis?`<div class="mt-2 text-sm text-gray-300 line-clamp-2">${i.analysis.substring(0,200)}...</div>`:''}
</div>`).join(''):'<div class="p-8 text-center text-gray-500">No issues yet</div>';
}
load();setInterval(load,5000);
</script></body></html>"""
@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