885 lines
37 KiB
Python
885 lines
37 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
|
|
from app.dashboard_html import DASHBOARD_HTML if False else None # noqa
|
|
|
|
DASHBOARD_HTML = """<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>JIRA AI Fixer</title>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
body { font-family: 'Inter', system-ui, sans-serif; }
|
|
.gradient-bg { background: linear-gradient(135deg, #1e3a8a 0%, #7c3aed 100%); }
|
|
</style>
|
|
</head>
|
|
<body class="bg-gray-900 text-white min-h-screen">
|
|
<!-- Header -->
|
|
<header class="gradient-bg border-b border-white/10">
|
|
<div class="max-w-7xl mx-auto px-6 py-4">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-3">
|
|
<span class="text-3xl">🤖</span>
|
|
<div>
|
|
<h1 class="text-xl font-bold">JIRA AI Fixer</h1>
|
|
<p class="text-sm text-blue-200">Intelligent Support Case Resolution</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-4">
|
|
<span class="px-3 py-1 bg-green-500/20 text-green-400 rounded-full text-sm" id="status">● Online</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main class="max-w-7xl mx-auto px-6 py-8">
|
|
<!-- Stats Grid -->
|
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
|
<div class="bg-gray-800 rounded-xl p-6 border border-gray-700">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-gray-400 text-sm">Total Issues</p>
|
|
<p class="text-3xl font-bold mt-1" id="stat-total">0</p>
|
|
</div>
|
|
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
|
<span class="text-2xl">📋</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="bg-gray-800 rounded-xl p-6 border border-gray-700">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-gray-400 text-sm">Analyzed</p>
|
|
<p class="text-3xl font-bold mt-1 text-green-400" id="stat-analyzed">0</p>
|
|
</div>
|
|
<div class="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center">
|
|
<span class="text-2xl">✅</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="bg-gray-800 rounded-xl p-6 border border-gray-700">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-gray-400 text-sm">PRs Created</p>
|
|
<p class="text-3xl font-bold mt-1 text-purple-400" id="stat-prs">0</p>
|
|
</div>
|
|
<div class="w-12 h-12 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
|
<span class="text-2xl">🔀</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="bg-gray-800 rounded-xl p-6 border border-gray-700">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-gray-400 text-sm">Avg Confidence</p>
|
|
<p class="text-3xl font-bold mt-1 text-yellow-400" id="stat-confidence">0%</p>
|
|
</div>
|
|
<div class="w-12 h-12 bg-yellow-500/20 rounded-lg flex items-center justify-center">
|
|
<span class="text-2xl">🎯</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main Content Grid -->
|
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
<!-- Issues List -->
|
|
<div class="lg:col-span-2 bg-gray-800 rounded-xl border border-gray-700">
|
|
<div class="p-4 border-b border-gray-700 flex items-center justify-between">
|
|
<h2 class="font-semibold">Recent Issues</h2>
|
|
<select id="filter-status" onchange="loadIssues()" class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-1 text-sm">
|
|
<option value="">All Status</option>
|
|
<option value="pending">Pending</option>
|
|
<option value="analyzed">Analyzed</option>
|
|
<option value="error">Error</option>
|
|
</select>
|
|
</div>
|
|
<div id="issues-list" class="divide-y divide-gray-700 max-h-[600px] overflow-y-auto">
|
|
<div class="p-8 text-center text-gray-500">Loading...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sidebar -->
|
|
<div class="space-y-6">
|
|
<!-- Integrations -->
|
|
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
|
<h3 class="font-semibold mb-4">Integrations</h3>
|
|
<div class="space-y-3">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-2">
|
|
<span>🎫</span>
|
|
<span class="text-sm">TicketHub</span>
|
|
</div>
|
|
<span class="text-xs px-2 py-1 bg-green-500/20 text-green-400 rounded">Active</span>
|
|
</div>
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-2">
|
|
<span>📦</span>
|
|
<span class="text-sm">Gitea</span>
|
|
</div>
|
|
<span class="text-xs px-2 py-1 bg-green-500/20 text-green-400 rounded">Active</span>
|
|
</div>
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-2">
|
|
<span>🔵</span>
|
|
<span class="text-sm">JIRA</span>
|
|
</div>
|
|
<span class="text-xs px-2 py-1 bg-gray-500/20 text-gray-400 rounded">Ready</span>
|
|
</div>
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-2">
|
|
<span>⚙️</span>
|
|
<span class="text-sm">ServiceNow</span>
|
|
</div>
|
|
<span class="text-xs px-2 py-1 bg-gray-500/20 text-gray-400 rounded">Ready</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Connected Repos -->
|
|
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
|
<h3 class="font-semibold mb-4">Repositories</h3>
|
|
<div class="space-y-3" id="repos-list">
|
|
<div class="flex items-center gap-2 p-2 bg-gray-700/50 rounded-lg">
|
|
<span>📁</span>
|
|
<div>
|
|
<p class="text-sm font-medium">cobol-sample-app</p>
|
|
<p class="text-xs text-gray-400">4 COBOL files indexed</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Quick Actions -->
|
|
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
|
<h3 class="font-semibold mb-4">Webhook Endpoints</h3>
|
|
<div class="space-y-2 text-xs">
|
|
<div class="p-2 bg-gray-700/50 rounded font-mono break-all">
|
|
POST /api/webhook/tickethub
|
|
</div>
|
|
<div class="p-2 bg-gray-700/50 rounded font-mono break-all">
|
|
POST /api/webhook/jira
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<!-- Issue Detail Modal -->
|
|
<div id="issue-modal" class="fixed inset-0 bg-black/70 hidden items-center justify-center z-50 p-4">
|
|
<div class="bg-gray-800 rounded-xl w-full max-w-2xl max-h-[90vh] overflow-hidden border border-gray-700">
|
|
<div class="p-4 border-b border-gray-700 flex justify-between items-center">
|
|
<div>
|
|
<span class="font-mono text-blue-400" id="modal-key"></span>
|
|
<span class="ml-2 px-2 py-1 rounded text-xs" id="modal-status"></span>
|
|
</div>
|
|
<button onclick="hideModal()" class="text-gray-400 hover:text-white">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
|
</button>
|
|
</div>
|
|
<div class="p-6 overflow-y-auto max-h-[70vh]" id="modal-content"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
loadIssues();
|
|
setInterval(loadIssues, 10000);
|
|
|
|
async function loadIssues() {
|
|
const filter = document.getElementById('filter-status').value;
|
|
const url = filter ? '/api/issues?status=' + filter : '/api/issues';
|
|
|
|
try {
|
|
const r = await fetch(url);
|
|
const issues = await r.json();
|
|
|
|
// Update stats
|
|
document.getElementById('stat-total').textContent = issues.length;
|
|
document.getElementById('stat-analyzed').textContent = issues.filter(i => i.status === 'analyzed').length;
|
|
|
|
// Count PRs (issues with suggested_fix that aren't errors)
|
|
const prs = issues.filter(i => i.status === 'analyzed' && i.suggested_fix).length;
|
|
document.getElementById('stat-prs').textContent = prs;
|
|
|
|
// Avg confidence
|
|
const analyzed = issues.filter(i => i.confidence);
|
|
const avgConf = analyzed.length ? Math.round(analyzed.reduce((a, i) => a + (i.confidence || 0), 0) / analyzed.length * 100) : 0;
|
|
document.getElementById('stat-confidence').textContent = avgConf + '%';
|
|
|
|
// Render list
|
|
const list = document.getElementById('issues-list');
|
|
if (!issues.length) {
|
|
list.innerHTML = '<div class="p-8 text-center text-gray-500">No issues found</div>';
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = issues.map(i => `
|
|
<div onclick="showIssue(${i.id})" class="p-4 hover:bg-gray-700/50 cursor-pointer">
|
|
<div class="flex justify-between items-start">
|
|
<div class="flex-1">
|
|
<div class="flex items-center gap-2">
|
|
<span class="font-mono text-blue-400 text-sm">${i.external_key || '#' + i.id}</span>
|
|
<span class="text-xs px-2 py-0.5 rounded ${getStatusClass(i.status)}">${i.status}</span>
|
|
</div>
|
|
<h4 class="font-medium mt-1">${i.title}</h4>
|
|
${i.confidence ? `<div class="mt-2 flex items-center gap-2">
|
|
<div class="flex-1 bg-gray-700 rounded-full h-2">
|
|
<div class="bg-green-500 h-2 rounded-full" style="width: ${Math.round(i.confidence * 100)}%"></div>
|
|
</div>
|
|
<span class="text-xs text-gray-400">${Math.round(i.confidence * 100)}%</span>
|
|
</div>` : ''}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
}
|
|
|
|
function getStatusClass(status) {
|
|
switch(status) {
|
|
case 'analyzed': return 'bg-green-500/20 text-green-400';
|
|
case 'pending': return 'bg-yellow-500/20 text-yellow-400';
|
|
case 'error': return 'bg-red-500/20 text-red-400';
|
|
default: return 'bg-gray-500/20 text-gray-400';
|
|
}
|
|
}
|
|
|
|
async function showIssue(id) {
|
|
const r = await fetch('/api/issues/' + id);
|
|
const issue = await r.json();
|
|
|
|
document.getElementById('modal-key').textContent = issue.external_key || '#' + issue.id;
|
|
document.getElementById('modal-status').textContent = issue.status;
|
|
document.getElementById('modal-status').className = 'ml-2 px-2 py-1 rounded text-xs ' + getStatusClass(issue.status);
|
|
|
|
let affectedFiles = [];
|
|
try {
|
|
affectedFiles = JSON.parse(issue.affected_files || '[]');
|
|
} catch(e) {}
|
|
|
|
document.getElementById('modal-content').innerHTML = `
|
|
<h3 class="text-lg font-semibold">${issue.title}</h3>
|
|
<p class="text-gray-400 text-sm mt-1">Source: ${issue.source}</p>
|
|
|
|
<div class="mt-4 p-4 bg-gray-700/50 rounded-lg">
|
|
<h4 class="text-sm font-medium text-gray-300 mb-2">Description</h4>
|
|
<pre class="whitespace-pre-wrap text-sm">${issue.description || 'N/A'}</pre>
|
|
</div>
|
|
|
|
${issue.analysis ? `
|
|
<div class="mt-4 p-4 bg-green-500/10 border border-green-500/30 rounded-lg">
|
|
<h4 class="text-sm font-medium text-green-400 mb-2">🔍 Analysis</h4>
|
|
<pre class="whitespace-pre-wrap text-sm">${issue.analysis}</pre>
|
|
</div>
|
|
` : ''}
|
|
|
|
${affectedFiles.length ? `
|
|
<div class="mt-4">
|
|
<h4 class="text-sm font-medium text-gray-300 mb-2">📁 Affected Files</h4>
|
|
<div class="flex flex-wrap gap-2">
|
|
${affectedFiles.map(f => `<span class="px-2 py-1 bg-gray-700 rounded text-sm font-mono">${f}</span>`).join('')}
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
|
|
${issue.suggested_fix ? `
|
|
<div class="mt-4 p-4 bg-purple-500/10 border border-purple-500/30 rounded-lg">
|
|
<h4 class="text-sm font-medium text-purple-400 mb-2">🔧 Suggested Fix</h4>
|
|
<pre class="whitespace-pre-wrap text-sm font-mono bg-gray-900 p-3 rounded">${issue.suggested_fix}</pre>
|
|
</div>
|
|
` : ''}
|
|
|
|
${issue.confidence ? `
|
|
<div class="mt-4 flex items-center gap-3">
|
|
<span class="text-sm text-gray-400">Confidence:</span>
|
|
<div class="flex-1 bg-gray-700 rounded-full h-3">
|
|
<div class="bg-green-500 h-3 rounded-full" style="width: ${Math.round(issue.confidence * 100)}%"></div>
|
|
</div>
|
|
<span class="font-bold text-green-400">${Math.round(issue.confidence * 100)}%</span>
|
|
</div>
|
|
` : ''}
|
|
|
|
<div class="mt-4 text-xs text-gray-500">
|
|
Created: ${new Date(issue.created_at).toLocaleString()}
|
|
${issue.analyzed_at ? `<br>Analyzed: ${new Date(issue.analyzed_at).toLocaleString()}` : ''}
|
|
</div>
|
|
`;
|
|
|
|
document.getElementById('issue-modal').classList.remove('hidden');
|
|
document.getElementById('issue-modal').classList.add('flex');
|
|
}
|
|
|
|
function hideModal() {
|
|
document.getElementById('issue-modal').classList.add('hidden');
|
|
document.getElementById('issue-modal').classList.remove('flex');
|
|
}
|
|
</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
|