jira-ai-fixer/app/services/analysis.py

221 lines
8.3 KiB
Python

"""Analysis service - AI-powered issue analysis."""
import httpx
import json
from datetime import datetime
from typing import Optional, Dict, Any, List
from app.core.config import settings
class AnalysisService:
OPENROUTER_API = "https://openrouter.ai/api/v1/chat/completions"
MODEL = "meta-llama/llama-3.3-70b-instruct:free"
@classmethod
async def fetch_repository_files(cls, repo: str, path: str = "") -> List[Dict[str, str]]:
"""Fetch files from Gitea repository."""
files = []
async with httpx.AsyncClient() as client:
try:
url = f"{settings.GITEA_URL}/api/v1/repos/{repo}/contents/{path}"
headers = {}
if settings.GITEA_TOKEN:
headers["Authorization"] = f"token {settings.GITEA_TOKEN}"
response = await client.get(url, headers=headers)
if response.status_code != 200:
return files
items = response.json()
for item in items:
if item["type"] == "file" and item["name"].endswith((".cbl", ".cob", ".py", ".java", ".js", ".ts")):
content_resp = await client.get(item["download_url"], headers=headers)
if content_resp.status_code == 200:
files.append({
"path": item["path"],
"content": content_resp.text[:10000] # Limit size
})
elif item["type"] == "dir":
sub_files = await cls.fetch_repository_files(repo, item["path"])
files.extend(sub_files)
except Exception as e:
print(f"Error fetching repo: {e}")
return files[:20] # Limit to 20 files
@classmethod
def build_prompt(cls, issue: Dict[str, Any], files: List[Dict[str, str]]) -> str:
"""Build analysis prompt for LLM."""
files_context = "\n\n".join([
f"### {f['path']}\n```\n{f['content']}\n```"
for f in files
])
return f"""You are an expert software engineer analyzing a support issue.
## Issue Details
**Title:** {issue.get('title', 'N/A')}
**Description:** {issue.get('description', 'N/A')}
**Priority:** {issue.get('priority', 'N/A')}
## Source Code Files
{files_context}
## Your Task
Analyze the issue and identify:
1. Root cause of the problem
2. Which files are affected
3. Suggested code fix
## Response Format (JSON)
{{
"root_cause": "Detailed explanation of what's causing the issue",
"affected_files": ["file1.py", "file2.py"],
"suggested_fix": "Code changes needed to fix the issue",
"confidence": 0.85,
"explanation": "Step-by-step explanation of the fix"
}}
Respond ONLY with valid JSON."""
@classmethod
async def analyze(cls, issue: Dict[str, Any], repo: Optional[str] = None) -> Dict[str, Any]:
"""Run AI analysis on an issue."""
# Fetch code context
files = []
if repo:
files = await cls.fetch_repository_files(repo)
# Build prompt
prompt = cls.build_prompt(issue, files)
# Call LLM
if not settings.OPENROUTER_API_KEY:
# Mock response for testing
return {
"root_cause": "Mock analysis - configure OPENROUTER_API_KEY for real analysis",
"affected_files": ["example.py"],
"suggested_fix": "# Mock fix",
"confidence": 0.5,
"explanation": "This is a mock response"
}
async with httpx.AsyncClient() as client:
try:
response = await client.post(
cls.OPENROUTER_API,
headers={
"Authorization": f"Bearer {settings.OPENROUTER_API_KEY}",
"Content-Type": "application/json"
},
json={
"model": cls.MODEL,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.2,
"max_tokens": 2000
},
timeout=120
)
if response.status_code == 200:
data = response.json()
content = data["choices"][0]["message"]["content"]
# Parse JSON from response
try:
# Handle markdown code blocks
if "```json" in content:
content = content.split("```json")[1].split("```")[0]
elif "```" in content:
content = content.split("```")[1].split("```")[0]
return json.loads(content.strip())
except json.JSONDecodeError:
return {
"root_cause": content[:500],
"affected_files": [],
"suggested_fix": "",
"confidence": 0.3,
"explanation": "Could not parse structured response"
}
else:
return {
"root_cause": f"API error: {response.status_code}",
"affected_files": [],
"suggested_fix": "",
"confidence": 0,
"explanation": response.text[:500]
}
except Exception as e:
return {
"root_cause": f"Analysis error: {str(e)}",
"affected_files": [],
"suggested_fix": "",
"confidence": 0,
"explanation": str(e)
}
@classmethod
async def create_pull_request(
cls,
repo: str,
branch: str,
title: str,
description: str,
file_changes: List[Dict[str, str]]
) -> Optional[str]:
"""Create a pull request with suggested fix."""
if not settings.GITEA_TOKEN:
return None
async with httpx.AsyncClient() as client:
headers = {"Authorization": f"token {settings.GITEA_TOKEN}"}
try:
# 1. Get default branch
repo_resp = await client.get(
f"{settings.GITEA_URL}/api/v1/repos/{repo}",
headers=headers
)
if repo_resp.status_code != 200:
return None
default_branch = repo_resp.json().get("default_branch", "main")
# 2. Get latest commit SHA
ref_resp = await client.get(
f"{settings.GITEA_URL}/api/v1/repos/{repo}/git/refs/heads/{default_branch}",
headers=headers
)
if ref_resp.status_code != 200:
return None
sha = ref_resp.json()["object"]["sha"]
# 3. Create branch
await client.post(
f"{settings.GITEA_URL}/api/v1/repos/{repo}/git/refs",
headers=headers,
json={"ref": f"refs/heads/{branch}", "sha": sha}
)
# 4. Commit changes (simplified - just description for now)
# Full implementation would update actual files
# 5. Create PR
pr_resp = await client.post(
f"{settings.GITEA_URL}/api/v1/repos/{repo}/pulls",
headers=headers,
json={
"title": title,
"body": description,
"head": branch,
"base": default_branch
}
)
if pr_resp.status_code in (200, 201):
pr_data = pr_resp.json()
return pr_data.get("html_url")
except Exception as e:
print(f"PR creation error: {e}")
return None