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