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
|
# 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue