techprotrade's picture
Full stack ATOM backend + AIMONEYFLOW clients (port 7860) (part 3)
d04f525 verified
Raw
History Blame Contribute Delete
19.3 kB
"""
Linear Integration Adapter
Provides OAuth-based integration with Linear for issue tracking and project management.
"""
import logging
import os
import httpx
from typing import Dict, Any, List, Optional
from datetime import datetime, timedelta
from urllib.parse import urlencode
logger = logging.getLogger(__name__)
class LinearAdapter:
"""
Adapter for Linear OAuth integration.
Supports:
- OAuth 2.0 authentication
- Issue and project management
- Team and workflow access
- Sprint and cycle tracking
"""
def __init__(self, db, workspace_id: str):
self.db = db
self.workspace_id = workspace_id
self.service_name = "linear"
self.base_url = "https://api.linear.app"
# OAuth credentials from environment
self.client_id = os.getenv("LINEAR_CLIENT_ID")
self.client_secret = os.getenv("LINEAR_CLIENT_SECRET")
self.redirect_uri = os.getenv("LINEAR_REDIRECT_URI")
# Token storage
self._access_token: Optional[str] = None
_refresh_token: Optional[str] = None
self._token_expires_at: Optional[datetime] = None
async def get_oauth_url(self) -> str:
"""
Generate Linear OAuth authorization URL.
Returns:
Authorization URL to redirect user to Linear OAuth consent screen
"""
if not self.client_id:
raise ValueError("LINEAR_CLIENT_ID not configured")
# Linear OAuth endpoint
auth_url = "https://linear.app/oauth/authorize"
# Build authorization URL
params = {
"client_id": self.client_id,
"redirect_uri": self.redirect_uri,
"scope": "read write issues:read issues:write projects:read projects:write teams:read",
"response_type": "code",
"state": self.workspace_id, # Use workspace_id as state
}
auth_url_with_params = f"{auth_url}?{urlencode(params)}"
logger.info(f"Generated Linear OAuth URL for workspace {self.workspace_id}")
return auth_url_with_params
async def exchange_code_for_token(self, code: str) -> Dict[str, Any]:
"""
Exchange OAuth authorization code for access token.
Args:
code: Authorization code from OAuth callback
Returns:
Token response with access_token, refresh_token, etc.
"""
if not self.client_id or not self.client_secret:
raise ValueError("Linear OAuth credentials not configured")
token_url = f"{self.base_url}/oauth/token"
data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": self.redirect_uri,
"client_id": self.client_id,
"client_secret": self.client_secret,
}
try:
async with httpx.AsyncClient() as client:
response = await client.post(token_url, json=data)
response.raise_for_status()
token_data = response.json()
# Store tokens
self._access_token = token_data.get("access_token")
_refresh_token = token_data.get("refresh_token")
# Calculate token expiration (Linear tokens don't expire by default)
if "expires_in" in token_data:
self._token_expires_at = datetime.now() + timedelta(
seconds=token_data["expires_in"]
)
logger.info(f"Successfully obtained Linear access token for workspace {self.workspace_id}")
return token_data
except httpx.HTTPStatusError as e:
logger.error(f"Linear token exchange failed: {e}")
raise
async def test_connection(self) -> bool:
"""
Test the Linear API connection.
Returns:
True if connection successful, False otherwise
"""
if not self._access_token:
return False
try:
async with httpx.AsyncClient() as client:
# Test by getting current user info
response = await client.post(
f"{self.base_url}/graphql",
headers={
"Authorization": f"{self._access_token}",
"Content-Type": "application/json"
},
json={
"query": """
query {
viewer {
id
name
email
}
}
"""
}
)
response.raise_for_status()
logger.info(f"Linear connection test successful for workspace {self.workspace_id}")
return True
except Exception as e:
logger.error(f"Linear connection test failed: {e}")
return False
async def search_issues(self, query: str, limit: int = 20) -> List[Dict[str, Any]]:
"""
Search Linear issues by title or description.
Args:
query: Search query string
limit: Maximum number of results
Returns:
List of issue objects
"""
if not self._access_token:
raise ValueError("Linear access token not available")
try:
async with httpx.AsyncClient() as client:
# Linear uses GraphQL
response = await client.post(
f"{self.base_url}/graphql",
headers={
"Authorization": f"{self._access_token}",
"Content-Type": "application/json"
},
json={
"query": """
query($filter: IssueFilter, $first: Int) {
issues(filter: $filter, first: $first) {
nodes {
id
title
description
state {
name
}
priority
assignee {
name
email
}
labels {
nodes {
name
}
}
}
}
}
""",
"variables": {
"filter": {
"query": query
},
"first": limit
}
}
)
response.raise_for_status()
data = response.json()
issues = data.get("data", {}).get("issues", {}).get("nodes", [])
logger.info(f"Linear search returned {len(issues)} issues for workspace {self.workspace_id}")
return issues
except Exception as e:
logger.error(f"Linear issue search failed: {e}")
raise
async def get_issue(self, issue_id: str) -> Dict[str, Any]:
"""
Retrieve a specific Linear issue by ID.
Args:
issue_id: Linear issue ID
Returns:
Issue details with all fields
"""
if not self._access_token:
raise ValueError("Linear access token not available")
try:
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.base_url}/graphql",
headers={
"Authorization": f"{self._access_token}",
"Content-Type": "application/json"
},
json={
"query": """
query($id: String!) {
issue(id: $id) {
id
title
description
state {
id
name
}
priority
assignee {
id
name
email
}
team {
id
name
}
labels {
nodes {
id
name
}
}
project {
id
name
}
createdAt
updatedAt
}
}
""",
"variables": {
"id": issue_id
}
}
)
response.raise_for_status()
data = response.json()
issue = data.get("data", {}).get("issue")
logger.info(f"Retrieved Linear issue {issue_id} for workspace {self.workspace_id}")
return issue
except Exception as e:
logger.error(f"Failed to retrieve Linear issue {issue_id}: {e}")
raise
async def create_issue(self, team_id: str, title: str, description: str = None,
priority: int = 0, assignee_id: str = None) -> Dict[str, Any]:
"""
Create a new Linear issue.
Args:
team_id: Team ID to create issue in
title: Issue title
description: Issue description
priority: Priority level (0=Urgent, 1=High, 2=Medium, 3=Low, 4=No priority)
assignee_id: User ID to assign issue to
Returns:
Created issue object with ID
"""
if not self._access_token:
raise ValueError("Linear access token not available")
try:
# Build mutation
mutation = """
mutation($input: IssueCreateInput!) {
issueCreate(input: $input) {
success
issue {
id
title
description
state {
id
name
}
priority
assignee {
id
name
}
}
}
}
"""
variables = {
"input": {
"teamId": team_id,
"title": title,
"description": description,
"priority": priority
}
}
if assignee_id:
variables["input"]["assigneeId"] = assignee_id
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.base_url}/graphql",
headers={
"Authorization": f"{self._access_token}",
"Content-Type": "application/json"
},
json={
"query": mutation,
"variables": variables
}
)
response.raise_for_status()
data = response.json()
issue_data = data.get("data", {}).get("issueCreate", {})
if issue_data.get("success"):
issue = issue_data.get("issue")
logger.info(f"Created Linear issue {issue.get('id')} for workspace {self.workspace_id}")
return issue
else:
raise Exception("Failed to create Linear issue")
except Exception as e:
logger.error(f"Failed to create Linear issue: {e}")
raise
async def update_issue(self, issue_id: str, updates: Dict[str, Any]) -> Dict[str, Any]:
"""
Update a Linear issue.
Args:
issue_id: Issue ID to update
updates: Dictionary of fields to update (title, description, stateId, priority, etc.)
Returns:
Updated issue object
"""
if not self._access_token:
raise ValueError("Linear access token not available")
try:
mutation = """
mutation($input: IssueUpdateInput!) {
issueUpdate(input: $input) {
success
issue {
id
title
description
state {
id
name
}
priority
}
}
}
"""
variables = {
"input": {
"id": issue_id,
**updates
}
}
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.base_url}/graphql",
headers={
"Authorization": f"{self._access_token}",
"Content-Type": "application/json"
},
json={
"query": mutation,
"variables": variables
}
)
response.raise_for_status()
data = response.json()
issue_data = data.get("data", {}).get("issueUpdate", {})
if issue_data.get("success"):
issue = issue_data.get("issue")
logger.info(f"Updated Linear issue {issue_id} in workspace {self.workspace_id}")
return issue
else:
raise Exception("Failed to update Linear issue")
except Exception as e:
logger.error(f"Failed to update Linear issue {issue_id}: {e}")
raise
async def get_teams(self) -> List[Dict[str, Any]]:
"""
Retrieve all Linear teams.
Returns:
List of team objects
"""
if not self._access_token:
raise ValueError("Linear access token not available")
try:
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.base_url}/graphql",
headers={
"Authorization": f"{self._access_token}",
"Content-Type": "application/json"
},
json={
"query": """
query {
teams {
nodes {
id
name
description
key
}
}
}
"""
}
)
response.raise_for_status()
data = response.json()
teams = data.get("data", {}).get("teams", {}).get("nodes", [])
logger.info(f"Retrieved {len(teams)} Linear teams for workspace {self.workspace_id}")
return teams
except Exception as e:
logger.error(f"Failed to retrieve Linear teams: {e}")
raise
async def add_comment(self, issue_id: str, body: str) -> Dict[str, Any]:
"""
Add a comment to a Linear issue.
Args:
issue_id: Issue ID
body: Comment content (supports Markdown)
Returns:
Created comment object
"""
if not self._access_token:
raise ValueError("Linear access token not available")
try:
mutation = """
mutation($input: CommentCreateInput!) {
commentCreate(input: $input) {
success
comment {
id
body
user {
name
}
createdAt
}
}
}
"""
variables = {
"input": {
"issueId": issue_id,
"body": body
}
}
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.base_url}/graphql",
headers={
"Authorization": f"{self._access_token}",
"Content-Type": "application/json"
},
json={
"query": mutation,
"variables": variables
}
)
response.raise_for_status()
data = response.json()
comment_data = data.get("data", {}).get("commentCreate", {})
if comment_data.get("success"):
comment = comment_data.get("comment")
logger.info(f"Added comment to Linear issue {issue_id} in workspace {self.workspace_id}")
return comment
else:
raise Exception("Failed to add comment to Linear issue")
except Exception as e:
logger.error(f"Failed to add comment to Linear issue {issue_id}: {e}")
raise