diff --git a/surfsense_backend/app/services/linear/tool_metadata_service.py b/surfsense_backend/app/services/linear/tool_metadata_service.py index 2f19bb72b..e2dbe315d 100644 --- a/surfsense_backend/app/services/linear/tool_metadata_service.py +++ b/surfsense_backend/app/services/linear/tool_metadata_service.py @@ -89,32 +89,36 @@ class LinearToolMetadataService: 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 teams with their states, members, and labels from the - Linear API, along with workspace info from the DB connector. + 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 keys: workspace, priorities, teams. + Returns a dict with key: workspaces (each entry has id, name, organization_name, teams, priorities). Returns a dict with key 'error' on failure. """ - connector = await self._get_linear_connector(search_space_id, user_id) - if not connector: + connectors = await self._get_all_linear_connectors(search_space_id, user_id) + if not connectors: return {"error": "No Linear account connected"} - workspace = LinearWorkspace.from_connector(connector) - linear_client = LinearConnector( - session=self._db_session, connector_id=connector.id - ) + 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: + return {"error": f"Failed to fetch Linear context: {e!s}"} + workspaces.append({ + "id": workspace.id, + "name": workspace.name, + "organization_name": workspace.organization_name, + "teams": teams, + "priorities": priorities, + }) - try: - priorities = await self._fetch_priority_values(linear_client) - teams_raw = await self._fetch_teams_context(linear_client) - except Exception as e: - return {"error": f"Failed to fetch Linear context: {e!s}"} - - return { - "workspace": workspace.to_dict(), - "priorities": priorities, - "teams": teams_raw, - } + return {"workspaces": workspaces} async def get_update_context( self, search_space_id: int, user_id: str, issue_ref: str @@ -310,6 +314,22 @@ class LinearToolMetadataService: ) 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_linear_connector( self, search_space_id: int, user_id: str ) -> SearchSourceConnector | None: diff --git a/surfsense_web/components/tool-ui/create-linear-issue.tsx b/surfsense_web/components/tool-ui/create-linear-issue.tsx new file mode 100644 index 000000000..d74b80237 --- /dev/null +++ b/surfsense_web/components/tool-ui/create-linear-issue.tsx @@ -0,0 +1,620 @@ +"use client"; + +import { makeAssistantToolUI } from "@assistant-ui/react"; +import { AlertTriangleIcon, CheckIcon, Loader2Icon, PencilIcon, XIcon } from "lucide-react"; +import { useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; + +interface LinearLabel { + id: string; + name: string; + color: string; +} + +interface LinearState { + id: string; + name: string; + type: string; + color: string; + position: number; +} + +interface LinearMember { + id: string; + name: string; + displayName: string; + email: string; + active: boolean; +} + +interface LinearTeam { + id: string; + name: string; + key: string; + states: LinearState[]; + members: LinearMember[]; + labels: LinearLabel[]; +} + +interface LinearPriority { + priority: number; + label: string; +} + +interface LinearWorkspace { + id: number; + name: string; + organization_name: string; + teams: LinearTeam[]; + priorities: LinearPriority[]; +} + +interface InterruptResult { + __interrupt__: true; + __decided__?: "approve" | "reject" | "edit"; + action_requests: Array<{ + name: string; + args: Record; + }>; + review_configs: Array<{ + action_name: string; + allowed_decisions: Array<"approve" | "edit" | "reject">; + }>; + interrupt_type?: string; + context?: { + workspaces?: LinearWorkspace[]; + error?: string; + }; +} + +interface SuccessResult { + status: "success"; + issue_id: string; + identifier: string; + url: string; + message?: string; +} + +interface ErrorResult { + status: "error"; + message: string; +} + +type CreateLinearIssueResult = InterruptResult | SuccessResult | ErrorResult; + +function isInterruptResult(result: unknown): result is InterruptResult { + return ( + typeof result === "object" && + result !== null && + "__interrupt__" in result && + (result as InterruptResult).__interrupt__ === true + ); +} + +function isErrorResult(result: unknown): result is ErrorResult { + return ( + typeof result === "object" && + result !== null && + "status" in result && + (result as ErrorResult).status === "error" + ); +} + +function ApprovalCard({ + args, + interruptData, + onDecision, +}: { + args: { title: string; description?: string }; + interruptData: InterruptResult; + onDecision: (decision: { + type: "approve" | "reject" | "edit"; + message?: string; + edited_action?: { name: string; args: Record }; + }) => void; +}) { + const [decided, setDecided] = useState<"approve" | "reject" | "edit" | null>( + interruptData.__decided__ ?? null + ); + const [isEditing, setIsEditing] = useState(false); + const [editedTitle, setEditedTitle] = useState(args.title ?? ""); + const [editedDescription, setEditedDescription] = useState(args.description ?? ""); + const [selectedWorkspaceId, setSelectedWorkspaceId] = useState(""); + const [selectedTeamId, setSelectedTeamId] = useState(""); + const [selectedStateId, setSelectedStateId] = useState("__none__"); + const [selectedAssigneeId, setSelectedAssigneeId] = useState("__none__"); + const [selectedPriority, setSelectedPriority] = useState("__none__"); + const [selectedLabelIds, setSelectedLabelIds] = useState([]); + + const workspaces = interruptData.context?.workspaces ?? []; + + const selectedWorkspace = useMemo( + () => workspaces.find((w) => String(w.id) === selectedWorkspaceId) ?? null, + [workspaces, selectedWorkspaceId] + ); + + const selectedTeam = useMemo( + () => selectedWorkspace?.teams.find((t) => t.id === selectedTeamId) ?? null, + [selectedWorkspace, selectedTeamId] + ); + + const isTitleValid = editedTitle.trim().length > 0; + const canApprove = !!selectedWorkspaceId && !!selectedTeamId && isTitleValid; + + const reviewConfig = interruptData.review_configs[0]; + const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"]; + const canEdit = allowedDecisions.includes("edit"); + + function buildFinalArgs() { + return { + title: editedTitle, + description: editedDescription || null, + connector_id: selectedWorkspaceId ? Number(selectedWorkspaceId) : null, + team_id: selectedTeamId || null, + state_id: selectedStateId === "__none__" ? null : selectedStateId, + assignee_id: selectedAssigneeId === "__none__" ? null : selectedAssigneeId, + priority: selectedPriority === "__none__" ? null : Number(selectedPriority), + label_ids: selectedLabelIds, + }; + } + + return ( +
+ {/* Header */} +
+
+ +
+
+

Create Linear Issue

+

+ {isEditing ? "You can edit the arguments below" : "Requires your approval to proceed"} +

+
+
+ + {/* Context section */} + {!decided && ( +
+ {interruptData.context?.error ? ( +

{interruptData.context.error}

+ ) : ( + <> + {workspaces.length > 0 && ( +
+
+ Linear Account * +
+ +
+ )} + + {selectedWorkspace && ( + <> +
+
+ Team * +
+ +
+ + {selectedTeam && ( + <> +
+
State
+ +
+ +
+
Assignee
+ +
+ +
+
Priority
+ +
+ + {selectedTeam.labels.length > 0 && ( +
+
Labels
+
+ {selectedTeam.labels.map((label) => { + const isSelected = selectedLabelIds.includes(label.id); + return ( + + ); + })} +
+
+ )} + + )} + + )} + + )} +
+ )} + + {/* Display mode */} + {!isEditing && ( +
+
+

Title

+

{args.title}

+
+ {args.description && ( +
+

Description

+

+ {args.description} +

+
+ )} +
+ )} + + {/* Edit mode */} + {isEditing && !decided && ( +
+
+ + setEditedTitle(e.target.value)} + placeholder="Enter issue title" + className={!isTitleValid ? "border-destructive" : ""} + /> + {!isTitleValid &&

Title is required

} +
+
+ +