diff --git a/surfsense_backend/app/agents/new_chat/tools/linear/create_issue.py b/surfsense_backend/app/agents/new_chat/tools/linear/create_issue.py index b86119836..a97cecbb7 100644 --- a/surfsense_backend/app/agents/new_chat/tools/linear/create_issue.py +++ b/surfsense_backend/app/agents/new_chat/tools/linear/create_issue.py @@ -5,7 +5,7 @@ from langchain_core.tools import tool from langgraph.types import interrupt from sqlalchemy.ext.asyncio import AsyncSession -from app.connectors.linear_connector import LinearConnector +from app.connectors.linear_connector import LinearAPIError, LinearConnector from app.services.linear import LinearToolMetadataService logger = logging.getLogger(__name__) @@ -230,11 +230,10 @@ def create_create_linear_issue_tool( raise logger.error(f"Error creating Linear issue: {e}", exc_info=True) - return { - "status": "error", - "message": str(e) - if isinstance(e, ValueError) - else f"Unexpected error: {e!s}", - } + if isinstance(e, (ValueError, LinearAPIError)): + message = str(e) + else: + message = "Something went wrong while creating the issue. Please try again." + return {"status": "error", "message": message} return create_linear_issue diff --git a/surfsense_backend/app/agents/new_chat/tools/linear/delete_issue.py b/surfsense_backend/app/agents/new_chat/tools/linear/delete_issue.py index 361603d05..a1931077e 100644 --- a/surfsense_backend/app/agents/new_chat/tools/linear/delete_issue.py +++ b/surfsense_backend/app/agents/new_chat/tools/linear/delete_issue.py @@ -5,7 +5,7 @@ from langchain_core.tools import tool from langgraph.types import interrupt from sqlalchemy.ext.asyncio import AsyncSession -from app.connectors.linear_connector import LinearConnector +from app.connectors.linear_connector import LinearAPIError, LinearConnector from app.services.linear import LinearToolMetadataService logger = logging.getLogger(__name__) @@ -253,11 +253,10 @@ def create_delete_linear_issue_tool( raise logger.error(f"Error deleting Linear issue: {e}", exc_info=True) - return { - "status": "error", - "message": str(e) - if isinstance(e, ValueError) - else f"Unexpected error: {e!s}", - } + if isinstance(e, (ValueError, LinearAPIError)): + message = str(e) + else: + message = "Something went wrong while deleting the issue. Please try again." + return {"status": "error", "message": message} return delete_linear_issue diff --git a/surfsense_backend/app/agents/new_chat/tools/linear/update_issue.py b/surfsense_backend/app/agents/new_chat/tools/linear/update_issue.py index f67cf9472..efffaa098 100644 --- a/surfsense_backend/app/agents/new_chat/tools/linear/update_issue.py +++ b/surfsense_backend/app/agents/new_chat/tools/linear/update_issue.py @@ -5,7 +5,7 @@ from langchain_core.tools import tool from langgraph.types import interrupt from sqlalchemy.ext.asyncio import AsyncSession -from app.connectors.linear_connector import LinearConnector +from app.connectors.linear_connector import LinearAPIError, LinearConnector from app.services.linear import LinearKBSyncService, LinearToolMetadataService logger = logging.getLogger(__name__) @@ -290,12 +290,11 @@ def create_update_linear_issue_tool( raise logger.error(f"Error updating Linear issue: {e}", exc_info=True) - return { - "status": "error", - "message": str(e) - if isinstance(e, ValueError) - else f"Unexpected error: {e!s}", - } + if isinstance(e, (ValueError, LinearAPIError)): + message = str(e) + else: + message = "Something went wrong while updating the issue. Please try again." + return {"status": "error", "message": message} return update_linear_issue diff --git a/surfsense_backend/app/agents/new_chat/tools/notion/create_page.py b/surfsense_backend/app/agents/new_chat/tools/notion/create_page.py index a0206fa46..552910382 100644 --- a/surfsense_backend/app/agents/new_chat/tools/notion/create_page.py +++ b/surfsense_backend/app/agents/new_chat/tools/notion/create_page.py @@ -5,7 +5,7 @@ from langchain_core.tools import tool from langgraph.types import interrupt from sqlalchemy.ext.asyncio import AsyncSession -from app.connectors.notion_history import NotionHistoryConnector +from app.connectors.notion_history import NotionAPIError, NotionHistoryConnector from app.services.notion import NotionToolMetadataService logger = logging.getLogger(__name__) @@ -224,11 +224,10 @@ def create_create_notion_page_tool( raise logger.error(f"Error creating Notion page: {e}", exc_info=True) - return { - "status": "error", - "message": str(e) - if isinstance(e, ValueError) - else f"Unexpected error: {e!s}", - } + if isinstance(e, (ValueError, NotionAPIError)): + message = str(e) + else: + message = "Something went wrong while creating the page. Please try again." + return {"status": "error", "message": message} return create_notion_page diff --git a/surfsense_backend/app/agents/new_chat/tools/notion/delete_page.py b/surfsense_backend/app/agents/new_chat/tools/notion/delete_page.py index 65628a38d..b86c8dee4 100644 --- a/surfsense_backend/app/agents/new_chat/tools/notion/delete_page.py +++ b/surfsense_backend/app/agents/new_chat/tools/notion/delete_page.py @@ -5,7 +5,7 @@ from langchain_core.tools import tool from langgraph.types import interrupt from sqlalchemy.ext.asyncio import AsyncSession -from app.connectors.notion_history import NotionHistoryConnector +from app.connectors.notion_history import NotionAPIError, NotionHistoryConnector from app.services.notion.tool_metadata_service import NotionToolMetadataService logger = logging.getLogger(__name__) @@ -262,11 +262,10 @@ def create_delete_notion_page_tool( raise logger.error(f"Error deleting Notion page: {e}", exc_info=True) - return { - "status": "error", - "message": str(e) - if isinstance(e, ValueError) - else f"Unexpected error: {e!s}", - } + if isinstance(e, (ValueError, NotionAPIError)): + message = str(e) + else: + message = "Something went wrong while deleting the page. Please try again." + return {"status": "error", "message": message} return delete_notion_page diff --git a/surfsense_backend/app/agents/new_chat/tools/notion/update_page.py b/surfsense_backend/app/agents/new_chat/tools/notion/update_page.py index 890f3fefe..ad7fe088d 100644 --- a/surfsense_backend/app/agents/new_chat/tools/notion/update_page.py +++ b/surfsense_backend/app/agents/new_chat/tools/notion/update_page.py @@ -5,7 +5,7 @@ from langchain_core.tools import tool from langgraph.types import interrupt from sqlalchemy.ext.asyncio import AsyncSession -from app.connectors.notion_history import NotionHistoryConnector +from app.connectors.notion_history import NotionAPIError, NotionHistoryConnector from app.services.notion import NotionToolMetadataService logger = logging.getLogger(__name__) @@ -261,11 +261,10 @@ def create_update_notion_page_tool( raise logger.error(f"Error updating Notion page: {e}", exc_info=True) - return { - "status": "error", - "message": str(e) - if isinstance(e, ValueError) - else f"Unexpected error: {e!s}", - } + if isinstance(e, (ValueError, NotionAPIError)): + message = str(e) + else: + message = "Something went wrong while updating the page. Please try again." + return {"status": "error", "message": message} return update_notion_page diff --git a/surfsense_backend/app/connectors/linear_connector.py b/surfsense_backend/app/connectors/linear_connector.py index 16115c159..8805219a3 100644 --- a/surfsense_backend/app/connectors/linear_connector.py +++ b/surfsense_backend/app/connectors/linear_connector.py @@ -22,6 +22,14 @@ logger = logging.getLogger(__name__) LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql" + +class LinearAPIError(Exception): + """Raised when the Linear API returns a non-200 response. + + The message is always user-presentable; callers should surface it directly + without any additional prefix or wrapping. + """ + ORGANIZATION_QUERY = """ query { organization { @@ -243,6 +251,37 @@ class LinearConnector: "Authorization": f"Bearer {self._credentials.access_token}", } + @staticmethod + def _raise_api_error(status_code: int, body: str) -> None: + """Parse a non-200 Linear API response and raise a clean exception. + + Translates known Linear error codes into user-readable messages so that + raw GraphQL payloads never reach the end user. + """ + import json as _json + + friendly = None + try: + payload = _json.loads(body) + errors = payload.get("errors", []) + if errors: + ext = errors[0].get("extensions", {}) + code = ext.get("code", "") + if code == "INPUT_ERROR" and "too complex" in errors[0].get("message", "").lower(): + friendly = ( + "Linear rejected the request because the workspace is too large " + "to fetch in one query. Please try again — if the problem persists, " + "contact support." + ) + elif ext.get("userPresentableMessage"): + friendly = ext["userPresentableMessage"] + elif errors[0].get("message"): + friendly = errors[0]["message"] + except Exception: + pass + + raise LinearAPIError(friendly or f"Linear API error (HTTP {status_code})") + async def execute_graphql_query( self, query: str, variables: dict[str, Any] | None = None ) -> dict[str, Any]: @@ -281,9 +320,7 @@ class LinearConnector: if response.status_code == 200: return response.json() else: - raise Exception( - f"Query failed with status code {response.status_code}: {response.text}" - ) + self._raise_api_error(response.status_code, response.text) async def get_all_issues( self, include_comments: bool = True diff --git a/surfsense_backend/app/connectors/notion_history.py b/surfsense_backend/app/connectors/notion_history.py index 2539c952f..7425ceafc 100644 --- a/surfsense_backend/app/connectors/notion_history.py +++ b/surfsense_backend/app/connectors/notion_history.py @@ -17,6 +17,15 @@ from app.utils.oauth_security import TokenEncryption logger = logging.getLogger(__name__) + +class NotionAPIError(Exception): + """Raised when the Notion API returns a non-200 response. + + The message is always user-presentable; callers should surface it directly + without any additional prefix or wrapping. + """ + + # Type variable for generic return type T = TypeVar("T") @@ -250,8 +259,9 @@ class NotionHistoryConnector: logger.error( f"Failed to refresh Notion token for connector {self._connector_id}: {e!s}" ) - raise Exception( - f"Failed to refresh Notion OAuth credentials: {e!s}" + raise NotionAPIError( + "Failed to refresh your Notion connection. " + "Please try again or reconnect your Notion account." ) from e return self._credentials.access_token diff --git a/surfsense_backend/app/services/linear/kb_sync_service.py b/surfsense_backend/app/services/linear/kb_sync_service.py index 8bb43475f..bbae8c6e8 100644 --- a/surfsense_backend/app/services/linear/kb_sync_service.py +++ b/surfsense_backend/app/services/linear/kb_sync_service.py @@ -78,7 +78,7 @@ class LinearKBSyncService: issue_identifier = formatted_issue.get("identifier", "") issue_title = formatted_issue.get("title", "") state = formatted_issue.get("state", "Unknown") - priority = issue_raw.get("priority", 0) + priority = issue_raw.get("priorityLabel", "Unknown") comment_count = len(formatted_issue.get("comments", [])) description = formatted_issue.get("description", "") @@ -125,16 +125,20 @@ class LinearKBSyncService: issue_content, search_space_id ) document.embedding = summary_embedding + from sqlalchemy.orm.attributes import flag_modified + document.document_metadata = { - **document.document_metadata, + **(document.document_metadata or {}), "issue_id": issue_id, "issue_identifier": issue_identifier, "issue_title": issue_title, "state": state, + "priority": priority, "comment_count": comment_count, "indexed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "connector_id": connector_id, } + flag_modified(document, "document_metadata") safe_set_chunks(document, chunks) document.updated_at = get_current_timestamp() @@ -159,7 +163,7 @@ class LinearKBSyncService: query = """ query LinearIssueSync($id: String!) { issue(id: $id) { - id identifier title description priority + id identifier title description priority priorityLabel createdAt updatedAt url state { id name type color } creator { id name email } diff --git a/surfsense_backend/app/services/linear/tool_metadata_service.py b/surfsense_backend/app/services/linear/tool_metadata_service.py index 05e49a84b..5e6345b85 100644 --- a/surfsense_backend/app/services/linear/tool_metadata_service.py +++ b/surfsense_backend/app/services/linear/tool_metadata_service.py @@ -237,7 +237,7 @@ class LinearToolMetadataService: """Fetch all teams with their states, members, and labels.""" query = """ query { - teams { + teams(first: 25) { nodes { id name key states { nodes { id name type color position } } @@ -248,7 +248,19 @@ class LinearToolMetadataService: } """ result = await client.execute_graphql_query(query) - return result.get("data", {}).get("teams", {}).get("nodes", []) + 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( @@ -296,11 +308,15 @@ class LinearToolMetadataService: SearchSourceConnector.user_id == user_id, or_( func.lower( - Document.document_metadata["issue_title"].astext + Document.document_metadata.op("->>")( + "issue_title" + ) ) == ref_lower, func.lower( - Document.document_metadata["issue_identifier"].astext + Document.document_metadata.op("->>")( + "issue_identifier" + ) ) == ref_lower, func.lower(Document.title) == ref_lower, diff --git a/surfsense_web/components/tool-ui/linear/create-linear-issue.tsx b/surfsense_web/components/tool-ui/linear/create-linear-issue.tsx index d74b80237..1fb113cbf 100644 --- a/surfsense_web/components/tool-ui/linear/create-linear-issue.tsx +++ b/surfsense_web/components/tool-ui/linear/create-linear-issue.tsx @@ -227,7 +227,7 @@ function ApprovalCard({ {workspaces.map((w) => ( - {w.organization_name} + {w.name} ))} diff --git a/surfsense_web/components/tool-ui/linear/update-linear-issue.tsx b/surfsense_web/components/tool-ui/linear/update-linear-issue.tsx index 7caab9420..22bd48554 100644 --- a/surfsense_web/components/tool-ui/linear/update-linear-issue.tsx +++ b/surfsense_web/components/tool-ui/linear/update-linear-issue.tsx @@ -150,28 +150,31 @@ function ApprovalCard({ const priorities = context?.priorities ?? []; const issue = context?.issue; + const initialEditState = { + title: actionArgs.new_title ? String(actionArgs.new_title) : (issue?.title ?? ""), + description: actionArgs.new_description + ? String(actionArgs.new_description) + : (issue?.description ?? ""), + stateId: actionArgs.new_state_id + ? String(actionArgs.new_state_id) + : (issue?.current_state?.id ?? "__none__"), + assigneeId: actionArgs.new_assignee_id + ? String(actionArgs.new_assignee_id) + : (issue?.current_assignee?.id ?? "__none__"), + priority: + actionArgs.new_priority != null + ? String(actionArgs.new_priority) + : String(issue?.priority ?? 0), + labelIds: Array.isArray(actionArgs.new_label_ids) + ? (actionArgs.new_label_ids as string[]) + : (issue?.current_labels?.map((l) => l.id) ?? []), + }; + const [decided, setDecided] = useState<"approve" | "reject" | "edit" | null>( interruptData.__decided__ ?? null ); const [isEditing, setIsEditing] = useState(false); - const [editedTitle, setEditedTitle] = useState( - actionArgs.new_title ? String(actionArgs.new_title) : "" - ); - const [editedDescription, setEditedDescription] = useState( - actionArgs.new_description ? String(actionArgs.new_description) : "" - ); - const [selectedStateId, setSelectedStateId] = useState( - actionArgs.new_state_id ? String(actionArgs.new_state_id) : "__none__" - ); - const [selectedAssigneeId, setSelectedAssigneeId] = useState( - actionArgs.new_assignee_id ? String(actionArgs.new_assignee_id) : "__none__" - ); - const [selectedPriority, setSelectedPriority] = useState( - actionArgs.new_priority != null ? String(actionArgs.new_priority) : "__none__" - ); - const [selectedLabelIds, setSelectedLabelIds] = useState( - Array.isArray(actionArgs.new_label_ids) ? (actionArgs.new_label_ids as string[]) : [] - ); + const [editedArgs, setEditedArgs] = useState(initialEditState); const reviewConfig = interruptData.review_configs[0]; const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"]; @@ -203,12 +206,13 @@ function ApprovalCard({ issue_id: issue?.id, document_id: issue?.document_id, connector_id: context?.workspace?.id, - new_title: editedTitle || null, - new_description: editedDescription || null, - new_state_id: selectedStateId === "__none__" ? null : selectedStateId, - new_assignee_id: selectedAssigneeId === "__none__" ? null : selectedAssigneeId, - new_priority: selectedPriority === "__none__" ? null : Number(selectedPriority), - new_label_ids: labelsWereProposed || selectedLabelIds.length > 0 ? selectedLabelIds : null, + new_title: editedArgs.title || null, + new_description: editedArgs.description || null, + new_state_id: editedArgs.stateId === "__none__" ? null : editedArgs.stateId, + new_assignee_id: editedArgs.assigneeId === "__none__" ? null : editedArgs.assigneeId, + new_priority: Number(editedArgs.priority), + new_label_ids: + labelsWereProposed || editedArgs.labelIds.length > 0 ? editedArgs.labelIds : null, }; } @@ -411,126 +415,134 @@ function ApprovalCard({ > New Title - setEditedTitle(e.target.value)} - placeholder="Leave empty to keep current title" - /> - + setEditedArgs({ ...editedArgs, title: e.target.value })} + placeholder="Issue title" + /> + -
- -