fix: resolve graphql complexity, json extraction, kb sync, and ui prefill for linear HIL

This commit is contained in:
CREDO23 2026-02-19 18:30:20 +02:00
parent d4e2ebb99f
commit c8413ee2bf
12 changed files with 269 additions and 213 deletions

View file

@ -5,7 +5,7 @@ from langchain_core.tools import tool
from langgraph.types import interrupt from langgraph.types import interrupt
from sqlalchemy.ext.asyncio import AsyncSession 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 from app.services.linear import LinearToolMetadataService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -230,11 +230,10 @@ def create_create_linear_issue_tool(
raise raise
logger.error(f"Error creating Linear issue: {e}", exc_info=True) logger.error(f"Error creating Linear issue: {e}", exc_info=True)
return { if isinstance(e, (ValueError, LinearAPIError)):
"status": "error", message = str(e)
"message": str(e) else:
if isinstance(e, ValueError) message = "Something went wrong while creating the issue. Please try again."
else f"Unexpected error: {e!s}", return {"status": "error", "message": message}
}
return create_linear_issue return create_linear_issue

View file

@ -5,7 +5,7 @@ from langchain_core.tools import tool
from langgraph.types import interrupt from langgraph.types import interrupt
from sqlalchemy.ext.asyncio import AsyncSession 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 from app.services.linear import LinearToolMetadataService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -253,11 +253,10 @@ def create_delete_linear_issue_tool(
raise raise
logger.error(f"Error deleting Linear issue: {e}", exc_info=True) logger.error(f"Error deleting Linear issue: {e}", exc_info=True)
return { if isinstance(e, (ValueError, LinearAPIError)):
"status": "error", message = str(e)
"message": str(e) else:
if isinstance(e, ValueError) message = "Something went wrong while deleting the issue. Please try again."
else f"Unexpected error: {e!s}", return {"status": "error", "message": message}
}
return delete_linear_issue return delete_linear_issue

View file

@ -5,7 +5,7 @@ from langchain_core.tools import tool
from langgraph.types import interrupt from langgraph.types import interrupt
from sqlalchemy.ext.asyncio import AsyncSession 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 from app.services.linear import LinearKBSyncService, LinearToolMetadataService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -290,12 +290,11 @@ def create_update_linear_issue_tool(
raise raise
logger.error(f"Error updating Linear issue: {e}", exc_info=True) logger.error(f"Error updating Linear issue: {e}", exc_info=True)
return { if isinstance(e, (ValueError, LinearAPIError)):
"status": "error", message = str(e)
"message": str(e) else:
if isinstance(e, ValueError) message = "Something went wrong while updating the issue. Please try again."
else f"Unexpected error: {e!s}", return {"status": "error", "message": message}
}
return update_linear_issue return update_linear_issue

View file

@ -5,7 +5,7 @@ from langchain_core.tools import tool
from langgraph.types import interrupt from langgraph.types import interrupt
from sqlalchemy.ext.asyncio import AsyncSession 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 from app.services.notion import NotionToolMetadataService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -224,11 +224,10 @@ def create_create_notion_page_tool(
raise raise
logger.error(f"Error creating Notion page: {e}", exc_info=True) logger.error(f"Error creating Notion page: {e}", exc_info=True)
return { if isinstance(e, (ValueError, NotionAPIError)):
"status": "error", message = str(e)
"message": str(e) else:
if isinstance(e, ValueError) message = "Something went wrong while creating the page. Please try again."
else f"Unexpected error: {e!s}", return {"status": "error", "message": message}
}
return create_notion_page return create_notion_page

View file

@ -5,7 +5,7 @@ from langchain_core.tools import tool
from langgraph.types import interrupt from langgraph.types import interrupt
from sqlalchemy.ext.asyncio import AsyncSession 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 from app.services.notion.tool_metadata_service import NotionToolMetadataService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -262,11 +262,10 @@ def create_delete_notion_page_tool(
raise raise
logger.error(f"Error deleting Notion page: {e}", exc_info=True) logger.error(f"Error deleting Notion page: {e}", exc_info=True)
return { if isinstance(e, (ValueError, NotionAPIError)):
"status": "error", message = str(e)
"message": str(e) else:
if isinstance(e, ValueError) message = "Something went wrong while deleting the page. Please try again."
else f"Unexpected error: {e!s}", return {"status": "error", "message": message}
}
return delete_notion_page return delete_notion_page

View file

@ -5,7 +5,7 @@ from langchain_core.tools import tool
from langgraph.types import interrupt from langgraph.types import interrupt
from sqlalchemy.ext.asyncio import AsyncSession 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 from app.services.notion import NotionToolMetadataService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -261,11 +261,10 @@ def create_update_notion_page_tool(
raise raise
logger.error(f"Error updating Notion page: {e}", exc_info=True) logger.error(f"Error updating Notion page: {e}", exc_info=True)
return { if isinstance(e, (ValueError, NotionAPIError)):
"status": "error", message = str(e)
"message": str(e) else:
if isinstance(e, ValueError) message = "Something went wrong while updating the page. Please try again."
else f"Unexpected error: {e!s}", return {"status": "error", "message": message}
}
return update_notion_page return update_notion_page

View file

@ -22,6 +22,14 @@ logger = logging.getLogger(__name__)
LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql" 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 = """ ORGANIZATION_QUERY = """
query { query {
organization { organization {
@ -243,6 +251,37 @@ class LinearConnector:
"Authorization": f"Bearer {self._credentials.access_token}", "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( async def execute_graphql_query(
self, query: str, variables: dict[str, Any] | None = None self, query: str, variables: dict[str, Any] | None = None
) -> dict[str, Any]: ) -> dict[str, Any]:
@ -281,9 +320,7 @@ class LinearConnector:
if response.status_code == 200: if response.status_code == 200:
return response.json() return response.json()
else: else:
raise Exception( self._raise_api_error(response.status_code, response.text)
f"Query failed with status code {response.status_code}: {response.text}"
)
async def get_all_issues( async def get_all_issues(
self, include_comments: bool = True self, include_comments: bool = True

View file

@ -17,6 +17,15 @@ from app.utils.oauth_security import TokenEncryption
logger = logging.getLogger(__name__) 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 # Type variable for generic return type
T = TypeVar("T") T = TypeVar("T")
@ -250,8 +259,9 @@ class NotionHistoryConnector:
logger.error( logger.error(
f"Failed to refresh Notion token for connector {self._connector_id}: {e!s}" f"Failed to refresh Notion token for connector {self._connector_id}: {e!s}"
) )
raise Exception( raise NotionAPIError(
f"Failed to refresh Notion OAuth credentials: {e!s}" "Failed to refresh your Notion connection. "
"Please try again or reconnect your Notion account."
) from e ) from e
return self._credentials.access_token return self._credentials.access_token

View file

@ -78,7 +78,7 @@ class LinearKBSyncService:
issue_identifier = formatted_issue.get("identifier", "") issue_identifier = formatted_issue.get("identifier", "")
issue_title = formatted_issue.get("title", "") issue_title = formatted_issue.get("title", "")
state = formatted_issue.get("state", "Unknown") 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", [])) comment_count = len(formatted_issue.get("comments", []))
description = formatted_issue.get("description", "") description = formatted_issue.get("description", "")
@ -125,16 +125,20 @@ class LinearKBSyncService:
issue_content, search_space_id issue_content, search_space_id
) )
document.embedding = summary_embedding document.embedding = summary_embedding
from sqlalchemy.orm.attributes import flag_modified
document.document_metadata = { document.document_metadata = {
**document.document_metadata, **(document.document_metadata or {}),
"issue_id": issue_id, "issue_id": issue_id,
"issue_identifier": issue_identifier, "issue_identifier": issue_identifier,
"issue_title": issue_title, "issue_title": issue_title,
"state": state, "state": state,
"priority": priority,
"comment_count": comment_count, "comment_count": comment_count,
"indexed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "indexed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"connector_id": connector_id, "connector_id": connector_id,
} }
flag_modified(document, "document_metadata")
safe_set_chunks(document, chunks) safe_set_chunks(document, chunks)
document.updated_at = get_current_timestamp() document.updated_at = get_current_timestamp()
@ -159,7 +163,7 @@ class LinearKBSyncService:
query = """ query = """
query LinearIssueSync($id: String!) { query LinearIssueSync($id: String!) {
issue(id: $id) { issue(id: $id) {
id identifier title description priority id identifier title description priority priorityLabel
createdAt updatedAt url createdAt updatedAt url
state { id name type color } state { id name type color }
creator { id name email } creator { id name email }

View file

@ -237,7 +237,7 @@ class LinearToolMetadataService:
"""Fetch all teams with their states, members, and labels.""" """Fetch all teams with their states, members, and labels."""
query = """ query = """
query { query {
teams { teams(first: 25) {
nodes { nodes {
id name key id name key
states { nodes { id name type color position } } states { nodes { id name type color position } }
@ -248,7 +248,19 @@ class LinearToolMetadataService:
} }
""" """
result = await client.execute_graphql_query(query) 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 @staticmethod
async def _fetch_issue_context( async def _fetch_issue_context(
@ -296,11 +308,15 @@ class LinearToolMetadataService:
SearchSourceConnector.user_id == user_id, SearchSourceConnector.user_id == user_id,
or_( or_(
func.lower( func.lower(
Document.document_metadata["issue_title"].astext Document.document_metadata.op("->>")(
"issue_title"
)
) )
== ref_lower, == ref_lower,
func.lower( func.lower(
Document.document_metadata["issue_identifier"].astext Document.document_metadata.op("->>")(
"issue_identifier"
)
) )
== ref_lower, == ref_lower,
func.lower(Document.title) == ref_lower, func.lower(Document.title) == ref_lower,

View file

@ -227,7 +227,7 @@ function ApprovalCard({
<SelectContent> <SelectContent>
{workspaces.map((w) => ( {workspaces.map((w) => (
<SelectItem key={w.id} value={String(w.id)}> <SelectItem key={w.id} value={String(w.id)}>
{w.organization_name} {w.name}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>

View file

@ -150,28 +150,31 @@ function ApprovalCard({
const priorities = context?.priorities ?? []; const priorities = context?.priorities ?? [];
const issue = context?.issue; 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>( const [decided, setDecided] = useState<"approve" | "reject" | "edit" | null>(
interruptData.__decided__ ?? null interruptData.__decided__ ?? null
); );
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [editedTitle, setEditedTitle] = useState( const [editedArgs, setEditedArgs] = useState(initialEditState);
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<string[]>(
Array.isArray(actionArgs.new_label_ids) ? (actionArgs.new_label_ids as string[]) : []
);
const reviewConfig = interruptData.review_configs[0]; const reviewConfig = interruptData.review_configs[0];
const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"]; const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"];
@ -203,12 +206,13 @@ function ApprovalCard({
issue_id: issue?.id, issue_id: issue?.id,
document_id: issue?.document_id, document_id: issue?.document_id,
connector_id: context?.workspace?.id, connector_id: context?.workspace?.id,
new_title: editedTitle || null, new_title: editedArgs.title || null,
new_description: editedDescription || null, new_description: editedArgs.description || null,
new_state_id: selectedStateId === "__none__" ? null : selectedStateId, new_state_id: editedArgs.stateId === "__none__" ? null : editedArgs.stateId,
new_assignee_id: selectedAssigneeId === "__none__" ? null : selectedAssigneeId, new_assignee_id: editedArgs.assigneeId === "__none__" ? null : editedArgs.assigneeId,
new_priority: selectedPriority === "__none__" ? null : Number(selectedPriority), new_priority: Number(editedArgs.priority),
new_label_ids: labelsWereProposed || selectedLabelIds.length > 0 ? selectedLabelIds : null, new_label_ids:
labelsWereProposed || editedArgs.labelIds.length > 0 ? editedArgs.labelIds : null,
}; };
} }
@ -413,9 +417,9 @@ function ApprovalCard({
</label> </label>
<Input <Input
id="linear-update-title" id="linear-update-title"
value={editedTitle} value={editedArgs.title}
onChange={(e) => setEditedTitle(e.target.value)} onChange={(e) => setEditedArgs({ ...editedArgs, title: e.target.value })}
placeholder="Leave empty to keep current title" placeholder="Issue title"
/> />
</div> </div>
@ -424,13 +428,13 @@ function ApprovalCard({
htmlFor="linear-update-description" htmlFor="linear-update-description"
className="text-xs font-medium text-muted-foreground mb-1.5 block" className="text-xs font-medium text-muted-foreground mb-1.5 block"
> >
New Description Description
</label> </label>
<Textarea <Textarea
id="linear-update-description" id="linear-update-description"
value={editedDescription} value={editedArgs.description}
onChange={(e) => setEditedDescription(e.target.value)} onChange={(e) => setEditedArgs({ ...editedArgs, description: e.target.value })}
placeholder="Leave empty to keep current description" placeholder="Issue description"
rows={5} rows={5}
className="resize-none" className="resize-none"
/> />
@ -440,12 +444,14 @@ function ApprovalCard({
<> <>
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">State</div> <div className="text-xs font-medium text-muted-foreground">State</div>
<Select value={selectedStateId} onValueChange={setSelectedStateId}> <Select
value={editedArgs.stateId}
onValueChange={(v) => setEditedArgs({ ...editedArgs, stateId: v })}
>
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue placeholder="Unchanged" /> <SelectValue placeholder="Select state" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="__none__">Unchanged</SelectItem>
{team.states.map((s) => ( {team.states.map((s) => (
<SelectItem key={s.id} value={s.id}> <SelectItem key={s.id} value={s.id}>
{s.name} {s.name}
@ -457,12 +463,15 @@ function ApprovalCard({
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">Assignee</div> <div className="text-xs font-medium text-muted-foreground">Assignee</div>
<Select value={selectedAssigneeId} onValueChange={setSelectedAssigneeId}> <Select
value={editedArgs.assigneeId}
onValueChange={(v) => setEditedArgs({ ...editedArgs, assigneeId: v })}
>
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue placeholder="Unchanged" /> <SelectValue placeholder="Select assignee" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="__none__">Unchanged</SelectItem> <SelectItem value="__none__">Unassigned</SelectItem>
{team.members {team.members
.filter((m) => m.active) .filter((m) => m.active)
.map((m) => ( .map((m) => (
@ -476,12 +485,14 @@ function ApprovalCard({
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">Priority</div> <div className="text-xs font-medium text-muted-foreground">Priority</div>
<Select value={selectedPriority} onValueChange={setSelectedPriority}> <Select
value={editedArgs.priority}
onValueChange={(v) => setEditedArgs({ ...editedArgs, priority: v })}
>
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue placeholder="Unchanged" /> <SelectValue placeholder="Select priority" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="__none__">Unchanged</SelectItem>
{priorities.map((p) => ( {priorities.map((p) => (
<SelectItem key={p.priority} value={String(p.priority)}> <SelectItem key={p.priority} value={String(p.priority)}>
{p.label} {p.label}
@ -496,17 +507,18 @@ function ApprovalCard({
<div className="text-xs font-medium text-muted-foreground">Labels</div> <div className="text-xs font-medium text-muted-foreground">Labels</div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{team.labels.map((label) => { {team.labels.map((label) => {
const isSelected = selectedLabelIds.includes(label.id); const isSelected = editedArgs.labelIds.includes(label.id);
return ( return (
<button <button
key={label.id} key={label.id}
type="button" type="button"
onClick={() => onClick={() =>
setSelectedLabelIds((prev) => setEditedArgs({
isSelected ...editedArgs,
? prev.filter((id) => id !== label.id) labelIds: isSelected
: [...prev, label.id] ? editedArgs.labelIds.filter((id) => id !== label.id)
) : [...editedArgs.labelIds, label.id],
})
} }
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium transition-opacity ${ className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium transition-opacity ${
isSelected isSelected
@ -578,24 +590,7 @@ function ApprovalCard({
variant="outline" variant="outline"
onClick={() => { onClick={() => {
setIsEditing(false); setIsEditing(false);
setEditedTitle(actionArgs.new_title ? String(actionArgs.new_title) : ""); setEditedArgs(initialEditState); // Reset to original args
setEditedDescription(
actionArgs.new_description ? String(actionArgs.new_description) : ""
);
setSelectedStateId(
actionArgs.new_state_id ? String(actionArgs.new_state_id) : "__none__"
);
setSelectedAssigneeId(
actionArgs.new_assignee_id ? String(actionArgs.new_assignee_id) : "__none__"
);
setSelectedPriority(
actionArgs.new_priority != null ? String(actionArgs.new_priority) : "__none__"
);
setSelectedLabelIds(
Array.isArray(actionArgs.new_label_ids)
? (actionArgs.new_label_ids as string[])
: []
);
}} }}
> >
Cancel Cancel