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
329 changes: 328 additions & 1 deletion src/chat_sdk/adapters/linear/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,16 @@
from chat_sdk.adapters.linear.cards import card_to_linear_markdown
from chat_sdk.adapters.linear.format_converter import LinearFormatConverter
from chat_sdk.adapters.linear.types import (
AgentActivityWebhookPayload,
AgentSessionEventWebhookPayload,
AgentSessionUserChild,
AgentSessionWebhookPayload,
CommentWebhookPayload,
LinearActorData,
LinearAdapterBaseConfig,
LinearAdapterConfig,
LinearAdapterMode,
LinearAgentSessionCommentRawMessage,
LinearAgentSessionThreadId,
LinearCommentData,
LinearCommentRawMessage,
Expand Down Expand Up @@ -88,6 +94,12 @@
COMMENT_THREAD_PATTERN = re.compile(r"^([^:]+):c:([^:]+)$")
ISSUE_SESSION_THREAD_PATTERN = re.compile(r"^([^:]+):s:([^:]+)$")

# Linear profile URL → display name. Faithful port of upstream
# ``PROFILE_URL_REGEX`` (utils.ts:34). Anchored at the start (``^``) and stops
# the captured slug at the first ``/``, ``?``, or ``#`` so query strings /
# fragments / trailing path segments are excluded.
PROFILE_URL_REGEX = re.compile(r"^https://linear\.app/\S+/profiles/([^/?#]+)")

# Linear GraphQL API endpoint
LINEAR_API_URL = "https://api.linear.app/graphql"

Expand All @@ -113,6 +125,22 @@
}


def get_user_name_from_profile_url(url: str) -> str:
"""Extract a user display name from a Linear profile URL.

Faithful port of upstream ``getUserNameFromProfileUrl`` (utils.ts:40). A bit
of a hack to avoid fetching the user just to get the display name: the slug
after ``/profiles/`` in a Linear profile URL is the user's name. Returns
``""`` (NOT ``None``) when the URL does not match — upstream returns the
empty string so the author's ``userName`` falls back to "" rather than
propagating an undefined.
"""
match = PROFILE_URL_REGEX.match(url)
if not match:
return ""
return match.group(1)
Comment on lines +128 to +141

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The get_user_name_from_profile_url function expects a string, but in webhook payloads, the url field can be None (null in JSON). Passing None to PROFILE_URL_REGEX.match will raise a TypeError: expected string or bytes-like object at runtime. We should defensively check if url is falsy/None before matching.

Suggested change
def get_user_name_from_profile_url(url: str) -> str:
"""Extract a user display name from a Linear profile URL.
Faithful port of upstream ``getUserNameFromProfileUrl`` (utils.ts:40). A bit
of a hack to avoid fetching the user just to get the display name: the slug
after ``/profiles/`` in a Linear profile URL is the user's name. Returns
``""`` (NOT ``None``) when the URL does not matchupstream returns the
empty string so the author's ``userName`` falls back to "" rather than
propagating an undefined.
"""
match = PROFILE_URL_REGEX.match(url)
if not match:
return ""
return match.group(1)
def get_user_name_from_profile_url(url: str | None) -> str:
"""Extract a user display name from a Linear profile URL.
Faithful port of upstream getUserNameFromProfileUrl (utils.ts:40). A bit
of a hack to avoid fetching the user just to get the display name: the slug
after /profiles/ in a Linear profile URL is the user's name. Returns
"" (NOT None) when the URL does not matchupstream returns the
empty string so the author's userName falls back to "" rather than
propagating an undefined.
"""
if not url:
return ""
match = PROFILE_URL_REGEX.match(url)
if not match:
return ""
return match.group(1)



def assert_agent_session_thread(
thread: LinearThreadId,
) -> LinearAgentSessionThreadId:
Expand Down Expand Up @@ -566,10 +594,29 @@ async def handle_webhook(
# Handle events based on type. The payload shape is determined by
# `type` at runtime — cast to the matching TypedDict so each handler
# sees the right variant.
#
# Mode-gating (faithful port of index.ts:1144-1165):
# - "Comment" events are only handled in mode="comments"
# (``this.mode !== "comments" || action !== "create"`` → return).
# - "AgentSessionEvent" events are only handled in
# mode="agent-sessions"; in any other mode we warn and ignore.
# The gates are mutually exclusive, so a comment event in
# agent-sessions mode (and vice-versa) is dropped — even when the body
# @-mentions the bot's userName.
payload_type = payload.get("type")
if payload_type == "Comment":
if payload.get("action") == "create":
# Combined guard mirrors upstream's single `if` (mode + action). An
# empty-string / wrong action and a non-"comments" mode both fall
# through to the no-op without dispatching.
if self._mode == "comments" and payload.get("action") == "create":
self._handle_comment_created(cast("CommentWebhookPayload", payload), options)
elif payload_type == "AgentSessionEvent":
if self._mode != "agent-sessions":
self._logger.warn(
"Received AgentSessionEvent webhook but adapter is not in agent-sessions mode, ignoring"
)
else:
self._handle_agent_session_event(cast("AgentSessionEventWebhookPayload", payload), options)
elif payload_type == "Reaction":
self._handle_reaction(cast("ReactionWebhookPayload", payload))

Expand Down Expand Up @@ -638,6 +685,286 @@ def _handle_comment_created(

self._chat.process_message(self, thread_id, message, options)

def _parse_agent_session_message(
self,
raw: LinearAgentSessionCommentRawMessage,
) -> Message:
"""Build a ``Message`` from an agent-session raw message.

Faithful port of upstream ``parseMessage`` (index.ts:2026) for the
``agent_session_comment`` branch. The existing :meth:`parse_message`
predates the upstream rewrite and does not reproduce the threadId
encode / ``is_mention`` / structured-author behavior, so the
agent-session path renders the ``Message`` here directly:

- ``is_mention=True`` — agent-session comments directly target the bot,
so upstream always treats them as mentions.
- ``thread_id`` is re-encoded from the raw comment so the session
segment (``:s:{agentSessionId}``) is present on the routed thread.
- ``author`` is read from the structured ``comment.user`` written by
:meth:`_parse_message_from_agent_session_event` (display name, full
name, ``is_bot`` from ``type == "bot"``, ``is_me`` from bot-user-id).
"""
comment = raw["comment"]
text = cast("str", comment.get("body", ""))
user: LinearActorData = cast("LinearActorData", comment.get("user", {}))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

In Python, dict.get(key, default) returns None if the key exists and its value is None (null in JSON). If comment.get("user") is None, using comment.get("user", {}) will return None instead of {}. This will cause an AttributeError when user.get(...) is called later. Using comment.get("user") or {} ensures we always fall back to an empty dictionary.

Suggested change
user: LinearActorData = cast("LinearActorData", comment.get("user", {}))
user: LinearActorData = cast("LinearActorData", comment.get("user") or {})


thread_id = self.encode_thread_id(
LinearThreadId(
issue_id=cast("str", comment.get("issueId", "")),
comment_id=cast("str | None", comment.get("id")),
agent_session_id=raw["agentSessionId"],
)
)

# createdAt / updatedAt are ISO strings (the "created" branch reads
# `payload.createdAt` as a raw string — no Date cast). `edited` mirrors
# upstream's `createdAt !== updatedAt`.
created_at = cast("str", comment.get("createdAt", ""))
updated_at = cast("str", comment.get("updatedAt", ""))

author = Author(
user_id=cast("str", user.get("id", "")),
user_name=cast("str", user.get("displayName", "")),
full_name=cast("str", user.get("fullName", "")),
is_bot=user.get("type") == "bot",
is_me=user.get("id") == self._bot_user_id,
)

return Message(
id=cast("str", comment.get("id", "")),
thread_id=thread_id,
is_mention=True,
text=text,
formatted=self._format_converter.to_ast(text),
author=author,
metadata=MessageMetadata(
date_sent=_parse_iso(created_at) if created_at else datetime.now(timezone.utc),
edited=created_at != updated_at,
edited_at=_parse_iso(updated_at) if (created_at != updated_at and updated_at) else None,
),
attachments=[],
raw=cast("LinearRawMessage", raw),
)

def _parse_message_from_agent_session_event(
self,
payload: AgentSessionEventWebhookPayload,
) -> Message | None:
"""Parse an agent-session webhook event into a chat message, if applicable.

Faithful port of upstream ``parseMessageFromAgentSessionEvent``
(index.ts:955). Returns ``None`` (and logs a warning) when the event
cannot be parsed. Handles two actions:

- ``"prompted"`` — a user posting a follow-up in an existing session.
- ``"created"`` — a user @-mentioning the bot, creating a new session.

Any other action logs an "Unsupported agent session event action"
warning and returns ``None``.
"""
agent_session = cast("AgentSessionWebhookPayload", payload.get("agentSession", {}))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

In Python, dict.get(key, default) returns None if the key exists and its value is None (null in JSON). If payload.get("agentSession") is None, using payload.get("agentSession", {}) will return None instead of {}. This will cause an AttributeError when agent_session.get(...) is called later. Using payload.get("agentSession") or {} ensures we always fall back to an empty dictionary.

Suggested change
agent_session = cast("AgentSessionWebhookPayload", payload.get("agentSession", {}))
agent_session = cast("AgentSessionWebhookPayload", payload.get("agentSession") or {})


# `issueId ?? issue?.id` — nullish (NOT truthy) fallback. Only fall back
# to the nested issue.id when issueId is *absent*; an empty string would
# be a real (if unusual) value, but we mirror upstream's `!issueId`
# falsy guard below, which still bails on empty.
issue_id = agent_session.get("issueId")
if issue_id is None:
issue = agent_session.get("issue")
issue_id = issue.get("id") if issue is not None else None
if not issue_id:
return None

action = payload.get("action")

#
# Follow-up message posted in an existing agent-session thread.
#
if action == "prompted":
agent_activity = cast("AgentActivityWebhookPayload | None", payload.get("agentActivity"))
if not agent_activity:
self._logger.warn(
"Missing agent activity for prompted action",
{"agentSessionId": agent_session.get("id")},
)
return None

source_comment_id = agent_activity.get("sourceCommentId")
if not source_comment_id:
self._logger.warn(
"Missing source comment ID for agent activity",
{
"agentSessionId": agent_session.get("id"),
"agentActivityId": agent_activity.get("id"),
},
)
return None

content = agent_activity.get("content", {})
activity_user = cast("AgentSessionUserChild", agent_activity.get("user", {}))
Comment on lines +804 to +805

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

In Python, dict.get(key, default) returns None if the key exists and its value is None (null in JSON). If agent_activity.get("content") or agent_activity.get("user") is None, using get(..., {}) will return None instead of {}. This will cause an AttributeError when content.get(...) or activity_user.get(...) is called later. Using or {} ensures we always fall back to an empty dictionary.

Suggested change
content = agent_activity.get("content", {})
activity_user = cast("AgentSessionUserChild", agent_activity.get("user", {}))
content = agent_activity.get("content") or {}
activity_user = cast("AgentSessionUserChild", agent_activity.get("user") or {})

# `agentActivity.user.avatarUrl ?? undefined` — nullish.
avatar_url = activity_user.get("avatarUrl")
# `parentId: payload.agentSession.comment?.id` — optional chain.
# Short-circuit only on a missing/None comment (NOT a falsy empty
# dict), mirroring `?.`; then read id (absent → None).
prompted_session_comment = agent_session.get("comment")
parent_id = prompted_session_comment.get("id") if prompted_session_comment is not None else None
comment_data: LinearCommentData = {
"id": cast("str", source_comment_id),
"body": cast("str", content.get("body", "")),
"issueId": cast("str", issue_id),
"user": {
"type": "user",
"id": cast("str", activity_user.get("id", "")),
"displayName": get_user_name_from_profile_url(cast("str", activity_user.get("url", ""))),
"fullName": cast("str", activity_user.get("name", "")),
"email": cast("str", activity_user.get("email")),
**({"avatarUrl": cast("str", avatar_url)} if avatar_url is not None else {}),
},
"parentId": cast("str", parent_id),
"createdAt": cast("str", agent_activity.get("createdAt", "")),
"updatedAt": cast("str", agent_activity.get("createdAt", "")),
}
# `payload.agentSession.url ?? undefined` — nullish.
session_url = agent_session.get("url")
if session_url is not None:
comment_data["url"] = cast("str", session_url)

# `payload.promptContext ?? undefined` — nullish.
prompt_context = payload.get("promptContext")
raw: LinearAgentSessionCommentRawMessage = {
"kind": "agent_session_comment",
"organizationId": cast("str", payload.get("organizationId", "")),
"comment": comment_data,
"agentSessionId": cast("str", agent_session.get("id", "")),
}
if prompt_context is not None:
raw["agentSessionPromptContext"] = cast("str", prompt_context)
return self._parse_agent_session_message(raw)

#
# New session: a user mentions the bot in an issue, opening a session
# and posting the first message.
#
if action == "created":
# App-ownership guard. `agentSession.appUserId !== this.botUserId`.
# We deliberately compare on the raw (possibly-None) values so a
# mismatch with a foreign bot's appUserId is rejected. A None
# botUserId only "matches" when appUserId is *also* None — that
# cannot happen for a real created event (appUserId is always set),
# so we never falsely accept a foreign session.
app_user_id = agent_session.get("appUserId")
if app_user_id != self._bot_user_id:
self._logger.warn(
"Ignoring agent session event from another bot",
{
"agentSessionId": agent_session.get("id"),
"appUserId": app_user_id,
},
)
return None

session_comment = agent_session.get("comment")
if not session_comment:
self._logger.warn(
"Missing comment for agent session",
{"agentSessionId": agent_session.get("id")},
)
return None
Comment on lines +868 to +874

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Handle issue-level agent sessions without comments

When a created AgentSessionEvent comes from issue assignment/delegation or an issue-level session, agentSession.comment can legitimately be absent; this repo already supports the linear:{issue}:s:{session} thread form for that case. Returning here drops the verified webhook before chat.process_message, so the agent never starts for valid issue-level agent sessions. Please route these using the session-only thread id instead of treating the missing comment as unparsable.

Useful? React with 👍 / 👎.


creator = agent_session.get("creator")
user: LinearActorData
if creator:
Comment on lines +876 to +878

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

When checking for optional values that can be falsy but valid (such as an empty dictionary {}), using a truthiness check like if creator: can silently ignore them because an empty dictionary is falsy in Python. To preserve the upstream TS behavior where empty objects are truthy, we should use is not None instead.

Suggested change
creator = agent_session.get("creator")
user: LinearActorData
if creator:
creator = agent_session.get("creator")
user: LinearActorData
if creator is not None:
References
  1. When checking for optional values that can be falsy but valid (e.g., 0, empty string, empty list), use is not None instead of a truthiness check to avoid silently ignoring them.

# `agentSession.creator.avatarUrl ?? undefined` — nullish.
creator_avatar = creator.get("avatarUrl")
user = {
"type": "user",
"id": cast("str", creator.get("id", "")),
"displayName": get_user_name_from_profile_url(cast("str", creator.get("url", ""))),
"fullName": cast("str", creator.get("name", "")),
"email": cast("str", creator.get("email")),
**({"avatarUrl": cast("str", creator_avatar)} if creator_avatar is not None else {}),
}
else:
# No creator → fall back to the bot author (upstream uses
# `this.botUserId` / `this.userName`). ``Author.user_id`` is a
# non-Optional ``str``, so coerce a None bot-user-id (not yet
# resolved by ``initialize``) to "" via ``is not None`` rather
# than truthiness (CLAUDE.md hazard).
user = {
"type": "bot",
"id": self._bot_user_id if self._bot_user_id is not None else "",
"displayName": self._user_name,
"fullName": self._user_name,
}

comment_data = {
"id": cast("str", session_comment.get("id", "")),
"body": cast("str", session_comment.get("body", "")),
"issueId": cast("str", issue_id),
"user": user,
# The `created` branch reads `payload.createdAt` as a raw STRING
# (no Date cast — upstream's `@ts-expect-error` notes the SDK
# types are wrong about Date coercion for webhook payloads).
"createdAt": cast("str", payload.get("createdAt", "")),
"updatedAt": cast("str", payload.get("createdAt", "")),
}
# `payload.agentSession.url ?? undefined` — nullish.
session_url = agent_session.get("url")
if session_url is not None:
comment_data["url"] = cast("str", session_url)

# `payload.promptContext ?? undefined` — nullish.
prompt_context = payload.get("promptContext")
raw = {
"kind": "agent_session_comment",
"organizationId": cast("str", payload.get("organizationId", "")),
"comment": comment_data,
"agentSessionId": cast("str", agent_session.get("id", "")),
}
if prompt_context is not None:
raw["agentSessionPromptContext"] = cast("str", prompt_context)
return self._parse_agent_session_message(raw)

self._logger.warn(
"Unsupported agent session event action",
{
"action": action,
"agentSessionId": agent_session.get("id"),
"issueId": issue_id,
},
)
return None

def _handle_agent_session_event(
self,
payload: AgentSessionEventWebhookPayload,
options: WebhookOptions | None = None,
) -> None:
"""Handle an agent-session webhook event.

Faithful port of upstream ``handleAgentSessionEvent`` (index.ts:1269).
Builds a message via :meth:`_parse_message_from_agent_session_event`
and routes it to ``chat.process_message``. There is NO automatic
acknowledgement — the bot does not auto-respond on receipt (no
agentActivityCreate / typing / stream side-effect here; those land in
L4). When the event cannot be parsed, logs a warning and returns.
"""
if not self._chat:
self._logger.warn("Chat instance not initialized, ignoring agent session event")
return

message = self._parse_message_from_agent_session_event(payload)
if not message:
self._logger.warn(
"Unable to build message for Linear agent session event",
{"agentSessionId": payload.get("agentSession", {}).get("id")},
)
Comment on lines +960 to +963

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

In Python, dict.get(key, default) returns None if the key exists and its value is None (null in JSON). If payload.get("agentSession") is None, using payload.get("agentSession", {}) will return None instead of {}. This will cause an AttributeError when calling .get("id") on it. Using (payload.get("agentSession") or {}) ensures we always fall back to an empty dictionary.

Suggested change
self._logger.warn(
"Unable to build message for Linear agent session event",
{"agentSessionId": payload.get("agentSession", {}).get("id")},
)
self._logger.warn(
"Unable to build message for Linear agent session event",
{"agentSessionId": (payload.get("agentSession") or {}).get("id")},
)

return

self._chat.process_message(self, message.thread_id, message, options)

def _handle_reaction(self, payload: ReactionWebhookPayload) -> None:
"""Handle reaction events (logging only)."""
if not self._chat:
Expand Down
Loading
Loading