arcade-mcp/toolkits/linear/arcade_linear/client.py
Shub 0b1f513826
Add Simplified Linear Toolkit (#465)
This PR adds a simplified Linear toolkit focused on core functionality:

## What's Included

**Tools:**
- `get_issue` - Get detailed information about Linear issues
- `get_teams` - Get team information and details

**Models:**
- `DateRange` enum with comprehensive date range support (TODAY,
YESTERDAY, THIS_WEEK, LAST_WEEK, THIS_MONTH, LAST_MONTH, THIS_YEAR,
LAST_YEAR, LAST_7_DAYS, LAST_30_DAYS)
- Timezone-aware datetime handling following Google toolkit patterns

## What's Simplified

This toolkit has been streamlined by removing:
- Cycles management tools
- Projects management tools  
- Users management tools
- Workflows management tools
- Corresponding tests and evaluations for removed features

## Quality Assurance

- All linting and formatting checks pass
- Comprehensive test coverage for included functionality
- Follows established patterns from Google toolkit

---------

Co-authored-by: Eric Gustin <34000337+EricGustin@users.noreply.github.com>
2025-07-18 14:08:58 -07:00

316 lines
9.5 KiB
Python

import asyncio
import json
from dataclasses import dataclass
from typing import Any, cast
import httpx
from arcade_tdk.errors import ToolExecutionError
from arcade_linear.constants import (
LINEAR_API_URL,
LINEAR_MAX_CONCURRENT_REQUESTS,
LINEAR_MAX_TIMEOUT_SECONDS,
)
@dataclass
class LinearClient:
"""Client for interacting with Linear's GraphQL API"""
auth_token: str
api_url: str = LINEAR_API_URL
max_concurrent_requests: int = LINEAR_MAX_CONCURRENT_REQUESTS
timeout_seconds: int = LINEAR_MAX_TIMEOUT_SECONDS
_semaphore: asyncio.Semaphore | None = None
def __post_init__(self) -> None:
self._semaphore = self._semaphore or asyncio.Semaphore(self.max_concurrent_requests)
def _build_headers(self, additional_headers: dict[str, str] | None = None) -> dict[str, str]:
"""Build headers for GraphQL requests"""
headers = {
"Authorization": f"Bearer {self.auth_token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
if additional_headers:
headers.update(additional_headers)
return headers
def _build_error_message(self, response: httpx.Response) -> tuple[str, str]:
"""Build user-friendly and developer error messages from response"""
try:
data = response.json()
if data.get("errors"):
errors = data["errors"]
if len(errors) == 1:
error = errors[0]
user_message = error.get("message", "Unknown GraphQL error")
dev_message = (
f"GraphQL error: {json.dumps(error)} (HTTP {response.status_code})"
)
else:
error_messages = [err.get("message", "Unknown error") for err in errors]
user_message = f"Multiple errors: {'; '.join(error_messages)}"
dev_message = (
f"Multiple GraphQL errors: {json.dumps(errors)} "
f"(HTTP {response.status_code})"
)
else:
user_message = f"HTTP {response.status_code}: {response.reason_phrase}"
dev_message = f"HTTP {response.status_code}: {response.text}"
except Exception as e:
user_message = "Failed to parse Linear API error response"
dev_message = (
f"Failed to parse error response: {type(e).__name__}: {e!s} | "
f"Raw response: {response.text}"
)
return user_message, dev_message
def _raise_for_status(self, response: httpx.Response) -> None:
"""Raise appropriate errors for non-200 responses"""
if response.status_code < 300:
# Check for GraphQL errors in successful HTTP responses
try:
data = response.json()
if data.get("errors"):
user_message, dev_message = self._build_error_message(response)
raise ToolExecutionError(user_message, developer_message=dev_message)
except (ValueError, KeyError):
# Response isn't JSON or doesn't have expected structure
pass
return
user_message, dev_message = self._build_error_message(response)
raise ToolExecutionError(user_message, developer_message=dev_message)
async def execute_query(
self, query: str, variables: dict[str, Any] | None = None, operation_name: str | None = None
) -> dict[str, Any]:
"""Execute a GraphQL query"""
payload: dict[str, Any] = {
"query": query.strip(),
}
if variables:
payload["variables"] = variables
if operation_name:
payload["operationName"] = operation_name
headers = self._build_headers()
async with self._semaphore, httpx.AsyncClient(timeout=self.timeout_seconds) as client: # type: ignore[union-attr]
response = await client.post(
self.api_url,
json=payload,
headers=headers,
)
self._raise_for_status(response)
return cast(dict[str, Any], response.json())
async def get_teams(
self,
first: int = 50,
after: str | None = None,
include_archived: bool = False,
name_filter: str | None = None,
) -> dict[str, Any]:
"""Get teams with optional filtering"""
query = """
query GetTeams($first: Int!, $after: String, $filter: TeamFilter) {
teams(first: $first, after: $after, filter: $filter) {
nodes {
id
key
name
description
private
archivedAt
createdAt
updatedAt
icon
color
cyclesEnabled
issueEstimationType
organization {
id
name
}
members {
nodes {
id
name
email
displayName
avatarUrl
}
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
"""
# Build filter
team_filter = {}
if name_filter:
team_filter["name"] = {"containsIgnoreCase": name_filter}
variables = {"first": first, "after": after, "filter": team_filter if team_filter else None}
result = await self.execute_query(query, variables)
return cast(dict[str, Any], result["data"]["teams"])
async def get_issue_by_id(self, issue_id: str) -> dict[str, Any]:
"""Get a single issue by ID"""
query = """
query GetIssue($id: String!) {
issue(id: $id) {
id
identifier
title
description
priority
priorityLabel
estimate
sortOrder
createdAt
updatedAt
completedAt
canceledAt
dueDate
url
branchName
creator {
id
name
email
displayName
avatarUrl
}
assignee {
id
name
email
displayName
avatarUrl
}
state {
id
name
type
color
position
}
team {
id
key
name
}
project {
id
name
description
state
progress
startDate
targetDate
}
cycle {
id
number
name
description
startsAt
endsAt
completedAt
progress
}
parent {
id
identifier
title
}
labels {
nodes {
id
name
color
description
}
}
attachments {
nodes {
id
title
subtitle
url
metadata
createdAt
}
}
comments {
nodes {
id
body
createdAt
updatedAt
user {
id
name
email
displayName
}
}
}
children {
nodes {
id
identifier
title
state {
id
name
type
}
}
}
relations {
nodes {
id
type
relatedIssue {
id
identifier
title
}
}
}
}
}
"""
variables = {"id": issue_id}
result = await self.execute_query(query, variables)
return cast(dict[str, Any], result["data"]["issue"])