feat(linear): import Linear issues into threads + deep two-way integration (#3703)#3711
feat(linear): import Linear issues into threads + deep two-way integration (#3703)#3711maslinedwin wants to merge 16 commits into
Conversation
Adds a native Linear integration for the web app (issue pingdotgg#3703): - contracts: Linear issue/auth schemas + 5 WS RPCs - server: LinearApi Effect service (GraphQL over HttpClient), PAT stored via ServerSecretStore with T3CODE_LINEAR_API_TOKEN env fallback - client-runtime/web: Linear atom family + state wiring - Settings -> Linear page to connect/disconnect a personal API key - per-folder Linear icon in the sidebar opening a browse/search/select popover with combine/subtasks toggle - import flow: fetch issues -> format markdown -> new draft thread -> pre-fill composer (formatLinearIssues, unit tested) - docs/integrations/linear.md
…+ link contract - contracts: LinearIssueFilter, paginated LinearListIssues, team/state/project/ label/user metadata, write-mutation inputs, LinearIssueLink; 9 new RPCs - server LinearApi: listIssues (filter+cursor), listTeams/workflowStates/projects/ labels/users, updateIssueState/createComment/createAttachment; ws handlers+scopes - client-runtime: list/metadata query families + mutation commands - orchestration contract: optional linearIssue link on thread/shell + create/meta commands + turn-start bootstrap (persistence wiring follows)
…t, import modes - /linear full-page browser: team/status/assignee filters, cursor pagination (Load more), select-all + multi-select table - import control: one-thread-per-issue (real started threads, each linked) or combine (draft) + destination-folder picker - entry points: command-palette 'Browse Linear issues' + popover 'Browse all' - useLinearImport gains perIssue mode via startTurn bootstrap
- migration 033 + ProjectionThreads schema/SQL store linked_linear_issue_json - thread linearIssue flows through decider → thread.created/meta-updated → projector → projection pipeline → snapshot query (read model + shell + detail) - turn-start bootstrap.createThread carries linearIssue - LinearIssueBadge on sidebar thread rows, colored by workflow-state type (advances In Progress → In Review → Done as status is written back)
- ServerSettings.linear: autoSync + per-transition toggles + comment/attachment toggles + per-team state-mapping override (+ patch) - linearStateMapping resolver: workflow-state type/name → target stateId, with per-team override (unit tested, 7 cases) - LinearSyncReactor: agent start → In Progress; PR open → In Review; merge → Done (via VcsStatusBroadcaster per-thread watch); dedupes per issue, posts optional comment/attachment, reflects state to the thread badge via thread.meta.update. Registered in OrchestrationReactor + server layer graph - Settings UI: Status write-back toggles on the Linear settings page
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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 |
ApprovabilityVerdict: Needs human review 3 blocking correctness issues found. Diff is too large for automated approval analysis. A human reviewer should evaluate this PR. You can customize Macroscope's approvability policy. Learn more. |
- LinearSyncReactor: inline the service interface in Context.Service per Effect conventions (drop standalone LinearSyncReactorShape) - reactor: key PR watchers by worktree so a later/changed worktree re-registers instead of being ignored (High); set appliedRank after the write + badge reflect so a failed issueUpdate can retry - drop the link-attachment write-back (+ setting/UI): it linked the issue to itself; a real thread URL isn't available server-side without OAuth/hosting - LinearBrowser: replace (not merge) rows when the filter changes so stale pages can't leak across filters; only offer 'Load more' when an endCursor exists - perIssue import: attempt all issues and report a summary instead of bailing mid-loop; surface issues that couldn't be loaded
|
Thanks for the automated review — pushed fixes in Macroscope (Effect Service Conventions): Cursor Bugbot (6 of 8 addressed):
Intentionally left:
Full recursive typecheck + lint pass and unit tests are green after the changes. |
Partial perIssue imports (some threads created, or some issues not loaded) now return ok:true with a `warning`, so consumers navigate and show a non-blocking warning toast instead of a full-failure error. Only a total failure returns ok:false. (addresses Bugbot follow-up)
Server settings updates deepMerge (never delete missing keys), so a nested patch could add but not remove team state-mapping overrides. applyServerSettingsPatch now replaces linear.stateMappingByTeam wholesale (like providerInstances), so an empty map clears stale overrides. (addresses Macroscope review)
…rtial import - probeAuth now only swallows LinearAuthError (rejected token → unauthenticated) and lets LinearRequestError propagate, so a transient outage/misconfig isn't shown as 'Not connected' (Settings surfaces the error). Widened setToken/ clearToken error channels + clearToken RPC union accordingly. - useLinearImport returns failedIds; LinearBrowser/LinearBrowsePopover keep just the failed issues selected (and don't navigate/close) on a partial import so they can be retried without re-running the ones that already succeeded. (addresses Macroscope + Bugbot follow-ups)
… on toggle
- setToken/clearToken no longer fail on a post-write probe outage (probeAuth
stays strict for the status query; the writes swallow LinearRequestError and
report a best-effort status). Reverted their error channels + clearToken RPC.
- LinearBrowsePopover surfaces auth query errors ('Couldn't reach Linear')
before the 'not connected' prompt, and prunes selections that leave the
current result set so Import can't act on invisible issues.
- Linear settings toggles send only the changed key via the server settings
command, so stateMappingByTeam is no longer carried (and wiped) on every
toggle; explicit map edits still replace wholesale.
(addresses Macroscope + Bugbot follow-ups on the prior fixes)
There was a problem hiding this comment.
One convention issue: recovering a single known tagged failure should use Effect.catchTags rather than Effect.catch with a manual _tag check. See inline comments.
Posted via Macroscope — Effect Service Conventions
…ection polish - probeAuth/probeAuthLenient use Effect.catchTags to recover specific error tags (Effect Service Conventions) instead of Effect.catch + manual _tag - probeAuth only treats auth-message GraphQL errors as a rejected token; other 200-with-errors responses propagate as LinearRequestError (outage) - Settings save: a stored-but-unverified token (outage) is a saved success with a warning, not a connect failure - import retry-selection: keep only failed issues still visible in the current results/rows selected, so no un-clearable off-screen selection and the popover prune can't drop them
Only narrow the selection to failed issues when the hook actually reports failedIds; a total transient failure (e.g. fetchIssues failed, nothing imported) now keeps the current selection so it can be retried as-is.
|
One more note: I'm genuinely happy to take this in whatever direction the team wants — reshape the scope, split it into smaller stacked PRs, adjust the UX/architecture, drop pieces, or rework anything to fit how you'd want a Linear integration to look in T3 Code. Just point me at the changes and I'll turn them around. More than that, I'd love to actually collaborate on bringing this Linear integration into the platform properly, not just land a one-off PR. If there's appetite for it, I'm keen to help shape and maintain it alongside you all. cc @juliusmarminge — would love your thoughts on whether this is a direction worth pursuing, and how you'd want it scoped. |
…issue done' - perIssue imports now start threads on Claude Opus 4.8 (claudeAgent / claude-opus-4-8) instead of the project/Codex default. - New linear.completeThreadIssue RPC: resolves the team's completed state, writes it back via issueUpdate, and reflects it on the thread (badge → Done) via thread.meta.update. Surfaced as a 'Mark <ID> done in Linear' item in the thread right-click menu, shown only when the thread has a linked issue.
| cause, | ||
| }), | ||
| ), | ||
| ); |
There was a problem hiding this comment.
RPC fails after Linear updates
High Severity
The completeLinearThreadIssue RPC reports a failure if the internal thread.meta.update dispatch fails, even when the Linear issue has been successfully updated. This causes the client to incorrectly indicate the action failed and potentially display stale UI, despite the issue being marked as done in Linear.
Reviewed by Cursor Bugbot for commit 08fa28b. Configure here.
| ), | ||
| ); | ||
| } | ||
| return { success: true }; |
There was a problem hiding this comment.
Manual done skips reactor rank
High Severity
Marking a linked issue done via completeLinearThreadIssue updates Linear but never advances LinearSyncReactor's per-issue appliedRank. A later agent start can still run the "started" transition and move a Done issue back to In Progress.
Reviewed by Cursor Bugbot for commit 08fa28b. Configure here.
| description: | ||
| result._tag === "Failure" | ||
| ? "Linear rejected the update — check the connection in Settings." | ||
| : "No completed state is configured for this team.", |
There was a problem hiding this comment.
Complete action mislabels failures
Medium Severity
The toast messages for completing a Linear issue are too generic. The client-side logic incorrectly interprets completeLinearIssue results: it maps various server-side failures (like missing Linear links or API rejections) to "No completed state is configured," and some internal failures to "Linear rejected the update." This gives users misleading feedback.
Reviewed by Cursor Bugbot for commit 08fa28b. Configure here.
- share formatLinearIssues via @t3tools/client-runtime/linear-format (web re-exports) - mobile: linearEnvironment atoms; Settings → Linear (connect token, status, write-back toggles); Linear import sheet (search, multi-select, per-issue / combine, Opus 4.8, linked threads); thread-row Linear badge + long-press 'Mark <ID> done' via linear.completeThreadIssue; LinearIcon; screens wired into the native-stack navigator + settings sheet - projectThreadStartTurn carries linearIssue through the bootstrap
| export function SettingsLinearRouteScreen() { | ||
| const navigation = useNavigation(); | ||
| const { environments } = useEnvironments(); | ||
| const environmentId = environments[0]?.environmentId ?? null; |
There was a problem hiding this comment.
🟡 Medium settings/SettingsLinearRouteScreen.tsx:22
SettingsLinearRouteScreen sets environmentId to environments[0]?.environmentId, so every Linear action on this screen — connect, disconnect, toggle sync settings, and open import — targets whichever environment happens to be first in presentationById iteration order. In a multi-environment setup this causes writes to the wrong server and displays the wrong auth status. Consider deriving environmentId from the user's current/selected environment instead of assuming the first entry is the intended one.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/settings/SettingsLinearRouteScreen.tsx around line 22:
`SettingsLinearRouteScreen` sets `environmentId` to `environments[0]?.environmentId`, so every Linear action on this screen — connect, disconnect, toggle sync settings, and open import — targets whichever environment happens to be first in `presentationById` iteration order. In a multi-environment setup this causes writes to the wrong server and displays the wrong auth status. Consider deriving `environmentId` from the user's current/selected environment instead of assuming the first entry is the intended one.
There was a problem hiding this comment.
🟡 Medium
t3code/apps/mobile/src/Stack.tsx
Line 231 in 2638ae9
The new LinearImport route is presented as a formSheet overlay but is missing from WORKSPACE_OVERLAY_ROUTES. While the import sheet is open, workspacePathFromState returns /linear-import instead of the underlying workspace path, so AdaptiveWorkspaceLayout drops the active thread selection and recomputes pane behavior as if the user navigated away. Add "LinearImport" to the WORKSPACE_OVERLAY_ROUTES set alongside the other sheet routes.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/Stack.tsx around line 231:
The new `LinearImport` route is presented as a `formSheet` overlay but is missing from `WORKSPACE_OVERLAY_ROUTES`. While the import sheet is open, `workspacePathFromState` returns `/linear-import` instead of the underlying workspace path, so `AdaptiveWorkspaceLayout` drops the active thread selection and recomputes pane behavior as if the user navigated away. Add `"LinearImport"` to the `WORKSPACE_OVERLAY_ROUTES` set alongside the other sheet routes.
| [iconColor, placeholderColor, selected, toggle], | ||
| ); | ||
|
|
||
| const connected = search.error === null; |
There was a problem hiding this comment.
🟡 Medium linear/LinearImportRouteScreen.tsx:192
connected is computed as search.error === null, but when environmentId is null the screen passes null to useEnvironmentQuery, which returns an empty AsyncResult whose error is also null. As a result, connected is true even with no environment configured, so the empty state renders No issues found. instead of the intended Connect Linear in Settings to import issues. message and hides the disconnected state from the user. Consider deriving connected from the environment being present (e.g. environmentId !== null && search.error === null).
Also found in 1 other location(s)
apps/mobile/src/features/settings/SettingsLinearRouteScreen.tsx:46
handleConnectonly clears the input and refreshesauthQuerywhensetTokenreturnsstatus === "authenticated". On the server,linear.setTokenpersists the token first and then may returnstatus: "unauthenticated"when verification cannot reach Linear ("Saved, but couldn't reach Linear to verify the token."). In that reachable case, the mobile UI never refreshes and continues to showNot connected, even though the token was saved successfully, so users get a false failure state and no indication that the integration may already be configured.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/linear/LinearImportRouteScreen.tsx around line 192:
`connected` is computed as `search.error === null`, but when `environmentId` is `null` the screen passes `null` to `useEnvironmentQuery`, which returns an empty `AsyncResult` whose `error` is also `null`. As a result, `connected` is `true` even with no environment configured, so the empty state renders `No issues found.` instead of the intended `Connect Linear in Settings to import issues.` message and hides the disconnected state from the user. Consider deriving `connected` from the environment being present (e.g. `environmentId !== null && search.error === null`).
Also found in 1 other location(s):
- apps/mobile/src/features/settings/SettingsLinearRouteScreen.tsx:46 -- `handleConnect` only clears the input and refreshes `authQuery` when `setToken` returns `status === "authenticated"`. On the server, `linear.setToken` persists the token first and then may return `status: "unauthenticated"` when verification cannot reach Linear (`"Saved, but couldn't reach Linear to verify the token."`). In that reachable case, the mobile UI never refreshes and continues to show `Not connected`, even though the token was saved successfully, so users get a false failure state and no indication that the integration may already be configured.
| } else { | ||
| await start(formatLinearIssues(details, "combine"), null); | ||
| } | ||
| navigation.goBack(); |
There was a problem hiding this comment.
Import dismisses despite failures
High Severity
The handleImport function in LinearImportRouteScreen always navigates away after an import attempt, even if fetchIssues fails, returns fewer issues than selected, or if startTurn calls fail. This results in silent failures where the screen closes as if the import was fully successful, without user feedback.
Reviewed by Cursor Bugbot for commit 2638ae9. Configure here.
| <LinearIcon size={28} /> | ||
| <Text className="text-center text-base text-foreground-muted"> | ||
| {connected ? "No issues found." : "Connect Linear in Settings to import issues."} | ||
| </Text> |
There was a problem hiding this comment.
Search errors mimic disconnect
Medium Severity
The Linear import and settings screens incorrectly conflate API errors (e.g., network issues) with an unauthenticated state. This causes misleading UI messages, like "Connect Linear in Settings" or "Not connected," when the actual problem is service unavailability.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 2638ae9. Configure here.
| else next.add(id); | ||
| return next; | ||
| }); | ||
| }, []); |
There was a problem hiding this comment.
Hidden selections still import
Medium Severity
The selected state for Linear issues isn't pruned when search results update. This can lead to importing issues that are no longer visible in the list, creating unexpected threads.
Reviewed by Cursor Bugbot for commit 2638ae9. Configure here.
|
|
||
| if (perIssue) { | ||
| for (const detail of details) { | ||
| await start(formatLinearIssues([detail], "combine"), issueLink(detail)); |
There was a problem hiding this comment.
Imported threads wrong titles
Medium Severity
Per-issue Linear imports create threads with titles derived from the full prompt text, including boilerplate, rather than the Linear issue's identifier and title, unlike the web client.
Reviewed by Cursor Bugbot for commit 2638ae9. Configure here.
| } | ||
| } finally { | ||
| setBusy(false); | ||
| } |
There was a problem hiding this comment.
Connect ignores saved token
Medium Severity
After setToken, the handler only clears the field and refreshes auth when result.value.status === "authenticated". If the server saves the token but returns unauthenticated from a lenient post-save probe (e.g. outage message), the UI acts as if connect failed even though the token was stored.
Reviewed by Cursor Bugbot for commit 2638ae9. Configure here.
app.config.ts honors EXPO_OWNER, EAS_PROJECT_ID, APPLE_TEAM_ID, and T3CODE_IOS_BUNDLE_ID env overrides (all defaulting to the upstream values), so a fork can build and ship to its own TestFlight without editing the config.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
There are 9 total unresolved issues (including 8 from previous reviews).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 5ab69b5. Configure here.
| const iosBundleIdentifier = process.env.T3CODE_IOS_BUNDLE_ID ?? variant.iosBundleIdentifier; | ||
| const easProjectId = process.env.EAS_PROJECT_ID ?? "d763fcb8-d37c-41ea-a773-b54a0ab4a454"; | ||
| const appleTeamId = process.env.APPLE_TEAM_ID ?? "ARK85ZXQ4Z"; | ||
| const expoOwner = process.env.EXPO_OWNER ?? "pingdotgg"; |
There was a problem hiding this comment.
Empty env bypasses defaults
Medium Severity
The new fork overrides use ?? on process.env, so an empty string (e.g. a placeholder in .env / .env.local merged via loadRepoEnv) is treated as set and replaces the documented upstream defaults. That can yield an empty ios.bundleIdentifier, a broken updates.url, or invalid EAS owner/project values instead of falling back like “unset”.
Reviewed by Cursor Bugbot for commit 5ab69b5. Configure here.


Implements #3703 (I filed that issue and offered to help implement it).
Heads-up on size & scope
I've read
CONTRIBUTING.md— I know this is a large feature PR and that you're not actively taking contributions. It's ~3.6k lines because it's a full two-way integration, but it's cleanly split into 5 self-contained commits (Phase 0 → Phase 3) and I'm very happy to break it into stacked PRs (e.g. land just the read-only import first) or shrink scope to whatever you'd actually consider. Just say the word. UI screenshots / a short screen recording can be added on request.What this does & why
Today, turning a Linear ticket into an agent task means copy-pasting context by hand. This adds a native Linear integration so you can browse/search issues, bulk-import them into threads with full context pre-filled, and — as the agent works — write status back to Linear automatically. Research into Conductor's integration showed it's deliberately launch-only (no write-back), so a two-way integration is the differentiator.
Everything runs on a Linear personal API key (stored in the existing encrypted
ServerSecretStore, with aT3CODE_LINEAR_API_TOKENenv fallback). No OAuth, no public webhook URL, no new dependencies.Features
/linear) — a full-page table with team / status / assignee / project / label filters, cursor pagination, select-all + multi-select. Import as one thread per issue (parallel agents, each linked) or combined into one thread; pick the destination folder.LinearSyncReactormoves the issue In Progress on agent start → In Review on PR open → Done on merge (viaissueUpdate, mapped per-team by workflow-state type so it survives renamed states), plus optional progress comment and an idempotent "T3 Code" attachment link. Fully configurable/toggleable in Settings; defaults on.Architecture (follows existing conventions)
LinearApimirrors the Bitbucket source-control provider: an EffectContext.ServiceusingHttpClientfromeffect/unstable/httpfor GraphQL, a tagged-error family, andT3CODE_-prefixed config. Read ops (search/list/fetch/metadata) + write mutations (issueUpdate/commentCreate/attachmentCreate).effect/Schemainpackages/contracts; 14 RPCs registered in the existingWsRpcGroup, handled inws.tswith proper auth scopes (reads → read scope, writes → operate scope).033→ProjectionThreads→ decider → projector → pipeline → snapshot query → read model/shell), following the existingarchived_atcolumn-add pattern.LinearSyncReactoris modeled onThreadDeletionReactor(subscribes tostreamDomainEvents; watches PR state viaVcsStatusBroadcasterper linked thread's worktree), registered inOrchestrationReactorand the server layer graph. It reflects state changes back to the UI via athread.meta.updatedomain event, dedupes per issue, and never regresses a stage.Commits (each self-contained)
feat(linear): import Linear issues into threads— Phase 0: connect + per-folder import + formatter.feat(linear): deep-integration foundation— list/filter/metadata + mutation API +LinearIssueLinkcontract.feat(linear): Phase 1 bulk browser— filters, pagination, multi-select, import modes.feat(linear): Phase 2 — persist thread↔issue link + sidebar badge.feat(linear): Phase 3 — status write-back (LinearSyncReactor + settings).Testing
vp checkandvp run typecheckpass (all 15 packages, 0 errors); no lint errors introduced.formatLinearIssues(single/combine/subtasks/omit-empty) and the workflow-state resolver (type/name/override/fallback) — added, green./linear; imported an issue (one-thread-per-issue) → the thread was created & linked, the agent started, and the issue flipped Todo → In Progress in Linear, with the thread badge reflecting "In Progress" live. TheissueUpdatemutation was also validated directly (state change + revert).Known limitations / follow-ups (called out honestly)
issueUpdatepath itself is validated). The live E2E confirmed the start transition; the PR/merge transitions are unit-tested + typechecked but weren't driven with a real merge.actor=app+ the Linear Agent Interaction SDK (delegate/@mention T3 Code inside Linear) are intentionally deferred; the code is structured to add them without rework.Docs: added
docs/integrations/linear.md. Happy to adjust scope, split this up, or add screenshots/video — whatever makes it reviewable for you.Note
High Risk
Large feature touching auth-scoped external API calls, orchestration events, DB migration, and automatic third-party issue mutations; in-process sync dedupe resets on server restart.
Overview
Adds a Linear personal API key integration end-to-end: server GraphQL client, WebSocket RPCs, token storage, and clients on web and mobile.
Import: Browse/search issues from Settings, a
/linearpage (web), sidebar popover, or mobile import sheet. Selected issues become threads with formatted context; users can combine into one thread or start one per issue. Thread creation/bootstrap now accepts an optionallinearIssuelink, persisted via migration033(linked_linear_issue_json) through the orchestration stack and shown as badges in sidebar/thread lists. Manual mark done in Linear is available from thread menus.Write-back:
LinearSyncReactorreacts to thread lifecycle and VCS PR events to move linked issues (In Progress → In Review → Done), optionally post comments, and refresh thread metadata; toggles live under Linear settings.completeLinearThreadIssueRPC handles explicit completion.Mobile fork builds:
app.config.tsgains env overrides for iOS bundle ID, EAS project, Apple team, and Expo owner.Reviewed by Cursor Bugbot for commit 5ab69b5. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Add deep two-way Linear integration for importing issues into threads and syncing status
LinearBrowsePopoverandLinearImportRouteScreen.LinearSyncReactorthat automatically transitions linked Linear issue states on thread lifecycle events (start, PR open, merge), optionally posts a comment on start, and reflects state back to thread metadata.linked_linear_issue_jsonon theprojection_threadstable via migration 033; threads carry alinearIssuefield through the full event/projection/snapshot pipeline.LinearSyncReactorlifecycle transitions and comment posting are best-effort (failures logged at warning level); in-memory rank map preventing regressive transitions is lost on server restart.Macroscope summarized 5ab69b5.