Skip to content

feat: Linear integration — attach issue context from the composer#4115

Open
Gigioxx wants to merge 8 commits into
pingdotgg:mainfrom
Gigioxx:feat/linear-integration
Open

feat: Linear integration — attach issue context from the composer#4115
Gigioxx wants to merge 8 commits into
pingdotgg:mainfrom
Gigioxx:feat/linear-integration

Conversation

@Gigioxx

@Gigioxx Gigioxx commented Jul 18, 2026

Copy link
Copy Markdown

Problem

When working on a Linear ticket, there's no way to bring the ticket's context into a session — you copy/paste the title, description, and relevant comments by hand, and nothing links the session to the issue. Tools like conductor.build solve this with a first-class issue picker.

What this adds

Settings → Integrations (new section): connect a Linear workspace with a personal API key. The row follows the existing provider-row format (brand icon + status dot, Connected badge, "Connected as {user} · {org}"). Connect validates against Linear's API; malformed keys (lin_api_ prefix check) fail fast client-side.

Composer picker: a Linear button next to the model picker (rendered only when connected) opens a search popover. Opening it lists recently updated issues ("Recent"); typing switches to Linear's issue search. Selecting an issue attaches it as a chip; chips persist with the draft across reloads.

Prompt context: at send, each attached issue is serialized into the message as a <linear_issue> block (identifier, title, URL, state, priority/assignee/labels when present, description, up to 8 recent comments) with an explicit untrusted-content note and a 12k-char cap.

Design notes

  • No new dependency — 3 read-only RPCs (linear.getStatus/searchIssues/getIssue) call Linear's GraphQL API directly through the existing Effect HttpClient pattern (modeled on BitbucketApi). All three are registered in RPC_REQUIRED_SCOPE (read scope).
  • Key storage: the API key is written to ServerSecretStore — never to settings.json (the file holds only apiKeySet: true); legacy plaintext keys migrate to the secret store on server start. redactServerSettingsForClient blanks the key on every client-facing path; the UI reads connection state via linear.getStatus only.
  • Prompt-injection hardening: Linear-authored text is sanitized so literal <linear_issue>/</linear_issue> delimiters in issue descriptions/comments cannot escape the untrusted block (defanged to ‹linear_issue…).
  • Bounded persistence: payloads are truncated server-side (description 3000 chars, 8×800-char comments) and re-clamped client-side before landing in the localStorage-backed draft store, matching the element-context invariant.
  • The composer plumbing mirrors the existing elementContexts pattern at every touchpoint (draft slice, persisted schema, chips, append-at-send), so no new mechanisms were introduced.

Verification

  • tsgo --noEmit clean in contracts, server, client-runtime, web
  • Server suite: 1423 passed | 7 skipped (includes a new secret-persistence test mirroring the provider-secret test)
  • Web suite: 1335 passed (new: serializer tests incl. injection defusal, draft-store round-trip + clamp-on-add)
  • Manual: connect/disconnect/reconnect cycles, invalid-key error state, picker recents + search, chip persistence across reload, <linear_issue> block in the outgoing message, plaintext-key migration verified on a real settings.json
image image image image
Screen.Recording.2026-07-18.at.4.12.18.AM.mov

https://claude.ai/code/session_0128WBqER3f7gKnf13iUGcgD


Note

Medium Risk
Touches secret persistence and non-atomic settings/secret pairing (mitigated by tests and rollback), plus outbound prompt injection hardening for untrusted Linear content.

Overview
Adds end-to-end Linear integration so users can connect a workspace and attach issue context from the chat composer.

Server & contracts: New LinearApi service calls Linear’s GraphQL API for status, search/recent issues, and issue detail (with server-side truncation). Three WebSocket RPCs (linearGetStatus, linearSearchIssues, linearGetIssue) are wired with read scope. Settings gain linear.apiKey / apiKeySet; the real key lives in ServerSecretStore (not settings.json), with startup migration from legacy plaintext, client redaction, deferred commit after file write, rollback on secret write failure, and graceful “disconnected” when the secret can’t be read.

Web: New Settings → Integrations page to connect/disconnect via personal API key. Composer gets a Linear picker (when connected), pending issue chips, draft persistence/dedupe/clamping, and appendLinearIssuesToPrompt at send (untrusted <linear_issue> block with delimiter sanitization). Plan-implementation send paths intentionally skip linear context in v1.

Reviewed by Cursor Bugbot for commit d243eed. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add Linear integration to attach issue context from the composer

  • Adds a Linear integration settings page at /settings/integrations where users can connect via a personal API key (lin_api_*); the key is stored in the secret store rather than plaintext in settings.json, with migration for any pre-existing plaintext keys.
  • Exposes three new WebSocket RPC methods (linearGetStatus, linearSearchIssues, linearGetIssue) backed by a new server-side LinearApi service that queries Linear's GraphQL API.
  • Adds a Linear issue picker to the chat composer footer; users can search and attach issues, which appear as removable chips and are persisted in the composer draft store with deduplication and field clamping.
  • Attached issues are serialized into a <linear_issue> block and appended to outgoing message text; the first attached issue can seed the thread title, and failure recovery restores issues to the draft.
  • Risk: Linear API key secret-store write failures roll back only the linear placeholder in settings.json; all other settings changes in the same update are retained.

Macroscope summarized d243eed.

Connect a Linear workspace (personal API key) in Settings > Integrations,
then pick issues from a composer picker so their context (title, state,
description, recent comments, URL) is attached to the outgoing prompt as
a <linear_issue> block.

- Server: 3 read-only RPCs (linear.getStatus/searchIssues/getIssue) via
  raw GraphQL over Effect HttpClient — no new dependency. Empty search
  returns recently updated issues. Description/comments truncated
  server-side.
- API key stored in ServerSecretStore (never written to settings.json;
  legacy plaintext keys migrate on start) and redacted from all
  client-facing settings paths.
- Composer: picker in the footer (shown only when connected), pending
  chips, drafts persisted with clamped payloads, context appended at
  send alongside the existing element/terminal contexts.
- Prompt-injection hardening: Linear-authored text is sanitized so
  literal <linear_issue> delimiters cannot escape the untrusted block.
- Tests: serializer (13), draft-store round-trip/clamp, server
  settings secret persistence. Full suites green (server 1423, web 1335).

Claude-Session: https://claude.ai/code/session_0128WBqER3f7gKnf13iUGcgD
@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: b62c7385-a266-44cf-9b72-7fc580c615ec

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Jul 18, 2026
Comment thread apps/web/src/components/ChatView.tsx
Comment thread apps/server/src/linear/LinearApi.ts
Comment thread apps/web/src/components/ChatView.tsx
Comment thread apps/web/src/components/chat/ComposerLinearIssuePicker.tsx
Comment thread apps/server/src/serverSettings.ts Outdated
Comment thread apps/web/src/lib/linearIssueContext.ts
@macroscopeapp

macroscopeapp Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

This PR introduces a complete new Linear integration feature with new UI components, API endpoints, settings page, and external service integration. New features of this scope and complexity warrant human review.

You can customize Macroscope's approvability policy. Learn more.

- Restore attached Linear issues (chips) when a send fails, matching the
  other composer contexts; add setLinearIssueContexts store action
- Seed auto-title from the attached issue (identifier + title) when the
  first message has no text
- Surface Linear search failures in the picker instead of showing an
  empty "No issues found" state
- Make draft context ids collision-resistant across reloads (random
  suffix; module counter alone reset on reload while ids persist)
- Guard searchIssues/getIssue against an empty API key (no blank
  Authorization header sent to Linear)
- Commit the API-key secret-store mutation only after the settings file
  write succeeds, so a failed persist can't strand the previous key;
  covered by a write-failure test

Claude-Session: https://claude.ai/code/session_0128WBqER3f7gKnf13iUGcgD
@Gigioxx

Gigioxx commented Jul 18, 2026

Copy link
Copy Markdown
Author

Addressed all six review findings in 32d0177:

  • Send failure drops Linear chips (Bugbot): the failed-send restore path now snapshots and restores attached issues like the other contexts, and the emptiness guard includes them (new setLinearIssueContexts store action, with a round-trip test).
  • Search without key guard (Bugbot): searchIssues/getIssue now fail with "Linear is not connected." on an empty key instead of sending a blank Authorization header.
  • Auto-title ignores Linear chips (Bugbot): text-less first sends now seed the thread title from the first attached issue (IDENTIFIER title).
  • Picker swallows search errors (Macroscope): RPC failures now show "Couldn't load Linear issues" instead of a misleading empty state.
  • Secret written before settings persist (Macroscope, High): the secret-store mutation is now planned and committed only after writeSettingsAtomically succeeds, so a failed persist can't strand the previous key — covered by a new write-failure test (serverSettings.test.ts).
  • Persisted id collision after reload (Macroscope): draft context ids now carry a random suffix (li_<counter>_<rand36>); the analogous pattern in elementContext.ts was left untouched as pre-existing code.

Suites after the fixes: server 1424 passed | 7 skipped, web 1337 passed; typechecks clean across contracts/server/client-runtime/web.

Comment thread apps/web/src/components/chat/ComposerLinearIssuePicker.tsx
Comment thread apps/server/src/serverSettings.ts Outdated
- Keep the issue picker open until getIssue resolves: busy state while
  attaching, retryable error on failure, double-select guard (previously
  a failed fetch silently discarded the selection)
- Roll settings.json back to the prior persisted state if the secret
  store commit fails after the file write, keeping the two stores
  consistent; covered by an inverse write-failure test

Claude-Session: https://claude.ai/code/session_0128WBqER3f7gKnf13iUGcgD
@Gigioxx

Gigioxx commented Jul 18, 2026

Copy link
Copy Markdown
Author

Addressed the two follow-up findings in efffbae:

  • Picker drops selection on getIssue failure (Macroscope): the popover now stays open with a "Loading issue details…" state until the fetch resolves; on failure it shows a retryable error instead of silently discarding the selection, with a double-select guard while one attach is in flight.
  • Settings file written before secret store (Bugbot): since the two stores share no transaction, the fix is compensation rather than ordering — if the secret commit fails after the file write, updateSettings rolls settings.json (and the in-memory cache) back to the prior persisted state and surfaces the original error. Covered by an inverse write-failure test (secrets dir read-only → file must not retain apiKeySet: true).

Server suite: 1425 passed | 7 skipped; web suite green; typechecks clean.

Comment thread apps/web/src/components/chat/ComposerLinearIssuePicker.tsx
Comment thread apps/web/src/components/chat/ComposerLinearIssuePicker.tsx
Comment thread apps/server/src/serverSettings.ts
- Invalidate in-flight getIssue fetches on popover close/reopen via a
  request-id counter, so a canceled or superseded selection can never
  attach late or close a newer picker session
- Scope the secret-commit-failure rollback to the linear placeholder
  only, keeping the rest of the persisted patch consistent with
  provider secrets already written earlier in the flow

Claude-Session: https://claude.ai/code/session_0128WBqER3f7gKnf13iUGcgD
@Gigioxx

Gigioxx commented Jul 18, 2026

Copy link
Copy Markdown
Author

Addressed the latest round in 51a5559:

  • Stale attach callbacks (Bugbot + Macroscope): the issue-attach fetch now uses a request-id counter mirroring the search path — closing the popover or selecting again invalidates any in-flight getIssue, so a canceled/superseded selection can never attach late or close a newer picker session.
  • Combined-patch rollback divergence (Macroscope): the secret-commit-failure rollback now reverts only the linear placeholder ({ ...next, linear: current.linear }, re-normalized) instead of the whole previous settings object, so provider env-var secrets written earlier in the same patch stay consistent with their persisted placeholders. The pre-existing provider-secret flow is intentionally untouched.

Server 1425 passed | 7 skipped, web green, typechecks clean.

Comment thread apps/web/src/components/chat/ComposerLinearIssuePicker.tsx
Comment thread apps/server/src/serverSettings.ts

@macroscopeapp macroscopeapp Bot left a comment

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.

Effect Service Conventions: one finding in the new Linear service. See the inline comment on LinearApiError.

Posted via Macroscope — Effect Service Conventions

Comment thread packages/contracts/src/linear.ts Outdated
- materializeLinearApiKey no longer fails settings reads: a secret-store
  read error logs a warning and degrades to disconnected, so getSettings
  and streamChanges (and its provider-secret materialization) survive a
  linear-only failure; error channel removed from its type
- Restructure LinearApiError per the Effect service conventions:
  structured reason/status/detail/issueId fields with a derived message
  getter, cause reserved for real underlying failures
- Invalidate in-flight searches when the picker closes so late
  responses can't repopulate stale results on reopen

Claude-Session: https://claude.ai/code/session_0128WBqER3f7gKnf13iUGcgD
@Gigioxx

Gigioxx commented Jul 18, 2026

Copy link
Copy Markdown
Author

Latest round addressed in the newest commit:

  • streamChanges catch scope (Macroscope, High): fixed at the source — materializeLinearApiKey now degrades to disconnected (with a warning) on a secret-store read failure instead of failing, so provider-secret materialization survives linear-only errors and getSettings (which had no catch at all) can no longer break over a linear problem. Its error channel is removed from the type. Covered by a new unreadable-secret test.
  • LinearApiError conventions (Effect Service Conventions): single class kept, but message is now a getter derived from structured reason/status/detail/issueId fields; cause carries only real underlying failures — sentinel strings removed.
  • Stale search after close (Macroscope): searchRequestRef is invalidated in the popover close branch, mirroring the attach-request invalidation.

Server 1426 passed | 7 skipped, web 1337 passed, typechecks clean.

@macroscopeapp macroscopeapp Bot left a comment

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.

One Effect service-convention issue remains around LinearApiError. The earlier feedback moved the message out of a stored Schema.String, but the revised shape still models several semantically distinct failures through a single reason discriminator whose values pick the user-facing message via a switch, and it copies raw wire payloads into a caller-visible detail field. Details inline.

Posted via Macroscope — Effect Service Conventions

Comment thread packages/contracts/src/linear.ts Outdated
Comment thread apps/server/src/linear/LinearApi.ts Outdated
Follow the Effect service conventions (mirroring BitbucketApiError):
seven tagged classes combined via Schema.Union, each deriving its
message from structured fields; raw response bodies and GraphQL error
payloads now live only in cause, never in message-visible fields.

Claude-Session: https://claude.ai/code/session_0128WBqER3f7gKnf13iUGcgD
@Gigioxx

Gigioxx commented Jul 18, 2026

Copy link
Copy Markdown
Author

Addressed both Effect Service Conventions follow-ups in c287754:

  • Split the discriminated error: LinearApiError is now a Schema.Union of seven per-condition tagged classes (LinearNotConnectedError, LinearRequestError, LinearHttpError, LinearGraphqlError, LinearResponseDecodeError, LinearEmptyResponseError, LinearIssueNotFoundError), each deriving message from its own structured fields — mirroring BitbucketApiError's union + type alias + Schema.is guard exactly.
  • No wire payloads in message-visible fields: detail is gone; the HTTP response body and the GraphQL errors array live only in cause, with status/issueId as the normalized diagnostics.

Typechecks clean across contracts/server/web; server suite 1426 passed | 7 skipped.

Comment thread apps/server/src/linear/LinearApi.ts
Truncated descriptions/comments now stay within the 3000/800 limits
instead of exceeding them by the suffix length.

Claude-Session: https://claude.ai/code/session_0128WBqER3f7gKnf13iUGcgD
@Gigioxx

Gigioxx commented Jul 18, 2026

Copy link
Copy Markdown
Author

Fixed the truncation off-by-suffix in the latest commit: truncate now reserves space for the … [truncated] marker (length derived from the constant), so descriptions/comments stay within the 3000/800 limits. The client-side truncateString already reserved correctly and is unchanged. Server suite 1426 passed | 7 skipped.

@cursor cursor Bot left a comment

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.

Cursor Bugbot has reviewed your changes using high effort and found 4 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit d243eed. Configure here.

materializeProviderEnvironmentSecrets(settings).pipe(
Effect.flatMap(materializeLinearApiKey),
Effect.catch((error: ServerSettingsError) =>
Effect.logWarning("failed to materialize provider environment secrets", {

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.

Watcher skips Linear key migration

Medium Severity

Plaintext linear.apiKey values are only migrated to the secret store during server startup. When settings.json is reloaded via the file watcher, revalidateAndEmit refreshes the cache but never runs migration, so a plaintext key reintroduced on disk (restore, hand edit, older client) can remain in the file indefinitely while the server keeps using it.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d243eed. Configure here.

setError(INVALID_KEY_MESSAGE);
}
}
}, [pending, status.data, status.isPending]);

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.

Connect pending never clears

Medium Severity

After a successful settings persist, the connect/disconnect flow waits for linear.getStatus to return data with a new object identity. If the refetch ends in error while the query still exposes the same status.data reference as before refresh, the effect exits early and never clears pending, leaving the row stuck on “Connecting…” or “Disconnecting…”.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d243eed. Configure here.

setError(null);
} else {
setError(INVALID_KEY_MESSAGE);
}

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.

Transient status failure misreported

Medium Severity

When connect persists the API key successfully but the follow-up linear.getStatus refetch fails or yields connected: false without a successful validation read, the UI always shows “Invalid API key or connection failed” even though the secret may already be stored and working.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d243eed. Configure here.

images: composerImagesRef.current,
terminalContexts: composerTerminalContextsRef.current,
elementContexts: composerElementContextsRef.current,
linearIssues: composerLinearIssues,

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.

Send omits Linear context

Medium Severity

getSendContext reads linearIssues from render state while terminal and element attachments use refs synced for send. A send that runs before the imperative handle picks up the latest draft can omit attached issues from the outgoing message even though chips remain in the draft store until cleared.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d243eed. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant