JIRA AI Fixer v2.0 - Enterprise Edition
Backend: - FastAPI with async SQLAlchemy - JWT auth with refresh tokens - RBAC (viewer→analyst→manager→admin→owner) - Multi-tenant organizations - Integrations: JIRA, ServiceNow, Zendesk, GitHub, GitLab, Azure DevOps - Webhook endpoints for all platforms - Reports with CSV export - Email via Resend - AI analysis via OpenRouter - PR creation via Gitea API - Audit logging Frontend: - React 18 + Vite + Tailwind - React Query for state/cache - Recharts for analytics - Dark enterprise theme - 8 pages: Login, Register, Dashboard, Issues, IssueDetail, Integrations, Team, Reports, Settings Ready for Hetzner deployment.
This commit is contained in:
commit
bfe59c2d57
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Database (use shared PostgreSQL Stack 49)
|
||||||
|
DATABASE_URL=postgresql://postgres:postgres@postgres_database:5432/jira_fixer_v2
|
||||||
|
|
||||||
|
# Redis (use shared Redis Stack 12)
|
||||||
|
REDIS_URL=redis://redis_redis:6379
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET=your-super-secret-jwt-key-change-me
|
||||||
|
|
||||||
|
# Email (Resend)
|
||||||
|
RESEND_API_KEY=re_LP4Vf7jA_E9fvcBtQ9aD219jA2QEBcZs7
|
||||||
|
|
||||||
|
# AI (OpenRouter)
|
||||||
|
OPENROUTER_API_KEY=your-openrouter-key
|
||||||
|
|
||||||
|
# Git (Gitea)
|
||||||
|
GITEA_URL=https://gitea.startdata.com.br
|
||||||
|
GITEA_TOKEN=4b28e0a797f16e0f9f986ad03a77a320fe90d3d6
|
||||||
|
|
||||||
|
# App
|
||||||
|
APP_URL=https://jira-fixer.startdata.com.br
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
# Stage 1: Build frontend
|
||||||
|
FROM node:20-alpine AS frontend-builder
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
COPY frontend/package.json frontend/package-lock.json* ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Python backend
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy backend
|
||||||
|
COPY app/ ./app/
|
||||||
|
|
||||||
|
# Copy built frontend
|
||||||
|
COPY --from=frontend-builder /build/dist ./frontend/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from .auth import router as auth_router
|
||||||
|
from .users import router as users_router
|
||||||
|
from .organizations import router as orgs_router
|
||||||
|
from .integrations import router as integrations_router
|
||||||
|
from .issues import router as issues_router
|
||||||
|
from .webhooks import router as webhooks_router
|
||||||
|
from .reports import router as reports_router
|
||||||
|
|
||||||
|
api_router = APIRouter()
|
||||||
|
api_router.include_router(auth_router, prefix="/auth", tags=["Authentication"])
|
||||||
|
api_router.include_router(users_router, prefix="/users", tags=["Users"])
|
||||||
|
api_router.include_router(orgs_router, prefix="/organizations", tags=["Organizations"])
|
||||||
|
api_router.include_router(integrations_router, prefix="/integrations", tags=["Integrations"])
|
||||||
|
api_router.include_router(issues_router, prefix="/issues", tags=["Issues"])
|
||||||
|
api_router.include_router(webhooks_router, prefix="/webhooks", tags=["Webhooks"])
|
||||||
|
api_router.include_router(reports_router, prefix="/reports", tags=["Reports"])
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
"""Authentication endpoints."""
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import verify_password, get_password_hash, create_access_token, create_refresh_token, decode_token
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.user import UserCreate, UserRead, Token
|
||||||
|
from app.services.audit import AuditService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.post("/register", response_model=UserRead)
|
||||||
|
async def register(
|
||||||
|
user_in: UserCreate,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Register a new user."""
|
||||||
|
# Check if email exists
|
||||||
|
result = await db.execute(select(User).where(User.email == user_in.email))
|
||||||
|
if result.scalar_one_or_none():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Email already registered"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create user
|
||||||
|
user = User(
|
||||||
|
email=user_in.email,
|
||||||
|
hashed_password=get_password_hash(user_in.password),
|
||||||
|
full_name=user_in.full_name
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Audit log
|
||||||
|
await AuditService.log(
|
||||||
|
db,
|
||||||
|
action="user.register",
|
||||||
|
user_id=user.id,
|
||||||
|
resource_type="user",
|
||||||
|
resource_id=user.id,
|
||||||
|
ip_address=request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
@router.post("/login", response_model=Token)
|
||||||
|
async def login(
|
||||||
|
email: str,
|
||||||
|
password: str,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Login and get access token."""
|
||||||
|
result = await db.execute(select(User).where(User.email == email))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user or not verify_password(password, user.hashed_password):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid email or password"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="User is inactive"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update last login
|
||||||
|
user.last_login = datetime.utcnow()
|
||||||
|
|
||||||
|
# Audit log
|
||||||
|
await AuditService.log(
|
||||||
|
db,
|
||||||
|
action="user.login",
|
||||||
|
user_id=user.id,
|
||||||
|
resource_type="user",
|
||||||
|
resource_id=user.id,
|
||||||
|
ip_address=request.client.host if request.client else None,
|
||||||
|
user_agent=request.headers.get("user-agent")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create tokens
|
||||||
|
token_data = {"user_id": user.id, "email": user.email}
|
||||||
|
access_token = create_access_token(token_data)
|
||||||
|
refresh_token = create_refresh_token(token_data)
|
||||||
|
|
||||||
|
return Token(access_token=access_token, refresh_token=refresh_token)
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=Token)
|
||||||
|
async def refresh_token(
|
||||||
|
refresh_token: str,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Refresh access token."""
|
||||||
|
payload = decode_token(refresh_token)
|
||||||
|
|
||||||
|
if not payload or payload.get("type") != "refresh":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid refresh token"
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id = payload.get("user_id")
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user or not user.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="User not found or inactive"
|
||||||
|
)
|
||||||
|
|
||||||
|
token_data = {"user_id": user.id, "email": user.email}
|
||||||
|
new_access_token = create_access_token(token_data)
|
||||||
|
new_refresh_token = create_refresh_token(token_data)
|
||||||
|
|
||||||
|
return Token(access_token=new_access_token, refresh_token=new_refresh_token)
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
"""API dependencies."""
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import decode_token, has_permission
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.organization import OrganizationMember
|
||||||
|
|
||||||
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
) -> User:
|
||||||
|
"""Get current authenticated user."""
|
||||||
|
token = credentials.credentials
|
||||||
|
payload = decode_token(token)
|
||||||
|
|
||||||
|
if not payload or payload.get("type") != "access":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid or expired token"
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id = payload.get("user_id")
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user or not user.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="User not found or inactive"
|
||||||
|
)
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
async def get_org_member(
|
||||||
|
org_id: int,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
) -> OrganizationMember:
|
||||||
|
"""Get user's membership in organization."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(OrganizationMember)
|
||||||
|
.where(OrganizationMember.organization_id == org_id)
|
||||||
|
.where(OrganizationMember.user_id == user.id)
|
||||||
|
)
|
||||||
|
member = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not member and not user.is_superuser:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Not a member of this organization"
|
||||||
|
)
|
||||||
|
|
||||||
|
return member
|
||||||
|
|
||||||
|
def require_role(required_role: str):
|
||||||
|
"""Dependency to require a minimum role."""
|
||||||
|
async def check_role(member: OrganizationMember = Depends(get_org_member)):
|
||||||
|
if not has_permission(member.role.value, required_role):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Requires {required_role} role or higher"
|
||||||
|
)
|
||||||
|
return member
|
||||||
|
return check_role
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
"""Integration management endpoints."""
|
||||||
|
from typing import List
|
||||||
|
import secrets
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.models.integration import Integration, IntegrationType, IntegrationStatus
|
||||||
|
from app.models.organization import OrganizationMember
|
||||||
|
from app.schemas.integration import IntegrationCreate, IntegrationRead, IntegrationUpdate
|
||||||
|
from app.api.deps import get_current_user, require_role
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[IntegrationRead])
|
||||||
|
async def list_integrations(
|
||||||
|
org_id: int,
|
||||||
|
member: OrganizationMember = Depends(require_role("analyst")),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""List integrations for organization."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Integration).where(Integration.organization_id == org_id)
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
@router.post("/", response_model=IntegrationRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_integration(
|
||||||
|
org_id: int,
|
||||||
|
integration_in: IntegrationCreate,
|
||||||
|
member: OrganizationMember = Depends(require_role("admin")),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Create a new integration."""
|
||||||
|
# Generate webhook secret
|
||||||
|
webhook_secret = secrets.token_hex(32)
|
||||||
|
|
||||||
|
# Generate webhook URL
|
||||||
|
webhook_url = f"https://jira-fixer.startdata.com.br/api/webhooks/{org_id}/{integration_in.type.value}"
|
||||||
|
|
||||||
|
integration = Integration(
|
||||||
|
organization_id=org_id,
|
||||||
|
name=integration_in.name,
|
||||||
|
type=integration_in.type,
|
||||||
|
base_url=integration_in.base_url,
|
||||||
|
auth_type=integration_in.auth_type,
|
||||||
|
api_key=integration_in.api_key,
|
||||||
|
api_secret=integration_in.api_secret,
|
||||||
|
webhook_url=webhook_url,
|
||||||
|
webhook_secret=webhook_secret,
|
||||||
|
callback_url=integration_in.callback_url,
|
||||||
|
auto_analyze=integration_in.auto_analyze,
|
||||||
|
sync_comments=integration_in.sync_comments,
|
||||||
|
create_prs=integration_in.create_prs,
|
||||||
|
repositories=integration_in.repositories,
|
||||||
|
created_by_id=member.user_id,
|
||||||
|
status=IntegrationStatus.ACTIVE
|
||||||
|
)
|
||||||
|
db.add(integration)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return integration
|
||||||
|
|
||||||
|
@router.get("/{integration_id}", response_model=IntegrationRead)
|
||||||
|
async def get_integration(
|
||||||
|
org_id: int,
|
||||||
|
integration_id: int,
|
||||||
|
member: OrganizationMember = Depends(require_role("analyst")),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get integration details."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Integration)
|
||||||
|
.where(Integration.id == integration_id)
|
||||||
|
.where(Integration.organization_id == org_id)
|
||||||
|
)
|
||||||
|
integration = result.scalar_one_or_none()
|
||||||
|
if not integration:
|
||||||
|
raise HTTPException(status_code=404, detail="Integration not found")
|
||||||
|
return integration
|
||||||
|
|
||||||
|
@router.patch("/{integration_id}", response_model=IntegrationRead)
|
||||||
|
async def update_integration(
|
||||||
|
org_id: int,
|
||||||
|
integration_id: int,
|
||||||
|
integration_update: IntegrationUpdate,
|
||||||
|
member: OrganizationMember = Depends(require_role("admin")),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Update integration."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Integration)
|
||||||
|
.where(Integration.id == integration_id)
|
||||||
|
.where(Integration.organization_id == org_id)
|
||||||
|
)
|
||||||
|
integration = result.scalar_one_or_none()
|
||||||
|
if not integration:
|
||||||
|
raise HTTPException(status_code=404, detail="Integration not found")
|
||||||
|
|
||||||
|
for field, value in integration_update.dict(exclude_unset=True).items():
|
||||||
|
setattr(integration, field, value)
|
||||||
|
|
||||||
|
return integration
|
||||||
|
|
||||||
|
@router.delete("/{integration_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_integration(
|
||||||
|
org_id: int,
|
||||||
|
integration_id: int,
|
||||||
|
member: OrganizationMember = Depends(require_role("admin")),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Delete integration."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Integration)
|
||||||
|
.where(Integration.id == integration_id)
|
||||||
|
.where(Integration.organization_id == org_id)
|
||||||
|
)
|
||||||
|
integration = result.scalar_one_or_none()
|
||||||
|
if not integration:
|
||||||
|
raise HTTPException(status_code=404, detail="Integration not found")
|
||||||
|
|
||||||
|
await db.delete(integration)
|
||||||
|
|
||||||
|
@router.post("/{integration_id}/test")
|
||||||
|
async def test_integration(
|
||||||
|
org_id: int,
|
||||||
|
integration_id: int,
|
||||||
|
member: OrganizationMember = Depends(require_role("admin")),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Test integration connection."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Integration)
|
||||||
|
.where(Integration.id == integration_id)
|
||||||
|
.where(Integration.organization_id == org_id)
|
||||||
|
)
|
||||||
|
integration = result.scalar_one_or_none()
|
||||||
|
if not integration:
|
||||||
|
raise HTTPException(status_code=404, detail="Integration not found")
|
||||||
|
|
||||||
|
# TODO: Implement actual connection test based on integration type
|
||||||
|
return {"status": "ok", "message": "Connection successful"}
|
||||||
|
|
@ -0,0 +1,271 @@
|
||||||
|
"""Issue management endpoints."""
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.models.issue import Issue, IssueStatus, IssueComment
|
||||||
|
from app.models.organization import OrganizationMember
|
||||||
|
from app.models.integration import Integration
|
||||||
|
from app.schemas.issue import IssueCreate, IssueRead, IssueUpdate, IssueStats, IssueComment as IssueCommentSchema
|
||||||
|
from app.api.deps import get_current_user, require_role
|
||||||
|
from app.services.analysis import AnalysisService
|
||||||
|
from app.services.email import EmailService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
async def run_analysis(issue_id: int, db_url: str):
|
||||||
|
"""Background task to analyze issue."""
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
engine = create_async_engine(db_url)
|
||||||
|
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
async with async_session() as db:
|
||||||
|
result = await db.execute(select(Issue).where(Issue.id == issue_id))
|
||||||
|
issue = result.scalar_one_or_none()
|
||||||
|
if not issue:
|
||||||
|
return
|
||||||
|
|
||||||
|
issue.status = IssueStatus.ANALYZING
|
||||||
|
issue.analysis_started_at = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get integration repo if available
|
||||||
|
repo = None
|
||||||
|
if issue.integration_id:
|
||||||
|
int_result = await db.execute(select(Integration).where(Integration.id == issue.integration_id))
|
||||||
|
integration = int_result.scalar_one_or_none()
|
||||||
|
if integration and integration.repositories:
|
||||||
|
repo = integration.repositories[0].get("gitea_repo")
|
||||||
|
|
||||||
|
# Run analysis
|
||||||
|
analysis = await AnalysisService.analyze({
|
||||||
|
"title": issue.title,
|
||||||
|
"description": issue.description,
|
||||||
|
"priority": issue.priority.value if issue.priority else "medium"
|
||||||
|
}, repo)
|
||||||
|
|
||||||
|
issue.root_cause = analysis.get("root_cause")
|
||||||
|
issue.affected_files = analysis.get("affected_files", [])
|
||||||
|
issue.suggested_fix = analysis.get("suggested_fix")
|
||||||
|
issue.confidence = analysis.get("confidence", 0)
|
||||||
|
issue.analysis_raw = analysis
|
||||||
|
issue.status = IssueStatus.ANALYZED
|
||||||
|
issue.analysis_completed_at = datetime.utcnow()
|
||||||
|
|
||||||
|
# Create PR if enabled and confidence > 70%
|
||||||
|
if repo and issue.confidence and issue.confidence >= 0.7:
|
||||||
|
branch = f"fix/{issue.external_key or issue.id}-auto-fix"
|
||||||
|
pr_url = await AnalysisService.create_pull_request(
|
||||||
|
repo=repo,
|
||||||
|
branch=branch,
|
||||||
|
title=f"Fix: {issue.title}",
|
||||||
|
description=f"## Root Cause\n{issue.root_cause}\n\n## Suggested Fix\n{issue.suggested_fix}",
|
||||||
|
file_changes=[]
|
||||||
|
)
|
||||||
|
if pr_url:
|
||||||
|
issue.pr_url = pr_url
|
||||||
|
issue.pr_branch = branch
|
||||||
|
issue.status = IssueStatus.PR_CREATED
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
issue.status = IssueStatus.ERROR
|
||||||
|
issue.root_cause = f"Analysis failed: {str(e)}"
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[IssueRead])
|
||||||
|
async def list_issues(
|
||||||
|
org_id: int,
|
||||||
|
status: Optional[IssueStatus] = None,
|
||||||
|
source: Optional[str] = None,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
member: OrganizationMember = Depends(require_role("viewer")),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""List issues for organization."""
|
||||||
|
query = select(Issue).where(Issue.organization_id == org_id)
|
||||||
|
|
||||||
|
if status:
|
||||||
|
query = query.where(Issue.status == status)
|
||||||
|
if source:
|
||||||
|
query = query.where(Issue.source == source)
|
||||||
|
|
||||||
|
query = query.order_by(Issue.created_at.desc()).offset(offset).limit(limit)
|
||||||
|
result = await db.execute(query)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
@router.get("/stats", response_model=IssueStats)
|
||||||
|
async def get_stats(
|
||||||
|
org_id: int,
|
||||||
|
member: OrganizationMember = Depends(require_role("viewer")),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get issue statistics."""
|
||||||
|
# Total counts by status
|
||||||
|
total_result = await db.execute(
|
||||||
|
select(func.count(Issue.id)).where(Issue.organization_id == org_id)
|
||||||
|
)
|
||||||
|
total = total_result.scalar() or 0
|
||||||
|
|
||||||
|
status_counts = {}
|
||||||
|
for s in IssueStatus:
|
||||||
|
result = await db.execute(
|
||||||
|
select(func.count(Issue.id))
|
||||||
|
.where(Issue.organization_id == org_id)
|
||||||
|
.where(Issue.status == s)
|
||||||
|
)
|
||||||
|
status_counts[s.value] = result.scalar() or 0
|
||||||
|
|
||||||
|
# By source
|
||||||
|
source_result = await db.execute(
|
||||||
|
select(Issue.source, func.count(Issue.id))
|
||||||
|
.where(Issue.organization_id == org_id)
|
||||||
|
.group_by(Issue.source)
|
||||||
|
)
|
||||||
|
by_source = {row[0] or "unknown": row[1] for row in source_result.all()}
|
||||||
|
|
||||||
|
# By priority
|
||||||
|
priority_result = await db.execute(
|
||||||
|
select(Issue.priority, func.count(Issue.id))
|
||||||
|
.where(Issue.organization_id == org_id)
|
||||||
|
.group_by(Issue.priority)
|
||||||
|
)
|
||||||
|
by_priority = {str(row[0].value) if row[0] else "unknown": row[1] for row in priority_result.all()}
|
||||||
|
|
||||||
|
# Avg confidence
|
||||||
|
avg_result = await db.execute(
|
||||||
|
select(func.avg(Issue.confidence))
|
||||||
|
.where(Issue.organization_id == org_id)
|
||||||
|
.where(Issue.confidence.isnot(None))
|
||||||
|
)
|
||||||
|
avg_confidence = avg_result.scalar() or 0
|
||||||
|
|
||||||
|
# SLA breached
|
||||||
|
sla_result = await db.execute(
|
||||||
|
select(func.count(Issue.id))
|
||||||
|
.where(Issue.organization_id == org_id)
|
||||||
|
.where(Issue.sla_breached == True)
|
||||||
|
)
|
||||||
|
sla_breached = sla_result.scalar() or 0
|
||||||
|
|
||||||
|
return IssueStats(
|
||||||
|
total=total,
|
||||||
|
pending=status_counts.get("pending", 0),
|
||||||
|
analyzing=status_counts.get("analyzing", 0),
|
||||||
|
analyzed=status_counts.get("analyzed", 0),
|
||||||
|
pr_created=status_counts.get("pr_created", 0),
|
||||||
|
completed=status_counts.get("completed", 0),
|
||||||
|
error=status_counts.get("error", 0),
|
||||||
|
avg_confidence=avg_confidence,
|
||||||
|
by_source=by_source,
|
||||||
|
by_priority=by_priority,
|
||||||
|
sla_breached=sla_breached
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/", response_model=IssueRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_issue(
|
||||||
|
org_id: int,
|
||||||
|
issue_in: IssueCreate,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
member: OrganizationMember = Depends(require_role("analyst")),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Create and analyze a new issue."""
|
||||||
|
issue = Issue(
|
||||||
|
organization_id=org_id,
|
||||||
|
title=issue_in.title,
|
||||||
|
description=issue_in.description,
|
||||||
|
priority=issue_in.priority,
|
||||||
|
external_id=issue_in.external_id,
|
||||||
|
external_key=issue_in.external_key,
|
||||||
|
external_url=issue_in.external_url,
|
||||||
|
source=issue_in.source,
|
||||||
|
labels=issue_in.labels,
|
||||||
|
callback_url=issue_in.callback_url,
|
||||||
|
raw_payload=issue_in.raw_payload
|
||||||
|
)
|
||||||
|
db.add(issue)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Queue analysis
|
||||||
|
from app.core.config import settings
|
||||||
|
background_tasks.add_task(run_analysis, issue.id, settings.DATABASE_URL.replace("postgresql://", "postgresql+asyncpg://"))
|
||||||
|
|
||||||
|
return issue
|
||||||
|
|
||||||
|
@router.get("/{issue_id}", response_model=IssueRead)
|
||||||
|
async def get_issue(
|
||||||
|
org_id: int,
|
||||||
|
issue_id: int,
|
||||||
|
member: OrganizationMember = Depends(require_role("viewer")),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get issue details."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Issue)
|
||||||
|
.where(Issue.id == issue_id)
|
||||||
|
.where(Issue.organization_id == org_id)
|
||||||
|
)
|
||||||
|
issue = result.scalar_one_or_none()
|
||||||
|
if not issue:
|
||||||
|
raise HTTPException(status_code=404, detail="Issue not found")
|
||||||
|
return issue
|
||||||
|
|
||||||
|
@router.post("/{issue_id}/reanalyze", response_model=IssueRead)
|
||||||
|
async def reanalyze_issue(
|
||||||
|
org_id: int,
|
||||||
|
issue_id: int,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
member: OrganizationMember = Depends(require_role("analyst")),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Rerun analysis on issue."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Issue)
|
||||||
|
.where(Issue.id == issue_id)
|
||||||
|
.where(Issue.organization_id == org_id)
|
||||||
|
)
|
||||||
|
issue = result.scalar_one_or_none()
|
||||||
|
if not issue:
|
||||||
|
raise HTTPException(status_code=404, detail="Issue not found")
|
||||||
|
|
||||||
|
issue.status = IssueStatus.PENDING
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
background_tasks.add_task(run_analysis, issue.id, settings.DATABASE_URL.replace("postgresql://", "postgresql+asyncpg://"))
|
||||||
|
|
||||||
|
return issue
|
||||||
|
|
||||||
|
@router.post("/{issue_id}/comments")
|
||||||
|
async def add_comment(
|
||||||
|
org_id: int,
|
||||||
|
issue_id: int,
|
||||||
|
comment: IssueCommentSchema,
|
||||||
|
member: OrganizationMember = Depends(require_role("analyst")),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Add comment to issue."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Issue)
|
||||||
|
.where(Issue.id == issue_id)
|
||||||
|
.where(Issue.organization_id == org_id)
|
||||||
|
)
|
||||||
|
issue = result.scalar_one_or_none()
|
||||||
|
if not issue:
|
||||||
|
raise HTTPException(status_code=404, detail="Issue not found")
|
||||||
|
|
||||||
|
new_comment = IssueComment(
|
||||||
|
issue_id=issue_id,
|
||||||
|
author=comment.author,
|
||||||
|
content=comment.content,
|
||||||
|
author_type=comment.author_type
|
||||||
|
)
|
||||||
|
db.add(new_comment)
|
||||||
|
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
"""Organization management endpoints."""
|
||||||
|
from typing import List
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.organization import Organization, OrganizationMember, MemberRole
|
||||||
|
from app.schemas.organization import OrganizationCreate, OrganizationRead, OrganizationUpdate, MemberCreate, MemberRead
|
||||||
|
from app.api.deps import get_current_user, require_role
|
||||||
|
from app.services.email import EmailService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[OrganizationRead])
|
||||||
|
async def list_organizations(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""List organizations user belongs to."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Organization)
|
||||||
|
.join(OrganizationMember)
|
||||||
|
.where(OrganizationMember.user_id == user.id)
|
||||||
|
.where(Organization.is_active == True)
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
@router.post("/", response_model=OrganizationRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_organization(
|
||||||
|
org_in: OrganizationCreate,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Create a new organization."""
|
||||||
|
# Check slug uniqueness
|
||||||
|
result = await db.execute(select(Organization).where(Organization.slug == org_in.slug))
|
||||||
|
if result.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=400, detail="Slug already exists")
|
||||||
|
|
||||||
|
# Create org
|
||||||
|
org = Organization(
|
||||||
|
name=org_in.name,
|
||||||
|
slug=org_in.slug,
|
||||||
|
description=org_in.description
|
||||||
|
)
|
||||||
|
db.add(org)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Add creator as owner
|
||||||
|
member = OrganizationMember(
|
||||||
|
organization_id=org.id,
|
||||||
|
user_id=user.id,
|
||||||
|
role=MemberRole.OWNER
|
||||||
|
)
|
||||||
|
db.add(member)
|
||||||
|
|
||||||
|
return org
|
||||||
|
|
||||||
|
@router.get("/{org_id}", response_model=OrganizationRead)
|
||||||
|
async def get_organization(
|
||||||
|
org_id: int,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get organization details."""
|
||||||
|
result = await db.execute(select(Organization).where(Organization.id == org_id))
|
||||||
|
org = result.scalar_one_or_none()
|
||||||
|
if not org:
|
||||||
|
raise HTTPException(status_code=404, detail="Organization not found")
|
||||||
|
return org
|
||||||
|
|
||||||
|
@router.patch("/{org_id}", response_model=OrganizationRead)
|
||||||
|
async def update_organization(
|
||||||
|
org_id: int,
|
||||||
|
org_update: OrganizationUpdate,
|
||||||
|
member: OrganizationMember = Depends(require_role("admin")),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Update organization (admin only)."""
|
||||||
|
result = await db.execute(select(Organization).where(Organization.id == org_id))
|
||||||
|
org = result.scalar_one_or_none()
|
||||||
|
if not org:
|
||||||
|
raise HTTPException(status_code=404, detail="Organization not found")
|
||||||
|
|
||||||
|
for field, value in org_update.dict(exclude_unset=True).items():
|
||||||
|
setattr(org, field, value)
|
||||||
|
|
||||||
|
return org
|
||||||
|
|
||||||
|
@router.get("/{org_id}/members", response_model=List[MemberRead])
|
||||||
|
async def list_members(
|
||||||
|
org_id: int,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""List organization members."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(OrganizationMember)
|
||||||
|
.where(OrganizationMember.organization_id == org_id)
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
@router.post("/{org_id}/members", response_model=MemberRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def invite_member(
|
||||||
|
org_id: int,
|
||||||
|
member_in: MemberCreate,
|
||||||
|
current_member: OrganizationMember = Depends(require_role("admin")),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Invite a new member (admin only)."""
|
||||||
|
# Find or create user
|
||||||
|
result = await db.execute(select(User).where(User.email == member_in.email))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
# Create placeholder user
|
||||||
|
from app.core.security import get_password_hash
|
||||||
|
import secrets
|
||||||
|
user = User(
|
||||||
|
email=member_in.email,
|
||||||
|
hashed_password=get_password_hash(secrets.token_urlsafe(32)),
|
||||||
|
is_active=False # Will activate on first login
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Check if already member
|
||||||
|
result = await db.execute(
|
||||||
|
select(OrganizationMember)
|
||||||
|
.where(OrganizationMember.organization_id == org_id)
|
||||||
|
.where(OrganizationMember.user_id == user.id)
|
||||||
|
)
|
||||||
|
if result.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=400, detail="User is already a member")
|
||||||
|
|
||||||
|
# Add member
|
||||||
|
member = OrganizationMember(
|
||||||
|
organization_id=org_id,
|
||||||
|
user_id=user.id,
|
||||||
|
role=member_in.role,
|
||||||
|
invited_by_id=current_member.user_id
|
||||||
|
)
|
||||||
|
db.add(member)
|
||||||
|
|
||||||
|
# Get org name for email
|
||||||
|
org_result = await db.execute(select(Organization).where(Organization.id == org_id))
|
||||||
|
org = org_result.scalar_one()
|
||||||
|
|
||||||
|
# Send welcome email
|
||||||
|
await EmailService.send_welcome(user.email, user.full_name or user.email, org.name)
|
||||||
|
|
||||||
|
return member
|
||||||
|
|
@ -0,0 +1,192 @@
|
||||||
|
"""Reports and analytics endpoints."""
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func, and_
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.models.issue import Issue, IssueStatus
|
||||||
|
from app.models.organization import OrganizationMember
|
||||||
|
from app.api.deps import require_role
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
class DailyStats(BaseModel):
|
||||||
|
date: str
|
||||||
|
total: int
|
||||||
|
analyzed: int
|
||||||
|
prs_created: int
|
||||||
|
avg_confidence: float
|
||||||
|
|
||||||
|
class ReportSummary(BaseModel):
|
||||||
|
period_start: datetime
|
||||||
|
period_end: datetime
|
||||||
|
total_issues: int
|
||||||
|
analyzed_issues: int
|
||||||
|
prs_created: int
|
||||||
|
avg_confidence: float
|
||||||
|
avg_analysis_time_hours: Optional[float]
|
||||||
|
top_sources: List[dict]
|
||||||
|
daily_breakdown: List[DailyStats]
|
||||||
|
|
||||||
|
@router.get("/summary", response_model=ReportSummary)
|
||||||
|
async def get_report_summary(
|
||||||
|
org_id: int,
|
||||||
|
days: int = 30,
|
||||||
|
member: OrganizationMember = Depends(require_role("viewer")),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get summary report for organization."""
|
||||||
|
end_date = datetime.utcnow()
|
||||||
|
start_date = end_date - timedelta(days=days)
|
||||||
|
|
||||||
|
# Total issues
|
||||||
|
total_result = await db.execute(
|
||||||
|
select(func.count(Issue.id))
|
||||||
|
.where(Issue.organization_id == org_id)
|
||||||
|
.where(Issue.created_at >= start_date)
|
||||||
|
)
|
||||||
|
total = total_result.scalar() or 0
|
||||||
|
|
||||||
|
# Analyzed
|
||||||
|
analyzed_result = await db.execute(
|
||||||
|
select(func.count(Issue.id))
|
||||||
|
.where(Issue.organization_id == org_id)
|
||||||
|
.where(Issue.created_at >= start_date)
|
||||||
|
.where(Issue.status.in_([IssueStatus.ANALYZED, IssueStatus.PR_CREATED, IssueStatus.COMPLETED]))
|
||||||
|
)
|
||||||
|
analyzed = analyzed_result.scalar() or 0
|
||||||
|
|
||||||
|
# PRs created
|
||||||
|
prs_result = await db.execute(
|
||||||
|
select(func.count(Issue.id))
|
||||||
|
.where(Issue.organization_id == org_id)
|
||||||
|
.where(Issue.created_at >= start_date)
|
||||||
|
.where(Issue.pr_url.isnot(None))
|
||||||
|
)
|
||||||
|
prs = prs_result.scalar() or 0
|
||||||
|
|
||||||
|
# Avg confidence
|
||||||
|
avg_conf_result = await db.execute(
|
||||||
|
select(func.avg(Issue.confidence))
|
||||||
|
.where(Issue.organization_id == org_id)
|
||||||
|
.where(Issue.created_at >= start_date)
|
||||||
|
.where(Issue.confidence.isnot(None))
|
||||||
|
)
|
||||||
|
avg_confidence = avg_conf_result.scalar() or 0
|
||||||
|
|
||||||
|
# Top sources
|
||||||
|
sources_result = await db.execute(
|
||||||
|
select(Issue.source, func.count(Issue.id).label("count"))
|
||||||
|
.where(Issue.organization_id == org_id)
|
||||||
|
.where(Issue.created_at >= start_date)
|
||||||
|
.group_by(Issue.source)
|
||||||
|
.order_by(func.count(Issue.id).desc())
|
||||||
|
.limit(5)
|
||||||
|
)
|
||||||
|
top_sources = [{"source": r[0] or "unknown", "count": r[1]} for r in sources_result.all()]
|
||||||
|
|
||||||
|
# Daily breakdown (simplified)
|
||||||
|
daily_breakdown = []
|
||||||
|
for i in range(min(days, 30)):
|
||||||
|
day_start = start_date + timedelta(days=i)
|
||||||
|
day_end = day_start + timedelta(days=1)
|
||||||
|
|
||||||
|
day_total = await db.execute(
|
||||||
|
select(func.count(Issue.id))
|
||||||
|
.where(Issue.organization_id == org_id)
|
||||||
|
.where(Issue.created_at >= day_start)
|
||||||
|
.where(Issue.created_at < day_end)
|
||||||
|
)
|
||||||
|
day_analyzed = await db.execute(
|
||||||
|
select(func.count(Issue.id))
|
||||||
|
.where(Issue.organization_id == org_id)
|
||||||
|
.where(Issue.created_at >= day_start)
|
||||||
|
.where(Issue.created_at < day_end)
|
||||||
|
.where(Issue.status.in_([IssueStatus.ANALYZED, IssueStatus.PR_CREATED, IssueStatus.COMPLETED]))
|
||||||
|
)
|
||||||
|
day_prs = await db.execute(
|
||||||
|
select(func.count(Issue.id))
|
||||||
|
.where(Issue.organization_id == org_id)
|
||||||
|
.where(Issue.created_at >= day_start)
|
||||||
|
.where(Issue.created_at < day_end)
|
||||||
|
.where(Issue.pr_url.isnot(None))
|
||||||
|
)
|
||||||
|
day_conf = await db.execute(
|
||||||
|
select(func.avg(Issue.confidence))
|
||||||
|
.where(Issue.organization_id == org_id)
|
||||||
|
.where(Issue.created_at >= day_start)
|
||||||
|
.where(Issue.created_at < day_end)
|
||||||
|
.where(Issue.confidence.isnot(None))
|
||||||
|
)
|
||||||
|
|
||||||
|
daily_breakdown.append(DailyStats(
|
||||||
|
date=day_start.strftime("%Y-%m-%d"),
|
||||||
|
total=day_total.scalar() or 0,
|
||||||
|
analyzed=day_analyzed.scalar() or 0,
|
||||||
|
prs_created=day_prs.scalar() or 0,
|
||||||
|
avg_confidence=day_conf.scalar() or 0
|
||||||
|
))
|
||||||
|
|
||||||
|
return ReportSummary(
|
||||||
|
period_start=start_date,
|
||||||
|
period_end=end_date,
|
||||||
|
total_issues=total,
|
||||||
|
analyzed_issues=analyzed,
|
||||||
|
prs_created=prs,
|
||||||
|
avg_confidence=avg_confidence,
|
||||||
|
avg_analysis_time_hours=None, # TODO: calculate
|
||||||
|
top_sources=top_sources,
|
||||||
|
daily_breakdown=daily_breakdown
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/export/csv")
|
||||||
|
async def export_csv(
|
||||||
|
org_id: int,
|
||||||
|
days: int = 30,
|
||||||
|
member: OrganizationMember = Depends(require_role("manager")),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Export issues as CSV."""
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
import io
|
||||||
|
import csv
|
||||||
|
|
||||||
|
start_date = datetime.utcnow() - timedelta(days=days)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Issue)
|
||||||
|
.where(Issue.organization_id == org_id)
|
||||||
|
.where(Issue.created_at >= start_date)
|
||||||
|
.order_by(Issue.created_at.desc())
|
||||||
|
)
|
||||||
|
issues = result.scalars().all()
|
||||||
|
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
writer.writerow([
|
||||||
|
"ID", "Key", "Title", "Source", "Status", "Priority",
|
||||||
|
"Confidence", "PR URL", "Created At", "Analyzed At"
|
||||||
|
])
|
||||||
|
|
||||||
|
for issue in issues:
|
||||||
|
writer.writerow([
|
||||||
|
issue.id,
|
||||||
|
issue.external_key,
|
||||||
|
issue.title,
|
||||||
|
issue.source,
|
||||||
|
issue.status.value if issue.status else "",
|
||||||
|
issue.priority.value if issue.priority else "",
|
||||||
|
f"{issue.confidence:.0%}" if issue.confidence else "",
|
||||||
|
issue.pr_url or "",
|
||||||
|
issue.created_at.isoformat() if issue.created_at else "",
|
||||||
|
issue.analysis_completed_at.isoformat() if issue.analysis_completed_at else ""
|
||||||
|
])
|
||||||
|
|
||||||
|
output.seek(0)
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([output.getvalue()]),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename=issues-{datetime.utcnow().strftime('%Y%m%d')}.csv"}
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
"""User management endpoints."""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_password_hash
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.user import UserRead, UserUpdate
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserRead)
|
||||||
|
async def get_me(user: User = Depends(get_current_user)):
|
||||||
|
"""Get current user profile."""
|
||||||
|
return user
|
||||||
|
|
||||||
|
@router.patch("/me", response_model=UserRead)
|
||||||
|
async def update_me(
|
||||||
|
user_update: UserUpdate,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Update current user profile."""
|
||||||
|
if user_update.email:
|
||||||
|
user.email = user_update.email
|
||||||
|
if user_update.full_name:
|
||||||
|
user.full_name = user_update.full_name
|
||||||
|
if user_update.avatar_url:
|
||||||
|
user.avatar_url = user_update.avatar_url
|
||||||
|
if user_update.password:
|
||||||
|
user.hashed_password = get_password_hash(user_update.password)
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
@ -0,0 +1,286 @@
|
||||||
|
"""Webhook endpoints for external integrations."""
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Request, Header
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.models.organization import Organization
|
||||||
|
from app.models.integration import Integration, IntegrationType, IntegrationStatus
|
||||||
|
from app.models.issue import Issue, IssueStatus, IssuePriority
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
def verify_signature(payload: bytes, signature: str, secret: str) -> bool:
|
||||||
|
"""Verify webhook signature."""
|
||||||
|
if not secret or not signature:
|
||||||
|
return True # Skip verification if no secret configured
|
||||||
|
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
|
||||||
|
return hmac.compare_digest(f"sha256={expected}", signature)
|
||||||
|
|
||||||
|
async def process_webhook(
|
||||||
|
org_id: int,
|
||||||
|
integration_type: IntegrationType,
|
||||||
|
payload: dict,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: AsyncSession
|
||||||
|
) -> dict:
|
||||||
|
"""Process incoming webhook and create issue."""
|
||||||
|
# Find integration
|
||||||
|
result = await db.execute(
|
||||||
|
select(Integration)
|
||||||
|
.where(Integration.organization_id == org_id)
|
||||||
|
.where(Integration.type == integration_type)
|
||||||
|
.where(Integration.status == IntegrationStatus.ACTIVE)
|
||||||
|
)
|
||||||
|
integration = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not integration:
|
||||||
|
return {"status": "ignored", "message": "No active integration found"}
|
||||||
|
|
||||||
|
# Update integration stats
|
||||||
|
integration.issues_processed = (integration.issues_processed or 0) + 1
|
||||||
|
integration.last_sync_at = datetime.utcnow()
|
||||||
|
|
||||||
|
# Normalize payload based on type
|
||||||
|
issue_data = normalize_payload(integration_type, payload)
|
||||||
|
if not issue_data:
|
||||||
|
return {"status": "ignored", "message": "Event not processed"}
|
||||||
|
|
||||||
|
# Create issue
|
||||||
|
issue = Issue(
|
||||||
|
organization_id=org_id,
|
||||||
|
integration_id=integration.id,
|
||||||
|
external_id=issue_data.get("external_id"),
|
||||||
|
external_key=issue_data.get("external_key"),
|
||||||
|
external_url=issue_data.get("external_url"),
|
||||||
|
source=integration_type.value,
|
||||||
|
title=issue_data.get("title"),
|
||||||
|
description=issue_data.get("description"),
|
||||||
|
priority=IssuePriority(issue_data.get("priority", "medium")),
|
||||||
|
labels=issue_data.get("labels"),
|
||||||
|
callback_url=issue_data.get("callback_url") or integration.callback_url,
|
||||||
|
raw_payload=payload
|
||||||
|
)
|
||||||
|
db.add(issue)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Queue analysis if auto_analyze enabled
|
||||||
|
if integration.auto_analyze:
|
||||||
|
from app.api.issues import run_analysis
|
||||||
|
from app.core.config import settings
|
||||||
|
background_tasks.add_task(
|
||||||
|
run_analysis,
|
||||||
|
issue.id,
|
||||||
|
settings.DATABASE_URL.replace("postgresql://", "postgresql+asyncpg://")
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "accepted", "issue_id": issue.id}
|
||||||
|
|
||||||
|
def normalize_payload(integration_type: IntegrationType, payload: dict) -> Optional[dict]:
|
||||||
|
"""Normalize webhook payload to common format."""
|
||||||
|
|
||||||
|
if integration_type == IntegrationType.JIRA_CLOUD:
|
||||||
|
event = payload.get("webhookEvent", "")
|
||||||
|
if "issue_created" not in event:
|
||||||
|
return None
|
||||||
|
issue = payload.get("issue", {})
|
||||||
|
fields = issue.get("fields", {})
|
||||||
|
return {
|
||||||
|
"external_id": str(issue.get("id")),
|
||||||
|
"external_key": issue.get("key"),
|
||||||
|
"external_url": f"{payload.get('issue', {}).get('self', '').split('/rest/')[0]}/browse/{issue.get('key')}",
|
||||||
|
"title": fields.get("summary"),
|
||||||
|
"description": fields.get("description"),
|
||||||
|
"priority": normalize_priority(fields.get("priority", {}).get("name")),
|
||||||
|
"labels": fields.get("labels", [])
|
||||||
|
}
|
||||||
|
|
||||||
|
elif integration_type == IntegrationType.SERVICENOW:
|
||||||
|
return {
|
||||||
|
"external_id": payload.get("sys_id"),
|
||||||
|
"external_key": payload.get("number"),
|
||||||
|
"external_url": payload.get("url"),
|
||||||
|
"title": payload.get("short_description"),
|
||||||
|
"description": payload.get("description"),
|
||||||
|
"priority": normalize_priority(payload.get("priority")),
|
||||||
|
"callback_url": payload.get("callback_url")
|
||||||
|
}
|
||||||
|
|
||||||
|
elif integration_type == IntegrationType.ZENDESK:
|
||||||
|
ticket = payload.get("ticket", payload)
|
||||||
|
return {
|
||||||
|
"external_id": str(ticket.get("id")),
|
||||||
|
"external_key": f"ZD-{ticket.get('id')}",
|
||||||
|
"external_url": ticket.get("url"),
|
||||||
|
"title": ticket.get("subject"),
|
||||||
|
"description": ticket.get("description"),
|
||||||
|
"priority": normalize_priority(ticket.get("priority")),
|
||||||
|
"labels": ticket.get("tags", [])
|
||||||
|
}
|
||||||
|
|
||||||
|
elif integration_type == IntegrationType.GITHUB:
|
||||||
|
action = payload.get("action")
|
||||||
|
if action != "opened":
|
||||||
|
return None
|
||||||
|
issue = payload.get("issue", {})
|
||||||
|
return {
|
||||||
|
"external_id": str(issue.get("id")),
|
||||||
|
"external_key": f"GH-{issue.get('number')}",
|
||||||
|
"external_url": issue.get("html_url"),
|
||||||
|
"title": issue.get("title"),
|
||||||
|
"description": issue.get("body"),
|
||||||
|
"priority": "medium",
|
||||||
|
"labels": [l.get("name") for l in issue.get("labels", [])]
|
||||||
|
}
|
||||||
|
|
||||||
|
elif integration_type == IntegrationType.GITLAB:
|
||||||
|
event = payload.get("object_kind")
|
||||||
|
if event != "issue":
|
||||||
|
return None
|
||||||
|
attrs = payload.get("object_attributes", {})
|
||||||
|
if attrs.get("action") != "open":
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"external_id": str(attrs.get("id")),
|
||||||
|
"external_key": f"GL-{attrs.get('iid')}",
|
||||||
|
"external_url": attrs.get("url"),
|
||||||
|
"title": attrs.get("title"),
|
||||||
|
"description": attrs.get("description"),
|
||||||
|
"priority": "medium",
|
||||||
|
"labels": payload.get("labels", [])
|
||||||
|
}
|
||||||
|
|
||||||
|
elif integration_type == IntegrationType.TICKETHUB:
|
||||||
|
event = payload.get("event", "")
|
||||||
|
if "created" not in event:
|
||||||
|
return None
|
||||||
|
data = payload.get("data", payload)
|
||||||
|
return {
|
||||||
|
"external_id": str(data.get("id")),
|
||||||
|
"external_key": data.get("key"),
|
||||||
|
"external_url": f"https://tickethub.startdata.com.br/tickets/{data.get('id')}",
|
||||||
|
"title": data.get("title"),
|
||||||
|
"description": data.get("description"),
|
||||||
|
"priority": normalize_priority(data.get("priority")),
|
||||||
|
"labels": data.get("labels", [])
|
||||||
|
}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def normalize_priority(priority: Optional[str]) -> str:
|
||||||
|
"""Normalize priority to standard values."""
|
||||||
|
if not priority:
|
||||||
|
return "medium"
|
||||||
|
priority = str(priority).lower()
|
||||||
|
if priority in ("1", "critical", "highest", "urgent"):
|
||||||
|
return "critical"
|
||||||
|
elif priority in ("2", "high"):
|
||||||
|
return "high"
|
||||||
|
elif priority in ("3", "medium", "normal"):
|
||||||
|
return "medium"
|
||||||
|
else:
|
||||||
|
return "low"
|
||||||
|
|
||||||
|
# Webhook endpoints for each integration type
|
||||||
|
@router.post("/{org_id}/jira")
|
||||||
|
async def webhook_jira(
|
||||||
|
org_id: int,
|
||||||
|
request: Request,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
payload = await request.json()
|
||||||
|
return await process_webhook(org_id, IntegrationType.JIRA_CLOUD, payload, background_tasks, db)
|
||||||
|
|
||||||
|
@router.post("/{org_id}/servicenow")
|
||||||
|
async def webhook_servicenow(
|
||||||
|
org_id: int,
|
||||||
|
request: Request,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
payload = await request.json()
|
||||||
|
return await process_webhook(org_id, IntegrationType.SERVICENOW, payload, background_tasks, db)
|
||||||
|
|
||||||
|
@router.post("/{org_id}/zendesk")
|
||||||
|
async def webhook_zendesk(
|
||||||
|
org_id: int,
|
||||||
|
request: Request,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
payload = await request.json()
|
||||||
|
return await process_webhook(org_id, IntegrationType.ZENDESK, payload, background_tasks, db)
|
||||||
|
|
||||||
|
@router.post("/{org_id}/github")
|
||||||
|
async def webhook_github(
|
||||||
|
org_id: int,
|
||||||
|
request: Request,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
x_github_event: Optional[str] = Header(None),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
payload = await request.json()
|
||||||
|
if x_github_event != "issues":
|
||||||
|
return {"status": "ignored", "message": "Not an issues event"}
|
||||||
|
return await process_webhook(org_id, IntegrationType.GITHUB, payload, background_tasks, db)
|
||||||
|
|
||||||
|
@router.post("/{org_id}/gitlab")
|
||||||
|
async def webhook_gitlab(
|
||||||
|
org_id: int,
|
||||||
|
request: Request,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
payload = await request.json()
|
||||||
|
return await process_webhook(org_id, IntegrationType.GITLAB, payload, background_tasks, db)
|
||||||
|
|
||||||
|
@router.post("/{org_id}/tickethub")
|
||||||
|
async def webhook_tickethub(
|
||||||
|
org_id: int,
|
||||||
|
request: Request,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
payload = await request.json()
|
||||||
|
return await process_webhook(org_id, IntegrationType.TICKETHUB, payload, background_tasks, db)
|
||||||
|
|
||||||
|
@router.post("/{org_id}/generic")
|
||||||
|
async def webhook_generic(
|
||||||
|
org_id: int,
|
||||||
|
request: Request,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Generic webhook for custom integrations."""
|
||||||
|
payload = await request.json()
|
||||||
|
|
||||||
|
# Direct mapping
|
||||||
|
issue = Issue(
|
||||||
|
organization_id=org_id,
|
||||||
|
external_id=str(payload.get("id")),
|
||||||
|
external_key=payload.get("key"),
|
||||||
|
external_url=payload.get("url"),
|
||||||
|
source=payload.get("source", "generic"),
|
||||||
|
title=payload.get("title"),
|
||||||
|
description=payload.get("description"),
|
||||||
|
priority=IssuePriority(normalize_priority(payload.get("priority"))),
|
||||||
|
labels=payload.get("labels"),
|
||||||
|
callback_url=payload.get("callback_url"),
|
||||||
|
raw_payload=payload
|
||||||
|
)
|
||||||
|
db.add(issue)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
from app.api.issues import run_analysis
|
||||||
|
from app.core.config import settings
|
||||||
|
background_tasks.add_task(
|
||||||
|
run_analysis,
|
||||||
|
issue.id,
|
||||||
|
settings.DATABASE_URL.replace("postgresql://", "postgresql+asyncpg://")
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "accepted", "issue_id": issue.id}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
"""Application configuration."""
|
||||||
|
import os
|
||||||
|
from functools import lru_cache
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
# App
|
||||||
|
APP_NAME: str = "JIRA AI Fixer"
|
||||||
|
APP_VERSION: str = "2.0.0"
|
||||||
|
DEBUG: bool = False
|
||||||
|
SECRET_KEY: str = os.getenv("SECRET_KEY", "change-me-in-production-use-openssl-rand-hex-32")
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL: str = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@postgres_database:5432/jira_fixer_v2")
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL: str = os.getenv("REDIS_URL", "redis://redis_redis:6379/0")
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET: str = os.getenv("JWT_SECRET", "jwt-secret-change-in-production")
|
||||||
|
JWT_ALGORITHM: str = "HS256"
|
||||||
|
JWT_EXPIRE_MINUTES: int = 60 * 24 # 24 hours
|
||||||
|
JWT_REFRESH_DAYS: int = 7
|
||||||
|
|
||||||
|
# Email (Resend)
|
||||||
|
RESEND_API_KEY: str = os.getenv("RESEND_API_KEY", "")
|
||||||
|
EMAIL_FROM: str = os.getenv("EMAIL_FROM", "JIRA AI Fixer <noreply@startdata.com.br>")
|
||||||
|
|
||||||
|
# External APIs
|
||||||
|
OPENROUTER_API_KEY: str = os.getenv("OPENROUTER_API_KEY", "")
|
||||||
|
GITEA_URL: str = os.getenv("GITEA_URL", "https://gitea.startdata.com.br")
|
||||||
|
GITEA_TOKEN: str = os.getenv("GITEA_TOKEN", "")
|
||||||
|
|
||||||
|
# OAuth (for integrations)
|
||||||
|
JIRA_CLIENT_ID: str = os.getenv("JIRA_CLIENT_ID", "")
|
||||||
|
JIRA_CLIENT_SECRET: str = os.getenv("JIRA_CLIENT_SECRET", "")
|
||||||
|
GITHUB_CLIENT_ID: str = os.getenv("GITHUB_CLIENT_ID", "")
|
||||||
|
GITHUB_CLIENT_SECRET: str = os.getenv("GITHUB_CLIENT_SECRET", "")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
|
||||||
|
@lru_cache()
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
"""Database setup with SQLAlchemy async."""
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||||
|
from sqlalchemy.orm import declarative_base
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
# Convert sync URL to async
|
||||||
|
DATABASE_URL = settings.DATABASE_URL.replace("postgresql://", "postgresql+asyncpg://")
|
||||||
|
|
||||||
|
engine = create_async_engine(DATABASE_URL, echo=settings.DEBUG, pool_size=10, max_overflow=20)
|
||||||
|
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
async def get_db() -> AsyncSession:
|
||||||
|
async with async_session() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
async def init_db():
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
"""Security utilities - JWT, password hashing, RBAC."""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional, Any
|
||||||
|
from jose import jwt, JWTError
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
# Roles hierarchy
|
||||||
|
class Role:
|
||||||
|
VIEWER = "viewer"
|
||||||
|
ANALYST = "analyst"
|
||||||
|
MANAGER = "manager"
|
||||||
|
ADMIN = "admin"
|
||||||
|
OWNER = "owner"
|
||||||
|
|
||||||
|
ROLE_HIERARCHY = {
|
||||||
|
Role.VIEWER: 1,
|
||||||
|
Role.ANALYST: 2,
|
||||||
|
Role.MANAGER: 3,
|
||||||
|
Role.ADMIN: 4,
|
||||||
|
Role.OWNER: 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
def get_password_hash(password: str) -> str:
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||||
|
to_encode = data.copy()
|
||||||
|
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=settings.JWT_EXPIRE_MINUTES))
|
||||||
|
to_encode.update({"exp": expire, "type": "access"})
|
||||||
|
return jwt.encode(to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
|
||||||
|
|
||||||
|
def create_refresh_token(data: dict) -> str:
|
||||||
|
to_encode = data.copy()
|
||||||
|
expire = datetime.utcnow() + timedelta(days=settings.JWT_REFRESH_DAYS)
|
||||||
|
to_encode.update({"exp": expire, "type": "refresh"})
|
||||||
|
return jwt.encode(to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
|
||||||
|
|
||||||
|
def decode_token(token: str) -> Optional[dict]:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])
|
||||||
|
return payload
|
||||||
|
except JWTError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def has_permission(user_role: str, required_role: str) -> bool:
|
||||||
|
"""Check if user_role has at least the required_role level."""
|
||||||
|
return ROLE_HIERARCHY.get(user_role, 0) >= ROLE_HIERARCHY.get(required_role, 0)
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
"""JIRA AI Fixer - Enterprise Issue Analysis Platform."""
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
import os
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import init_db
|
||||||
|
from app.api import api_router
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
# Startup
|
||||||
|
await init_db()
|
||||||
|
yield
|
||||||
|
# Shutdown
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title=settings.APP_NAME,
|
||||||
|
version=settings.APP_VERSION,
|
||||||
|
description="Enterprise AI-powered issue analysis and automated fix generation",
|
||||||
|
docs_url="/api/docs",
|
||||||
|
redoc_url="/api/redoc",
|
||||||
|
openapi_url="/api/openapi.json",
|
||||||
|
lifespan=lifespan
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# API routes
|
||||||
|
app.include_router(api_router, prefix="/api")
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def health():
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"service": "jira-ai-fixer",
|
||||||
|
"version": settings.APP_VERSION
|
||||||
|
}
|
||||||
|
|
||||||
|
# Serve static frontend (will be mounted if exists)
|
||||||
|
FRONTEND_DIR = "/app/frontend"
|
||||||
|
if os.path.exists(FRONTEND_DIR):
|
||||||
|
app.mount("/assets", StaticFiles(directory=f"{FRONTEND_DIR}/assets"), name="assets")
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def serve_frontend():
|
||||||
|
return FileResponse(f"{FRONTEND_DIR}/index.html")
|
||||||
|
|
||||||
|
@app.get("/{path:path}")
|
||||||
|
async def serve_spa(path: str):
|
||||||
|
file_path = f"{FRONTEND_DIR}/{path}"
|
||||||
|
if os.path.exists(file_path) and os.path.isfile(file_path):
|
||||||
|
return FileResponse(file_path)
|
||||||
|
return FileResponse(f"{FRONTEND_DIR}/index.html")
|
||||||
|
else:
|
||||||
|
# Fallback: serve basic info page
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
return {
|
||||||
|
"service": settings.APP_NAME,
|
||||||
|
"version": settings.APP_VERSION,
|
||||||
|
"docs": "/api/docs",
|
||||||
|
"health": "/api/health"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
from .user import User
|
||||||
|
from .organization import Organization, OrganizationMember
|
||||||
|
from .integration import Integration
|
||||||
|
from .issue import Issue
|
||||||
|
from .audit import AuditLog
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
"""Audit log for compliance and tracking."""
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, JSON, Text
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
class AuditLog(Base):
|
||||||
|
__tablename__ = "audit_logs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
organization_id = Column(Integer, ForeignKey("organizations.id"))
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"))
|
||||||
|
|
||||||
|
# Action details
|
||||||
|
action = Column(String(100), nullable=False, index=True) # user.login, issue.created, integration.updated
|
||||||
|
resource_type = Column(String(50)) # user, issue, integration, etc
|
||||||
|
resource_id = Column(Integer)
|
||||||
|
|
||||||
|
# Context
|
||||||
|
ip_address = Column(String(45))
|
||||||
|
user_agent = Column(String(500))
|
||||||
|
|
||||||
|
# Changes
|
||||||
|
old_values = Column(JSON)
|
||||||
|
new_values = Column(JSON)
|
||||||
|
description = Column(Text)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
success = Column(String(10), default="success") # success, failure
|
||||||
|
error_message = Column(String(500))
|
||||||
|
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
organization = relationship("Organization", back_populates="audit_logs")
|
||||||
|
user = relationship("User", back_populates="audit_logs")
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
"""Integration model."""
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Enum, Boolean, Text
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from app.core.database import Base
|
||||||
|
import enum
|
||||||
|
|
||||||
|
class IntegrationType(str, enum.Enum):
|
||||||
|
JIRA_CLOUD = "jira_cloud"
|
||||||
|
JIRA_SERVER = "jira_server"
|
||||||
|
SERVICENOW = "servicenow"
|
||||||
|
ZENDESK = "zendesk"
|
||||||
|
GITHUB = "github"
|
||||||
|
GITLAB = "gitlab"
|
||||||
|
AZURE_DEVOPS = "azure_devops"
|
||||||
|
TICKETHUB = "tickethub"
|
||||||
|
CUSTOM_WEBHOOK = "custom_webhook"
|
||||||
|
|
||||||
|
class IntegrationStatus(str, enum.Enum):
|
||||||
|
ACTIVE = "active"
|
||||||
|
INACTIVE = "inactive"
|
||||||
|
ERROR = "error"
|
||||||
|
|
||||||
|
class Integration(Base):
|
||||||
|
__tablename__ = "integrations"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=False)
|
||||||
|
|
||||||
|
name = Column(String(255), nullable=False)
|
||||||
|
type = Column(Enum(IntegrationType), nullable=False)
|
||||||
|
status = Column(Enum(IntegrationStatus), default=IntegrationStatus.ACTIVE)
|
||||||
|
|
||||||
|
# Config
|
||||||
|
base_url = Column(String(1024))
|
||||||
|
api_key = Column(Text) # Encrypted
|
||||||
|
oauth_token = Column(Text)
|
||||||
|
webhook_secret = Column(String(255))
|
||||||
|
callback_url = Column(String(1024))
|
||||||
|
|
||||||
|
# Stats
|
||||||
|
issues_processed = Column(Integer, default=0)
|
||||||
|
last_sync_at = Column(DateTime)
|
||||||
|
last_error = Column(Text)
|
||||||
|
|
||||||
|
# Settings
|
||||||
|
auto_analyze = Column(Boolean, default=True)
|
||||||
|
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relations
|
||||||
|
organization = relationship("Organization", back_populates="integrations")
|
||||||
|
issues = relationship("Issue", back_populates="integration")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def webhook_url(self) -> str:
|
||||||
|
return f"https://jira-fixer.startdata.com.br/api/webhook/{self.organization_id}/{self.type.value}"
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
"""Issue model."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
from sqlalchemy import Column, Integer, String, Text, DateTime, Float, ForeignKey, Enum, JSON
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from app.core.database import Base
|
||||||
|
import enum
|
||||||
|
|
||||||
|
class IssueStatus(str, enum.Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
ANALYZING = "analyzing"
|
||||||
|
ANALYZED = "analyzed"
|
||||||
|
PR_CREATED = "pr_created"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
ERROR = "error"
|
||||||
|
|
||||||
|
class IssuePriority(str, enum.Enum):
|
||||||
|
CRITICAL = "critical"
|
||||||
|
HIGH = "high"
|
||||||
|
MEDIUM = "medium"
|
||||||
|
LOW = "low"
|
||||||
|
|
||||||
|
class Issue(Base):
|
||||||
|
__tablename__ = "issues"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=False)
|
||||||
|
integration_id = Column(Integer, ForeignKey("integrations.id"), nullable=True)
|
||||||
|
|
||||||
|
# External reference
|
||||||
|
external_id = Column(String(255), index=True)
|
||||||
|
external_key = Column(String(100), index=True) # JIRA-123, INC0001234
|
||||||
|
external_url = Column(String(1024))
|
||||||
|
source = Column(String(50)) # jira_cloud, servicenow, etc
|
||||||
|
|
||||||
|
# Issue data
|
||||||
|
title = Column(String(500), nullable=False)
|
||||||
|
description = Column(Text)
|
||||||
|
priority = Column(Enum(IssuePriority), default=IssuePriority.MEDIUM)
|
||||||
|
labels = Column(JSON)
|
||||||
|
|
||||||
|
# Analysis
|
||||||
|
status = Column(Enum(IssueStatus), default=IssueStatus.PENDING)
|
||||||
|
root_cause = Column(Text)
|
||||||
|
suggested_fix = Column(Text)
|
||||||
|
affected_files = Column(JSON)
|
||||||
|
confidence = Column(Float)
|
||||||
|
analysis_completed_at = Column(DateTime)
|
||||||
|
error_message = Column(Text)
|
||||||
|
|
||||||
|
# PR
|
||||||
|
pr_url = Column(String(1024))
|
||||||
|
pr_branch = Column(String(255))
|
||||||
|
|
||||||
|
# Callback
|
||||||
|
callback_url = Column(String(1024))
|
||||||
|
callback_sent = Column(DateTime)
|
||||||
|
|
||||||
|
# Meta
|
||||||
|
raw_payload = Column(JSON)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relations
|
||||||
|
organization = relationship("Organization", back_populates="issues")
|
||||||
|
integration = relationship("Integration", back_populates="issues")
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
"""Organization model."""
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Enum, Text
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from app.core.database import Base
|
||||||
|
import enum
|
||||||
|
|
||||||
|
class MemberRole(str, enum.Enum):
|
||||||
|
VIEWER = "viewer"
|
||||||
|
ANALYST = "analyst"
|
||||||
|
MANAGER = "manager"
|
||||||
|
ADMIN = "admin"
|
||||||
|
OWNER = "owner"
|
||||||
|
|
||||||
|
class Organization(Base):
|
||||||
|
__tablename__ = "organizations"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String(255), nullable=False)
|
||||||
|
slug = Column(String(100), unique=True, nullable=False, index=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relations
|
||||||
|
members = relationship("OrganizationMember", back_populates="organization", cascade="all, delete-orphan")
|
||||||
|
integrations = relationship("Integration", back_populates="organization", cascade="all, delete-orphan")
|
||||||
|
issues = relationship("Issue", back_populates="organization", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
class OrganizationMember(Base):
|
||||||
|
__tablename__ = "organization_members"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=False)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
role = Column(Enum(MemberRole), default=MemberRole.VIEWER)
|
||||||
|
joined_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relations
|
||||||
|
organization = relationship("Organization", back_populates="members")
|
||||||
|
user = relationship("User", back_populates="memberships")
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
"""User model."""
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
email = Column(String(255), unique=True, index=True, nullable=False)
|
||||||
|
hashed_password = Column(String(255), nullable=False)
|
||||||
|
full_name = Column(String(255))
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
is_superuser = Column(Boolean, default=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
last_login = Column(DateTime)
|
||||||
|
|
||||||
|
# Relations
|
||||||
|
memberships = relationship("OrganizationMember", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
from .user import UserCreate, UserRead, UserUpdate, Token, TokenData
|
||||||
|
from .organization import OrganizationCreate, OrganizationRead, OrganizationUpdate, MemberCreate, MemberRead
|
||||||
|
from .integration import IntegrationCreate, IntegrationRead, IntegrationUpdate
|
||||||
|
from .issue import IssueCreate, IssueRead, IssueUpdate, IssueStats
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
"""Integration schemas."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from app.models.integration import IntegrationType, IntegrationStatus
|
||||||
|
|
||||||
|
class IntegrationBase(BaseModel):
|
||||||
|
name: str
|
||||||
|
type: IntegrationType
|
||||||
|
|
||||||
|
class IntegrationCreate(IntegrationBase):
|
||||||
|
base_url: Optional[str] = None
|
||||||
|
auth_type: str = "api_key"
|
||||||
|
api_key: Optional[str] = None
|
||||||
|
api_secret: Optional[str] = None
|
||||||
|
webhook_url: Optional[str] = None
|
||||||
|
callback_url: Optional[str] = None
|
||||||
|
auto_analyze: bool = True
|
||||||
|
sync_comments: bool = True
|
||||||
|
create_prs: bool = True
|
||||||
|
repositories: Optional[List[Dict[str, str]]] = None
|
||||||
|
|
||||||
|
class IntegrationUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
base_url: Optional[str] = None
|
||||||
|
api_key: Optional[str] = None
|
||||||
|
api_secret: Optional[str] = None
|
||||||
|
callback_url: Optional[str] = None
|
||||||
|
auto_analyze: Optional[bool] = None
|
||||||
|
sync_comments: Optional[bool] = None
|
||||||
|
create_prs: Optional[bool] = None
|
||||||
|
repositories: Optional[List[Dict[str, str]]] = None
|
||||||
|
status: Optional[IntegrationStatus] = None
|
||||||
|
|
||||||
|
class IntegrationRead(IntegrationBase):
|
||||||
|
id: int
|
||||||
|
organization_id: int
|
||||||
|
status: IntegrationStatus
|
||||||
|
base_url: Optional[str] = None
|
||||||
|
webhook_url: Optional[str] = None
|
||||||
|
auto_analyze: bool
|
||||||
|
issues_processed: int
|
||||||
|
last_sync_at: Optional[datetime] = None
|
||||||
|
last_error: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class OAuthCallback(BaseModel):
|
||||||
|
code: str
|
||||||
|
state: str
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
"""Issue schemas."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from app.models.issue import IssueStatus, IssuePriority
|
||||||
|
|
||||||
|
class IssueBase(BaseModel):
|
||||||
|
title: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
priority: IssuePriority = IssuePriority.MEDIUM
|
||||||
|
|
||||||
|
class IssueCreate(IssueBase):
|
||||||
|
external_id: Optional[str] = None
|
||||||
|
external_key: Optional[str] = None
|
||||||
|
external_url: Optional[str] = None
|
||||||
|
source: Optional[str] = None
|
||||||
|
labels: Optional[List[str]] = None
|
||||||
|
callback_url: Optional[str] = None
|
||||||
|
raw_payload: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
class IssueUpdate(BaseModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
priority: Optional[IssuePriority] = None
|
||||||
|
status: Optional[IssueStatus] = None
|
||||||
|
labels: Optional[List[str]] = None
|
||||||
|
|
||||||
|
class IssueRead(IssueBase):
|
||||||
|
id: int
|
||||||
|
organization_id: int
|
||||||
|
integration_id: Optional[int] = None
|
||||||
|
external_id: Optional[str] = None
|
||||||
|
external_key: Optional[str] = None
|
||||||
|
external_url: Optional[str] = None
|
||||||
|
source: Optional[str] = None
|
||||||
|
labels: Optional[List[str]] = None
|
||||||
|
|
||||||
|
status: IssueStatus
|
||||||
|
root_cause: Optional[str] = None
|
||||||
|
affected_files: Optional[List[str]] = None
|
||||||
|
suggested_fix: Optional[str] = None
|
||||||
|
confidence: Optional[float] = None
|
||||||
|
|
||||||
|
pr_url: Optional[str] = None
|
||||||
|
pr_branch: Optional[str] = None
|
||||||
|
pr_status: Optional[str] = None
|
||||||
|
|
||||||
|
sla_deadline: Optional[datetime] = None
|
||||||
|
sla_breached: bool = False
|
||||||
|
|
||||||
|
created_at: datetime
|
||||||
|
analysis_completed_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class IssueStats(BaseModel):
|
||||||
|
total: int
|
||||||
|
pending: int
|
||||||
|
analyzing: int
|
||||||
|
analyzed: int
|
||||||
|
pr_created: int
|
||||||
|
completed: int
|
||||||
|
error: int
|
||||||
|
avg_confidence: float
|
||||||
|
avg_analysis_time_seconds: Optional[float] = None
|
||||||
|
by_source: Dict[str, int]
|
||||||
|
by_priority: Dict[str, int]
|
||||||
|
sla_breached: int
|
||||||
|
|
||||||
|
class IssueComment(BaseModel):
|
||||||
|
author: str
|
||||||
|
content: str
|
||||||
|
author_type: str = "user"
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
"""Organization schemas."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from app.models.organization import MemberRole
|
||||||
|
|
||||||
|
class OrganizationBase(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
class OrganizationCreate(OrganizationBase):
|
||||||
|
slug: str
|
||||||
|
|
||||||
|
class OrganizationUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
logo_url: Optional[str] = None
|
||||||
|
slack_webhook_url: Optional[str] = None
|
||||||
|
teams_webhook_url: Optional[str] = None
|
||||||
|
|
||||||
|
class OrganizationRead(OrganizationBase):
|
||||||
|
id: int
|
||||||
|
slug: str
|
||||||
|
logo_url: Optional[str] = None
|
||||||
|
plan: str
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
member_count: Optional[int] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class MemberCreate(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
role: MemberRole = MemberRole.ANALYST
|
||||||
|
|
||||||
|
class MemberRead(BaseModel):
|
||||||
|
id: int
|
||||||
|
user_id: int
|
||||||
|
role: MemberRole
|
||||||
|
joined_at: datetime
|
||||||
|
user_email: Optional[str] = None
|
||||||
|
user_name: Optional[str] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
from pydantic import EmailStr
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
"""User schemas."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
|
||||||
|
class UserBase(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
full_name: Optional[str] = None
|
||||||
|
|
||||||
|
class UserCreate(UserBase):
|
||||||
|
password: str
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
email: Optional[EmailStr] = None
|
||||||
|
full_name: Optional[str] = None
|
||||||
|
avatar_url: Optional[str] = None
|
||||||
|
password: Optional[str] = None
|
||||||
|
|
||||||
|
class UserRead(UserBase):
|
||||||
|
id: int
|
||||||
|
avatar_url: Optional[str] = None
|
||||||
|
is_active: bool
|
||||||
|
email_verified: bool
|
||||||
|
created_at: datetime
|
||||||
|
last_login: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class Token(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
|
||||||
|
class TokenData(BaseModel):
|
||||||
|
user_id: int
|
||||||
|
email: str
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
from .email import EmailService
|
||||||
|
from .analysis import AnalysisService
|
||||||
|
from .audit import AuditService
|
||||||
|
|
@ -0,0 +1,220 @@
|
||||||
|
"""Analysis service - AI-powered issue analysis."""
|
||||||
|
import httpx
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
class AnalysisService:
|
||||||
|
OPENROUTER_API = "https://openrouter.ai/api/v1/chat/completions"
|
||||||
|
MODEL = "meta-llama/llama-3.3-70b-instruct:free"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def fetch_repository_files(cls, repo: str, path: str = "") -> List[Dict[str, str]]:
|
||||||
|
"""Fetch files from Gitea repository."""
|
||||||
|
files = []
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
try:
|
||||||
|
url = f"{settings.GITEA_URL}/api/v1/repos/{repo}/contents/{path}"
|
||||||
|
headers = {}
|
||||||
|
if settings.GITEA_TOKEN:
|
||||||
|
headers["Authorization"] = f"token {settings.GITEA_TOKEN}"
|
||||||
|
|
||||||
|
response = await client.get(url, headers=headers)
|
||||||
|
if response.status_code != 200:
|
||||||
|
return files
|
||||||
|
|
||||||
|
items = response.json()
|
||||||
|
for item in items:
|
||||||
|
if item["type"] == "file" and item["name"].endswith((".cbl", ".cob", ".py", ".java", ".js", ".ts")):
|
||||||
|
content_resp = await client.get(item["download_url"], headers=headers)
|
||||||
|
if content_resp.status_code == 200:
|
||||||
|
files.append({
|
||||||
|
"path": item["path"],
|
||||||
|
"content": content_resp.text[:10000] # Limit size
|
||||||
|
})
|
||||||
|
elif item["type"] == "dir":
|
||||||
|
sub_files = await cls.fetch_repository_files(repo, item["path"])
|
||||||
|
files.extend(sub_files)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error fetching repo: {e}")
|
||||||
|
|
||||||
|
return files[:20] # Limit to 20 files
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def build_prompt(cls, issue: Dict[str, Any], files: List[Dict[str, str]]) -> str:
|
||||||
|
"""Build analysis prompt for LLM."""
|
||||||
|
files_context = "\n\n".join([
|
||||||
|
f"### {f['path']}\n```\n{f['content']}\n```"
|
||||||
|
for f in files
|
||||||
|
])
|
||||||
|
|
||||||
|
return f"""You are an expert software engineer analyzing a support issue.
|
||||||
|
|
||||||
|
## Issue Details
|
||||||
|
**Title:** {issue.get('title', 'N/A')}
|
||||||
|
**Description:** {issue.get('description', 'N/A')}
|
||||||
|
**Priority:** {issue.get('priority', 'N/A')}
|
||||||
|
|
||||||
|
## Source Code Files
|
||||||
|
{files_context}
|
||||||
|
|
||||||
|
## Your Task
|
||||||
|
Analyze the issue and identify:
|
||||||
|
1. Root cause of the problem
|
||||||
|
2. Which files are affected
|
||||||
|
3. Suggested code fix
|
||||||
|
|
||||||
|
## Response Format (JSON)
|
||||||
|
{{
|
||||||
|
"root_cause": "Detailed explanation of what's causing the issue",
|
||||||
|
"affected_files": ["file1.py", "file2.py"],
|
||||||
|
"suggested_fix": "Code changes needed to fix the issue",
|
||||||
|
"confidence": 0.85,
|
||||||
|
"explanation": "Step-by-step explanation of the fix"
|
||||||
|
}}
|
||||||
|
|
||||||
|
Respond ONLY with valid JSON."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def analyze(cls, issue: Dict[str, Any], repo: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
"""Run AI analysis on an issue."""
|
||||||
|
# Fetch code context
|
||||||
|
files = []
|
||||||
|
if repo:
|
||||||
|
files = await cls.fetch_repository_files(repo)
|
||||||
|
|
||||||
|
# Build prompt
|
||||||
|
prompt = cls.build_prompt(issue, files)
|
||||||
|
|
||||||
|
# Call LLM
|
||||||
|
if not settings.OPENROUTER_API_KEY:
|
||||||
|
# Mock response for testing
|
||||||
|
return {
|
||||||
|
"root_cause": "Mock analysis - configure OPENROUTER_API_KEY for real analysis",
|
||||||
|
"affected_files": ["example.py"],
|
||||||
|
"suggested_fix": "# Mock fix",
|
||||||
|
"confidence": 0.5,
|
||||||
|
"explanation": "This is a mock response"
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
try:
|
||||||
|
response = await client.post(
|
||||||
|
cls.OPENROUTER_API,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {settings.OPENROUTER_API_KEY}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"model": cls.MODEL,
|
||||||
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
|
"temperature": 0.2,
|
||||||
|
"max_tokens": 2000
|
||||||
|
},
|
||||||
|
timeout=120
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
content = data["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
# Parse JSON from response
|
||||||
|
try:
|
||||||
|
# Handle markdown code blocks
|
||||||
|
if "```json" in content:
|
||||||
|
content = content.split("```json")[1].split("```")[0]
|
||||||
|
elif "```" in content:
|
||||||
|
content = content.split("```")[1].split("```")[0]
|
||||||
|
|
||||||
|
return json.loads(content.strip())
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {
|
||||||
|
"root_cause": content[:500],
|
||||||
|
"affected_files": [],
|
||||||
|
"suggested_fix": "",
|
||||||
|
"confidence": 0.3,
|
||||||
|
"explanation": "Could not parse structured response"
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"root_cause": f"API error: {response.status_code}",
|
||||||
|
"affected_files": [],
|
||||||
|
"suggested_fix": "",
|
||||||
|
"confidence": 0,
|
||||||
|
"explanation": response.text[:500]
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"root_cause": f"Analysis error: {str(e)}",
|
||||||
|
"affected_files": [],
|
||||||
|
"suggested_fix": "",
|
||||||
|
"confidence": 0,
|
||||||
|
"explanation": str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def create_pull_request(
|
||||||
|
cls,
|
||||||
|
repo: str,
|
||||||
|
branch: str,
|
||||||
|
title: str,
|
||||||
|
description: str,
|
||||||
|
file_changes: List[Dict[str, str]]
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Create a pull request with suggested fix."""
|
||||||
|
if not settings.GITEA_TOKEN:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
headers = {"Authorization": f"token {settings.GITEA_TOKEN}"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Get default branch
|
||||||
|
repo_resp = await client.get(
|
||||||
|
f"{settings.GITEA_URL}/api/v1/repos/{repo}",
|
||||||
|
headers=headers
|
||||||
|
)
|
||||||
|
if repo_resp.status_code != 200:
|
||||||
|
return None
|
||||||
|
default_branch = repo_resp.json().get("default_branch", "main")
|
||||||
|
|
||||||
|
# 2. Get latest commit SHA
|
||||||
|
ref_resp = await client.get(
|
||||||
|
f"{settings.GITEA_URL}/api/v1/repos/{repo}/git/refs/heads/{default_branch}",
|
||||||
|
headers=headers
|
||||||
|
)
|
||||||
|
if ref_resp.status_code != 200:
|
||||||
|
return None
|
||||||
|
sha = ref_resp.json()["object"]["sha"]
|
||||||
|
|
||||||
|
# 3. Create branch
|
||||||
|
await client.post(
|
||||||
|
f"{settings.GITEA_URL}/api/v1/repos/{repo}/git/refs",
|
||||||
|
headers=headers,
|
||||||
|
json={"ref": f"refs/heads/{branch}", "sha": sha}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Commit changes (simplified - just description for now)
|
||||||
|
# Full implementation would update actual files
|
||||||
|
|
||||||
|
# 5. Create PR
|
||||||
|
pr_resp = await client.post(
|
||||||
|
f"{settings.GITEA_URL}/api/v1/repos/{repo}/pulls",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"title": title,
|
||||||
|
"body": description,
|
||||||
|
"head": branch,
|
||||||
|
"base": default_branch
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if pr_resp.status_code in (200, 201):
|
||||||
|
pr_data = pr_resp.json()
|
||||||
|
return pr_data.get("html_url")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"PR creation error: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
"""Audit logging service."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from app.models.audit import AuditLog
|
||||||
|
|
||||||
|
class AuditService:
|
||||||
|
@classmethod
|
||||||
|
async def log(
|
||||||
|
cls,
|
||||||
|
db: AsyncSession,
|
||||||
|
action: str,
|
||||||
|
user_id: Optional[int] = None,
|
||||||
|
organization_id: Optional[int] = None,
|
||||||
|
resource_type: Optional[str] = None,
|
||||||
|
resource_id: Optional[int] = None,
|
||||||
|
old_values: Optional[Dict[str, Any]] = None,
|
||||||
|
new_values: Optional[Dict[str, Any]] = None,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
ip_address: Optional[str] = None,
|
||||||
|
user_agent: Optional[str] = None,
|
||||||
|
success: str = "success",
|
||||||
|
error_message: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""Create an audit log entry."""
|
||||||
|
log = AuditLog(
|
||||||
|
action=action,
|
||||||
|
user_id=user_id,
|
||||||
|
organization_id=organization_id,
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
old_values=old_values,
|
||||||
|
new_values=new_values,
|
||||||
|
description=description,
|
||||||
|
ip_address=ip_address,
|
||||||
|
user_agent=user_agent,
|
||||||
|
success=success,
|
||||||
|
error_message=error_message
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
await db.flush()
|
||||||
|
return log
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
"""Email service using Resend."""
|
||||||
|
import httpx
|
||||||
|
from typing import Optional, List
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
class EmailService:
|
||||||
|
RESEND_API = "https://api.resend.com/emails"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def send(
|
||||||
|
cls,
|
||||||
|
to: List[str],
|
||||||
|
subject: str,
|
||||||
|
html: str,
|
||||||
|
text: Optional[str] = None
|
||||||
|
) -> bool:
|
||||||
|
if not settings.RESEND_API_KEY:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
try:
|
||||||
|
response = await client.post(
|
||||||
|
cls.RESEND_API,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {settings.RESEND_API_KEY}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"from": settings.EMAIL_FROM,
|
||||||
|
"to": to,
|
||||||
|
"subject": subject,
|
||||||
|
"html": html,
|
||||||
|
"text": text
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return response.status_code == 200
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def send_welcome(cls, email: str, name: str, org_name: str):
|
||||||
|
html = f"""
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h1 style="color: #4F46E5;">Welcome to JIRA AI Fixer! 🤖</h1>
|
||||||
|
<p>Hi {name},</p>
|
||||||
|
<p>You've been added to <strong>{org_name}</strong>.</p>
|
||||||
|
<p>JIRA AI Fixer automatically analyzes support issues and suggests code fixes using AI.</p>
|
||||||
|
<div style="margin: 30px 0;">
|
||||||
|
<a href="https://jira-fixer.startdata.com.br"
|
||||||
|
style="background: #4F46E5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px;">
|
||||||
|
Get Started
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p style="color: #666;">— The JIRA AI Fixer Team</p>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
await cls.send([email], f"Welcome to {org_name} on JIRA AI Fixer", html)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def send_analysis_complete(cls, email: str, issue_key: str, confidence: float, pr_url: Optional[str]):
|
||||||
|
html = f"""
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h1 style="color: #10B981;">Analysis Complete ✅</h1>
|
||||||
|
<p>Issue <strong>{issue_key}</strong> has been analyzed.</p>
|
||||||
|
<div style="background: #F3F4F6; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||||
|
<p><strong>Confidence:</strong> {confidence:.0%}</p>
|
||||||
|
{f'<p><strong>Pull Request:</strong> <a href="{pr_url}">{pr_url}</a></p>' if pr_url else ''}
|
||||||
|
</div>
|
||||||
|
<a href="https://jira-fixer.startdata.com.br"
|
||||||
|
style="background: #4F46E5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px;">
|
||||||
|
View Details
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
await cls.send([email], f"Analysis Complete: {issue_key}", html)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def send_weekly_digest(cls, email: str, org_name: str, stats: dict):
|
||||||
|
html = f"""
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h1 style="color: #4F46E5;">Weekly Digest 📊</h1>
|
||||||
|
<p>Here's what happened in <strong>{org_name}</strong> this week:</p>
|
||||||
|
<div style="background: #F3F4F6; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||||
|
<p><strong>Issues Analyzed:</strong> {stats.get('analyzed', 0)}</p>
|
||||||
|
<p><strong>PRs Created:</strong> {stats.get('prs', 0)}</p>
|
||||||
|
<p><strong>Avg Confidence:</strong> {stats.get('confidence', 0):.0%}</p>
|
||||||
|
</div>
|
||||||
|
<a href="https://jira-fixer.startdata.com.br/reports"
|
||||||
|
style="background: #4F46E5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px;">
|
||||||
|
View Full Report
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
await cls.send([email], f"Weekly Digest: {org_name}", html)
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5433/jira_fixer_v2
|
||||||
|
- REDIS_URL=redis://host.docker.internal:6379
|
||||||
|
- JWT_SECRET=dev-secret-change-in-production
|
||||||
|
- RESEND_API_KEY=${RESEND_API_KEY}
|
||||||
|
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
|
||||||
|
- GITEA_URL=https://gitea.startdata.com.br
|
||||||
|
- GITEA_TOKEN=${GITEA_TOKEN}
|
||||||
|
volumes:
|
||||||
|
- ./app:/app/app:ro
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>JIRA AI Fixer</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-900 text-white">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"name": "jira-ai-fixer-portal",
|
||||||
|
"private": true,
|
||||||
|
"version": "2.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.22.0",
|
||||||
|
"@tanstack/react-query": "^5.17.0",
|
||||||
|
"axios": "^1.6.5",
|
||||||
|
"recharts": "^2.10.4",
|
||||||
|
"date-fns": "^3.2.0",
|
||||||
|
"clsx": "^2.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.48",
|
||||||
|
"@types/react-dom": "^18.2.18",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"autoprefixer": "^10.4.17",
|
||||||
|
"postcss": "^8.4.33",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"vite": "^5.0.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from './context/AuthContext';
|
||||||
|
import Layout from './components/Layout';
|
||||||
|
import Login from './pages/Login';
|
||||||
|
import Register from './pages/Register';
|
||||||
|
import Dashboard from './pages/Dashboard';
|
||||||
|
import Issues from './pages/Issues';
|
||||||
|
import IssueDetail from './pages/IssueDetail';
|
||||||
|
import Integrations from './pages/Integrations';
|
||||||
|
import Team from './pages/Team';
|
||||||
|
import Reports from './pages/Reports';
|
||||||
|
import Settings from './pages/Settings';
|
||||||
|
|
||||||
|
function PrivateRoute({ children }) {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
if (loading) return <div className="flex items-center justify-center h-screen">Loading...</div>;
|
||||||
|
return user ? children : <Navigate to="/login" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/register" element={<Register />} />
|
||||||
|
<Route path="/" element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<Layout />
|
||||||
|
</PrivateRoute>
|
||||||
|
}>
|
||||||
|
<Route index element={<Dashboard />} />
|
||||||
|
<Route path="issues" element={<Issues />} />
|
||||||
|
<Route path="issues/:id" element={<IssueDetail />} />
|
||||||
|
<Route path="integrations" element={<Integrations />} />
|
||||||
|
<Route path="team" element={<Team />} />
|
||||||
|
<Route path="reports" element={<Reports />} />
|
||||||
|
<Route path="settings" element={<Settings />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { organizations } from '../services/api';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ path: '/', label: 'Dashboard', icon: '📊' },
|
||||||
|
{ path: '/issues', label: 'Issues', icon: '🎫' },
|
||||||
|
{ path: '/integrations', label: 'Integrations', icon: '🔌' },
|
||||||
|
{ path: '/team', label: 'Team', icon: '👥' },
|
||||||
|
{ path: '/reports', label: 'Reports', icon: '📈' },
|
||||||
|
{ path: '/settings', label: 'Settings', icon: '⚙️' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
const { user, logout, currentOrg, selectOrg } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [showOrgMenu, setShowOrgMenu] = useState(false);
|
||||||
|
|
||||||
|
const { data: orgs } = useQuery({
|
||||||
|
queryKey: ['organizations'],
|
||||||
|
queryFn: () => organizations.list()
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="w-64 bg-gray-800 border-r border-gray-700 flex flex-col">
|
||||||
|
<div className="p-4 border-b border-gray-700">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-2xl">🤖</span>
|
||||||
|
<div>
|
||||||
|
<h1 className="font-bold">JIRA AI Fixer</h1>
|
||||||
|
<p className="text-xs text-gray-400">v2.0</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Org selector */}
|
||||||
|
<div className="p-4 border-b border-gray-700 relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowOrgMenu(!showOrgMenu)}
|
||||||
|
className="w-full flex items-center justify-between p-2 rounded-lg bg-gray-700 hover:bg-gray-600"
|
||||||
|
>
|
||||||
|
<span className="truncate">{currentOrg?.name || 'Select organization'}</span>
|
||||||
|
<span>▾</span>
|
||||||
|
</button>
|
||||||
|
{showOrgMenu && orgs?.data && (
|
||||||
|
<div className="absolute top-full left-4 right-4 mt-1 bg-gray-700 rounded-lg shadow-lg z-10">
|
||||||
|
{orgs.data.map(org => (
|
||||||
|
<button
|
||||||
|
key={org.id}
|
||||||
|
onClick={() => { selectOrg(org); setShowOrgMenu(false); }}
|
||||||
|
className={clsx(
|
||||||
|
"w-full text-left px-4 py-2 hover:bg-gray-600 first:rounded-t-lg last:rounded-b-lg",
|
||||||
|
currentOrg?.id === org.id && "bg-primary-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{org.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => { navigate('/settings'); setShowOrgMenu(false); }}
|
||||||
|
className="w-full text-left px-4 py-2 hover:bg-gray-600 text-primary-400 border-t border-gray-600"
|
||||||
|
>
|
||||||
|
+ Create organization
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav */}
|
||||||
|
<nav className="flex-1 p-4">
|
||||||
|
{navItems.map(item => (
|
||||||
|
<Link
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center gap-3 px-4 py-2 rounded-lg mb-1",
|
||||||
|
location.pathname === item.path
|
||||||
|
? "bg-primary-600 text-white"
|
||||||
|
: "text-gray-400 hover:bg-gray-700 hover:text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{item.icon}</span>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User */}
|
||||||
|
<div className="p-4 border-t border-gray-700">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 bg-primary-600 rounded-full flex items-center justify-center">
|
||||||
|
{user?.full_name?.[0] || user?.email?.[0] || '?'}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="truncate font-medium">{user?.full_name || user?.email}</p>
|
||||||
|
<button onClick={logout} className="text-xs text-gray-400 hover:text-red-400">
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<main className="flex-1 overflow-auto">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
import { auth, users } from '../services/api';
|
||||||
|
|
||||||
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [currentOrg, setCurrentOrg] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
if (token) {
|
||||||
|
loadUser();
|
||||||
|
} else {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadUser = async () => {
|
||||||
|
try {
|
||||||
|
const res = await users.me();
|
||||||
|
setUser(res.data);
|
||||||
|
const savedOrg = localStorage.getItem('current_org');
|
||||||
|
if (savedOrg) setCurrentOrg(JSON.parse(savedOrg));
|
||||||
|
} catch (e) {
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const login = async (email, password) => {
|
||||||
|
const res = await auth.login(email, password);
|
||||||
|
localStorage.setItem('access_token', res.data.access_token);
|
||||||
|
localStorage.setItem('refresh_token', res.data.refresh_token);
|
||||||
|
await loadUser();
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
|
localStorage.removeItem('current_org');
|
||||||
|
setUser(null);
|
||||||
|
setCurrentOrg(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectOrg = (org) => {
|
||||||
|
setCurrentOrg(org);
|
||||||
|
localStorage.setItem('current_org', JSON.stringify(org));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, loading, login, logout, currentOrg, selectOrg }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuth = () => useContext(AuthContext);
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
@apply antialiased;
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.btn {
|
||||||
|
@apply px-4 py-2 rounded-lg font-medium transition-colors;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
@apply bg-primary-600 hover:bg-primary-700 text-white;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
@apply bg-gray-700 hover:bg-gray-600 text-white;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
@apply w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
@apply bg-gray-800 border border-gray-700 rounded-xl p-6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { AuthProvider } from './context/AuthContext';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { staleTime: 30000, retry: 1 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { issues, reports } from '../services/api';
|
||||||
|
import { AreaChart, Area, BarChart, Bar, PieChart, Pie, Cell, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
|
|
||||||
|
const COLORS = ['#6366f1', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6'];
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const { currentOrg } = useAuth();
|
||||||
|
|
||||||
|
const { data: stats } = useQuery({
|
||||||
|
queryKey: ['issues-stats', currentOrg?.id],
|
||||||
|
queryFn: () => issues.stats(currentOrg.id),
|
||||||
|
enabled: !!currentOrg
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: report } = useQuery({
|
||||||
|
queryKey: ['report-summary', currentOrg?.id],
|
||||||
|
queryFn: () => reports.summary(currentOrg.id, 14),
|
||||||
|
enabled: !!currentOrg
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!currentOrg) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<span className="text-6xl">🏢</span>
|
||||||
|
<h2 className="text-2xl font-bold mt-4">Select an organization</h2>
|
||||||
|
<p className="text-gray-400 mt-2">Choose an organization from the sidebar to get started</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = stats?.data || {};
|
||||||
|
const r = report?.data || {};
|
||||||
|
|
||||||
|
const statusData = [
|
||||||
|
{ name: 'Pending', value: s.pending || 0 },
|
||||||
|
{ name: 'Analyzing', value: s.analyzing || 0 },
|
||||||
|
{ name: 'Analyzed', value: s.analyzed || 0 },
|
||||||
|
{ name: 'PR Created', value: s.pr_created || 0 },
|
||||||
|
{ name: 'Error', value: s.error || 0 }
|
||||||
|
].filter(d => d.value > 0);
|
||||||
|
|
||||||
|
const sourceData = Object.entries(s.by_source || {}).map(([name, value]) => ({ name, value }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
|
||||||
|
|
||||||
|
{/* Stats cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-400 text-sm">Total Issues</p>
|
||||||
|
<p className="text-3xl font-bold mt-1">{s.total || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center text-2xl">
|
||||||
|
📋
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-400 text-sm">Analyzed</p>
|
||||||
|
<p className="text-3xl font-bold mt-1 text-green-400">{s.analyzed || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center text-2xl">
|
||||||
|
✅
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-400 text-sm">PRs Created</p>
|
||||||
|
<p className="text-3xl font-bold mt-1 text-purple-400">{s.pr_created || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-purple-500/20 rounded-lg flex items-center justify-center text-2xl">
|
||||||
|
🔀
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-400 text-sm">Avg Confidence</p>
|
||||||
|
<p className="text-3xl font-bold mt-1 text-yellow-400">
|
||||||
|
{s.avg_confidence ? `${(s.avg_confidence * 100).toFixed(0)}%` : 'N/A'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-yellow-500/20 rounded-lg flex items-center justify-center text-2xl">
|
||||||
|
🎯
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||||
|
{/* Trend chart */}
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-semibold mb-4">Issues Trend (14 days)</h3>
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={r.daily_breakdown || []}>
|
||||||
|
<XAxis dataKey="date" tick={{ fill: '#9ca3af', fontSize: 12 }} />
|
||||||
|
<YAxis tick={{ fill: '#9ca3af', fontSize: 12 }} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: '#1f2937', border: '1px solid #374151' }}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="total"
|
||||||
|
stroke="#6366f1"
|
||||||
|
fill="#6366f1"
|
||||||
|
fillOpacity={0.3}
|
||||||
|
name="Total"
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="analyzed"
|
||||||
|
stroke="#22c55e"
|
||||||
|
fill="#22c55e"
|
||||||
|
fillOpacity={0.3}
|
||||||
|
name="Analyzed"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status distribution */}
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-semibold mb-4">Status Distribution</h3>
|
||||||
|
<div className="h-64 flex items-center">
|
||||||
|
{statusData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={statusData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={60}
|
||||||
|
outerRadius={80}
|
||||||
|
paddingAngle={5}
|
||||||
|
dataKey="value"
|
||||||
|
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
||||||
|
>
|
||||||
|
{statusData.map((entry, index) => (
|
||||||
|
<Cell key={entry.name} fill={COLORS[index % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-400 text-center w-full">No data yet</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* By source */}
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-semibold mb-4">Issues by Source</h3>
|
||||||
|
<div className="h-64">
|
||||||
|
{sourceData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={sourceData} layout="vertical">
|
||||||
|
<XAxis type="number" tick={{ fill: '#9ca3af', fontSize: 12 }} />
|
||||||
|
<YAxis type="category" dataKey="name" tick={{ fill: '#9ca3af', fontSize: 12 }} width={100} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: '#1f2937', border: '1px solid #374151' }}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="value" fill="#6366f1" radius={[0, 4, 4, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-400 text-center">No data yet</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,225 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { integrations } from '../services/api';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
const integrationTypes = [
|
||||||
|
{ type: 'jira_cloud', name: 'JIRA Cloud', icon: '🔵', desc: 'Atlassian JIRA Cloud' },
|
||||||
|
{ type: 'servicenow', name: 'ServiceNow', icon: '⚙️', desc: 'ServiceNow ITSM' },
|
||||||
|
{ type: 'zendesk', name: 'Zendesk', icon: '💚', desc: 'Zendesk Support' },
|
||||||
|
{ type: 'github', name: 'GitHub', icon: '🐙', desc: 'GitHub Issues' },
|
||||||
|
{ type: 'gitlab', name: 'GitLab', icon: '🦊', desc: 'GitLab Issues' },
|
||||||
|
{ type: 'azure_devops', name: 'Azure DevOps', icon: '🔷', desc: 'Azure Boards' },
|
||||||
|
{ type: 'tickethub', name: 'TicketHub', icon: '🎫', desc: 'TicketHub' },
|
||||||
|
{ type: 'custom_webhook', name: 'Custom Webhook', icon: '🔗', desc: 'Custom integration' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Integrations() {
|
||||||
|
const { currentOrg } = useAuth();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [selectedType, setSelectedType] = useState(null);
|
||||||
|
const [form, setForm] = useState({});
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['integrations', currentOrg?.id],
|
||||||
|
queryFn: () => integrations.list(currentOrg.id),
|
||||||
|
enabled: !!currentOrg
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data) => integrations.create(currentOrg.id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries(['integrations', currentOrg?.id]);
|
||||||
|
setShowModal(false);
|
||||||
|
setForm({});
|
||||||
|
setSelectedType(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id) => integrations.delete(currentOrg.id, id),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries(['integrations', currentOrg?.id])
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!currentOrg) return <div className="p-8 text-center text-gray-400">Select an organization</div>;
|
||||||
|
|
||||||
|
const list = data?.data || [];
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
createMutation.mutate({
|
||||||
|
name: form.name,
|
||||||
|
type: selectedType.type,
|
||||||
|
base_url: form.base_url,
|
||||||
|
api_key: form.api_key,
|
||||||
|
callback_url: form.callback_url
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">Integrations</h1>
|
||||||
|
<button onClick={() => setShowModal(true)} className="btn btn-primary">
|
||||||
|
+ Add Integration
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Existing integrations */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{list.map(int => {
|
||||||
|
const typeInfo = integrationTypes.find(t => t.type === int.type);
|
||||||
|
return (
|
||||||
|
<div key={int.id} className="card">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-3xl">{typeInfo?.icon || '🔗'}</span>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">{int.name}</h3>
|
||||||
|
<p className="text-sm text-gray-400">{typeInfo?.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={clsx(
|
||||||
|
'px-2 py-1 rounded text-xs',
|
||||||
|
int.status === 'active' ? 'bg-green-500/20 text-green-400' : 'bg-gray-500/20 text-gray-400'
|
||||||
|
)}>
|
||||||
|
{int.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-sm mb-4">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Issues Processed</span>
|
||||||
|
<span>{int.issues_processed || 0}</span>
|
||||||
|
</div>
|
||||||
|
{int.last_sync_at && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Last Event</span>
|
||||||
|
<span>{new Date(int.last_sync_at).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 bg-gray-900 rounded-lg mb-4">
|
||||||
|
<p className="text-xs text-gray-400 mb-1">Webhook URL</p>
|
||||||
|
<code className="text-xs text-primary-400 break-all">{int.webhook_url}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="btn btn-secondary flex-1 text-sm">Configure</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteMutation.mutate(int.id)}
|
||||||
|
className="btn bg-red-500/20 text-red-400 hover:bg-red-500/30 text-sm"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{list.length === 0 && !isLoading && (
|
||||||
|
<div className="col-span-full text-center py-12">
|
||||||
|
<span className="text-5xl">🔌</span>
|
||||||
|
<h3 className="text-xl font-semibold mt-4">No integrations yet</h3>
|
||||||
|
<p className="text-gray-400 mt-2">Connect your first issue tracker to get started</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add integration modal */}
|
||||||
|
{showModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-gray-800 rounded-xl w-full max-w-2xl max-h-[90vh] overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-gray-700 flex justify-between items-center">
|
||||||
|
<h2 className="text-lg font-semibold">
|
||||||
|
{selectedType ? `Configure ${selectedType.name}` : 'Add Integration'}
|
||||||
|
</h2>
|
||||||
|
<button onClick={() => { setShowModal(false); setSelectedType(null); }} className="text-gray-400 hover:text-white">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 overflow-y-auto">
|
||||||
|
{!selectedType ? (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{integrationTypes.map(type => (
|
||||||
|
<button
|
||||||
|
key={type.type}
|
||||||
|
onClick={() => setSelectedType(type)}
|
||||||
|
className="p-4 bg-gray-700 rounded-lg hover:bg-gray-600 text-left"
|
||||||
|
>
|
||||||
|
<span className="text-3xl">{type.icon}</span>
|
||||||
|
<h3 className="font-semibold mt-2">{type.name}</h3>
|
||||||
|
<p className="text-sm text-gray-400">{type.desc}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.name || ''}
|
||||||
|
onChange={(e) => setForm({...form, name: e.target.value})}
|
||||||
|
placeholder={`My ${selectedType.name}`}
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">Base URL (optional)</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={form.base_url || ''}
|
||||||
|
onChange={(e) => setForm({...form, base_url: e.target.value})}
|
||||||
|
placeholder="https://your-instance.atlassian.net"
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">API Key (optional)</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={form.api_key || ''}
|
||||||
|
onChange={(e) => setForm({...form, api_key: e.target.value})}
|
||||||
|
placeholder="Your API key"
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">Callback URL (where to post results)</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={form.callback_url || ''}
|
||||||
|
onChange={(e) => setForm({...form, callback_url: e.target.value})}
|
||||||
|
placeholder="https://your-instance.atlassian.net/rest/api/2"
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-4">
|
||||||
|
<button onClick={() => setSelectedType(null)} className="btn btn-secondary flex-1">
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={!form.name || createMutation.isPending}
|
||||||
|
className="btn btn-primary flex-1"
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? 'Creating...' : 'Create Integration'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
import { useParams, Link } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { issues } from '../services/api';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
export default function IssueDetail() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const { currentOrg } = useAuth();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['issue', currentOrg?.id, id],
|
||||||
|
queryFn: () => issues.get(currentOrg.id, id),
|
||||||
|
enabled: !!currentOrg
|
||||||
|
});
|
||||||
|
|
||||||
|
const reanalyzeMutation = useMutation({
|
||||||
|
mutationFn: () => issues.reanalyze(currentOrg.id, id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries(['issue', currentOrg?.id, id]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!currentOrg) return null;
|
||||||
|
if (isLoading) return <div className="p-8 text-center text-gray-400">Loading...</div>;
|
||||||
|
|
||||||
|
const issue = data?.data;
|
||||||
|
if (!issue) return <div className="p-8 text-center text-gray-400">Issue not found</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link to="/issues" className="text-gray-400 hover:text-white">← Back to Issues</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<span className="font-mono text-primary-400 text-xl">{issue.external_key || `#${issue.id}`}</span>
|
||||||
|
<span className={clsx(
|
||||||
|
'px-3 py-1 rounded text-sm',
|
||||||
|
issue.status === 'analyzed' ? 'bg-green-500/20 text-green-400' :
|
||||||
|
issue.status === 'pr_created' ? 'bg-purple-500/20 text-purple-400' :
|
||||||
|
issue.status === 'error' ? 'bg-red-500/20 text-red-400' :
|
||||||
|
'bg-yellow-500/20 text-yellow-400'
|
||||||
|
)}>
|
||||||
|
{issue.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold">{issue.title}</h1>
|
||||||
|
<p className="text-gray-400 mt-1">
|
||||||
|
Source: {issue.source} • Created: {new Date(issue.created_at).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{issue.external_url && (
|
||||||
|
<a href={issue.external_url} target="_blank" rel="noopener noreferrer" className="btn btn-secondary">
|
||||||
|
View Original →
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => reanalyzeMutation.mutate()}
|
||||||
|
disabled={reanalyzeMutation.isPending}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
{reanalyzeMutation.isPending ? 'Analyzing...' : '🔄 Re-analyze'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Description */}
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-semibold mb-3">Description</h3>
|
||||||
|
<pre className="whitespace-pre-wrap text-gray-300 text-sm bg-gray-900 p-4 rounded-lg">
|
||||||
|
{issue.description || 'No description'}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Analysis */}
|
||||||
|
{issue.root_cause && (
|
||||||
|
<div className="card border-green-500/30 bg-green-500/5">
|
||||||
|
<h3 className="font-semibold mb-3 text-green-400">🔍 Root Cause Analysis</h3>
|
||||||
|
<pre className="whitespace-pre-wrap text-gray-300 text-sm">
|
||||||
|
{issue.root_cause}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Affected Files */}
|
||||||
|
{issue.affected_files?.length > 0 && (
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-semibold mb-3">📁 Affected Files</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{issue.affected_files.map(file => (
|
||||||
|
<span key={file} className="px-3 py-1 bg-gray-700 rounded font-mono text-sm">
|
||||||
|
{file}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Suggested Fix */}
|
||||||
|
{issue.suggested_fix && (
|
||||||
|
<div className="card border-purple-500/30 bg-purple-500/5">
|
||||||
|
<h3 className="font-semibold mb-3 text-purple-400">🔧 Suggested Fix</h3>
|
||||||
|
<pre className="whitespace-pre-wrap text-gray-300 text-sm font-mono bg-gray-900 p-4 rounded-lg overflow-x-auto">
|
||||||
|
{issue.suggested_fix}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Confidence */}
|
||||||
|
{issue.confidence && (
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-semibold mb-3">Confidence</h3>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-4xl font-bold text-green-400">
|
||||||
|
{(issue.confidence * 100).toFixed(0)}%
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-700 rounded-full h-3 mt-3">
|
||||||
|
<div
|
||||||
|
className="bg-green-500 h-3 rounded-full transition-all"
|
||||||
|
style={{ width: `${issue.confidence * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PR Info */}
|
||||||
|
{issue.pr_url && (
|
||||||
|
<div className="card border-blue-500/30 bg-blue-500/5">
|
||||||
|
<h3 className="font-semibold mb-3 text-blue-400">🔀 Pull Request</h3>
|
||||||
|
<p className="text-sm text-gray-400 mb-2">Branch: {issue.pr_branch}</p>
|
||||||
|
<a
|
||||||
|
href={issue.pr_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="btn btn-primary w-full"
|
||||||
|
>
|
||||||
|
View PR →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Labels */}
|
||||||
|
{issue.labels?.length > 0 && (
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-semibold mb-3">Labels</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{issue.labels.map(label => (
|
||||||
|
<span key={label} className="px-2 py-1 bg-primary-500/20 text-primary-400 rounded text-sm">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-semibold mb-3">Timeline</h3>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Created</span>
|
||||||
|
<span>{new Date(issue.created_at).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
{issue.analysis_completed_at && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Analyzed</span>
|
||||||
|
<span>{new Date(issue.analysis_completed_at).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { issues } from '../services/api';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
const statusColors = {
|
||||||
|
pending: 'bg-yellow-500/20 text-yellow-400',
|
||||||
|
analyzing: 'bg-blue-500/20 text-blue-400',
|
||||||
|
analyzed: 'bg-green-500/20 text-green-400',
|
||||||
|
pr_created: 'bg-purple-500/20 text-purple-400',
|
||||||
|
completed: 'bg-gray-500/20 text-gray-400',
|
||||||
|
error: 'bg-red-500/20 text-red-400'
|
||||||
|
};
|
||||||
|
|
||||||
|
const priorityColors = {
|
||||||
|
critical: 'bg-red-500/20 text-red-400',
|
||||||
|
high: 'bg-orange-500/20 text-orange-400',
|
||||||
|
medium: 'bg-yellow-500/20 text-yellow-400',
|
||||||
|
low: 'bg-green-500/20 text-green-400'
|
||||||
|
};
|
||||||
|
|
||||||
|
const sourceIcons = {
|
||||||
|
jira_cloud: '🔵', servicenow: '⚙️', zendesk: '💚',
|
||||||
|
github: '🐙', gitlab: '🦊', tickethub: '🎫', generic: '📝'
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Issues() {
|
||||||
|
const { currentOrg } = useAuth();
|
||||||
|
const [filters, setFilters] = useState({ status: '', source: '' });
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['issues', currentOrg?.id, filters],
|
||||||
|
queryFn: () => issues.list(currentOrg.id, filters),
|
||||||
|
enabled: !!currentOrg
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!currentOrg) {
|
||||||
|
return <div className="p-8 text-center text-gray-400">Select an organization</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const issueList = data?.data || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">Issues</h1>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<select
|
||||||
|
value={filters.status}
|
||||||
|
onChange={(e) => setFilters({...filters, status: e.target.value})}
|
||||||
|
className="input w-40"
|
||||||
|
>
|
||||||
|
<option value="">All Status</option>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="analyzing">Analyzing</option>
|
||||||
|
<option value="analyzed">Analyzed</option>
|
||||||
|
<option value="pr_created">PR Created</option>
|
||||||
|
<option value="error">Error</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={filters.source}
|
||||||
|
onChange={(e) => setFilters({...filters, source: e.target.value})}
|
||||||
|
className="input w-40"
|
||||||
|
>
|
||||||
|
<option value="">All Sources</option>
|
||||||
|
<option value="jira_cloud">JIRA</option>
|
||||||
|
<option value="servicenow">ServiceNow</option>
|
||||||
|
<option value="zendesk">Zendesk</option>
|
||||||
|
<option value="github">GitHub</option>
|
||||||
|
<option value="gitlab">GitLab</option>
|
||||||
|
<option value="tickethub">TicketHub</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-8 text-gray-400">Loading...</div>
|
||||||
|
) : issueList.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-400">
|
||||||
|
<span className="text-4xl">📭</span>
|
||||||
|
<p className="mt-2">No issues found</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-700">
|
||||||
|
{issueList.map(issue => (
|
||||||
|
<Link
|
||||||
|
key={issue.id}
|
||||||
|
to={`/issues/${issue.id}`}
|
||||||
|
className="block p-4 hover:bg-gray-700/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<span className="text-2xl">{sourceIcons[issue.source] || '📝'}</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="font-mono text-primary-400 text-sm">
|
||||||
|
{issue.external_key || `#${issue.id}`}
|
||||||
|
</span>
|
||||||
|
<span className={clsx('px-2 py-0.5 rounded text-xs', statusColors[issue.status])}>
|
||||||
|
{issue.status}
|
||||||
|
</span>
|
||||||
|
{issue.priority && (
|
||||||
|
<span className={clsx('px-2 py-0.5 rounded text-xs', priorityColors[issue.priority])}>
|
||||||
|
{issue.priority}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h3 className="font-medium truncate">{issue.title}</h3>
|
||||||
|
{issue.confidence && (
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<div className="flex-1 max-w-[200px] bg-gray-700 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-green-500 h-2 rounded-full"
|
||||||
|
style={{ width: `${issue.confidence * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{(issue.confidence * 100).toFixed(0)}% confidence
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-400">→</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { login } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await login(email, password);
|
||||||
|
navigate('/');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.detail || 'Login failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-900 px-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<span className="text-5xl">🤖</span>
|
||||||
|
<h1 className="text-2xl font-bold mt-4">JIRA AI Fixer</h1>
|
||||||
|
<p className="text-gray-400 mt-2">Sign in to your account</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="card">
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium mb-2">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium mb-2">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" disabled={loading} className="btn btn-primary w-full">
|
||||||
|
{loading ? 'Signing in...' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="text-center mt-4 text-gray-400">
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<Link to="/register" className="text-primary-400 hover:underline">Sign up</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { auth } from '../services/api';
|
||||||
|
|
||||||
|
export default function Register() {
|
||||||
|
const [form, setForm] = useState({ email: '', password: '', full_name: '' });
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await auth.register(form);
|
||||||
|
navigate('/login?registered=true');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.detail || 'Registration failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-900 px-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<span className="text-5xl">🤖</span>
|
||||||
|
<h1 className="text-2xl font-bold mt-4">Create Account</h1>
|
||||||
|
<p className="text-gray-400 mt-2">Get started with JIRA AI Fixer</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="card">
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium mb-2">Full Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.full_name}
|
||||||
|
onChange={(e) => setForm({...form, full_name: e.target.value})}
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium mb-2">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => setForm({...form, email: e.target.value})}
|
||||||
|
className="input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium mb-2">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) => setForm({...form, password: e.target.value})}
|
||||||
|
className="input"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" disabled={loading} className="btn btn-primary w-full">
|
||||||
|
{loading ? 'Creating account...' : 'Create account'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="text-center mt-4 text-gray-400">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link to="/login" className="text-primary-400 hover:underline">Sign in</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { reports } from '../services/api';
|
||||||
|
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
|
|
||||||
|
export default function Reports() {
|
||||||
|
const { currentOrg } = useAuth();
|
||||||
|
const [days, setDays] = useState(30);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['report', currentOrg?.id, days],
|
||||||
|
queryFn: () => reports.summary(currentOrg.id, days),
|
||||||
|
enabled: !!currentOrg
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
const res = await reports.exportCsv(currentOrg.id, days);
|
||||||
|
const url = window.URL.createObjectURL(new Blob([res.data]));
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `issues-report-${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
|
a.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!currentOrg) return <div className="p-8 text-center text-gray-400">Select an organization</div>;
|
||||||
|
|
||||||
|
const r = data?.data || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">Reports</h1>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<select value={days} onChange={(e) => setDays(Number(e.target.value))} className="input w-40">
|
||||||
|
<option value={7}>Last 7 days</option>
|
||||||
|
<option value={14}>Last 14 days</option>
|
||||||
|
<option value={30}>Last 30 days</option>
|
||||||
|
<option value={90}>Last 90 days</option>
|
||||||
|
</select>
|
||||||
|
<button onClick={handleExport} className="btn btn-primary">📥 Export CSV</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||||
|
<div className="card">
|
||||||
|
<p className="text-gray-400 text-sm">Total Issues</p>
|
||||||
|
<p className="text-3xl font-bold mt-1">{r.total_issues || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<p className="text-gray-400 text-sm">Analyzed</p>
|
||||||
|
<p className="text-3xl font-bold mt-1 text-green-400">{r.analyzed_issues || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<p className="text-gray-400 text-sm">PRs Created</p>
|
||||||
|
<p className="text-3xl font-bold mt-1 text-purple-400">{r.prs_created || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<p className="text-gray-400 text-sm">Avg Confidence</p>
|
||||||
|
<p className="text-3xl font-bold mt-1 text-yellow-400">
|
||||||
|
{r.avg_confidence ? `${(r.avg_confidence * 100).toFixed(0)}%` : 'N/A'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart */}
|
||||||
|
<div className="card mb-8">
|
||||||
|
<h3 className="font-semibold mb-4">Trend</h3>
|
||||||
|
<div className="h-80">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray-400">Loading...</div>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={r.daily_breakdown || []}>
|
||||||
|
<XAxis dataKey="date" tick={{ fill: '#9ca3af', fontSize: 12 }} />
|
||||||
|
<YAxis tick={{ fill: '#9ca3af', fontSize: 12 }} />
|
||||||
|
<Tooltip contentStyle={{ backgroundColor: '#1f2937', border: '1px solid #374151' }} />
|
||||||
|
<Area type="monotone" dataKey="total" stroke="#6366f1" fill="#6366f1" fillOpacity={0.3} name="Total" />
|
||||||
|
<Area type="monotone" dataKey="analyzed" stroke="#22c55e" fill="#22c55e" fillOpacity={0.3} name="Analyzed" />
|
||||||
|
<Area type="monotone" dataKey="prs_created" stroke="#a855f7" fill="#a855f7" fillOpacity={0.3} name="PRs" />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top sources */}
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-semibold mb-4">Top Sources</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{(r.top_sources || []).map(source => (
|
||||||
|
<div key={source.source} className="flex items-center gap-4">
|
||||||
|
<span className="w-24 text-gray-400">{source.source}</span>
|
||||||
|
<div className="flex-1 bg-gray-700 rounded-full h-4">
|
||||||
|
<div
|
||||||
|
className="bg-primary-500 h-4 rounded-full"
|
||||||
|
style={{ width: `${(source.count / r.total_issues * 100) || 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="w-12 text-right">{source.count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,175 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { organizations, users } from '../services/api';
|
||||||
|
|
||||||
|
export default function Settings() {
|
||||||
|
const { currentOrg, selectOrg, user } = useAuth();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [activeTab, setActiveTab] = useState('profile');
|
||||||
|
const [profileForm, setProfileForm] = useState({ full_name: user?.full_name || '' });
|
||||||
|
const [orgForm, setOrgForm] = useState({ name: currentOrg?.name || '', slug: currentOrg?.slug || '' });
|
||||||
|
const [newOrgForm, setNewOrgForm] = useState({ name: '', slug: '' });
|
||||||
|
|
||||||
|
const updateProfileMutation = useMutation({
|
||||||
|
mutationFn: (data) => users.updateMe(data),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries(['user'])
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateOrgMutation = useMutation({
|
||||||
|
mutationFn: (data) => organizations.update(currentOrg.id, data),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
queryClient.invalidateQueries(['organizations']);
|
||||||
|
selectOrg(res.data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const createOrgMutation = useMutation({
|
||||||
|
mutationFn: (data) => organizations.create(data),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
queryClient.invalidateQueries(['organizations']);
|
||||||
|
selectOrg(res.data);
|
||||||
|
setNewOrgForm({ name: '', slug: '' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'profile', label: 'Profile', icon: '👤' },
|
||||||
|
{ id: 'organization', label: 'Organization', icon: '🏢' },
|
||||||
|
{ id: 'new-org', label: 'New Organization', icon: '➕' }
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<h1 className="text-2xl font-bold mb-6">Settings</h1>
|
||||||
|
|
||||||
|
<div className="flex gap-8">
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="w-48 space-y-1">
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`w-full text-left px-4 py-2 rounded-lg flex items-center gap-2 ${
|
||||||
|
activeTab === tab.id ? 'bg-primary-600 text-white' : 'text-gray-400 hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{tab.icon}</span>
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1">
|
||||||
|
{activeTab === 'profile' && (
|
||||||
|
<div className="card max-w-xl">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Profile Settings</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">Email</label>
|
||||||
|
<input type="email" value={user?.email || ''} disabled className="input bg-gray-900" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">Full Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={profileForm.full_name}
|
||||||
|
onChange={(e) => setProfileForm({...profileForm, full_name: e.target.value})}
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => updateProfileMutation.mutate(profileForm)}
|
||||||
|
disabled={updateProfileMutation.isPending}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
{updateProfileMutation.isPending ? 'Saving...' : 'Save Profile'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'organization' && currentOrg && (
|
||||||
|
<div className="card max-w-xl">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Organization Settings</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">Organization Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={orgForm.name}
|
||||||
|
onChange={(e) => setOrgForm({...orgForm, name: e.target.value})}
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">Slug</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={orgForm.slug}
|
||||||
|
onChange={(e) => setOrgForm({...orgForm, slug: e.target.value})}
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 border-t border-gray-700">
|
||||||
|
<h3 className="font-medium mb-2">Webhook Base URL</h3>
|
||||||
|
<code className="block p-3 bg-gray-900 rounded-lg text-sm text-primary-400 break-all">
|
||||||
|
https://jira-fixer.startdata.com.br/api/webhook/{currentOrg.id}/
|
||||||
|
</code>
|
||||||
|
<p className="text-xs text-gray-400 mt-2">
|
||||||
|
Append: jira, servicenow, zendesk, github, gitlab, tickethub, or generic
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => updateOrgMutation.mutate(orgForm)}
|
||||||
|
disabled={updateOrgMutation.isPending}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
{updateOrgMutation.isPending ? 'Saving...' : 'Save Organization'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'new-org' && (
|
||||||
|
<div className="card max-w-xl">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Create New Organization</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">Organization Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newOrgForm.name}
|
||||||
|
onChange={(e) => setNewOrgForm({...newOrgForm, name: e.target.value})}
|
||||||
|
className="input"
|
||||||
|
placeholder="Acme Corp"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">Slug (URL-friendly)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newOrgForm.slug}
|
||||||
|
onChange={(e) => setNewOrgForm({...newOrgForm, slug: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '')})}
|
||||||
|
className="input"
|
||||||
|
placeholder="acme-corp"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => createOrgMutation.mutate(newOrgForm)}
|
||||||
|
disabled={!newOrgForm.name || !newOrgForm.slug || createOrgMutation.isPending}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
{createOrgMutation.isPending ? 'Creating...' : 'Create Organization'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { organizations } from '../services/api';
|
||||||
|
|
||||||
|
const roleColors = {
|
||||||
|
owner: 'bg-yellow-500/20 text-yellow-400',
|
||||||
|
admin: 'bg-red-500/20 text-red-400',
|
||||||
|
manager: 'bg-purple-500/20 text-purple-400',
|
||||||
|
analyst: 'bg-blue-500/20 text-blue-400',
|
||||||
|
viewer: 'bg-gray-500/20 text-gray-400'
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Team() {
|
||||||
|
const { currentOrg } = useAuth();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [showInvite, setShowInvite] = useState(false);
|
||||||
|
const [inviteForm, setInviteForm] = useState({ email: '', role: 'viewer' });
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['org-members', currentOrg?.id],
|
||||||
|
queryFn: () => organizations.members(currentOrg.id),
|
||||||
|
enabled: !!currentOrg
|
||||||
|
});
|
||||||
|
|
||||||
|
const inviteMutation = useMutation({
|
||||||
|
mutationFn: () => organizations.invite(currentOrg.id, inviteForm),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries(['org-members', currentOrg?.id]);
|
||||||
|
setShowInvite(false);
|
||||||
|
setInviteForm({ email: '', role: 'viewer' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!currentOrg) return <div className="p-8 text-center text-gray-400">Select an organization</div>;
|
||||||
|
|
||||||
|
const members = data?.data || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">Team</h1>
|
||||||
|
<button onClick={() => setShowInvite(true)} className="btn btn-primary">
|
||||||
|
+ Invite Member
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-8 text-gray-400">Loading...</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-700">
|
||||||
|
{members.map(member => (
|
||||||
|
<div key={member.id} className="flex items-center justify-between p-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 bg-primary-600 rounded-full flex items-center justify-center font-semibold">
|
||||||
|
{member.user?.full_name?.[0] || member.user?.email?.[0] || '?'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{member.user?.full_name || 'Unknown'}</p>
|
||||||
|
<p className="text-sm text-gray-400">{member.user?.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={`px-3 py-1 rounded text-sm ${roleColors[member.role]}`}>
|
||||||
|
{member.role}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invite modal */}
|
||||||
|
{showInvite && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-gray-800 rounded-xl w-full max-w-md p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Invite Team Member</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={inviteForm.email}
|
||||||
|
onChange={(e) => setInviteForm({...inviteForm, email: e.target.value})}
|
||||||
|
className="input"
|
||||||
|
placeholder="colleague@company.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">Role</label>
|
||||||
|
<select
|
||||||
|
value={inviteForm.role}
|
||||||
|
onChange={(e) => setInviteForm({...inviteForm, role: e.target.value})}
|
||||||
|
className="input"
|
||||||
|
>
|
||||||
|
<option value="viewer">Viewer - Read only</option>
|
||||||
|
<option value="analyst">Analyst - Can analyze</option>
|
||||||
|
<option value="manager">Manager - Can manage issues</option>
|
||||||
|
<option value="admin">Admin - Full access</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-6">
|
||||||
|
<button onClick={() => setShowInvite(false)} className="btn btn-secondary flex-1">Cancel</button>
|
||||||
|
<button
|
||||||
|
onClick={() => inviteMutation.mutate()}
|
||||||
|
disabled={!inviteForm.email || inviteMutation.isPending}
|
||||||
|
className="btn btn-primary flex-1"
|
||||||
|
>
|
||||||
|
{inviteMutation.isPending ? 'Sending...' : 'Send Invite'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add auth token to requests
|
||||||
|
api.interceptors.request.use(config => {
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle 401 errors
|
||||||
|
api.interceptors.response.use(
|
||||||
|
response => response,
|
||||||
|
async error => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
export const auth = {
|
||||||
|
login: (email, password) => api.post('/auth/login', null, { params: { email, password } }),
|
||||||
|
register: (data) => api.post('/auth/register', data),
|
||||||
|
refresh: (token) => api.post('/auth/refresh', null, { params: { refresh_token: token } })
|
||||||
|
};
|
||||||
|
|
||||||
|
// Users
|
||||||
|
export const users = {
|
||||||
|
me: () => api.get('/users/me'),
|
||||||
|
updateMe: (data) => api.patch('/users/me', data)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Organizations
|
||||||
|
export const organizations = {
|
||||||
|
list: () => api.get('/organizations'),
|
||||||
|
create: (data) => api.post('/organizations', data),
|
||||||
|
get: (id) => api.get(`/organizations/${id}`),
|
||||||
|
update: (id, data) => api.patch(`/organizations/${id}`, data),
|
||||||
|
members: (id) => api.get(`/organizations/${id}/members`),
|
||||||
|
invite: (id, data) => api.post(`/organizations/${id}/members`, data)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Integrations
|
||||||
|
export const integrations = {
|
||||||
|
list: (orgId) => api.get('/integrations', { params: { org_id: orgId } }),
|
||||||
|
create: (orgId, data) => api.post('/integrations', data, { params: { org_id: orgId } }),
|
||||||
|
get: (orgId, id) => api.get(`/integrations/${id}`, { params: { org_id: orgId } }),
|
||||||
|
update: (orgId, id, data) => api.patch(`/integrations/${id}`, data, { params: { org_id: orgId } }),
|
||||||
|
delete: (orgId, id) => api.delete(`/integrations/${id}`, { params: { org_id: orgId } }),
|
||||||
|
test: (orgId, id) => api.post(`/integrations/${id}/test`, null, { params: { org_id: orgId } })
|
||||||
|
};
|
||||||
|
|
||||||
|
// Issues
|
||||||
|
export const issues = {
|
||||||
|
list: (orgId, params = {}) => api.get('/issues', { params: { org_id: orgId, ...params } }),
|
||||||
|
stats: (orgId) => api.get('/issues/stats', { params: { org_id: orgId } }),
|
||||||
|
get: (orgId, id) => api.get(`/issues/${id}`, { params: { org_id: orgId } }),
|
||||||
|
create: (orgId, data) => api.post('/issues', data, { params: { org_id: orgId } }),
|
||||||
|
reanalyze: (orgId, id) => api.post(`/issues/${id}/reanalyze`, null, { params: { org_id: orgId } }),
|
||||||
|
addComment: (orgId, id, data) => api.post(`/issues/${id}/comments`, data, { params: { org_id: orgId } })
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reports
|
||||||
|
export const reports = {
|
||||||
|
summary: (orgId, days = 30) => api.get('/reports/summary', { params: { org_id: orgId, days } }),
|
||||||
|
exportCsv: (orgId, days = 30) => api.get('/reports/export/csv', {
|
||||||
|
params: { org_id: orgId, days },
|
||||||
|
responseType: 'blob'
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
export default api;
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,jsx}'],
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
50: '#eef2ff',
|
||||||
|
100: '#e0e7ff',
|
||||||
|
500: '#6366f1',
|
||||||
|
600: '#4f46e5',
|
||||||
|
700: '#4338ca',
|
||||||
|
900: '#312e81',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: []
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:8000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
fastapi==0.109.0
|
||||||
|
uvicorn[standard]==0.27.0
|
||||||
|
sqlalchemy[asyncio]==2.0.25
|
||||||
|
asyncpg==0.29.0
|
||||||
|
pydantic==2.5.3
|
||||||
|
pydantic-settings==2.1.0
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
httpx==0.26.0
|
||||||
|
python-multipart==0.0.6
|
||||||
Loading…
Reference in New Issue