feat(linear): agent-session webhook parse + routing (chat@4.31/#151 — L3/5)#171
Conversation
… L3/5) Port the agent-session inbound webhook path from upstream adapter-linear/src/index.ts (chat@4.31): - handle_webhook: add an AgentSessionEvent branch and mode-gate both branches — Comment events only in mode="comments", AgentSessionEvent only in mode="agent-sessions" (warn + ignore otherwise). The gates are mutually exclusive, so a comment in agent-sessions mode (even one that @-mentions the bot's userName) and an agent event in comments mode are both dropped, matching index.ts:1144-1165. - _parse_message_from_agent_session_event: handle the "created" and "prompted" actions, with the null-return + warn branches (missing agent activity, missing source comment id, missing comment, another bot's session, unsupported action). Faithful port of index.ts:955. - _handle_agent_session_event: route the parsed message to chat.process_message with no automatic acknowledgement (no outbound API call on receipt; the agentActivityCreate/typing/stream emit lands in L4). Faithful port of index.ts:1269. - get_user_name_from_profile_url: extract the slug after /profiles/ from a Linear profile URL, returning "" on non-match (utils.ts:40). - LinearActorData type + an optional normalized comment.user, so the agent-session author (display name / full name / bot vs user) is carried without disturbing the flat comment-webhook path. Nullish-vs-truthy fidelity (the #1 port risk): every upstream ?? / ?. site is reproduced with is-not-None semantics, not truthiness — issueId ?? issue?.id, agentSession.url ?? undefined, promptContext ?? undefined, creator/activity avatarUrl ?? undefined, agentSession.comment?.id. The created branch reads payload.createdAt as a raw string (no Date cast). The app-ownership guard compares appUserId != botUserId on the raw values so a foreign session is rejected and a None botUserId never falsely matches. Tests (tests/test_linear_webhook.py): created/prompted dispatch, both mode-gate directions, the null-return + warn paths, app-ownership, bot-author fallback, createdAt-string, no-auto-ack, and the profile-url regex. Each fails under a plausible mutation (??->or swap, mode-gate inversion, app-ownership ==/!=, is_mention flip, url nullish->truthy). L4 (emit) and L5 (fetch) consume the mode gate, the decoded session thread, and the agent_session_comment raw message produced here.
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
📝 WalkthroughWalkthroughThe PR adds Linear agent-session webhook support to the adapter. It introduces ChangesLinear Agent-Session Webhook Support
Sequence Diagram(s)sequenceDiagram
participant Webhook as Linear Webhook
participant Adapter as LinearAdapter.handle_webhook
participant ModeGate as Mode Gate
participant Parser as _parse_message_from_agent_session_event
participant Builder as _parse_agent_session_message
participant Chat as chat.process_message
Webhook->>Adapter: POST AgentSessionEvent payload
Adapter->>ModeGate: check mode == "agent-sessions"
alt mode mismatch
ModeGate-->>Adapter: warn + ignore
else mode matches
ModeGate->>Parser: validate issueId, action, app ownership
alt "created" action
Parser->>Builder: agent_session_comment + creator user data
Builder-->>Parser: Message (re-encoded threadId, is_mention=True)
else "prompted" action
Parser->>Builder: agent_session_comment + activity user data
Builder-->>Parser: Message
else unsupported / missing fields
Parser-->>Adapter: None + warning
end
Parser-->>Adapter: Message
Adapter->>Chat: process_message(Message)
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
✨ Finishing Touches📝 Generate docstrings
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request introduces support for Linear agent-session webhook events in the Linear adapter, including mode-gating, message parsing for both session creation and follow-up prompts, and comprehensive test coverage. The review feedback highlights several critical robustness improvements: defensively handling potential None values in get_user_name_from_profile_url to avoid TypeErrors, using or {} instead of dict.get(key, default) to prevent AttributeErrors when JSON keys are explicitly null, and using is not None checks rather than truthiness for creator to correctly handle empty dictionaries.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| 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) |
There was a problem hiding this comment.
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.
| 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) | |
| 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 match — upstream 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) |
| """ | ||
| comment = raw["comment"] | ||
| text = cast("str", comment.get("body", "")) | ||
| user: LinearActorData = cast("LinearActorData", comment.get("user", {})) |
There was a problem hiding this comment.
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.
| user: LinearActorData = cast("LinearActorData", comment.get("user", {})) | |
| user: LinearActorData = cast("LinearActorData", comment.get("user") or {}) |
| Any other action logs an "Unsupported agent session event action" | ||
| warning and returns ``None``. | ||
| """ | ||
| agent_session = cast("AgentSessionWebhookPayload", payload.get("agentSession", {})) |
There was a problem hiding this comment.
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.
| agent_session = cast("AgentSessionWebhookPayload", payload.get("agentSession", {})) | |
| agent_session = cast("AgentSessionWebhookPayload", payload.get("agentSession") or {}) |
| content = agent_activity.get("content", {}) | ||
| activity_user = cast("AgentSessionUserChild", agent_activity.get("user", {})) |
There was a problem hiding this comment.
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.
| 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 {}) |
| self._logger.warn( | ||
| "Unable to build message for Linear agent session event", | ||
| {"agentSessionId": payload.get("agentSession", {}).get("id")}, | ||
| ) |
There was a problem hiding this comment.
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.
| 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")}, | |
| ) |
| creator = agent_session.get("creator") | ||
| user: LinearActorData | ||
| if creator: |
There was a problem hiding this comment.
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.
| creator = agent_session.get("creator") | |
| user: LinearActorData | |
| if creator: | |
| creator = agent_session.get("creator") | |
| user: LinearActorData | |
| if creator is not None: |
References
- When checking for optional values that can be falsy but valid (e.g., 0, empty string, empty list), use
is not Noneinstead of a truthiness check to avoid silently ignoring them.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e83c6a68c4
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| 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 |
There was a problem hiding this comment.
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 👍 / 👎.
Third of the 5-PR Wave D (#151). Builds on L1 types (#168) + L2 thread-id (#170, on main). Ports the agent-session inbound webhook path from upstream `adapter-linear/src/index.ts` (chat@4.31). Strictly scoped to PARSE + ROUTING — no agent-activity EMIT (L4) or FETCH (L5).
Mode-gating (index.ts:1144-1165)
`_parse_message_from_agent_session_event` (index.ts:955)
Nullish-vs-truthy fidelity (the #1 port risk)
Every upstream `??` / `?.` site is reproduced with is-not-None semantics, never truthiness:
`issueId ?? issue?.id`, `agentSession.url ?? undefined`, `promptContext ?? undefined`, `creator/activity avatarUrl ?? undefined`, `agentSession.comment?.id`. The app-ownership guard compares `appUserId != botUserId` on raw values so a foreign session is rejected and a None botUserId never falsely matches.
Tests (`tests/test_linear_webhook.py`, 31 tests)
created/prompted dispatch, both mode-gate directions, every null-return + warn path, app-ownership, bot-author fallback, createdAt-as-string, no-auto-ack, and the profile-url regex. Each FAILS under a plausible mutation (verified): `??`→`or` swap, mode-gate inversion (both directions), app-ownership `==`/`!=`, `is_mention` flip, url nullish→truthy.
Gauntlet
ruff check + ruff format --check + audit_test_quality.py (0 hard failures) + pyrefly (0 src errors, test file clean) + verify_test_fidelity (100%) + `pytest tests/` → 5156 passed, 4 skipped.
What L4/L5 consume
The mode gate, the decoded session thread (L2's `decode_thread_id`/`assert_agent_session_thread`), and the `agent_session_comment` raw message produced here.
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Tests