mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-28 21:49:40 +02:00
feat: add Linear OAuth integration and connector routes
- Introduced Linear OAuth support with new environment variables for client ID, client secret, and redirect URI. - Implemented Linear connector routes for OAuth flow, including authorization and callback handling. - Updated existing components to accommodate Linear integration, including validation changes and connector configuration. - Enhanced the Linear indexer to utilize OAuth access tokens instead of API keys. - Adjusted UI components to reflect the new Linear connector without requiring special configuration.
This commit is contained in:
parent
c5b184d475
commit
b81af397c0
19 changed files with 309 additions and 1035 deletions
|
|
@ -39,16 +39,21 @@ GOOGLE_CALENDAR_REDIRECT_URI=http://localhost:8000/api/v1/auth/google/calendar/c
|
|||
GOOGLE_GMAIL_REDIRECT_URI=http://localhost:8000/api/v1/auth/google/gmail/connector/callback
|
||||
GOOGLE_DRIVE_REDIRECT_URI=http://localhost:8000/api/v1/auth/google/drive/connector/callback
|
||||
|
||||
# Notion OAuth for Notion Connector
|
||||
NOTION_CLIENT_ID=your_notion_client_id
|
||||
NOTION_CLIENT_SECRET=your_notion_client_secret
|
||||
NOTION_REDIRECT_URI=http://localhost:8000/api/v1/auth/notion/connector/callback
|
||||
|
||||
# Airtable OAuth for Aitable Connector
|
||||
# OAuth for Aitable Connector
|
||||
AIRTABLE_CLIENT_ID=your_airtable_client_id
|
||||
AIRTABLE_CLIENT_SECRET=your_airtable_client_secret
|
||||
AIRTABLE_REDIRECT_URI=http://localhost:8000/api/v1/auth/airtable/connector/callback
|
||||
|
||||
# OAuth for Linear Connector
|
||||
LINEAR_CLIENT_ID=your_linear_client_id
|
||||
LINEAR_CLIENT_SECRET=your_linear_client_secret
|
||||
LINEAR_REDIRECT_URI=http://localhost:8000/api/v1/auth/linear/connector/callback
|
||||
|
||||
# OAuth for Notion Connector
|
||||
NOTION_CLIENT_ID=your_notion_client_id
|
||||
NOTION_CLIENT_SECRET=your_notion_client_secret
|
||||
NOTION_REDIRECT_URI=http://localhost:8000/api/v1/auth/notion/connector/callback
|
||||
|
||||
# Embedding Model
|
||||
# Examples:
|
||||
# # Get sentence transformers embeddings
|
||||
|
|
|
|||
|
|
@ -95,6 +95,11 @@ class Config:
|
|||
NOTION_CLIENT_SECRET = os.getenv("NOTION_CLIENT_SECRET")
|
||||
NOTION_REDIRECT_URI = os.getenv("NOTION_REDIRECT_URI")
|
||||
|
||||
# Linear OAuth
|
||||
LINEAR_CLIENT_ID = os.getenv("LINEAR_CLIENT_ID")
|
||||
LINEAR_CLIENT_SECRET = os.getenv("LINEAR_CLIENT_SECRET")
|
||||
LINEAR_REDIRECT_URI = os.getenv("LINEAR_REDIRECT_URI")
|
||||
|
||||
# LLM instances are now managed per-user through the LLMConfig system
|
||||
# Legacy environment variables removed in favor of user-specific configurations
|
||||
|
||||
|
|
|
|||
|
|
@ -14,24 +14,24 @@ import requests
|
|||
class LinearConnector:
|
||||
"""Class for retrieving issues and comments from Linear."""
|
||||
|
||||
def __init__(self, token: str | None = None):
|
||||
def __init__(self, access_token: str | None = None):
|
||||
"""
|
||||
Initialize the LinearConnector class.
|
||||
|
||||
Args:
|
||||
token: Linear API token (optional, can be set later with set_token)
|
||||
access_token: Linear OAuth access token (optional, can be set later with set_token)
|
||||
"""
|
||||
self.token = token
|
||||
self.access_token = access_token
|
||||
self.api_url = "https://api.linear.app/graphql"
|
||||
|
||||
def set_token(self, token: str) -> None:
|
||||
def set_token(self, access_token: str) -> None:
|
||||
"""
|
||||
Set the Linear API token.
|
||||
Set the Linear OAuth access token.
|
||||
|
||||
Args:
|
||||
token: Linear API token
|
||||
access_token: Linear OAuth access token
|
||||
"""
|
||||
self.token = token
|
||||
self.access_token = access_token
|
||||
|
||||
def get_headers(self) -> dict[str, str]:
|
||||
"""
|
||||
|
|
@ -41,12 +41,12 @@ class LinearConnector:
|
|||
Dictionary of headers
|
||||
|
||||
Raises:
|
||||
ValueError: If no Linear token has been set
|
||||
ValueError: If no Linear access token has been set
|
||||
"""
|
||||
if not self.token:
|
||||
raise ValueError("Linear token not initialized. Call set_token() first.")
|
||||
if not self.access_token:
|
||||
raise ValueError("Linear access token not initialized. Call set_token() first.")
|
||||
|
||||
return {"Content-Type": "application/json", "Authorization": self.token}
|
||||
return {"Content-Type": "application/json", "Authorization": f"Bearer {self.access_token}"}
|
||||
|
||||
def execute_graphql_query(
|
||||
self, query: str, variables: dict[str, Any] | None = None
|
||||
|
|
@ -62,11 +62,11 @@ class LinearConnector:
|
|||
Response data from the API
|
||||
|
||||
Raises:
|
||||
ValueError: If no Linear token has been set
|
||||
ValueError: If no Linear access token has been set
|
||||
Exception: If the API request fails
|
||||
"""
|
||||
if not self.token:
|
||||
raise ValueError("Linear token not initialized. Call set_token() first.")
|
||||
if not self.access_token:
|
||||
raise ValueError("Linear access token not initialized. Call set_token() first.")
|
||||
|
||||
headers = self.get_headers()
|
||||
payload = {"query": query}
|
||||
|
|
@ -94,7 +94,7 @@ class LinearConnector:
|
|||
List of issue objects
|
||||
|
||||
Raises:
|
||||
ValueError: If no Linear token has been set
|
||||
ValueError: If no Linear access token has been set
|
||||
Exception: If the API request fails
|
||||
"""
|
||||
comments_query = ""
|
||||
|
|
@ -451,10 +451,10 @@ class LinearConnector:
|
|||
# Example usage (uncomment to use):
|
||||
"""
|
||||
if __name__ == "__main__":
|
||||
# Set your token here
|
||||
token = "YOUR_LINEAR_API_KEY"
|
||||
# Set your OAuth access token here
|
||||
access_token = "YOUR_LINEAR_ACCESS_TOKEN"
|
||||
|
||||
linear = LinearConnector(token)
|
||||
linear = LinearConnector(access_token=access_token)
|
||||
|
||||
try:
|
||||
# Get all issues with comments
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from .google_gmail_add_connector_route import (
|
|||
from .logs_routes import router as logs_router
|
||||
from .luma_add_connector_route import router as luma_add_connector_router
|
||||
from .new_chat_routes import router as new_chat_router
|
||||
from .linear_add_connector_route import router as linear_add_connector_router
|
||||
from .notion_add_connector_route import router as notion_add_connector_router
|
||||
from .new_llm_config_routes import router as new_llm_config_router
|
||||
from .notes_routes import router as notes_router
|
||||
|
|
@ -40,6 +41,7 @@ router.include_router(google_calendar_add_connector_router)
|
|||
router.include_router(google_gmail_add_connector_router)
|
||||
router.include_router(google_drive_add_connector_router)
|
||||
router.include_router(airtable_add_connector_router)
|
||||
router.include_router(linear_add_connector_router)
|
||||
router.include_router(luma_add_connector_router)
|
||||
router.include_router(notion_add_connector_router)
|
||||
router.include_router(new_llm_config_router) # LLM configs with prompt configuration
|
||||
|
|
|
|||
256
surfsense_backend/app/routes/linear_add_connector_route.py
Normal file
256
surfsense_backend/app/routes/linear_add_connector_route.py
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
"""
|
||||
Linear Connector OAuth Routes.
|
||||
|
||||
Handles OAuth 2.0 authentication flow for Linear connector.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
|
||||
from app.config import config
|
||||
from app.db import (
|
||||
SearchSourceConnector,
|
||||
SearchSourceConnectorType,
|
||||
User,
|
||||
get_async_session,
|
||||
)
|
||||
from app.users import current_active_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Linear OAuth endpoints
|
||||
AUTHORIZATION_URL = "https://linear.app/oauth/authorize"
|
||||
TOKEN_URL = "https://api.linear.app/oauth/token"
|
||||
|
||||
# OAuth scopes for Linear
|
||||
SCOPES = ["read", "write"]
|
||||
|
||||
|
||||
def make_basic_auth_header(client_id: str, client_secret: str) -> str:
|
||||
"""Create Basic Auth header for Linear OAuth."""
|
||||
credentials = f"{client_id}:{client_secret}".encode()
|
||||
b64 = base64.b64encode(credentials).decode("ascii")
|
||||
return f"Basic {b64}"
|
||||
|
||||
|
||||
@router.get("/auth/linear/connector/add")
|
||||
async def connect_linear(space_id: int, user: User = Depends(current_active_user)):
|
||||
"""
|
||||
Initiate Linear OAuth flow.
|
||||
|
||||
Args:
|
||||
space_id: The search space ID
|
||||
user: Current authenticated user
|
||||
|
||||
Returns:
|
||||
Authorization URL for redirect
|
||||
"""
|
||||
try:
|
||||
if not space_id:
|
||||
raise HTTPException(status_code=400, detail="space_id is required")
|
||||
|
||||
if not config.LINEAR_CLIENT_ID:
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Linear OAuth not configured."
|
||||
)
|
||||
|
||||
# Generate state parameter
|
||||
state_payload = json.dumps(
|
||||
{
|
||||
"space_id": space_id,
|
||||
"user_id": str(user.id),
|
||||
}
|
||||
)
|
||||
state_encoded = base64.urlsafe_b64encode(state_payload.encode()).decode()
|
||||
|
||||
# Build authorization URL
|
||||
from urllib.parse import urlencode
|
||||
|
||||
auth_params = {
|
||||
"client_id": config.LINEAR_CLIENT_ID,
|
||||
"response_type": "code",
|
||||
"redirect_uri": config.LINEAR_REDIRECT_URI,
|
||||
"scope": " ".join(SCOPES),
|
||||
"state": state_encoded,
|
||||
}
|
||||
|
||||
auth_url = f"{AUTHORIZATION_URL}?{urlencode(auth_params)}"
|
||||
|
||||
logger.info(
|
||||
f"Generated Linear OAuth URL for user {user.id}, space {space_id}"
|
||||
)
|
||||
return {"auth_url": auth_url}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initiate Linear OAuth: {e!s}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to initiate Linear OAuth: {e!s}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/auth/linear/connector/callback")
|
||||
async def linear_callback(
|
||||
request: Request,
|
||||
code: str,
|
||||
state: str,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""
|
||||
Handle Linear OAuth callback.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object
|
||||
code: Authorization code from Linear
|
||||
state: State parameter containing user/space info
|
||||
session: Database session
|
||||
|
||||
Returns:
|
||||
Redirect response to frontend
|
||||
"""
|
||||
try:
|
||||
# Decode and parse the state
|
||||
try:
|
||||
decoded_state = base64.urlsafe_b64decode(state.encode()).decode()
|
||||
data = json.loads(decoded_state)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Invalid state parameter: {e!s}"
|
||||
) from e
|
||||
|
||||
user_id = UUID(data["user_id"])
|
||||
space_id = data["space_id"]
|
||||
|
||||
# Exchange authorization code for access token
|
||||
auth_header = make_basic_auth_header(
|
||||
config.LINEAR_CLIENT_ID, config.LINEAR_CLIENT_SECRET
|
||||
)
|
||||
|
||||
token_data = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": config.LINEAR_REDIRECT_URI,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
token_response = await client.post(
|
||||
TOKEN_URL,
|
||||
data=token_data,
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Authorization": auth_header,
|
||||
},
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
if token_response.status_code != 200:
|
||||
error_detail = token_response.text
|
||||
try:
|
||||
error_json = token_response.json()
|
||||
error_detail = error_json.get("error_description", error_detail)
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Token exchange failed: {error_detail}"
|
||||
)
|
||||
|
||||
token_json = token_response.json()
|
||||
|
||||
# Calculate expiration time (UTC, tz-aware)
|
||||
expires_at = None
|
||||
if token_json.get("expires_in"):
|
||||
now_utc = datetime.now(UTC)
|
||||
expires_at = now_utc + timedelta(seconds=int(token_json["expires_in"]))
|
||||
|
||||
# Store the access token and refresh token in connector config
|
||||
connector_config = {
|
||||
"access_token": token_json["access_token"],
|
||||
"refresh_token": token_json.get("refresh_token"),
|
||||
"token_type": token_json.get("token_type", "Bearer"),
|
||||
"expires_in": token_json.get("expires_in"),
|
||||
"expires_at": expires_at.isoformat() if expires_at else None,
|
||||
"scope": token_json.get("scope"),
|
||||
}
|
||||
|
||||
# Check if connector already exists for this search space and user
|
||||
existing_connector_result = await session.execute(
|
||||
select(SearchSourceConnector).filter(
|
||||
SearchSourceConnector.search_space_id == space_id,
|
||||
SearchSourceConnector.user_id == user_id,
|
||||
SearchSourceConnector.connector_type
|
||||
== SearchSourceConnectorType.LINEAR_CONNECTOR,
|
||||
)
|
||||
)
|
||||
existing_connector = existing_connector_result.scalars().first()
|
||||
|
||||
if existing_connector:
|
||||
# Update existing connector
|
||||
existing_connector.config = connector_config
|
||||
existing_connector.name = "Linear Connector"
|
||||
existing_connector.is_indexable = True
|
||||
logger.info(
|
||||
f"Updated existing Linear connector for user {user_id} in space {space_id}"
|
||||
)
|
||||
else:
|
||||
# Create new connector
|
||||
new_connector = SearchSourceConnector(
|
||||
name="Linear Connector",
|
||||
connector_type=SearchSourceConnectorType.LINEAR_CONNECTOR,
|
||||
is_indexable=True,
|
||||
config=connector_config,
|
||||
search_space_id=space_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
session.add(new_connector)
|
||||
logger.info(
|
||||
f"Created new Linear connector for user {user_id} in space {space_id}"
|
||||
)
|
||||
|
||||
try:
|
||||
await session.commit()
|
||||
logger.info(f"Successfully saved Linear connector for user {user_id}")
|
||||
|
||||
# Redirect to the frontend with success params
|
||||
return RedirectResponse(
|
||||
url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=linear-connector"
|
||||
)
|
||||
|
||||
except ValidationError as e:
|
||||
await session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"Validation error: {e!s}"
|
||||
) from e
|
||||
except IntegrityError as e:
|
||||
await session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Integrity error: A connector with this type already exists. {e!s}",
|
||||
) from e
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create search source connector: {e!s}")
|
||||
await session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to create search source connector: {e!s}",
|
||||
) from e
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to complete Linear OAuth: {e!s}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to complete Linear OAuth: {e!s}"
|
||||
) from e
|
||||
|
||||
|
|
@ -92,16 +92,16 @@ async def index_linear_issues(
|
|||
f"Connector with ID {connector_id} not found or is not a Linear connector",
|
||||
)
|
||||
|
||||
# Get the Linear token from the connector config
|
||||
linear_token = connector.config.get("LINEAR_API_KEY")
|
||||
if not linear_token:
|
||||
# Get the Linear access token from the connector config
|
||||
linear_access_token = connector.config.get("access_token")
|
||||
if not linear_access_token:
|
||||
await task_logger.log_task_failure(
|
||||
log_entry,
|
||||
f"Linear API token not found in connector config for connector {connector_id}",
|
||||
"Missing Linear token",
|
||||
f"Linear access token not found in connector config for connector {connector_id}",
|
||||
"Missing Linear access token",
|
||||
{"error_type": "MissingToken"},
|
||||
)
|
||||
return 0, "Linear API token not found in connector config"
|
||||
return 0, "Linear access token not found in connector config"
|
||||
|
||||
# Initialize Linear client
|
||||
await task_logger.log_task_progress(
|
||||
|
|
@ -110,7 +110,7 @@ async def index_linear_issues(
|
|||
{"stage": "client_initialization"},
|
||||
)
|
||||
|
||||
linear_client = LinearConnector(token=linear_token)
|
||||
linear_client = LinearConnector(access_token=linear_access_token)
|
||||
|
||||
# Calculate date range
|
||||
start_date_str, end_date_str = calculate_date_range(
|
||||
|
|
|
|||
|
|
@ -532,7 +532,6 @@ def validate_connector_config(
|
|||
)
|
||||
},
|
||||
},
|
||||
"LINEAR_CONNECTOR": {"required": ["LINEAR_API_KEY"], "validators": {}},
|
||||
"DISCORD_CONNECTOR": {"required": ["DISCORD_BOT_TOKEN"], "validators": {}},
|
||||
"JIRA_CONNECTOR": {
|
||||
"required": ["JIRA_EMAIL", "JIRA_API_TOKEN", "JIRA_BASE_URL"],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue