"""Linear API utilities.""" from __future__ import annotations import logging import os from typing import Any import httpx from agent.utils.langsmith import get_langsmith_trace_url from .http import DEFAULT_HTTP_TIMEOUT logger = logging.getLogger(__name__) LINEAR_API_KEY = os.environ.get("LINEAR_API_KEY", "") LINEAR_API_URL = "https://api.linear.app/graphql" def _headers() -> dict[str, str]: return { "Authorization": LINEAR_API_KEY, "Content-Type": "application/json", } async def _graphql_request(query: str, variables: dict[str, Any] | None = None) -> dict[str, Any]: """Execute a GraphQL request against the Linear API.""" if not LINEAR_API_KEY: return {"error": "LINEAR_API_KEY is not set"} async with httpx.AsyncClient(timeout=DEFAULT_HTTP_TIMEOUT) as http_client: try: response = await http_client.post( LINEAR_API_URL, headers=_headers(), json={"query": query, "variables": variables or {}}, ) response.raise_for_status() result = response.json() if result.get("errors"): return {"error": result["errors"]} return result.get("data", {}) except Exception as e: # noqa: BLE001 return {"error": str(e)} async def comment_on_linear_issue( issue_id: str, comment_body: str, parent_id: str | None = None ) -> bool: """Add a comment to a Linear issue, optionally as a reply to a specific comment.""" mutation = """ mutation CommentCreate($issueId: String!, $body: String!, $parentId: String) { commentCreate(input: { issueId: $issueId, body: $body, parentId: $parentId }) { success comment { id } } } """ result = await _graphql_request( mutation, {"issueId": issue_id, "body": comment_body, "parentId": parent_id}, ) return bool(result.get("commentCreate", {}).get("success")) async def post_linear_trace_comment( issue_id: str, thread_id: str, triggering_comment_id: str ) -> None: """Post a trace URL comment on a Linear issue.""" trace_url = get_langsmith_trace_url(thread_id) if trace_url: await comment_on_linear_issue( issue_id, f"On it! [View trace]({trace_url})", parent_id=triggering_comment_id or None, ) else: await comment_on_linear_issue( issue_id, "On it!", parent_id=triggering_comment_id or None, ) async def list_teams() -> dict[str, Any]: """List all teams in the Linear workspace.""" query = """ query { teams { nodes { id name key description } } } """ result = await _graphql_request(query) if "error" in result: return result return {"teams": result.get("teams", {}).get("nodes", [])} async def get_issue(issue_id: str) -> dict[str, Any]: """Get a Linear issue by ID.""" query = """ query GetIssue($id: String!) { issue(id: $id) { id identifier title description priority priorityLabel state { id name } assignee { id name email } team { id name key } project { id name } labels { nodes { id name } } createdAt updatedAt url } } """ result = await _graphql_request(query, {"id": issue_id}) if "error" in result: return result return {"issue": result.get("issue")} async def search_issues( query: str, team_id: str | None = None, limit: int = 10, include_archived: bool = False, include_comments: bool = False, after: str | None = None, ) -> dict[str, Any]: """Search Linear issues by free-text query.""" query = query.strip() if not query: return {"error": "Search query must not be empty"} if not 1 <= limit <= 50: return {"error": "Search limit must be between 1 and 50"} search_query = """ query SearchIssues( $query: String! $filter: IssueFilter $limit: Int! $includeArchived: Boolean $includeComments: Boolean $after: String ) { searchIssues( term: $query filter: $filter first: $limit includeArchived: $includeArchived includeComments: $includeComments after: $after ) { totalCount pageInfo { hasNextPage endCursor } nodes { id identifier title priority priorityLabel state { id name type } assignee { id name email } team { id name key } project { id name } labels { nodes { id name } } createdAt updatedAt archivedAt url } } } """ result = await _graphql_request( search_query, { "query": query, "filter": {"team": {"id": {"eq": team_id}}} if team_id else None, "limit": limit, "includeArchived": include_archived, "includeComments": include_comments, "after": after, }, ) if "error" in result: return result search_results = result.get("searchIssues", {}) return { "issues": search_results.get("nodes", []), "total_count": search_results.get("totalCount", 0), "page_info": search_results.get("pageInfo", {}), } async def create_issue( team_id: str, title: str, description: str | None = None, assignee_id: str | None = None, priority: int | None = None, state_id: str | None = None, label_ids: list[str] | None = None, project_id: str | None = None, ) -> dict[str, Any]: """Create a new Linear issue.""" mutation = """ mutation IssueCreate($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { id identifier title url } } } """ input_vars: dict[str, Any] = {"teamId": team_id, "title": title} if description is not None: input_vars["description"] = description if assignee_id is not None: input_vars["assigneeId"] = assignee_id if priority is not None: input_vars["priority"] = priority if state_id is not None: input_vars["stateId"] = state_id if label_ids is not None: input_vars["labelIds"] = label_ids if project_id is not None: input_vars["projectId"] = project_id result = await _graphql_request(mutation, {"input": input_vars}) if "error" in result: return result issue_create = result.get("issueCreate", {}) return { "success": issue_create.get("success", False), "issue": issue_create.get("issue"), } async def get_issue_comments(issue_id: str) -> dict[str, Any]: """Get comments for a Linear issue.""" query = """ query GetIssueComments($id: String!) { issue(id: $id) { comments { nodes { id body createdAt updatedAt user { id name email } } } } } """ result = await _graphql_request(query, {"id": issue_id}) if "error" in result: return result issue = result.get("issue") if not issue: return {"error": f"Issue {issue_id} not found"} return {"comments": issue.get("comments", {}).get("nodes", [])} async def update_issue( issue_id: str, title: str | None = None, description: str | None = None, assignee_id: str | None = None, priority: int | None = None, state_id: str | None = None, label_ids: list[str] | None = None, ) -> dict[str, Any]: """Update an existing Linear issue.""" mutation = """ mutation IssueUpdate($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { success issue { id identifier title url } } } """ input_vars: dict[str, Any] = {} if title is not None: input_vars["title"] = title if description is not None: input_vars["description"] = description if assignee_id is not None: input_vars["assigneeId"] = assignee_id if priority is not None: input_vars["priority"] = priority if state_id is not None: input_vars["stateId"] = state_id if label_ids is not None: input_vars["labelIds"] = label_ids if not input_vars: return {"error": "No fields to update"} result = await _graphql_request(mutation, {"id": issue_id, "input": input_vars}) if "error" in result: return result issue_update = result.get("issueUpdate", {}) return { "success": issue_update.get("success", False), "issue": issue_update.get("issue"), } async def delete_issue(issue_id: str) -> dict[str, Any]: """Delete a Linear issue.""" mutation = """ mutation IssueDelete($id: String!) { issueDelete(id: $id) { success } } """ result = await _graphql_request(mutation, {"id": issue_id}) if "error" in result: return result return {"success": result.get("issueDelete", {}).get("success", False)}