feat: add config field to integrations, GITEA type, improved forms

This commit is contained in:
Ricel Leite 2026-02-19 01:53:23 -03:00
parent 7022c1f16f
commit 5689425232
13 changed files with 1023 additions and 585 deletions

View File

@ -35,29 +35,21 @@ async def create_integration(
# Generate webhook secret # Generate webhook secret
webhook_secret = secrets.token_hex(32) 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( integration = Integration(
organization_id=org_id, organization_id=org_id,
name=integration_in.name, name=integration_in.name,
type=integration_in.type, type=integration_in.type,
base_url=integration_in.base_url, base_url=integration_in.base_url,
auth_type=integration_in.auth_type,
api_key=integration_in.api_key, api_key=integration_in.api_key,
api_secret=integration_in.api_secret,
webhook_url=webhook_url,
webhook_secret=webhook_secret, webhook_secret=webhook_secret,
callback_url=integration_in.callback_url, callback_url=integration_in.callback_url,
auto_analyze=integration_in.auto_analyze, auto_analyze=integration_in.auto_analyze,
sync_comments=integration_in.sync_comments, config=integration_in.config or {},
create_prs=integration_in.create_prs,
repositories=integration_in.repositories,
created_by_id=member.user_id,
status=IntegrationStatus.ACTIVE status=IntegrationStatus.ACTIVE
) )
db.add(integration) db.add(integration)
await db.flush() await db.commit()
await db.refresh(integration)
return integration return integration

View File

@ -19,6 +19,7 @@ async def run_analysis(issue_id: int, db_url: str):
"""Background task to analyze issue.""" """Background task to analyze issue."""
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from app.models.organization import Organization
engine = create_async_engine(db_url) engine = create_async_engine(db_url)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) 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() await db.commit()
try: try:
# Fixed repo for now (can be configured later via integration config) # Get AI config from organization
repo = "startdata/cobol-sample-app" ai_config = await AnalysisService.get_org_ai_config(db, issue.organization_id)
# Run analysis # Get integration to find associated repo
analysis = await AnalysisService.analyze({ repo = "startdata/cobol-sample-app" # Default
"title": issue.title, if issue.integration_id:
"description": issue.description, intg_result = await db.execute(
"priority": issue.priority.value if issue.priority else "medium" select(Integration).where(Integration.id == issue.integration_id)
}, repo) )
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,
ai_config=ai_config
)
issue.root_cause = analysis.get("root_cause") issue.root_cause = analysis.get("root_cause")
issue.affected_files = analysis.get("affected_files", []) 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.status = IssueStatus.ANALYZED
issue.analysis_completed_at = datetime.utcnow() issue.analysis_completed_at = datetime.utcnow()
# Create PR if enabled and confidence > 70% # Create PR if enabled and confidence meets threshold
if repo and issue.confidence and issue.confidence >= 0.7: 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" branch = f"fix/{issue.external_key or issue.id}-auto-fix"
pr_url = await AnalysisService.create_pull_request( pr_url = await AnalysisService.create_pull_request(
repo=repo, repo=repo,

View File

@ -1,14 +1,15 @@
"""Organization settings endpoints.""" """Organization settings endpoints."""
from typing import Dict, Any, Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from pydantic import BaseModel from pydantic import BaseModel
import httpx import httpx
import base64
from app.core.database import get_db from app.core.database import get_db
from app.models.organization import Organization, OrganizationMember 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() router = APIRouter()
@ -28,8 +29,16 @@ class TestLLMRequest(BaseModel):
api_key: str api_key: str
model: str model: str
# In-memory storage for now (should be moved to database) def encrypt_key(key: str) -> str:
ORG_SETTINGS: Dict[int, Dict[str, Any]] = {} """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") @router.get("/{org_id}/settings")
async def get_settings( async def get_settings(
@ -38,11 +47,21 @@ async def get_settings(
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Get organization settings.""" """Get organization settings."""
settings = ORG_SETTINGS.get(org_id, {}) result = await db.execute(select(Organization).where(Organization.id == org_id))
# Mask API key for security org = result.scalar_one_or_none()
if settings.get("ai_config", {}).get("apiKey"): if not org:
settings["ai_config"]["apiKey"] = "***configured***" raise HTTPException(status_code=404, detail="Organization not found")
return settings
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") @router.put("/{org_id}/settings")
async def update_settings( async def update_settings(
@ -52,13 +71,24 @@ async def update_settings(
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Update organization settings.""" """Update organization settings."""
if org_id not in ORG_SETTINGS: result = await db.execute(select(Organization).where(Organization.id == org_id))
ORG_SETTINGS[org_id] = {} org = result.scalar_one_or_none()
if not org:
raise HTTPException(status_code=404, detail="Organization not found")
if settings.ai_config: 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
# 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)
return {"message": "Settings updated", "settings": ORG_SETTINGS[org_id]} await db.commit()
return {"message": "Settings updated"}
@router.post("/{org_id}/test-llm") @router.post("/{org_id}/test-llm")
async def test_llm_connection( async def test_llm_connection(
@ -138,7 +168,7 @@ async def test_llm_connection(
elif response.status_code == 403: elif response.status_code == 403:
raise HTTPException(status_code=400, detail="API key lacks permissions") raise HTTPException(status_code=400, detail="API key lacks permissions")
else: 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}") raise HTTPException(status_code=400, detail=f"API error: {error_detail}")
except httpx.TimeoutException: except httpx.TimeoutException:
raise HTTPException(status_code=400, detail="Connection timeout") raise HTTPException(status_code=400, detail="Connection timeout")

View File

@ -1,6 +1,6 @@
"""Integration model.""" """Integration model."""
from datetime import datetime 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 sqlalchemy.orm import relationship
from app.core.database import Base from app.core.database import Base
import enum import enum
@ -14,6 +14,7 @@ class IntegrationType(str, enum.Enum):
GITLAB = "gitlab" GITLAB = "gitlab"
AZURE_DEVOPS = "azure_devops" AZURE_DEVOPS = "azure_devops"
TICKETHUB = "tickethub" TICKETHUB = "tickethub"
GITEA = "gitea"
CUSTOM_WEBHOOK = "custom_webhook" CUSTOM_WEBHOOK = "custom_webhook"
class IntegrationStatus(str, enum.Enum): class IntegrationStatus(str, enum.Enum):
@ -37,6 +38,7 @@ class Integration(Base):
oauth_token = Column(Text) oauth_token = Column(Text)
webhook_secret = Column(String(255)) webhook_secret = Column(String(255))
callback_url = Column(String(1024)) callback_url = Column(String(1024))
config = Column(JSON, default=dict) # Additional config as JSON
# Stats # Stats
issues_processed = Column(Integer, default=0) issues_processed = Column(Integer, default=0)

View File

@ -1,6 +1,6 @@
"""Organization model.""" """Organization model."""
from datetime import datetime 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 sqlalchemy.orm import relationship
from app.core.database import Base from app.core.database import Base
import enum import enum
@ -20,6 +20,17 @@ class Organization(Base):
slug = Column(String(100), unique=True, nullable=False, index=True) slug = Column(String(100), unique=True, nullable=False, index=True)
created_at = Column(DateTime, default=datetime.utcnow) 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 # Relations
members = relationship("OrganizationMember", back_populates="organization", cascade="all, delete-orphan") members = relationship("OrganizationMember", back_populates="organization", cascade="all, delete-orphan")
integrations = relationship("Integration", back_populates="organization", cascade="all, delete-orphan") integrations = relationship("Integration", back_populates="organization", cascade="all, delete-orphan")

View File

@ -19,6 +19,7 @@ class IntegrationCreate(IntegrationBase):
sync_comments: bool = True sync_comments: bool = True
create_prs: bool = True create_prs: bool = True
repositories: Optional[List[Dict[str, str]]] = None repositories: Optional[List[Dict[str, str]]] = None
config: Optional[Dict[str, Any]] = None
class IntegrationUpdate(BaseModel): class IntegrationUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = None
@ -31,6 +32,7 @@ class IntegrationUpdate(BaseModel):
create_prs: Optional[bool] = None create_prs: Optional[bool] = None
repositories: Optional[List[Dict[str, str]]] = None repositories: Optional[List[Dict[str, str]]] = None
status: Optional[IntegrationStatus] = None status: Optional[IntegrationStatus] = None
config: Optional[Dict[str, Any]] = None
class IntegrationRead(IntegrationBase): class IntegrationRead(IntegrationBase):
id: int id: int
@ -39,9 +41,10 @@ class IntegrationRead(IntegrationBase):
base_url: Optional[str] = None base_url: Optional[str] = None
webhook_url: Optional[str] = None webhook_url: Optional[str] = None
auto_analyze: bool auto_analyze: bool
issues_processed: Optional[int] = 0 # Allow None, default 0 issues_processed: Optional[int] = 0
last_sync_at: Optional[datetime] = None last_sync_at: Optional[datetime] = None
last_error: Optional[str] = None last_error: Optional[str] = None
config: Optional[Dict[str, Any]] = None
created_at: datetime created_at: datetime
class Config: class Config:

View File

@ -1,13 +1,49 @@
"""Analysis service - AI-powered issue analysis.""" """Analysis service - AI-powered issue analysis."""
import httpx import httpx
import json import json
import base64
from datetime import datetime from datetime import datetime
from typing import Optional, Dict, Any, List 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.core.config import settings
from app.models.organization import Organization
class AnalysisService: 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 @classmethod
async def fetch_repository_files(cls, repo: str, path: str = "") -> List[Dict[str, str]]: async def fetch_repository_files(cls, repo: str, path: str = "") -> List[Dict[str, str]]:
@ -20,14 +56,14 @@ class AnalysisService:
if settings.GITEA_TOKEN: if settings.GITEA_TOKEN:
headers["Authorization"] = f"token {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: if response.status_code != 200:
return files return files
items = response.json() items = response.json()
for item in items: for item in items:
if item["type"] == "file" and item["name"].endswith((".cbl", ".cob", ".py", ".java", ".js", ".ts")): 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) content_resp = await client.get(item["download_url"], headers=headers, timeout=30)
if content_resp.status_code == 200: if content_resp.status_code == 200:
files.append({ files.append({
"path": item["path"], "path": item["path"],
@ -47,7 +83,7 @@ class AnalysisService:
files_context = "\n\n".join([ files_context = "\n\n".join([
f"### {f['path']}\n```\n{f['content']}\n```" f"### {f['path']}\n```\n{f['content']}\n```"
for f in files for f in files
]) ]) if files else "No source code files available."
return f"""You are an expert software engineer analyzing a support issue. 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.""" Respond ONLY with valid JSON."""
@classmethod @classmethod
async def analyze(cls, issue: Dict[str, Any], repo: Optional[str] = None) -> Dict[str, Any]: async def call_llm(cls, prompt: str, ai_config: Dict[str, Any]) -> Dict[str, Any]:
"""Run AI analysis on an issue.""" """Call the configured LLM provider."""
# Fetch code context provider = ai_config.get("provider", "openrouter")
files = [] api_key = ai_config.get("api_key", "")
if repo: model = ai_config.get("model", "meta-llama/llama-3.3-70b-instruct")
files = await cls.fetch_repository_files(repo)
# Build prompt if not api_key:
prompt = cls.build_prompt(issue, files)
# Call LLM
if not settings.OPENROUTER_API_KEY:
# Mock response for testing
return { return {
"root_cause": "Mock analysis - configure OPENROUTER_API_KEY for real analysis", "root_cause": "No API key configured. Go to Settings > AI Configuration.",
"affected_files": ["example.py"], "affected_files": [],
"suggested_fix": "# Mock fix", "suggested_fix": "",
"confidence": 0.5, "confidence": 0,
"explanation": "This is a mock response" "explanation": "Please configure an LLM API key in Settings."
} }
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
try: try:
response = await client.post( if provider == "openrouter":
cls.OPENROUTER_API, response = await client.post(
headers={ "https://openrouter.ai/api/v1/chat/completions",
"Authorization": f"Bearer {settings.OPENROUTER_API_KEY}", headers={
"Content-Type": "application/json" "Authorization": f"Bearer {api_key}",
}, "Content-Type": "application/json",
json={ "HTTP-Referer": "https://jira-fixer.startdata.com.br",
"model": cls.MODEL, "X-Title": "JIRA AI Fixer"
"messages": [{"role": "user", "content": prompt}], },
"temperature": 0.2, json={
"max_tokens": 2000 "model": model,
}, "messages": [{"role": "user", "content": prompt}],
timeout=120 "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: if response.status_code == 200:
data = response.json() data = response.json()
content = data["choices"][0]["message"]["content"]
# Extract content based on provider
if provider == "anthropic":
content = data["content"][0]["text"]
else:
content = data["choices"][0]["message"]["content"]
# Parse JSON from response # Parse JSON from response
try: try:
# Handle markdown code blocks
if "```json" in content: if "```json" in content:
content = content.split("```json")[1].split("```")[0] content = content.split("```json")[1].split("```")[0]
elif "```" in content: elif "```" in content:
@ -137,13 +227,28 @@ Respond ONLY with valid JSON."""
"explanation": "Could not parse structured response" "explanation": "Could not parse structured response"
} }
else: else:
error_msg = response.text[:200]
try:
error_data = response.json()
error_msg = error_data.get("error", {}).get("message", error_msg)
except:
pass
return { return {
"root_cause": f"API error: {response.status_code}", "root_cause": f"API error: {response.status_code}",
"affected_files": [], "affected_files": [],
"suggested_fix": "", "suggested_fix": "",
"confidence": 0, "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: except Exception as e:
return { return {
"root_cause": f"Analysis error: {str(e)}", "root_cause": f"Analysis error: {str(e)}",
@ -153,6 +258,28 @@ Respond ONLY with valid JSON."""
"explanation": str(e) "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 @classmethod
async def create_pull_request( async def create_pull_request(
cls, cls,
@ -173,7 +300,8 @@ Respond ONLY with valid JSON."""
# 1. Get default branch # 1. Get default branch
repo_resp = await client.get( repo_resp = await client.get(
f"{settings.GITEA_URL}/api/v1/repos/{repo}", f"{settings.GITEA_URL}/api/v1/repos/{repo}",
headers=headers headers=headers,
timeout=30
) )
if repo_resp.status_code != 200: if repo_resp.status_code != 200:
return None return None
@ -182,7 +310,8 @@ Respond ONLY with valid JSON."""
# 2. Get latest commit SHA # 2. Get latest commit SHA
ref_resp = await client.get( ref_resp = await client.get(
f"{settings.GITEA_URL}/api/v1/repos/{repo}/git/refs/heads/{default_branch}", 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: if ref_resp.status_code != 200:
return None return None
@ -192,13 +321,11 @@ Respond ONLY with valid JSON."""
await client.post( await client.post(
f"{settings.GITEA_URL}/api/v1/repos/{repo}/git/refs", f"{settings.GITEA_URL}/api/v1/repos/{repo}/git/refs",
headers=headers, 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) # 4. Create PR
# Full implementation would update actual files
# 5. Create PR
pr_resp = await client.post( pr_resp = await client.post(
f"{settings.GITEA_URL}/api/v1/repos/{repo}/pulls", f"{settings.GITEA_URL}/api/v1/repos/{repo}/pulls",
headers=headers, headers=headers,
@ -207,7 +334,8 @@ Respond ONLY with valid JSON."""
"body": description, "body": description,
"head": branch, "head": branch,
"base": default_branch "base": default_branch
} },
timeout=30
) )
if pr_resp.status_code in (200, 201): if pr_resp.status_code in (200, 201):

View File

@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JIRA AI Fixer</title> <title>JIRA AI Fixer</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<script type="module" crossorigin src="/assets/index-BY2tGtHO.js"></script> <script type="module" crossorigin src="/assets/index-CfAFg710.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bw0JDVcx.css"> <link rel="stylesheet" crossorigin href="/assets/index-4u66p920.css">
</head> </head>
<body class="bg-gray-900 text-white"> <body class="bg-gray-900 text-white">
<div id="root"></div> <div id="root"></div>

View File

@ -3,21 +3,89 @@ import { useAuth } from '../context/AuthContext';
import { integrations } from '../services/api'; import { integrations } from '../services/api';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { useState } from 'react'; 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 = { const platformConfig = {
jira_cloud: { name: 'JIRA Cloud', color: 'from-blue-600 to-blue-700', icon: '🔵', desc: 'Atlassian JIRA Cloud integration' }, tickethub: {
servicenow: { name: 'ServiceNow', color: 'from-emerald-600 to-emerald-700', icon: '⚙️', desc: 'ServiceNow ITSM platform' }, name: 'TicketHub',
github: { name: 'GitHub', color: 'from-gray-700 to-gray-800', icon: '🐙', desc: 'GitHub issues and repositories' }, color: 'from-indigo-600 to-indigo-700',
gitlab: { name: 'GitLab', color: 'from-orange-600 to-orange-700', icon: '🦊', desc: 'GitLab issues and merge requests' }, icon: <Ticket size={18} />,
zendesk: { name: 'Zendesk', color: 'from-green-600 to-green-700', icon: '💚', desc: 'Zendesk support tickets' }, desc: 'Internal ticket management system',
slack: { name: 'Slack', color: 'from-purple-600 to-purple-700', icon: '💬', desc: 'Slack notifications and alerts' }, 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() { export default function Integrations() {
const { currentOrg } = useAuth(); const { currentOrg } = useAuth();
const queryClient = useQueryClient(); 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({ const { data, isLoading } = useQuery({
queryKey: ['integrations', currentOrg?.id], queryKey: ['integrations', currentOrg?.id],
@ -25,8 +93,20 @@ export default function Integrations() {
enabled: !!currentOrg enabled: !!currentOrg
}); });
const createMutation = useMutation({
mutationFn: (data) => integrations.create(currentOrg.id, data),
onSuccess: () => {
queryClient.invalidateQueries(['integrations']);
setShowAddModal(false);
setSelectedPlatform(null);
setFormData({});
}
});
const testMutation = useMutation({ const testMutation = useMutation({
mutationFn: (id) => integrations.test(currentOrg.id, id), 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({ const deleteMutation = useMutation({
@ -38,6 +118,45 @@ export default function Integrations() {
const activeIntegrations = data?.data || []; 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 ( return (
<div className="p-6 animate-fade-in"> <div className="p-6 animate-fade-in">
<div className="page-header"> <div className="page-header">
@ -45,9 +164,6 @@ export default function Integrations() {
<h1 className="page-title">Integrations</h1> <h1 className="page-title">Integrations</h1>
<p className="page-subtitle">Connect your tools to start analyzing issues</p> <p className="page-subtitle">Connect your tools to start analyzing issues</p>
</div> </div>
<button onClick={() => setShowAdd(!showAdd)} className="btn btn-primary">
<Plus size={16} /> Add Integration
</button>
</div> </div>
{/* Active integrations */} {/* 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> <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"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{activeIntegrations.map(intg => { {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 ( return (
<div key={intg.id} className="card overflow-hidden"> <div key={intg.id} className="card overflow-hidden">
<div className={cn("h-1.5 bg-gradient-to-r", cfg.color)} /> <div className={cn("h-1.5 bg-gradient-to-r", cfg.color)} />
<div className="p-5"> <div className="p-5">
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-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> <div>
<h3 className="font-semibold text-white">{intg.name || cfg.name}</h3> <h3 className="font-semibold text-white">{intg.name || cfg.name}</h3>
<p className="text-xs text-gray-500">{cfg.name}</p> <p className="text-xs text-gray-500">{cfg.name}</p>
@ -74,18 +192,35 @@ export default function Integrations() {
</span> </span>
</div> </div>
{intg.base_url && ( {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"> <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} />} {testMutation.isPending ? <Loader2 size={12} className="animate-spin" /> : <TestTube size={12} />}
Test Test
</button> </button>
<button className="btn btn-secondary btn-sm flex-1"> <button className="btn btn-secondary btn-sm flex-1">
<Settings size={12} /> Configure <Settings size={12} /> Configure
</button> </button>
<button onClick={() => deleteMutation.mutate(intg.id)} className="btn btn-danger btn-sm btn-icon"> <button
<Trash2 size={12} /> 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> </button>
</div> </div>
</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> <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"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.entries(platformConfig).map(([key, cfg]) => { {Object.entries(platformConfig).map(([key, cfg]) => {
const isConnected = activeIntegrations.some(i => i.platform === key); const isConnected = activeIntegrations.some(i => i.type === key);
return ( return (
<div key={key} className="card-hover p-5"> <div key={key} className="card-hover p-5">
<div className="flex items-center gap-3 mb-3"> <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} {cfg.icon}
</div> </div>
<div> <div>
@ -113,14 +248,107 @@ export default function Integrations() {
<p className="text-xs text-gray-500">{cfg.desc}</p> <p className="text-xs text-gray-500">{cfg.desc}</p>
</div> </div>
</div> </div>
<button className={cn("btn w-full btn-sm", isConnected ? "btn-secondary" : "btn-primary")}> <button
{isConnected ? <><CheckCircle2 size={14} /> Connected</> : <><Plus size={14} /> Connect</>} 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> </button>
</div> </div>
); );
})} })}
</div> </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> </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

View File

@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JIRA AI Fixer</title> <title>JIRA AI Fixer</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<script type="module" crossorigin src="/assets/index-BY2tGtHO.js"></script> <script type="module" crossorigin src="/assets/index-CfAFg710.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bw0JDVcx.css"> <link rel="stylesheet" crossorigin href="/assets/index-4u66p920.css">
</head> </head>
<body class="bg-gray-900 text-white"> <body class="bg-gray-900 text-white">
<div id="root"></div> <div id="root"></div>