Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions src/chat_sdk/adapters/linear/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
"""Linear adapter for chat-sdk."""

from chat_sdk.adapters.linear.adapter import LinearAdapter, create_linear_adapter
from chat_sdk.adapters.linear.types import LinearInstallation
from chat_sdk.adapters.linear.types import (
AgentSessionEventWebhookPayload,
LinearAdapterMode,
LinearAgentSessionCommentRawMessage,
LinearAgentSessionThreadId,
LinearCommentRawMessage,
LinearInstallation,
LinearRawMessage,
LinearThreadId,
)

__all__ = ["LinearAdapter", "LinearInstallation", "create_linear_adapter"]
__all__ = [
"AgentSessionEventWebhookPayload",
"LinearAdapter",
"LinearAdapterMode",
"LinearAgentSessionCommentRawMessage",
"LinearAgentSessionThreadId",
"LinearCommentRawMessage",
"LinearInstallation",
"LinearRawMessage",
"LinearThreadId",
"create_linear_adapter",
]
28 changes: 24 additions & 4 deletions src/chat_sdk/adapters/linear/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@
CommentWebhookPayload,
LinearAdapterBaseConfig,
LinearAdapterConfig,
LinearAdapterMode,
LinearCommentData,
LinearCommentRawMessage,
LinearInstallation,
LinearRawMessage,
LinearThreadId,
Expand Down Expand Up @@ -133,6 +135,13 @@ def __init__(self, config: LinearAdapterConfig | None = None) -> None:
self._webhook_secret = webhook_secret
self._logger: Logger = getattr(config, "logger", None) or ConsoleLogger("info", prefix="linear")
self._user_name = getattr(config, "user_name", None) or os.environ.get("LINEAR_BOT_USERNAME", "linear-bot")
# Inbound webhook handling model. Faithful port of upstream
# ``this.mode = config.mode ?? "comments"`` (index.ts:236). "comments"
# is the data-change webhook model (existing behavior); "agent-sessions"
# is the app-actor model. The agent-session routing/emit/fetch logic
# lands in later waves (L3/L4/L5); L1 only plumbs the field.
config_mode: LinearAdapterMode | None = getattr(config, "mode", None)
self._mode: LinearAdapterMode = config_mode if config_mode is not None else "comments"
self._chat: ChatInstance | None = None
self._bot_user_id: str | None = None
self._format_converter = LinearFormatConverter()
Expand Down Expand Up @@ -220,6 +229,14 @@ def user_name(self) -> str:
def bot_user_id(self) -> str | None:
return self._bot_user_id

@property
def mode(self) -> LinearAdapterMode:
"""Inbound webhook handling model ("comments" or "agent-sessions").

Faithful port of upstream ``protected readonly mode`` (index.ts:201).
"""
return self._mode

@property
def lock_scope(self) -> LockScope | None:
return None
Expand Down Expand Up @@ -644,7 +661,7 @@ def _build_message(
thread_id=thread_id,
text=text,
formatted=formatted,
raw=LinearRawMessage(comment=comment),
raw=LinearCommentRawMessage(kind="comment", comment=comment),
author=author,
metadata=MessageMetadata(
date_sent=_parse_iso(created_at) if created_at else datetime.now(timezone.utc),
Expand Down Expand Up @@ -702,7 +719,8 @@ async def post_message(
return RawMessage(
id=comment_data.get("id", ""),
thread_id=thread_id,
raw=LinearRawMessage(
raw=LinearCommentRawMessage(
kind="comment",
comment={
"id": comment_data.get("id", ""),
"body": comment_data.get("body", ""),
Expand Down Expand Up @@ -755,7 +773,8 @@ async def edit_message(
return RawMessage(
id=comment_data.get("id", ""),
thread_id=thread_id,
raw=LinearRawMessage(
raw=LinearCommentRawMessage(
kind="comment",
comment={
"id": comment_data.get("id", ""),
"body": comment_data.get("body", ""),
Expand Down Expand Up @@ -962,7 +981,8 @@ def _comment_node_to_message(
thread_id=thread_id,
text=node.get("body", ""),
formatted=self._format_converter.to_ast(node.get("body", "")),
raw=LinearRawMessage(
raw=LinearCommentRawMessage(
kind="comment",
comment={
"id": node.get("id", ""),
"body": node.get("body", ""),
Expand Down
218 changes: 212 additions & 6 deletions src/chat_sdk/adapters/linear/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,33 @@

Based on the Linear API and webhook format.
See: https://linear.app/developers

Agent-session types (``mode``, the ``kind`` discriminator, the agent-session
raw-message variant, and ``AgentSessionEventWebhookPayload``) are a faithful
port of upstream ``packages/adapter-linear/src/types.ts`` (vercel/chat,
adapter-linear 4.27.0 / chat@4.31.0). Upstream re-uses ``@linear/sdk`` and
``@linear/sdk/webhooks`` types; our adapter is raw GraphQL, so the
agent-session webhook payload is hand-authored to mirror the SDK shape that
upstream ``index.ts`` consumes.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Any, TypedDict
from typing import Any, Literal, TypedDict

from chat_sdk.logger import Logger

# =============================================================================
# Configuration
# =============================================================================

# Incoming webhook handling mode for the Linear adapter. Mirrors upstream
# ``LinearAdapterMode`` (types.ts:44). ``"comments"`` is the data-change
# webhook model (the existing behavior); ``"agent-sessions"`` is the app-actor
# model used for ``AgentSessionEvent`` webhooks.
LinearAdapterMode = Literal["agent-sessions", "comments"]


@dataclass
class LinearAdapterBaseConfig:
Expand All @@ -28,6 +42,11 @@ class LinearAdapterBaseConfig:
api_url: str | None = None
# Logger instance for error reporting. Defaults to ConsoleLogger.
logger: Logger | None = None
# Controls which inbound Linear webhook model should trigger message
# handling. Defaults to "comments". Use "agent-sessions" for app-actor
# installs. Faithful port of upstream ``config.mode ?? "comments"``
# (types.ts:67, index.ts:236).
mode: LinearAdapterMode | None = None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve existing config positional arguments

Adding mode here changes the generated positional __init__ for all LinearAdapter*Config dataclasses. Existing callers that pass positional args after logger now have their user_name bound to mode, their webhook_secret bound to user_name, and so on, which can make LinearAdapter fail authentication/webhook-secret setup or silently use the wrong bot name. Make this new option keyword-only (or otherwise avoid shifting the public positional signature).

Useful? React with 👍 / 👎.

# Bot display name for @-mention detection.
# Defaults to LINEAR_BOT_USERNAME env var or "linear-bot".
user_name: str | None = None
Expand Down Expand Up @@ -130,6 +149,23 @@ class LinearThreadId:
issue_id: str
# Root comment ID for comment-level threads (optional)
comment_id: str | None = None
# Agent session UUID for app-actor interactions (optional). Faithful port of
# upstream ``LinearThreadId.agentSessionId`` (types.ts:189).
agent_session_id: str | None = None


@dataclass(frozen=True)
class LinearAgentSessionThreadId(LinearThreadId):
"""Decoded thread ID for Linear threads associated with agent sessions.

Faithful port of upstream ``LinearAgentSessionThreadId``
(``LinearThreadId & { agentSessionId: string }``, types.ts:203). Narrows
``agent_session_id`` to a required, non-optional ``str`` so that downstream
agent-session code (L4) can rely on the field being present.
"""

# Required for agent-session threads (narrows the optional base field).
agent_session_id: str = ""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require the agent session id

This subclass is documented and exported as the required-session form, but the default allows LinearAgentSessionThreadId(issue_id="...") to construct a value with agent_session_id == "". Code that accepts this type can therefore proceed with an empty Linear agent-session id instead of failing at the call site, so please validate it (or make the field truly required/keyword-only required).

Useful? React with 👍 / 👎.



# =============================================================================
Expand Down Expand Up @@ -222,19 +258,189 @@ class ReactionWebhookPayload(TypedDict, total=False):
webhookTimestamp: int


# -----------------------------------------------------------------------------
# Agent-session webhook payload (mode="agent-sessions")
# -----------------------------------------------------------------------------
#
# Hand-authored to mirror upstream ``@linear/sdk/webhooks``
# ``AgentSessionEventWebhookPayload`` as consumed by upstream
# ``adapter-linear/src/index.ts``. Webhook payloads are external JSON, so the
# RAW Linear wire keys (camelCase) are kept verbatim — no snake_case aliasing.
# These are NetworkError-prone external inputs; ``total=False`` mirrors the
# optional-heavy SDK shape and lets the parse logic (L3) read fields defensively.


class AgentSessionCommentChild(TypedDict, total=False):
"""Root comment of the thread an agent session is attached to.

Mirrors ``CommentChildWebhookPayload`` as consumed for
``agentSession.comment`` (index.ts:1026-1035).
"""

id: str
body: str
userId: str


class AgentSessionIssueChild(TypedDict, total=False):
"""Issue an agent session is associated with.

Mirrors ``IssueWithDescriptionChildWebhookPayload`` as consumed for
``agentSession.issue`` (index.ts:959).
"""

id: str


class AgentSessionUserChild(TypedDict, total=False):
"""Human user responsible for the agent session.

Mirrors ``UserChildWebhookPayload`` as consumed for
``agentSession.creator`` (index.ts:1037-1044).
"""

id: str
name: str
email: str
avatarUrl: str
url: str


class AgentSessionWebhookPayload(TypedDict, total=False):
"""The agent session an ``AgentSessionEvent`` belongs to.

Mirrors ``AgentSessionWebhookPayload`` (@linear/sdk/webhooks) as consumed by
upstream ``index.ts``.
"""

id: str
appUserId: str
issueId: str
issue: AgentSessionIssueChild
commentId: str
sourceCommentId: str
comment: AgentSessionCommentChild
creator: AgentSessionUserChild
url: str
status: str
summary: str
sourceMetadata: dict[str, Any]


class AgentActivityWebhookContent(TypedDict, total=False):
"""Content of an agent activity (e.g. the prompt body).

Mirrors the ``content`` discriminated object consumed at index.ts:984.
"""

type: str # e.g. "prompt"
body: str


class AgentActivityWebhookPayload(TypedDict, total=False):
"""The agent activity that triggered a ``prompted`` event.

Mirrors ``AgentActivityWebhookPayload`` (@linear/sdk/webhooks) as consumed
by upstream ``index.ts`` (e.g. index.ts:968-998).
"""

id: str
sourceCommentId: str
content: AgentActivityWebhookContent
user: AgentSessionUserChild
createdAt: str


class GuidanceRuleWebhookPayload(TypedDict, total=False):
"""A single guidance rule for the agent's behavior."""

body: str


class CommentChildWebhookPayload(TypedDict, total=False):
"""A comment in the thread before the agent session was initiated."""

id: str
body: str


class AgentSessionEventWebhookPayload(TypedDict, total=False):
"""Webhook payload for ``AgentSessionEvent`` events (mode="agent-sessions").

Faithful port of upstream ``AgentSessionEventWebhookPayload``
(@linear/sdk/webhooks), hand-authored to match the shape consumed by
upstream ``adapter-linear/src/index.ts``. Raw Linear wire keys (camelCase).
"""

type: str # "AgentSessionEvent"
action: str # "created" | "prompted"
createdAt: str
appUserId: str
oauthClientId: str
organizationId: str
webhookId: str
webhookTimestamp: int
# Formatted prompt with relevant context; present only for "created" events.
promptContext: str
agentSession: AgentSessionWebhookPayload
agentActivity: AgentActivityWebhookPayload
guidance: list[GuidanceRuleWebhookPayload]
previousComments: list[CommentChildWebhookPayload]


# Union type for all webhook payloads
LinearWebhookPayload = CommentWebhookPayload | ReactionWebhookPayload
LinearWebhookPayload = CommentWebhookPayload | ReactionWebhookPayload | AgentSessionEventWebhookPayload


# =============================================================================
# Raw Message Type
# =============================================================================
#
# Discriminated union on the ``kind`` field. Faithful port of upstream
# ``LinearRawMessage`` (types.ts:279). Every variant MUST carry ``kind`` so the
# union discriminates cleanly; constructing a raw message without ``kind``
# breaks the union (emit/parse symmetry). The existing (comment-based)
# producers in ``adapter.py`` all set ``kind="comment"``.


class LinearCommentRawMessage(TypedDict, total=False):
"""Platform-specific raw message for a standard Linear comment.

Faithful port of upstream ``LinearCommentRawMessage`` (types.ts:258).
``kind`` and ``comment`` are required; ``organizationId`` is part of the
upstream base but our raw-GraphQL producers do not always have it on hand,
so it stays optional (``total=False``) for back-compat with the existing
comment path.
"""

# Raw message kind discriminator. Always "comment" for this variant.
kind: Literal["comment"]
# The raw comment data from webhook or API.
comment: LinearCommentData
# Organization ID from the webhook or request context.
organizationId: str


class LinearAgentSessionCommentRawMessage(TypedDict, total=False):
"""Platform-specific raw message for a comment backed by an agent session.

class LinearRawMessage(TypedDict, total=False):
"""Platform-specific raw message type for Linear."""
Faithful port of upstream ``LinearAgentSessionCommentRawMessage``
(types.ts:265). Carries the agent session the comment belongs to plus an
optional prompt context.
"""

# The raw comment data from webhook or API
# Raw message kind discriminator. Always "agent_session_comment".
kind: Literal["agent_session_comment"]
# The visible Linear comment backing this message.
comment: LinearCommentData
# Organization ID from the webhook
# The agent session the comment belongs to.
agentSessionId: str
# The prompt context associated with this agent session comment (optional).
agentSessionPromptContext: str
# Organization ID from the webhook or request context.
organizationId: str


# Platform-specific raw message type for Linear (discriminated union on `kind`).
# Faithful port of upstream ``LinearRawMessage`` (types.ts:279).
LinearRawMessage = LinearCommentRawMessage | LinearAgentSessionCommentRawMessage
Loading
Loading