add create-linear-issue tool UI and multi-account creation context

This commit is contained in:
CREDO23 2026-02-18 20:01:05 +02:00
parent 5bb619227e
commit c5bd930a71
2 changed files with 660 additions and 20 deletions

View file

@ -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:

View file

@ -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<string, unknown>;
}>;
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<string, unknown> };
}) => 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<string[]>([]);
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 (
<div
className={`my-4 max-w-full overflow-hidden rounded-xl transition-all duration-300 ${
decided
? "border border-border bg-card shadow-sm"
: "border-2 border-foreground/20 bg-muted/30 dark:bg-muted/10 shadow-lg animate-pulse-subtle"
}`}
>
{/* Header */}
<div
className={`flex items-center gap-3 border-b ${
decided ? "border-border bg-card" : "border-foreground/15 bg-muted/40 dark:bg-muted/20"
} px-4 py-3`}
>
<div
className={`flex size-9 shrink-0 items-center justify-center rounded-lg ${
decided ? "bg-muted" : "bg-muted animate-pulse"
}`}
>
<AlertTriangleIcon
className={`size-4 ${decided ? "text-muted-foreground" : "text-foreground"}`}
/>
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">Create Linear Issue</p>
<p className="truncate text-xs text-muted-foreground">
{isEditing ? "You can edit the arguments below" : "Requires your approval to proceed"}
</p>
</div>
</div>
{/* Context section */}
{!decided && (
<div className="border-b border-border px-4 py-3 bg-muted/30 space-y-3">
{interruptData.context?.error ? (
<p className="text-sm text-destructive">{interruptData.context.error}</p>
) : (
<>
{workspaces.length > 0 && (
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">
Linear Account <span className="text-destructive">*</span>
</div>
<Select
value={selectedWorkspaceId}
onValueChange={(v) => {
setSelectedWorkspaceId(v);
setSelectedTeamId("");
setSelectedStateId("__none__");
setSelectedAssigneeId("__none__");
setSelectedPriority("__none__");
setSelectedLabelIds([]);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select an account" />
</SelectTrigger>
<SelectContent>
{workspaces.map((w) => (
<SelectItem key={w.id} value={String(w.id)}>
{w.organization_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{selectedWorkspace && (
<>
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">
Team <span className="text-destructive">*</span>
</div>
<Select
value={selectedTeamId}
onValueChange={(v) => {
setSelectedTeamId(v);
setSelectedStateId("__none__");
setSelectedAssigneeId("__none__");
setSelectedLabelIds([]);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a team" />
</SelectTrigger>
<SelectContent>
{selectedWorkspace.teams.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name} ({t.key})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedTeam && (
<>
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">State</div>
<Select value={selectedStateId} onValueChange={setSelectedStateId}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Default" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Default</SelectItem>
{selectedTeam.states.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">Assignee</div>
<Select value={selectedAssigneeId} onValueChange={setSelectedAssigneeId}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Unassigned" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Unassigned</SelectItem>
{selectedTeam.members
.filter((m) => m.active)
.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name} ({m.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">Priority</div>
<Select value={selectedPriority} onValueChange={setSelectedPriority}>
<SelectTrigger className="w-full">
<SelectValue placeholder="No priority" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No priority</SelectItem>
{selectedWorkspace.priorities.map((p) => (
<SelectItem key={p.priority} value={String(p.priority)}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedTeam.labels.length > 0 && (
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">Labels</div>
<div className="flex flex-wrap gap-1.5">
{selectedTeam.labels.map((label) => {
const isSelected = selectedLabelIds.includes(label.id);
return (
<button
key={label.id}
type="button"
onClick={() =>
setSelectedLabelIds((prev) =>
isSelected
? prev.filter((id) => id !== label.id)
: [...prev, label.id]
)
}
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium transition-opacity ${
isSelected
? "opacity-100 ring-2 ring-foreground/30"
: "opacity-50 hover:opacity-80"
}`}
style={{
backgroundColor: `${label.color}33`,
color: label.color,
}}
>
<span
className="size-1.5 rounded-full"
style={{ backgroundColor: label.color }}
/>
{label.name}
</button>
);
})}
</div>
</div>
)}
</>
)}
</>
)}
</>
)}
</div>
)}
{/* Display mode */}
{!isEditing && (
<div className="space-y-2 px-4 py-3 bg-card">
<div>
<p className="text-xs font-medium text-muted-foreground">Title</p>
<p className="text-sm text-foreground">{args.title}</p>
</div>
{args.description && (
<div>
<p className="text-xs font-medium text-muted-foreground">Description</p>
<p className="line-clamp-4 text-sm whitespace-pre-wrap text-foreground">
{args.description}
</p>
</div>
)}
</div>
)}
{/* Edit mode */}
{isEditing && !decided && (
<div className="space-y-3 px-4 py-3 bg-card">
<div>
<label
htmlFor="linear-title"
className="text-xs font-medium text-muted-foreground mb-1.5 block"
>
Title <span className="text-destructive">*</span>
</label>
<Input
id="linear-title"
value={editedTitle}
onChange={(e) => setEditedTitle(e.target.value)}
placeholder="Enter issue title"
className={!isTitleValid ? "border-destructive" : ""}
/>
{!isTitleValid && <p className="text-xs text-destructive mt-1">Title is required</p>}
</div>
<div>
<label
htmlFor="linear-description"
className="text-xs font-medium text-muted-foreground mb-1.5 block"
>
Description
</label>
<Textarea
id="linear-description"
value={editedDescription}
onChange={(e) => setEditedDescription(e.target.value)}
placeholder="Enter issue description (markdown supported)"
rows={5}
className="resize-none"
/>
</div>
</div>
)}
{/* Action buttons */}
<div
className={`flex items-center gap-2 border-t ${
decided ? "border-border bg-card" : "border-foreground/15 bg-muted/20 dark:bg-muted/10"
} px-4 py-3`}
>
{decided ? (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
{decided === "approve" || decided === "edit" ? (
<>
<CheckIcon className="size-3.5 text-green-500" />
{decided === "edit" ? "Approved with Changes" : "Approved"}
</>
) : (
<>
<XIcon className="size-3.5 text-destructive" />
Rejected
</>
)}
</p>
) : isEditing ? (
<>
<Button
size="sm"
onClick={() => {
setDecided("edit");
setIsEditing(false);
onDecision({
type: "edit",
edited_action: {
name: interruptData.action_requests[0].name,
args: buildFinalArgs(),
},
});
}}
disabled={!canApprove}
>
<CheckIcon />
Approve with Changes
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
setIsEditing(false);
setEditedTitle(args.title ?? "");
setEditedDescription(args.description ?? "");
}}
>
Cancel
</Button>
</>
) : (
<>
{allowedDecisions.includes("approve") && (
<Button
size="sm"
onClick={() => {
setDecided("approve");
onDecision({
type: "approve",
edited_action: {
name: interruptData.action_requests[0].name,
args: buildFinalArgs(),
},
});
}}
disabled={!canApprove}
>
<CheckIcon />
Approve
</Button>
)}
{canEdit && (
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)}>
<PencilIcon />
Edit
</Button>
)}
{allowedDecisions.includes("reject") && (
<Button
size="sm"
variant="outline"
onClick={() => {
setDecided("reject");
onDecision({ type: "reject", message: "User rejected the action." });
}}
>
<XIcon />
Reject
</Button>
)}
</>
)}
</div>
</div>
);
}
function ErrorCard({ result }: { result: ErrorResult }) {
return (
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-destructive/50 bg-card">
<div className="flex items-center gap-3 border-b border-destructive/50 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-destructive/10">
<XIcon className="size-4 text-destructive" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-destructive">Failed to create Linear issue</p>
</div>
</div>
<div className="px-4 py-3">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
);
}
function SuccessCard({ result }: { result: SuccessResult }) {
return (
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-border bg-card">
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-green-500/10">
<CheckIcon className="size-4 text-green-500" />
</div>
<div className="min-w-0 flex-1">
<p className="text-[.8rem] text-muted-foreground">
{result.message || "Linear issue created successfully"}
</p>
</div>
</div>
<div className="space-y-2 px-4 py-3 text-xs">
<div>
<span className="font-medium text-muted-foreground">Identifier: </span>
<span>{result.identifier}</span>
</div>
{result.url && (
<div>
<a
href={result.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Open in Linear
</a>
</div>
)}
</div>
</div>
);
}
export const CreateLinearIssueToolUI = makeAssistantToolUI<
{ title: string; description?: string },
CreateLinearIssueResult
>({
toolName: "create_linear_issue",
render: function CreateLinearIssueUI({ args, result, status }) {
if (status.type === "running") {
return (
<div className="my-4 flex max-w-md items-center gap-3 rounded-xl border border-border bg-card px-4 py-3">
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Preparing Linear issue...</p>
</div>
);
}
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
args={args}
interruptData={result}
onDecision={(decision) => {
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
/>
);
}
if (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as { status: string }).status === "rejected"
) {
return null;
}
if (isErrorResult(result)) return <ErrorCard result={result} />;
return <SuccessCard result={result as SuccessResult} />;
},
});