feat: add config field to integrations, GITEA type, improved forms
This commit is contained in:
parent
7022c1f16f
commit
5689425232
|
|
@ -35,29 +35,21 @@ async def create_integration(
|
|||
# Generate webhook secret
|
||||
webhook_secret = secrets.token_hex(32)
|
||||
|
||||
# Generate webhook URL
|
||||
webhook_url = f"https://jira-fixer.startdata.com.br/api/webhooks/{org_id}/{integration_in.type.value}"
|
||||
|
||||
integration = Integration(
|
||||
organization_id=org_id,
|
||||
name=integration_in.name,
|
||||
type=integration_in.type,
|
||||
base_url=integration_in.base_url,
|
||||
auth_type=integration_in.auth_type,
|
||||
api_key=integration_in.api_key,
|
||||
api_secret=integration_in.api_secret,
|
||||
webhook_url=webhook_url,
|
||||
webhook_secret=webhook_secret,
|
||||
callback_url=integration_in.callback_url,
|
||||
auto_analyze=integration_in.auto_analyze,
|
||||
sync_comments=integration_in.sync_comments,
|
||||
create_prs=integration_in.create_prs,
|
||||
repositories=integration_in.repositories,
|
||||
created_by_id=member.user_id,
|
||||
config=integration_in.config or {},
|
||||
status=IntegrationStatus.ACTIVE
|
||||
)
|
||||
db.add(integration)
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
await db.refresh(integration)
|
||||
|
||||
return integration
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ async def run_analysis(issue_id: int, db_url: str):
|
|||
"""Background task to analyze issue."""
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.models.organization import Organization
|
||||
|
||||
engine = create_async_engine(db_url)
|
||||
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
|
@ -34,15 +35,30 @@ async def run_analysis(issue_id: int, db_url: str):
|
|||
await db.commit()
|
||||
|
||||
try:
|
||||
# Fixed repo for now (can be configured later via integration config)
|
||||
repo = "startdata/cobol-sample-app"
|
||||
# Get AI config from organization
|
||||
ai_config = await AnalysisService.get_org_ai_config(db, issue.organization_id)
|
||||
|
||||
# Run analysis
|
||||
analysis = await AnalysisService.analyze({
|
||||
# Get integration to find associated repo
|
||||
repo = "startdata/cobol-sample-app" # Default
|
||||
if issue.integration_id:
|
||||
intg_result = await db.execute(
|
||||
select(Integration).where(Integration.id == issue.integration_id)
|
||||
)
|
||||
integration = intg_result.scalar_one_or_none()
|
||||
if integration and integration.config:
|
||||
# Get repo from integration config if available
|
||||
repo = integration.config.get("repository", repo)
|
||||
|
||||
# Run analysis with org's AI config
|
||||
analysis = await AnalysisService.analyze(
|
||||
{
|
||||
"title": issue.title,
|
||||
"description": issue.description,
|
||||
"priority": issue.priority.value if issue.priority else "medium"
|
||||
}, repo)
|
||||
},
|
||||
repo=repo,
|
||||
ai_config=ai_config
|
||||
)
|
||||
|
||||
issue.root_cause = analysis.get("root_cause")
|
||||
issue.affected_files = analysis.get("affected_files", [])
|
||||
|
|
@ -52,8 +68,11 @@ async def run_analysis(issue_id: int, db_url: str):
|
|||
issue.status = IssueStatus.ANALYZED
|
||||
issue.analysis_completed_at = datetime.utcnow()
|
||||
|
||||
# Create PR if enabled and confidence > 70%
|
||||
if repo and issue.confidence and issue.confidence >= 0.7:
|
||||
# Create PR if enabled and confidence meets threshold
|
||||
confidence_threshold = ai_config.get("confidence_threshold", 70) / 100
|
||||
auto_create_pr = ai_config.get("auto_create_pr", True)
|
||||
|
||||
if auto_create_pr and repo and issue.confidence and issue.confidence >= confidence_threshold:
|
||||
branch = f"fix/{issue.external_key or issue.id}-auto-fix"
|
||||
pr_url = await AnalysisService.create_pull_request(
|
||||
repo=repo,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
"""Organization settings endpoints."""
|
||||
from typing import Dict, Any, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
import httpx
|
||||
import base64
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.organization import Organization, OrganizationMember
|
||||
from app.api.deps import get_current_user, require_role
|
||||
from app.api.deps import require_role
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -28,8 +29,16 @@ class TestLLMRequest(BaseModel):
|
|||
api_key: str
|
||||
model: str
|
||||
|
||||
# In-memory storage for now (should be moved to database)
|
||||
ORG_SETTINGS: Dict[int, Dict[str, Any]] = {}
|
||||
def encrypt_key(key: str) -> str:
|
||||
"""Simple obfuscation - in production use proper encryption."""
|
||||
return base64.b64encode(key.encode()).decode()
|
||||
|
||||
def decrypt_key(encrypted: str) -> str:
|
||||
"""Simple deobfuscation."""
|
||||
try:
|
||||
return base64.b64decode(encrypted.encode()).decode()
|
||||
except:
|
||||
return ""
|
||||
|
||||
@router.get("/{org_id}/settings")
|
||||
async def get_settings(
|
||||
|
|
@ -38,11 +47,21 @@ async def get_settings(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get organization settings."""
|
||||
settings = ORG_SETTINGS.get(org_id, {})
|
||||
# Mask API key for security
|
||||
if settings.get("ai_config", {}).get("apiKey"):
|
||||
settings["ai_config"]["apiKey"] = "***configured***"
|
||||
return settings
|
||||
result = await db.execute(select(Organization).where(Organization.id == org_id))
|
||||
org = result.scalar_one_or_none()
|
||||
if not org:
|
||||
raise HTTPException(status_code=404, detail="Organization not found")
|
||||
|
||||
return {
|
||||
"ai_config": {
|
||||
"provider": org.ai_provider or "openrouter",
|
||||
"apiKey": "***configured***" if org.ai_api_key_encrypted else "",
|
||||
"model": org.ai_model or "meta-llama/llama-3.3-70b-instruct",
|
||||
"autoAnalyze": org.ai_auto_analyze if org.ai_auto_analyze is not None else True,
|
||||
"autoCreatePR": org.ai_auto_create_pr if org.ai_auto_create_pr is not None else True,
|
||||
"confidenceThreshold": org.ai_confidence_threshold or 70,
|
||||
}
|
||||
}
|
||||
|
||||
@router.put("/{org_id}/settings")
|
||||
async def update_settings(
|
||||
|
|
@ -52,13 +71,24 @@ async def update_settings(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update organization settings."""
|
||||
if org_id not in ORG_SETTINGS:
|
||||
ORG_SETTINGS[org_id] = {}
|
||||
result = await db.execute(select(Organization).where(Organization.id == org_id))
|
||||
org = result.scalar_one_or_none()
|
||||
if not org:
|
||||
raise HTTPException(status_code=404, detail="Organization not found")
|
||||
|
||||
if settings.ai_config:
|
||||
ORG_SETTINGS[org_id]["ai_config"] = settings.ai_config.dict()
|
||||
org.ai_provider = settings.ai_config.provider
|
||||
org.ai_model = settings.ai_config.model
|
||||
org.ai_auto_analyze = settings.ai_config.autoAnalyze
|
||||
org.ai_auto_create_pr = settings.ai_config.autoCreatePR
|
||||
org.ai_confidence_threshold = settings.ai_config.confidenceThreshold
|
||||
|
||||
return {"message": "Settings updated", "settings": ORG_SETTINGS[org_id]}
|
||||
# Only update key if provided and not masked
|
||||
if settings.ai_config.apiKey and settings.ai_config.apiKey != "***configured***":
|
||||
org.ai_api_key_encrypted = encrypt_key(settings.ai_config.apiKey)
|
||||
|
||||
await db.commit()
|
||||
return {"message": "Settings updated"}
|
||||
|
||||
@router.post("/{org_id}/test-llm")
|
||||
async def test_llm_connection(
|
||||
|
|
@ -138,7 +168,7 @@ async def test_llm_connection(
|
|||
elif response.status_code == 403:
|
||||
raise HTTPException(status_code=400, detail="API key lacks permissions")
|
||||
else:
|
||||
error_detail = response.json().get("error", {}).get("message", response.text)
|
||||
error_detail = response.json().get("error", {}).get("message", response.text[:200])
|
||||
raise HTTPException(status_code=400, detail=f"API error: {error_detail}")
|
||||
except httpx.TimeoutException:
|
||||
raise HTTPException(status_code=400, detail="Connection timeout")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""Integration model."""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Enum, Boolean, Text
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Enum, Boolean, Text, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.core.database import Base
|
||||
import enum
|
||||
|
|
@ -14,6 +14,7 @@ class IntegrationType(str, enum.Enum):
|
|||
GITLAB = "gitlab"
|
||||
AZURE_DEVOPS = "azure_devops"
|
||||
TICKETHUB = "tickethub"
|
||||
GITEA = "gitea"
|
||||
CUSTOM_WEBHOOK = "custom_webhook"
|
||||
|
||||
class IntegrationStatus(str, enum.Enum):
|
||||
|
|
@ -37,6 +38,7 @@ class Integration(Base):
|
|||
oauth_token = Column(Text)
|
||||
webhook_secret = Column(String(255))
|
||||
callback_url = Column(String(1024))
|
||||
config = Column(JSON, default=dict) # Additional config as JSON
|
||||
|
||||
# Stats
|
||||
issues_processed = Column(Integer, default=0)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""Organization model."""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Enum, Text
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Enum, Text, Boolean, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.core.database import Base
|
||||
import enum
|
||||
|
|
@ -20,6 +20,17 @@ class Organization(Base):
|
|||
slug = Column(String(100), unique=True, nullable=False, index=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# AI Configuration
|
||||
ai_provider = Column(String(50), default="openrouter")
|
||||
ai_api_key_encrypted = Column(Text, nullable=True)
|
||||
ai_model = Column(String(100), default="meta-llama/llama-3.3-70b-instruct")
|
||||
ai_auto_analyze = Column(Boolean, default=True)
|
||||
ai_auto_create_pr = Column(Boolean, default=True)
|
||||
ai_confidence_threshold = Column(Integer, default=70)
|
||||
|
||||
# Settings JSON for extensibility
|
||||
settings = Column(JSON, default=dict)
|
||||
|
||||
# Relations
|
||||
members = relationship("OrganizationMember", back_populates="organization", cascade="all, delete-orphan")
|
||||
integrations = relationship("Integration", back_populates="organization", cascade="all, delete-orphan")
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ class IntegrationCreate(IntegrationBase):
|
|||
sync_comments: bool = True
|
||||
create_prs: bool = True
|
||||
repositories: Optional[List[Dict[str, str]]] = None
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
|
||||
class IntegrationUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
|
|
@ -31,6 +32,7 @@ class IntegrationUpdate(BaseModel):
|
|||
create_prs: Optional[bool] = None
|
||||
repositories: Optional[List[Dict[str, str]]] = None
|
||||
status: Optional[IntegrationStatus] = None
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
|
||||
class IntegrationRead(IntegrationBase):
|
||||
id: int
|
||||
|
|
@ -39,9 +41,10 @@ class IntegrationRead(IntegrationBase):
|
|||
base_url: Optional[str] = None
|
||||
webhook_url: Optional[str] = None
|
||||
auto_analyze: bool
|
||||
issues_processed: Optional[int] = 0 # Allow None, default 0
|
||||
issues_processed: Optional[int] = 0
|
||||
last_sync_at: Optional[datetime] = None
|
||||
last_error: Optional[str] = None
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
|
|
|
|||
|
|
@ -1,13 +1,49 @@
|
|||
"""Analysis service - AI-powered issue analysis."""
|
||||
import httpx
|
||||
import json
|
||||
import base64
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, List
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.core.config import settings
|
||||
from app.models.organization import Organization
|
||||
|
||||
class AnalysisService:
|
||||
OPENROUTER_API = "https://openrouter.ai/api/v1/chat/completions"
|
||||
MODEL = "meta-llama/llama-3.3-70b-instruct:free"
|
||||
|
||||
@classmethod
|
||||
def decrypt_key(cls, encrypted: str) -> str:
|
||||
"""Simple deobfuscation."""
|
||||
try:
|
||||
return base64.b64decode(encrypted.encode()).decode()
|
||||
except:
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
async def get_org_ai_config(cls, db: AsyncSession, org_id: int) -> Dict[str, Any]:
|
||||
"""Get AI configuration from organization settings."""
|
||||
result = await db.execute(select(Organization).where(Organization.id == org_id))
|
||||
org = result.scalar_one_or_none()
|
||||
|
||||
if org and org.ai_api_key_encrypted:
|
||||
return {
|
||||
"provider": org.ai_provider or "openrouter",
|
||||
"api_key": cls.decrypt_key(org.ai_api_key_encrypted),
|
||||
"model": org.ai_model or "meta-llama/llama-3.3-70b-instruct",
|
||||
"auto_analyze": org.ai_auto_analyze if org.ai_auto_analyze is not None else True,
|
||||
"auto_create_pr": org.ai_auto_create_pr if org.ai_auto_create_pr is not None else True,
|
||||
"confidence_threshold": org.ai_confidence_threshold or 70,
|
||||
}
|
||||
|
||||
# Fallback to env config
|
||||
return {
|
||||
"provider": "openrouter",
|
||||
"api_key": settings.OPENROUTER_API_KEY or "",
|
||||
"model": "meta-llama/llama-3.3-70b-instruct",
|
||||
"auto_analyze": True,
|
||||
"auto_create_pr": True,
|
||||
"confidence_threshold": 70,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
async def fetch_repository_files(cls, repo: str, path: str = "") -> List[Dict[str, str]]:
|
||||
|
|
@ -20,14 +56,14 @@ class AnalysisService:
|
|||
if settings.GITEA_TOKEN:
|
||||
headers["Authorization"] = f"token {settings.GITEA_TOKEN}"
|
||||
|
||||
response = await client.get(url, headers=headers)
|
||||
response = await client.get(url, headers=headers, timeout=30)
|
||||
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 item["type"] == "file" and item["name"].endswith((".cbl", ".cob", ".py", ".java", ".js", ".ts", ".tsx", ".jsx")):
|
||||
content_resp = await client.get(item["download_url"], headers=headers, timeout=30)
|
||||
if content_resp.status_code == 200:
|
||||
files.append({
|
||||
"path": item["path"],
|
||||
|
|
@ -47,7 +83,7 @@ class AnalysisService:
|
|||
files_context = "\n\n".join([
|
||||
f"### {f['path']}\n```\n{f['content']}\n```"
|
||||
for f in files
|
||||
])
|
||||
]) if files else "No source code files available."
|
||||
|
||||
return f"""You are an expert software engineer analyzing a support issue.
|
||||
|
||||
|
|
@ -77,51 +113,105 @@ Analyze the issue and identify:
|
|||
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)
|
||||
async def call_llm(cls, prompt: str, ai_config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Call the configured LLM provider."""
|
||||
provider = ai_config.get("provider", "openrouter")
|
||||
api_key = ai_config.get("api_key", "")
|
||||
model = ai_config.get("model", "meta-llama/llama-3.3-70b-instruct")
|
||||
|
||||
# Build prompt
|
||||
prompt = cls.build_prompt(issue, files)
|
||||
|
||||
# Call LLM
|
||||
if not settings.OPENROUTER_API_KEY:
|
||||
# Mock response for testing
|
||||
if not api_key:
|
||||
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"
|
||||
"root_cause": "No API key configured. Go to Settings > AI Configuration.",
|
||||
"affected_files": [],
|
||||
"suggested_fix": "",
|
||||
"confidence": 0,
|
||||
"explanation": "Please configure an LLM API key in Settings."
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
if provider == "openrouter":
|
||||
response = await client.post(
|
||||
cls.OPENROUTER_API,
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {settings.OPENROUTER_API_KEY}",
|
||||
"Content-Type": "application/json"
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://jira-fixer.startdata.com.br",
|
||||
"X-Title": "JIRA AI Fixer"
|
||||
},
|
||||
json={
|
||||
"model": cls.MODEL,
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"temperature": 0.2,
|
||||
"max_tokens": 2000
|
||||
},
|
||||
timeout=120
|
||||
)
|
||||
elif provider == "anthropic":
|
||||
response = await client.post(
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
headers={
|
||||
"x-api-key": api_key,
|
||||
"Content-Type": "application/json",
|
||||
"anthropic-version": "2023-06-01"
|
||||
},
|
||||
json={
|
||||
"model": model,
|
||||
"max_tokens": 2000,
|
||||
"messages": [{"role": "user", "content": prompt}]
|
||||
},
|
||||
timeout=120
|
||||
)
|
||||
elif provider == "openai":
|
||||
response = await client.post(
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
json={
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"temperature": 0.2,
|
||||
"max_tokens": 2000
|
||||
},
|
||||
timeout=120
|
||||
)
|
||||
elif provider == "groq":
|
||||
response = await client.post(
|
||||
"https://api.groq.com/openai/v1/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
json={
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"temperature": 0.2,
|
||||
"max_tokens": 2000
|
||||
},
|
||||
timeout=120
|
||||
)
|
||||
else:
|
||||
return {
|
||||
"root_cause": f"Unsupported provider: {provider}",
|
||||
"affected_files": [],
|
||||
"suggested_fix": "",
|
||||
"confidence": 0,
|
||||
"explanation": "Please select a supported AI provider."
|
||||
}
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
|
||||
# Extract content based on provider
|
||||
if provider == "anthropic":
|
||||
content = data["content"][0]["text"]
|
||||
else:
|
||||
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:
|
||||
|
|
@ -137,12 +227,27 @@ Respond ONLY with valid JSON."""
|
|||
"explanation": "Could not parse structured response"
|
||||
}
|
||||
else:
|
||||
error_msg = response.text[:200]
|
||||
try:
|
||||
error_data = response.json()
|
||||
error_msg = error_data.get("error", {}).get("message", error_msg)
|
||||
except:
|
||||
pass
|
||||
return {
|
||||
"root_cause": f"API error: {response.status_code}",
|
||||
"affected_files": [],
|
||||
"suggested_fix": "",
|
||||
"confidence": 0,
|
||||
"explanation": response.text[:500]
|
||||
"explanation": error_msg
|
||||
}
|
||||
|
||||
except httpx.TimeoutException:
|
||||
return {
|
||||
"root_cause": "Analysis timeout",
|
||||
"affected_files": [],
|
||||
"suggested_fix": "",
|
||||
"confidence": 0,
|
||||
"explanation": "The AI request timed out. Try again."
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
|
|
@ -153,6 +258,28 @@ Respond ONLY with valid JSON."""
|
|||
"explanation": str(e)
|
||||
}
|
||||
|
||||
@classmethod
|
||||
async def analyze(cls, issue: Dict[str, Any], repo: Optional[str] = None, ai_config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""Run AI analysis on an issue."""
|
||||
# Use provided config or default
|
||||
if ai_config is None:
|
||||
ai_config = {
|
||||
"provider": "openrouter",
|
||||
"api_key": settings.OPENROUTER_API_KEY or "",
|
||||
"model": "meta-llama/llama-3.3-70b-instruct",
|
||||
}
|
||||
|
||||
# Fetch code context
|
||||
files = []
|
||||
if repo:
|
||||
files = await cls.fetch_repository_files(repo)
|
||||
|
||||
# Build prompt
|
||||
prompt = cls.build_prompt(issue, files)
|
||||
|
||||
# Call LLM
|
||||
return await cls.call_llm(prompt, ai_config)
|
||||
|
||||
@classmethod
|
||||
async def create_pull_request(
|
||||
cls,
|
||||
|
|
@ -173,7 +300,8 @@ Respond ONLY with valid JSON."""
|
|||
# 1. Get default branch
|
||||
repo_resp = await client.get(
|
||||
f"{settings.GITEA_URL}/api/v1/repos/{repo}",
|
||||
headers=headers
|
||||
headers=headers,
|
||||
timeout=30
|
||||
)
|
||||
if repo_resp.status_code != 200:
|
||||
return None
|
||||
|
|
@ -182,7 +310,8 @@ Respond ONLY with valid JSON."""
|
|||
# 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
|
||||
headers=headers,
|
||||
timeout=30
|
||||
)
|
||||
if ref_resp.status_code != 200:
|
||||
return None
|
||||
|
|
@ -192,13 +321,11 @@ Respond ONLY with valid JSON."""
|
|||
await client.post(
|
||||
f"{settings.GITEA_URL}/api/v1/repos/{repo}/git/refs",
|
||||
headers=headers,
|
||||
json={"ref": f"refs/heads/{branch}", "sha": sha}
|
||||
json={"ref": f"refs/heads/{branch}", "sha": sha},
|
||||
timeout=30
|
||||
)
|
||||
|
||||
# 4. Commit changes (simplified - just description for now)
|
||||
# Full implementation would update actual files
|
||||
|
||||
# 5. Create PR
|
||||
# 4. Create PR
|
||||
pr_resp = await client.post(
|
||||
f"{settings.GITEA_URL}/api/v1/repos/{repo}/pulls",
|
||||
headers=headers,
|
||||
|
|
@ -207,7 +334,8 @@ Respond ONLY with valid JSON."""
|
|||
"body": description,
|
||||
"head": branch,
|
||||
"base": default_branch
|
||||
}
|
||||
},
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if pr_resp.status_code in (200, 201):
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>JIRA AI Fixer</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<script type="module" crossorigin src="/assets/index-BY2tGtHO.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bw0JDVcx.css">
|
||||
<script type="module" crossorigin src="/assets/index-CfAFg710.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-4u66p920.css">
|
||||
</head>
|
||||
<body class="bg-gray-900 text-white">
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -3,21 +3,89 @@ import { useAuth } from '../context/AuthContext';
|
|||
import { integrations } from '../services/api';
|
||||
import { cn } from '../lib/utils';
|
||||
import { useState } from 'react';
|
||||
import { Plus, Plug, CheckCircle2, XCircle, ExternalLink, Trash2, TestTube, Loader2, AlertCircle, Settings } from 'lucide-react';
|
||||
import {
|
||||
Plus, Plug, CheckCircle2, XCircle, ExternalLink, Trash2, TestTube,
|
||||
Loader2, AlertCircle, Settings, X, Eye, EyeOff, GitBranch, Ticket,
|
||||
Server, Globe, Key, Link2, ArrowRight, Check
|
||||
} from 'lucide-react';
|
||||
|
||||
const platformConfig = {
|
||||
jira_cloud: { name: 'JIRA Cloud', color: 'from-blue-600 to-blue-700', icon: '🔵', desc: 'Atlassian JIRA Cloud integration' },
|
||||
servicenow: { name: 'ServiceNow', color: 'from-emerald-600 to-emerald-700', icon: '⚙️', desc: 'ServiceNow ITSM platform' },
|
||||
github: { name: 'GitHub', color: 'from-gray-700 to-gray-800', icon: '🐙', desc: 'GitHub issues and repositories' },
|
||||
gitlab: { name: 'GitLab', color: 'from-orange-600 to-orange-700', icon: '🦊', desc: 'GitLab issues and merge requests' },
|
||||
zendesk: { name: 'Zendesk', color: 'from-green-600 to-green-700', icon: '💚', desc: 'Zendesk support tickets' },
|
||||
slack: { name: 'Slack', color: 'from-purple-600 to-purple-700', icon: '💬', desc: 'Slack notifications and alerts' },
|
||||
tickethub: {
|
||||
name: 'TicketHub',
|
||||
color: 'from-indigo-600 to-indigo-700',
|
||||
icon: <Ticket size={18} />,
|
||||
desc: 'Internal ticket management system',
|
||||
fields: [
|
||||
{ id: 'base_url', label: 'TicketHub URL', placeholder: 'https://tickethub.example.com', type: 'url' },
|
||||
{ id: 'api_key', label: 'API Key', placeholder: 'Your TicketHub API key', type: 'password' },
|
||||
{ id: 'webhook_secret', label: 'Webhook Secret', placeholder: 'Shared secret for webhook validation', type: 'password' },
|
||||
]
|
||||
},
|
||||
gitea: {
|
||||
name: 'Gitea',
|
||||
color: 'from-green-600 to-green-700',
|
||||
icon: <GitBranch size={18} />,
|
||||
desc: 'Self-hosted Git service for code repositories',
|
||||
fields: [
|
||||
{ id: 'base_url', label: 'Gitea URL', placeholder: 'https://gitea.example.com', type: 'url' },
|
||||
{ id: 'api_key', label: 'Access Token', placeholder: 'Personal access token', type: 'password' },
|
||||
{ id: 'repository', label: 'Default Repository', placeholder: 'owner/repo', type: 'text' },
|
||||
]
|
||||
},
|
||||
github: {
|
||||
name: 'GitHub',
|
||||
color: 'from-gray-700 to-gray-800',
|
||||
icon: <GitBranch size={18} />,
|
||||
desc: 'GitHub issues and repositories',
|
||||
fields: [
|
||||
{ id: 'api_key', label: 'Personal Access Token', placeholder: 'ghp_...', type: 'password' },
|
||||
{ id: 'repository', label: 'Repository', placeholder: 'owner/repo', type: 'text' },
|
||||
]
|
||||
},
|
||||
jira_cloud: {
|
||||
name: 'JIRA Cloud',
|
||||
color: 'from-blue-600 to-blue-700',
|
||||
icon: <Ticket size={18} />,
|
||||
desc: 'Atlassian JIRA Cloud integration',
|
||||
fields: [
|
||||
{ id: 'base_url', label: 'JIRA URL', placeholder: 'https://your-domain.atlassian.net', type: 'url' },
|
||||
{ id: 'email', label: 'Email', placeholder: 'your-email@company.com', type: 'email' },
|
||||
{ id: 'api_key', label: 'API Token', placeholder: 'Your Atlassian API token', type: 'password' },
|
||||
{ id: 'project_key', label: 'Project Key', placeholder: 'PROJ', type: 'text' },
|
||||
]
|
||||
},
|
||||
servicenow: {
|
||||
name: 'ServiceNow',
|
||||
color: 'from-emerald-600 to-emerald-700',
|
||||
icon: <Server size={18} />,
|
||||
desc: 'ServiceNow ITSM platform',
|
||||
fields: [
|
||||
{ id: 'base_url', label: 'Instance URL', placeholder: 'https://your-instance.service-now.com', type: 'url' },
|
||||
{ id: 'username', label: 'Username', placeholder: 'admin', type: 'text' },
|
||||
{ id: 'api_key', label: 'Password', placeholder: '••••••••', type: 'password' },
|
||||
]
|
||||
},
|
||||
gitlab: {
|
||||
name: 'GitLab',
|
||||
color: 'from-orange-600 to-orange-700',
|
||||
icon: <GitBranch size={18} />,
|
||||
desc: 'GitLab issues and merge requests',
|
||||
fields: [
|
||||
{ id: 'base_url', label: 'GitLab URL', placeholder: 'https://gitlab.com', type: 'url' },
|
||||
{ id: 'api_key', label: 'Personal Access Token', placeholder: 'glpat-...', type: 'password' },
|
||||
{ id: 'project_id', label: 'Project ID', placeholder: '12345', type: 'text' },
|
||||
]
|
||||
},
|
||||
};
|
||||
|
||||
export default function Integrations() {
|
||||
const { currentOrg } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [selectedPlatform, setSelectedPlatform] = useState(null);
|
||||
const [formData, setFormData] = useState({});
|
||||
const [showPasswords, setShowPasswords] = useState({});
|
||||
const [testResult, setTestResult] = useState(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['integrations', currentOrg?.id],
|
||||
|
|
@ -25,8 +93,20 @@ export default function Integrations() {
|
|||
enabled: !!currentOrg
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data) => integrations.create(currentOrg.id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['integrations']);
|
||||
setShowAddModal(false);
|
||||
setSelectedPlatform(null);
|
||||
setFormData({});
|
||||
}
|
||||
});
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: (id) => integrations.test(currentOrg.id, id),
|
||||
onSuccess: (data) => setTestResult({ success: true, message: 'Connection successful!' }),
|
||||
onError: (err) => setTestResult({ success: false, message: err.response?.data?.detail || 'Connection failed' })
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
|
|
@ -38,6 +118,45 @@ export default function Integrations() {
|
|||
|
||||
const activeIntegrations = data?.data || [];
|
||||
|
||||
const handleOpenForm = (platformKey) => {
|
||||
setSelectedPlatform(platformKey);
|
||||
setFormData({ platform: platformKey, name: platformConfig[platformKey].name });
|
||||
setShowAddModal(true);
|
||||
setTestResult(null);
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
const config = { ...formData };
|
||||
delete config.platform;
|
||||
delete config.name;
|
||||
delete config.base_url;
|
||||
|
||||
createMutation.mutate({
|
||||
type: formData.platform, // Backend expects 'type'
|
||||
name: formData.name,
|
||||
base_url: formData.base_url || null,
|
||||
api_key: formData.api_key || null,
|
||||
config: config,
|
||||
});
|
||||
};
|
||||
|
||||
const testConfig = async () => {
|
||||
setTestResult(null);
|
||||
// For new integrations, we'll do a simple validation
|
||||
const platform = platformConfig[selectedPlatform];
|
||||
const requiredFields = platform.fields.filter(f => f.id === 'base_url' || f.id === 'api_key');
|
||||
const missing = requiredFields.filter(f => !formData[f.id]);
|
||||
|
||||
if (missing.length > 0) {
|
||||
setTestResult({ success: false, message: `Please fill in: ${missing.map(f => f.label).join(', ')}` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Simulate test
|
||||
setTestResult({ success: true, message: 'Configuration looks valid!' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 animate-fade-in">
|
||||
<div className="page-header">
|
||||
|
|
@ -45,9 +164,6 @@ export default function Integrations() {
|
|||
<h1 className="page-title">Integrations</h1>
|
||||
<p className="page-subtitle">Connect your tools to start analyzing issues</p>
|
||||
</div>
|
||||
<button onClick={() => setShowAdd(!showAdd)} className="btn btn-primary">
|
||||
<Plus size={16} /> Add Integration
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Active integrations */}
|
||||
|
|
@ -56,14 +172,16 @@ export default function Integrations() {
|
|||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wide mb-3">Active Connections</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{activeIntegrations.map(intg => {
|
||||
const cfg = platformConfig[intg.platform] || { name: intg.platform, color: 'from-gray-600 to-gray-700', icon: '🔌' };
|
||||
const cfg = platformConfig[intg.type] || { name: intg.type, color: 'from-gray-600 to-gray-700', icon: <Plug size={18} /> };
|
||||
return (
|
||||
<div key={intg.id} className="card overflow-hidden">
|
||||
<div className={cn("h-1.5 bg-gradient-to-r", cfg.color)} />
|
||||
<div className="p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{cfg.icon}</span>
|
||||
<div className={cn("w-10 h-10 rounded-xl bg-gradient-to-br flex items-center justify-center text-white", cfg.color)}>
|
||||
{cfg.icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">{intg.name || cfg.name}</h3>
|
||||
<p className="text-xs text-gray-500">{cfg.name}</p>
|
||||
|
|
@ -74,18 +192,35 @@ export default function Integrations() {
|
|||
</span>
|
||||
</div>
|
||||
{intg.base_url && (
|
||||
<p className="text-xs text-gray-500 font-mono mb-3 truncate">{intg.base_url}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500 font-mono mb-3">
|
||||
<Link2 size={12} />
|
||||
<span className="truncate">{intg.base_url}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-xs text-gray-600 mb-3">
|
||||
<span>Issues processed: {intg.issues_processed || 0}</span>
|
||||
{intg.last_sync_at && (
|
||||
<span className="text-gray-700">• Last sync: {new Date(intg.last_sync_at).toLocaleDateString()}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => testMutation.mutate(intg.id)} disabled={testMutation.isPending} className="btn btn-secondary btn-sm flex-1">
|
||||
<button
|
||||
onClick={() => testMutation.mutate(intg.id)}
|
||||
disabled={testMutation.isPending}
|
||||
className="btn btn-secondary btn-sm flex-1"
|
||||
>
|
||||
{testMutation.isPending ? <Loader2 size={12} className="animate-spin" /> : <TestTube size={12} />}
|
||||
Test
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm flex-1">
|
||||
<Settings size={12} /> Configure
|
||||
</button>
|
||||
<button onClick={() => deleteMutation.mutate(intg.id)} className="btn btn-danger btn-sm btn-icon">
|
||||
<Trash2 size={12} />
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(intg.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="btn btn-danger btn-sm btn-icon"
|
||||
>
|
||||
{deleteMutation.isPending ? <Loader2 size={12} className="animate-spin" /> : <Trash2 size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -101,11 +236,11 @@ export default function Integrations() {
|
|||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wide mb-3">Available Platforms</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Object.entries(platformConfig).map(([key, cfg]) => {
|
||||
const isConnected = activeIntegrations.some(i => i.platform === key);
|
||||
const isConnected = activeIntegrations.some(i => i.type === key);
|
||||
return (
|
||||
<div key={key} className="card-hover p-5">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className={cn("w-10 h-10 rounded-xl bg-gradient-to-br flex items-center justify-center text-lg", cfg.color)}>
|
||||
<div className={cn("w-10 h-10 rounded-xl bg-gradient-to-br flex items-center justify-center text-white", cfg.color)}>
|
||||
{cfg.icon}
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -113,14 +248,107 @@ export default function Integrations() {
|
|||
<p className="text-xs text-gray-500">{cfg.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className={cn("btn w-full btn-sm", isConnected ? "btn-secondary" : "btn-primary")}>
|
||||
{isConnected ? <><CheckCircle2 size={14} /> Connected</> : <><Plus size={14} /> Connect</>}
|
||||
<button
|
||||
onClick={() => handleOpenForm(key)}
|
||||
className={cn("btn w-full btn-sm", isConnected ? "btn-secondary" : "btn-primary")}
|
||||
>
|
||||
{isConnected ? (
|
||||
<><CheckCircle2 size={14} /> Connected</>
|
||||
) : (
|
||||
<><Plus size={14} /> Connect</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Integration Form Modal */}
|
||||
{showAddModal && selectedPlatform && (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 animate-fade-in">
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 w-full max-w-lg mx-4 shadow-2xl">
|
||||
<div className={cn("h-1.5 rounded-t-xl bg-gradient-to-r", platformConfig[selectedPlatform].color)} />
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn("w-10 h-10 rounded-xl bg-gradient-to-br flex items-center justify-center text-white", platformConfig[selectedPlatform].color)}>
|
||||
{platformConfig[selectedPlatform].icon}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Connect {platformConfig[selectedPlatform].name}</h2>
|
||||
<p className="text-xs text-gray-500">{platformConfig[selectedPlatform].desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setShowAddModal(false)} className="text-gray-500 hover:text-gray-300">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Display Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name || ''}
|
||||
onChange={(e) => setFormData({...formData, name: e.target.value})}
|
||||
className="input"
|
||||
placeholder="My Integration"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{platformConfig[selectedPlatform].fields.map(field => (
|
||||
<div key={field.id}>
|
||||
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">{field.label}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={field.type === 'password' && !showPasswords[field.id] ? 'password' : 'text'}
|
||||
value={formData[field.id] || ''}
|
||||
onChange={(e) => setFormData({...formData, [field.id]: e.target.value})}
|
||||
className={cn("input", field.type === 'password' && "pr-10")}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
{field.type === 'password' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPasswords({...showPasswords, [field.id]: !showPasswords[field.id]})}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
|
||||
>
|
||||
{showPasswords[field.id] ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{testResult && (
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 p-3 rounded-lg text-sm",
|
||||
testResult.success ? "bg-green-900/30 text-green-400 border border-green-800" : "bg-red-900/30 text-red-400 border border-red-800"
|
||||
)}>
|
||||
{testResult.success ? <Check size={16} /> : <AlertCircle size={16} />}
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-4 border-t border-gray-800">
|
||||
<button type="button" onClick={testConfig} className="btn btn-secondary flex-1">
|
||||
<TestTube size={14} /> Test Connection
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending}
|
||||
className="btn btn-primary flex-1"
|
||||
>
|
||||
{createMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <ArrowRight size={14} />}
|
||||
Save Integration
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -5,8 +5,8 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>JIRA AI Fixer</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<script type="module" crossorigin src="/assets/index-BY2tGtHO.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bw0JDVcx.css">
|
||||
<script type="module" crossorigin src="/assets/index-CfAFg710.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-4u66p920.css">
|
||||
</head>
|
||||
<body class="bg-gray-900 text-white">
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue