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:
Anish Sarkar 2026-01-02 21:24:28 +05:30
parent c5b184d475
commit b81af397c0
19 changed files with 309 additions and 1035 deletions

View file

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

View file

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

View file

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

View file

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

View 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

View file

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

View file

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