558 lines
19 KiB
Python
558 lines
19 KiB
Python
"""
|
|
Copyright 2024-2026 ReplyPier
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
"""
|
|
|
|
import traceback
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from fastapi.responses import RedirectResponse
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import String
|
|
import secrets
|
|
import uuid
|
|
|
|
from app.database import get_db
|
|
from app.services.jira import JiraService
|
|
from app.models.jira import JiraToken, AgentJiraConfig
|
|
from app.models.agent import Agent
|
|
from app.core.auth import get_current_organization, require_permissions
|
|
from app.models.organization import Organization
|
|
from app.models.user import User
|
|
from app.core.logger import get_logger
|
|
from app.core.config import settings
|
|
from app.core.oauth_state import pop_state, store_state
|
|
from pydantic import BaseModel
|
|
from typing import Optional, List
|
|
from app.core.exceptions import JiraAuthError
|
|
|
|
router = APIRouter()
|
|
jira_service = JiraService()
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
def _require_jira_agent_in_org(db, agent_id, org_id) -> None:
|
|
"""The agent must belong to the caller's org, else 404.
|
|
|
|
A malformed agent_id is treated as not-found rather than a 500.
|
|
"""
|
|
from app.repositories.agent import AgentRepository
|
|
try:
|
|
agent_uuid = uuid.UUID(str(agent_id))
|
|
except (ValueError, TypeError):
|
|
raise HTTPException(status_code=404, detail="Agent not found")
|
|
if not AgentRepository(db).get_agent_in_org(agent_uuid, org_id):
|
|
raise HTTPException(status_code=404, detail="Agent not found")
|
|
|
|
class JiraProject(BaseModel):
|
|
id: str
|
|
key: str
|
|
name: str
|
|
|
|
class JiraIssueType(BaseModel):
|
|
id: str
|
|
name: str
|
|
description: Optional[str] = None
|
|
|
|
class JiraPriority(BaseModel):
|
|
id: str
|
|
name: str
|
|
description: Optional[str] = None
|
|
iconUrl: Optional[str] = None
|
|
|
|
class AgentJiraConfigModel(BaseModel):
|
|
enabled: bool
|
|
projectKey: Optional[str] = None
|
|
issueTypeId: Optional[str] = None
|
|
|
|
class CreateJiraIssueModel(BaseModel):
|
|
projectKey: str
|
|
issueTypeId: str
|
|
summary: str
|
|
description: str
|
|
priority: Optional[str] = None
|
|
chatId: Optional[str] = None
|
|
|
|
@router.get("/status")
|
|
async def jira_status(
|
|
organization: Organization = Depends(get_current_organization),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Check if Jira is connected for the current organization.
|
|
"""
|
|
token = db.query(JiraToken).filter(
|
|
JiraToken.organization_id == organization.id
|
|
).first()
|
|
|
|
if not token:
|
|
return {"connected": False}
|
|
|
|
# Check if token is valid
|
|
is_valid = jira_service.validate_token(token)
|
|
|
|
# If token is expired, try to refresh it
|
|
if not is_valid:
|
|
try:
|
|
token_data = await jira_service.refresh_token(token.refresh_token)
|
|
|
|
# Update token in database
|
|
for key, value in token_data.items():
|
|
setattr(token, key, value)
|
|
|
|
db.commit()
|
|
is_valid = True
|
|
except Exception as e:
|
|
logger.error(f"Failed to refresh Jira token: {e}")
|
|
is_valid = False
|
|
|
|
return {
|
|
"connected": is_valid,
|
|
"site_url": token.site_url if is_valid else None
|
|
}
|
|
|
|
@router.delete("/disconnect")
|
|
async def disconnect_jira(
|
|
current_user: User = Depends(require_permissions("manage_organization")),
|
|
organization: Organization = Depends(get_current_organization),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Disconnect Jira integration for the current organization.
|
|
"""
|
|
token = db.query(JiraToken).filter(
|
|
JiraToken.organization_id == organization.id
|
|
).first()
|
|
|
|
if not token:
|
|
raise HTTPException(status_code=404, detail="No Jira connection found")
|
|
|
|
# Delete all agent Jira configurations for this organization
|
|
try:
|
|
agent_configs = db.query(AgentJiraConfig).join(
|
|
Agent, AgentJiraConfig.agent_id == Agent.id.cast(String)
|
|
).filter(
|
|
Agent.organization_id == organization.id
|
|
).all()
|
|
|
|
config_count = len(agent_configs)
|
|
for config in agent_configs:
|
|
db.delete(config)
|
|
|
|
# Delete the Jira token
|
|
db.delete(token)
|
|
db.commit()
|
|
|
|
logger.info(f"Jira disconnected for organization {organization.id} by user {current_user.id}. Deleted {config_count} agent configurations.")
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error(f"Error disconnecting Jira: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Error disconnecting Jira: {str(e)}")
|
|
|
|
return {"message": "Jira disconnected successfully"}
|
|
|
|
@router.get("/authorize")
|
|
async def authorize_jira(
|
|
request: Request,
|
|
current_user: User = Depends(require_permissions("manage_organization")),
|
|
organization: Organization = Depends(get_current_organization),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Start the Jira OAuth flow by redirecting to Jira's authorization page.
|
|
"""
|
|
logger.info(f"Authorizing Jira for organization: {organization.id}")
|
|
|
|
# Generate a random state for CSRF protection
|
|
state = secrets.token_urlsafe(32)
|
|
|
|
store_state("jira", state, str(organization.id))
|
|
|
|
# Get authorization URL
|
|
auth_url = jira_service.get_authorization_url(state)
|
|
logger.info(f"Redirecting to Jira authorization URL: {auth_url}")
|
|
return RedirectResponse(url=auth_url)
|
|
|
|
@router.get("/callback")
|
|
async def jira_oauth_callback(
|
|
code: Optional[str] = None,
|
|
state: Optional[str] = None,
|
|
error: Optional[str] = None,
|
|
error_description: Optional[str] = None,
|
|
request: Request = None,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Handle the OAuth callback from Jira.
|
|
This endpoint handles both successful authentication and cancellation/errors.
|
|
"""
|
|
# Handle cancellation or error from Jira
|
|
if error or not code or not state:
|
|
logger.warning(f"Jira OAuth flow cancelled or error: {error} - {error_description}")
|
|
pop_state("jira", state or "")
|
|
# Redirect to failure page
|
|
redirect_url = f"{settings.FRONTEND_URL}/settings/integrations?status=failure&reason={error or 'cancelled'}"
|
|
return RedirectResponse(url=redirect_url)
|
|
|
|
org_id = pop_state("jira", state)
|
|
|
|
if not org_id:
|
|
logger.error(f"Invalid or expired state parameter: {state}")
|
|
redirect_url = f"{settings.FRONTEND_URL}/settings/integrations?status=failure&reason=invalid_state"
|
|
return RedirectResponse(url=redirect_url)
|
|
|
|
try:
|
|
org_id = uuid.UUID(org_id)
|
|
# Exchange code for token
|
|
token_data = await jira_service.exchange_code_for_token(code)
|
|
|
|
# Get Jira Cloud ID and site URL
|
|
cloud_data = await jira_service.get_cloud_id(token_data["access_token"])
|
|
logger.info(f"Cloud data: {cloud_data}")
|
|
# Store token in database
|
|
jira_token = JiraToken(
|
|
organization_id=org_id,
|
|
access_token=token_data["access_token"],
|
|
refresh_token=token_data["refresh_token"],
|
|
token_type=token_data["token_type"],
|
|
expires_at=token_data["expires_at"],
|
|
cloud_id=cloud_data["cloud_id"],
|
|
site_url=cloud_data["site_url"]
|
|
)
|
|
|
|
# Check if token already exists for this organization
|
|
existing_token = db.query(JiraToken).filter(
|
|
JiraToken.organization_id == org_id
|
|
).first()
|
|
|
|
if existing_token:
|
|
# Update existing token
|
|
for key, value in token_data.items():
|
|
setattr(existing_token, key, value)
|
|
existing_token.cloud_id = cloud_data["cloud_id"]
|
|
existing_token.site_url = cloud_data["site_url"]
|
|
else:
|
|
db.add(jira_token)
|
|
|
|
db.commit()
|
|
|
|
# Redirect to success page using FRONTEND_URL from settings
|
|
redirect_url = f"{settings.FRONTEND_URL}/settings/integrations?status=success"
|
|
return RedirectResponse(url=redirect_url)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error during Jira OAuth callback: {e}")
|
|
traceback.print_exc()
|
|
# Redirect to failure page with error message
|
|
error_message = str(e).replace(" ", "_")[:100] # Limit length and replace spaces
|
|
redirect_url = f"{settings.FRONTEND_URL}/settings/integrations?status=failure&reason={error_message}"
|
|
return RedirectResponse(url=redirect_url)
|
|
|
|
@router.get("/refresh")
|
|
async def refresh_jira_token(
|
|
organization: Organization = Depends(get_current_organization),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Refresh the Jira access token using the refresh token.
|
|
"""
|
|
token = db.query(JiraToken).filter(
|
|
JiraToken.organization_id == organization.id
|
|
).first()
|
|
|
|
if not token:
|
|
raise HTTPException(status_code=404, detail="No Jira token found")
|
|
|
|
try:
|
|
token_data = await jira_service.refresh_token(token.refresh_token)
|
|
|
|
# Update token in database
|
|
for key, value in token_data.items():
|
|
setattr(token, key, value)
|
|
|
|
db.commit()
|
|
return {"message": "Token refreshed successfully"}
|
|
|
|
except Exception as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
@router.get("/projects", response_model=List[JiraProject])
|
|
async def get_jira_projects(
|
|
organization: Organization = Depends(get_current_organization),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Get all Jira projects for the current organization.
|
|
"""
|
|
token = db.query(JiraToken).filter(
|
|
JiraToken.organization_id == organization.id
|
|
).first()
|
|
|
|
if not token:
|
|
raise HTTPException(status_code=404, detail="No Jira connection found")
|
|
|
|
# Check if token is valid
|
|
is_valid = jira_service.validate_token(token)
|
|
|
|
# If token is expired, try to refresh it
|
|
if not is_valid:
|
|
try:
|
|
token_data = await jira_service.refresh_token(token.refresh_token)
|
|
|
|
# Update token in database
|
|
for key, value in token_data.items():
|
|
setattr(token, key, value)
|
|
|
|
db.commit()
|
|
except Exception as e:
|
|
logger.error(f"Failed to refresh Jira token: {e}")
|
|
raise HTTPException(status_code=401, detail="Jira token expired and could not be refreshed")
|
|
|
|
try:
|
|
projects = await jira_service.get_projects(token.access_token, token.cloud_id)
|
|
return projects
|
|
except Exception as e:
|
|
logger.error(f"Failed to get Jira projects: {e}")
|
|
raise HTTPException(status_code=500, detail="Failed to get Jira projects")
|
|
|
|
@router.get("/projects/{project_key}/issue-types", response_model=List[JiraIssueType])
|
|
async def get_jira_issue_types(
|
|
project_key: str,
|
|
organization: Organization = Depends(get_current_organization),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Get all issue types for a Jira project.
|
|
"""
|
|
token = db.query(JiraToken).filter(
|
|
JiraToken.organization_id == organization.id
|
|
).first()
|
|
|
|
if not token:
|
|
raise HTTPException(status_code=404, detail="No Jira connection found")
|
|
|
|
# Check if token is valid
|
|
is_valid = jira_service.validate_token(token)
|
|
|
|
# If token is expired, try to refresh it
|
|
if not is_valid:
|
|
try:
|
|
token_data = await jira_service.refresh_token(token.refresh_token)
|
|
|
|
# Update token in database
|
|
for key, value in token_data.items():
|
|
setattr(token, key, value)
|
|
|
|
db.commit()
|
|
except Exception as e:
|
|
logger.error(f"Failed to refresh Jira token: {e}")
|
|
raise HTTPException(status_code=401, detail="Jira token expired and could not be refreshed")
|
|
|
|
try:
|
|
issue_types = await jira_service.get_issue_types(token.access_token, token.cloud_id, project_key)
|
|
return issue_types
|
|
except Exception as e:
|
|
logger.error(f"Failed to get Jira issue types: {e}")
|
|
raise HTTPException(status_code=500, detail="Failed to get Jira issue types")
|
|
|
|
@router.get("/priorities", response_model=List[JiraPriority])
|
|
async def get_jira_priorities(
|
|
organization: Organization = Depends(get_current_organization),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Get all Jira priorities.
|
|
"""
|
|
token = db.query(JiraToken).filter(
|
|
JiraToken.organization_id == organization.id
|
|
).first()
|
|
|
|
if not token:
|
|
raise HTTPException(status_code=404, detail="No Jira connection found")
|
|
|
|
# Check if token is valid
|
|
is_valid = jira_service.validate_token(token)
|
|
|
|
# If token is expired, try to refresh it
|
|
if not is_valid:
|
|
try:
|
|
token_data = await jira_service.refresh_token(token.refresh_token)
|
|
|
|
# Update token in database
|
|
for key, value in token_data.items():
|
|
setattr(token, key, value)
|
|
|
|
db.commit()
|
|
except Exception as e:
|
|
logger.error(f"Failed to refresh Jira token: {e}")
|
|
raise HTTPException(status_code=401, detail="Jira token expired and could not be refreshed")
|
|
|
|
try:
|
|
priorities = await jira_service.get_priorities(token.access_token, token.cloud_id)
|
|
return priorities
|
|
except Exception as e:
|
|
logger.error(f"Failed to get Jira priorities: {e}")
|
|
raise HTTPException(status_code=500, detail="Failed to get Jira priorities")
|
|
|
|
@router.get("/projects/{project_key}/issue-types/{issue_type_id}/has-priority")
|
|
async def check_priority_availability(
|
|
project_key: str,
|
|
issue_type_id: str,
|
|
organization: Organization = Depends(get_current_organization),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Check if priority field is available for a specific project and issue type.
|
|
"""
|
|
token = db.query(JiraToken).filter(
|
|
JiraToken.organization_id == organization.id
|
|
).first()
|
|
|
|
if not token:
|
|
raise HTTPException(status_code=404, detail="No Jira connection found")
|
|
|
|
# Check if token is valid
|
|
is_valid = jira_service.validate_token(token)
|
|
|
|
# If token is expired, try to refresh it
|
|
if not is_valid:
|
|
try:
|
|
token_data = await jira_service.refresh_token(token.refresh_token)
|
|
|
|
# Update token in database
|
|
for key, value in token_data.items():
|
|
setattr(token, key, value)
|
|
|
|
db.commit()
|
|
except Exception as e:
|
|
logger.error(f"Failed to refresh Jira token: {e}")
|
|
raise HTTPException(status_code=401, detail="Jira token expired and could not be refreshed")
|
|
|
|
try:
|
|
has_priority = await jira_service.is_priority_available(
|
|
token.access_token,
|
|
token.cloud_id,
|
|
project_key,
|
|
issue_type_id
|
|
)
|
|
return {"hasPriority": has_priority}
|
|
except Exception as e:
|
|
logger.error(f"Failed to check priority availability: {e}")
|
|
raise HTTPException(status_code=500, detail="Failed to check priority availability")
|
|
|
|
@router.post("/issues")
|
|
async def create_jira_issue(
|
|
issue_data: CreateJiraIssueModel,
|
|
organization: Organization = Depends(get_current_organization),
|
|
current_user: User = Depends(require_permissions("manage_organization")),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Create a new Jira issue.
|
|
"""
|
|
try:
|
|
# Create the issue using the wrapper method
|
|
result = await jira_service.create_issue(
|
|
organization=organization,
|
|
db=db,
|
|
issue_data=issue_data
|
|
)
|
|
|
|
# Return the issue key and URL
|
|
return {
|
|
"key": result.get("key"),
|
|
"id": result.get("id"),
|
|
"self": result.get("self")
|
|
}
|
|
except JiraAuthError as e:
|
|
logger.error(f"Jira authentication error: {e}")
|
|
raise HTTPException(status_code=401, detail=str(e))
|
|
except Exception as e:
|
|
logger.error(f"Failed to create Jira issue: {e}")
|
|
raise HTTPException(status_code=500, detail="Failed to create Jira issue")
|
|
|
|
@router.post("/agent-config/{agent_id}")
|
|
async def save_agent_jira_config(
|
|
agent_id: str,
|
|
config: AgentJiraConfigModel,
|
|
organization: Organization = Depends(get_current_organization),
|
|
current_user: User = Depends(require_permissions("manage_organization")),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Save Jira configuration for an agent.
|
|
"""
|
|
# The agent must belong to the caller's org — otherwise any tenant could
|
|
# flip another tenant's project_key / issue_type and hijack escalation.
|
|
_require_jira_agent_in_org(db, agent_id, organization.id)
|
|
|
|
# Check if Jira is connected
|
|
token = db.query(JiraToken).filter(
|
|
JiraToken.organization_id == organization.id
|
|
).first()
|
|
|
|
if not token and config.enabled:
|
|
raise HTTPException(status_code=400, detail="Cannot enable Jira integration: Jira is not connected")
|
|
|
|
# Check if config already exists
|
|
existing_config = db.query(AgentJiraConfig).filter(
|
|
AgentJiraConfig.agent_id == agent_id
|
|
).first()
|
|
|
|
if existing_config:
|
|
# Update existing config
|
|
existing_config.enabled = config.enabled
|
|
existing_config.project_key = config.projectKey
|
|
existing_config.issue_type_id = config.issueTypeId
|
|
else:
|
|
# Create new config
|
|
new_config = AgentJiraConfig(
|
|
agent_id=agent_id,
|
|
enabled=config.enabled,
|
|
project_key=config.projectKey,
|
|
issue_type_id=config.issueTypeId
|
|
)
|
|
db.add(new_config)
|
|
|
|
db.commit()
|
|
|
|
return {"message": "Agent Jira configuration saved successfully"}
|
|
|
|
@router.get("/agent-config/{agent_id}")
|
|
async def get_agent_jira_config(
|
|
agent_id: str,
|
|
organization: Organization = Depends(get_current_organization),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Get Jira configuration for an agent.
|
|
"""
|
|
# The agent must belong to the caller's org before its config is exposed.
|
|
_require_jira_agent_in_org(db, agent_id, organization.id)
|
|
|
|
config = db.query(AgentJiraConfig).filter(
|
|
AgentJiraConfig.agent_id == agent_id
|
|
).first()
|
|
|
|
if not config:
|
|
return {
|
|
"enabled": False,
|
|
"projectKey": None,
|
|
"issueTypeId": None
|
|
}
|
|
|
|
return {
|
|
"enabled": config.enabled,
|
|
"projectKey": config.project_key,
|
|
"issueTypeId": config.issue_type_id
|
|
} |