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>
316 lines
9.5 KiB
Python
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"])
|