mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-29 10:56:24 +02:00
- Added a guideline to ensure that each tool (Gmail, Google Calendar, Google Drive, Linear, Notion) is called only once per user request. - Updated documentation to clarify that the system will automatically select the most relevant match when multiple items share the same title or subject, enhancing user experience and preventing redundant calls.
396 lines
15 KiB
Python
396 lines
15 KiB
Python
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()
|
|
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()
|