SurfSense/surfsense_backend/app/services/linear/tool_metadata_service.py

401 lines
15 KiB
Python
Raw Normal View History

import logging
from dataclasses import dataclass
from sqlalchemy import and_, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm.attributes import flag_modified
from app.connectors.linear_connector import LinearConnector
from app.db import (
Document,
DocumentType,
SearchSourceConnector,
SearchSourceConnectorType,
)
logger = logging.getLogger(__name__)
@dataclass
class LinearWorkspace:
"""Represents a Linear connector as a workspace for tool context."""
id: int
name: str
organization_name: str
@classmethod
def from_connector(cls, connector: SearchSourceConnector) -> "LinearWorkspace":
return cls(
id=connector.id,
name=connector.name,
organization_name=connector.config.get(
"organization_name", "Linear Workspace"
),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"name": self.name,
"organization_name": self.organization_name,
}
@dataclass
class LinearIssue:
"""Represents an indexed Linear issue resolved from the knowledge base."""
id: str
identifier: str
title: str
state: str
connector_id: int
document_id: int
indexed_at: str | None
@classmethod
def from_document(cls, document: Document) -> "LinearIssue":
meta = document.document_metadata or {}
return cls(
id=meta.get("issue_id", ""),
identifier=meta.get("issue_identifier", ""),
title=meta.get("issue_title", document.title),
state=meta.get("state", ""),
connector_id=document.connector_id,
document_id=document.id,
indexed_at=meta.get("indexed_at"),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"identifier": self.identifier,
"title": self.title,
"state": self.state,
"connector_id": self.connector_id,
"document_id": self.document_id,
"indexed_at": self.indexed_at,
}
class LinearToolMetadataService:
"""Builds interrupt context for Linear HITL tools.
All context queries (GraphQL reads) live here.
Write mutations live in LinearConnector.
"""
def __init__(self, db_session: AsyncSession):
self._db_session = db_session
async def get_creation_context(self, search_space_id: int, user_id: str) -> dict:
"""Return context needed to create a new Linear issue.
Fetches all connected Linear workspaces, and for each one fetches
its teams with states, members, and labels from the Linear API.
Returns a dict with key: workspaces (each entry has id, name, organization_name, teams, priorities).
Returns a dict with key 'error' on failure.
"""
connectors = await self._get_all_linear_connectors(search_space_id, user_id)
if not connectors:
return {"error": "No Linear account connected"}
workspaces = []
for connector in connectors:
workspace = LinearWorkspace.from_connector(connector)
linear_client = LinearConnector(
session=self._db_session, connector_id=connector.id
)
try:
priorities = await self._fetch_priority_values(linear_client)
teams = await self._fetch_teams_context(linear_client)
except Exception as e:
logger.warning(
"Linear connector %s (%s) auth failed, flagging as expired: %s",
connector.id,
workspace.name,
e,
)
try:
connector.config = {**connector.config, "auth_expired": True}
flag_modified(connector, "config")
await self._db_session.commit()
await self._db_session.refresh(connector)
except Exception:
logger.warning(
"Failed to persist auth_expired for connector %s",
connector.id,
exc_info=True,
)
workspaces.append(
{
"id": workspace.id,
"name": workspace.name,
"organization_name": workspace.organization_name,
"teams": [],
"priorities": [],
"auth_expired": True,
}
)
continue
workspaces.append(
{
"id": workspace.id,
"name": workspace.name,
"organization_name": workspace.organization_name,
"teams": teams,
"priorities": priorities,
"auth_expired": False,
}
)
return {"workspaces": workspaces}
async def get_update_context(
self, search_space_id: int, user_id: str, issue_ref: str
) -> dict:
"""Return context needed to update an indexed Linear issue.
Resolves the issue from the KB (title identifier full title),
then fetches its current state, assignee, labels, and team context
from the Linear API.
Returns a dict with keys: workspace, priorities, issue, team.
Returns a dict with key 'error' if the issue is not found or API fails.
"""
document = await self._resolve_issue(search_space_id, user_id, issue_ref)
if not document:
return {
"error": f"Issue '{issue_ref}' not found in your synced Linear issues. "
"This could mean: (1) the issue doesn't exist, (2) it hasn't been synced yet, "
"or (3) the title or identifier is different."
}
connector = await self._get_connector_for_document(document, user_id)
if not connector:
return {"error": "Connector not found or access denied"}
workspace = LinearWorkspace.from_connector(connector)
issue = LinearIssue.from_document(document)
linear_client = LinearConnector(
session=self._db_session, connector_id=connector.id
)
try:
priorities = await self._fetch_priority_values(linear_client)
issue_api = await self._fetch_issue_context(linear_client, issue.id)
except Exception as e:
error_str = str(e).lower()
2026-03-21 13:20:13 +05:30
if (
"401" in error_str
or "authentication" in error_str
or "re-authenticate" in error_str
):
return {
"error": f"Failed to fetch Linear issue context: {e!s}",
"auth_expired": True,
"connector_id": connector.id,
}
return {"error": f"Failed to fetch Linear issue context: {e!s}"}
if not issue_api:
return {
"error": f"Issue '{issue_ref}' could not be fetched from Linear API"
}
team_raw = issue_api.get("team") or {}
labels_raw = issue_api.get("labels") or {}
states_raw = team_raw.get("states") or {}
members_raw = team_raw.get("members") or {}
team_labels_raw = team_raw.get("labels") or {}
return {
"workspace": workspace.to_dict(),
"priorities": priorities,
"issue": {
"id": issue_api.get("id"),
"identifier": issue_api.get("identifier"),
"title": issue_api.get("title"),
"description": issue_api.get("description"),
"priority": issue_api.get("priority"),
"url": issue_api.get("url"),
"current_state": issue_api.get("state"),
"current_assignee": issue_api.get("assignee"),
"current_labels": labels_raw.get("nodes", []),
"team_id": team_raw.get("id"),
"document_id": issue.document_id,
"indexed_at": issue.indexed_at,
},
"team": {
"id": team_raw.get("id"),
"name": team_raw.get("name"),
"key": team_raw.get("key"),
"states": states_raw.get("nodes", []),
"members": members_raw.get("nodes", []),
"labels": team_labels_raw.get("nodes", []),
},
}
async def get_delete_context(
self, search_space_id: int, user_id: str, issue_ref: str
) -> dict:
"""Return context needed to archive an indexed Linear issue.
Resolves the issue from the KB only no Linear API call required.
Returns a dict with keys: workspace, issue.
Returns a dict with key 'error' if the issue is not found.
"""
document = await self._resolve_issue(search_space_id, user_id, issue_ref)
if not document:
return {
"error": f"Issue '{issue_ref}' not found in your synced Linear issues. "
"This could mean: (1) the issue doesn't exist, (2) it hasn't been synced yet, "
"or (3) the title or identifier is different."
}
connector = await self._get_connector_for_document(document, user_id)
if not connector:
return {"error": "Connector not found or access denied"}
workspace = LinearWorkspace.from_connector(connector)
issue = LinearIssue.from_document(document)
return {
"workspace": workspace.to_dict(),
"issue": issue.to_dict(),
}
@staticmethod
async def _fetch_priority_values(client: LinearConnector) -> list[dict]:
"""Fetch Linear priority values (0-4) with their display labels."""
query = "{ issuePriorityValues { priority label } }"
result = await client.execute_graphql_query(query)
return result.get("data", {}).get("issuePriorityValues", [])
@staticmethod
async def _fetch_teams_context(client: LinearConnector) -> list[dict]:
"""Fetch all teams with their states, members, and labels."""
query = """
query {
teams(first: 25) {
nodes {
id name key
states { nodes { id name type color position } }
members { nodes { id name displayName email avatarUrl active } }
labels { nodes { id name color } }
}
}
}
"""
result = await client.execute_graphql_query(query)
raw_teams = result.get("data", {}).get("teams", {}).get("nodes", [])
return [
{
"id": t.get("id"),
"name": t.get("name"),
"key": t.get("key"),
"states": (t.get("states") or {}).get("nodes", []),
"members": (t.get("members") or {}).get("nodes", []),
"labels": (t.get("labels") or {}).get("nodes", []),
}
for t in raw_teams
]
@staticmethod
async def _fetch_issue_context(
client: LinearConnector, issue_id: str
) -> dict | None:
"""Fetch a single issue with its current state, assignee, labels, and team context."""
query = """
query LinearIssueContext($id: String!) {
issue(id: $id) {
id identifier title description priority url
state { id name type color }
assignee { id name displayName email }
labels { nodes { id name color } }
team {
id name key
states { nodes { id name type color position } }
members { nodes { id name displayName email avatarUrl active } }
labels { nodes { id name color } }
}
}
}
"""
result = await client.execute_graphql_query(query, {"id": issue_id})
return result.get("data", {}).get("issue")
async def _resolve_issue(
self, search_space_id: int, user_id: str, issue_ref: str
) -> Document | None:
"""Resolve an issue from the KB using a 3-step fallback.
Order: issue_title (most natural) issue_identifier (e.g. ENG-42) document.title.
All comparisons are case-insensitive.
"""
ref_lower = issue_ref.lower()
result = await self._db_session.execute(
select(Document)
.join(
SearchSourceConnector, Document.connector_id == SearchSourceConnector.id
)
.filter(
and_(
Document.search_space_id == search_space_id,
Document.document_type == DocumentType.LINEAR_CONNECTOR,
SearchSourceConnector.user_id == user_id,
or_(
func.lower(Document.document_metadata.op("->>")("issue_title"))
== ref_lower,
func.lower(
Document.document_metadata.op("->>")("issue_identifier")
)
== ref_lower,
func.lower(Document.title) == ref_lower,
),
)
)
.order_by(Document.updated_at.desc().nullslast())
.limit(1)
)
return result.scalars().first()
async def _get_all_linear_connectors(
self, search_space_id: int, user_id: str
) -> list[SearchSourceConnector]:
"""Fetch all Linear connectors for the given search space and user."""
result = await self._db_session.execute(
select(SearchSourceConnector).filter(
and_(
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.LINEAR_CONNECTOR,
)
)
)
return result.scalars().all()
async def _get_connector_for_document(
self, document: Document, user_id: str
) -> SearchSourceConnector | None:
"""Fetch the connector associated with a document, scoped to the user."""
if not document.connector_id:
return None
result = await self._db_session.execute(
select(SearchSourceConnector).filter(
and_(
SearchSourceConnector.id == document.connector_id,
SearchSourceConnector.user_id == user_id,
)
)
)
return result.scalars().first()