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:
Ricel Leite 2026-02-18 19:51:46 -03:00
commit bfe59c2d57
53 changed files with 4094 additions and 0 deletions

21
.env.example Normal file
View File

@ -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

32
Dockerfile Normal file
View File

@ -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"]

17
app/api/__init__.py Normal file
View File

@ -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"])

122
app/api/auth.py Normal file
View File

@ -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)

69
app/api/deps.py Normal file
View File

@ -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

142
app/api/integrations.py Normal file
View File

@ -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"}

271
app/api/issues.py Normal file
View File

@ -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"}

153
app/api/organizations.py Normal file
View File

@ -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

192
app/api/reports.py Normal file
View File

@ -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"}
)

33
app/api/users.py Normal file
View File

@ -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

286
app/api/webhooks.py Normal file
View File

@ -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
app/core/__init__.py Normal file
View File

47
app/core/config.py Normal file
View File

@ -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()

27
app/core/database.py Normal file
View File

@ -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)

53
app/core/security.py Normal file
View File

@ -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)

75
app/main.py Normal file
View File

@ -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"
}

5
app/models/__init__.py Normal file
View File

@ -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

36
app/models/audit.py Normal file
View File

@ -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")

57
app/models/integration.py Normal file
View File

@ -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}"

66
app/models/issue.py Normal file
View File

@ -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")

View File

@ -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")

20
app/models/user.py Normal file
View File

@ -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")

4
app/schemas/__init__.py Normal file
View File

@ -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

View File

@ -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

74
app/schemas/issue.py Normal file
View File

@ -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"

View File

@ -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

37
app/schemas/user.py Normal file
View File

@ -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

3
app/services/__init__.py Normal file
View File

@ -0,0 +1,3 @@
from .email import EmailService
from .analysis import AnalysisService
from .audit import AuditService

220
app/services/analysis.py Normal file
View File

@ -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

42
app/services/audit.py Normal file
View File

@ -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

94
app/services/email.py Normal file
View File

@ -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)

17
docker-compose.yml Normal file
View File

@ -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

13
frontend/index.html Normal file
View File

@ -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>

30
frontend/package.json Normal file
View File

@ -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"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}

40
frontend/src/App.jsx Normal file
View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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);

28
frontend/src/index.css Normal file
View File

@ -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;
}
}

25
frontend/src/main.jsx Normal file
View File

@ -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>
);

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

118
frontend/src/pages/Team.jsx Normal file
View File

@ -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>
);
}

View File

@ -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;

View File

@ -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: []
}

11
frontend/vite.config.js Normal file
View File

@ -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'
}
}
})

10
requirements.txt Normal file
View File

@ -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