diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 192153316b0..36457fe4141 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -59,6 +59,14 @@ function resolveAppVariant(value: string | undefined): AppVariant { const variant = VARIANT_CONFIG[APP_VARIANT]; +// Account-specific overrides so a fork can build/submit under its own Apple + +// Expo account (e.g. for a personal TestFlight) without editing this file. +// All default to the upstream T3 Tools values. +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"; + const config: ExpoConfig = { name: variant.appName, slug: "t3-code", @@ -73,18 +81,19 @@ const config: ExpoConfig = { userInterfaceStyle: "automatic", updates: { enabled: true, - url: "https://u.expo.dev/d763fcb8-d37c-41ea-a773-b54a0ab4a454", + url: `https://u.expo.dev/${easProjectId}`, checkAutomatically: "ON_LOAD", fallbackToCacheTimeout: 0, }, ios: { icon: variant.iosIcon, supportsTablet: true, - bundleIdentifier: variant.iosBundleIdentifier, + bundleIdentifier: iosBundleIdentifier, // Pin code signing to the T3 Tools team so non-interactive `expo run:ios` // does not fall back to a personal team (which cannot sign app groups, - // Sign in with Apple, or push notification entitlements). - appleTeamId: "ARK85ZXQ4Z", + // Sign in with Apple, or push notification entitlements). Override with + // APPLE_TEAM_ID for a fork build under a different Apple account. + appleTeamId, associatedDomains: [ `applinks:${variant.relyingParty}`, `webcredentials:${variant.relyingParty}`, @@ -154,8 +163,8 @@ const config: ExpoConfig = { [ "expo-widgets", { - bundleIdentifier: `${variant.iosBundleIdentifier}.widgets`, - groupIdentifier: `group.${variant.iosBundleIdentifier}`, + bundleIdentifier: `${iosBundleIdentifier}.widgets`, + groupIdentifier: `group.${iosBundleIdentifier}`, enablePushNotifications: true, widgets: [ { @@ -185,10 +194,10 @@ const config: ExpoConfig = { tracesToken: repoEnv.EXPO_PUBLIC_OTLP_TRACES_TOKEN ?? null, }, eas: { - projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454", + projectId: easProjectId, }, }, - owner: "pingdotgg", + owner: expoOwner, }; export default config; diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 29cf29da90a..b4702b5d29a 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -38,7 +38,9 @@ import { AddProjectSourceRoute } from "./features/projects/AddProjectSourceRoute import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScreen"; import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider"; import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen"; +import { LinearImportRouteScreen } from "./features/linear/LinearImportRouteScreen"; import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen"; +import { SettingsLinearRouteScreen } from "./features/settings/SettingsLinearRouteScreen"; import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; @@ -145,6 +147,13 @@ const SettingsSheetStack = createNativeStackNavigator({ title: "Appearance", }, }), + SettingsLinear: createNativeStackScreen({ + screen: SettingsLinearRouteScreen, + linking: "linear", + options: { + title: "Linear", + }, + }), SettingsAuth: createNativeStackScreen({ screen: SettingsAuthRouteScreen, linking: "auth", @@ -431,6 +440,16 @@ export const RootStack = createNativeStackNavigator({ sheetGrabberVisible: true, }, }), + LinearImport: createNativeStackScreen({ + screen: LinearImportRouteScreen, + linking: "linear-import", + options: { + title: "Import from Linear", + presentation: "formSheet", + sheetAllowedDetents: [0.7, 0.92], + sheetGrabberVisible: true, + }, + }), NewTaskSheet: createNativeStackScreen({ screen: NewTaskSheetStack, linking: "new", diff --git a/apps/mobile/src/components/LinearIcon.tsx b/apps/mobile/src/components/LinearIcon.tsx new file mode 100644 index 00000000000..ce52b507000 --- /dev/null +++ b/apps/mobile/src/components/LinearIcon.tsx @@ -0,0 +1,14 @@ +import Svg, { Path } from "react-native-svg"; + +/** Linear brand mark. Defaults to the current-color tint when no color given. */ +export function LinearIcon(props: { readonly size?: number; readonly color?: string }) { + const size = props.size ?? 18; + return ( + + + + ); +} diff --git a/apps/mobile/src/features/linear/LinearImportRouteScreen.tsx b/apps/mobile/src/features/linear/LinearImportRouteScreen.tsx new file mode 100644 index 00000000000..c26bfc28db6 --- /dev/null +++ b/apps/mobile/src/features/linear/LinearImportRouteScreen.tsx @@ -0,0 +1,286 @@ +import { LegendList, type LegendListRenderItemProps } from "@legendapp/list/react-native"; +import { useNavigation } from "@react-navigation/native"; +import { + DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + ProviderInstanceId, + type LinearIssueDetail, + type LinearIssueLink, + type LinearIssueSummary, + type ModelSelection, +} from "@t3tools/contracts"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { formatLinearIssues } from "@t3tools/client-runtime/linear-format"; +import { SymbolView } from "expo-symbols"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; + +import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { LinearIcon } from "../../components/LinearIcon"; +import { cn } from "../../lib/cn"; +import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; +import { buildProjectThreadStartTurnInput } from "../../lib/projectThreadStartTurn"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { useEnvironments } from "../../state/environments"; +import { useProjects } from "../../state/entities"; +import { linearEnvironment } from "../../state/linear"; +import { useEnvironmentQuery } from "../../state/query"; +import { threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { ConnectionSheetButton } from "../connection/ConnectionSheetButton"; + +const SEARCH_LIMIT = 30; +const IMPORT_MODEL: ModelSelection = { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-opus-4-8", +}; + +function issueLink(issue: LinearIssueDetail): LinearIssueLink { + return { + id: issue.id, + identifier: issue.identifier, + title: issue.title, + url: issue.url, + ...(issue.teamId ? { teamId: issue.teamId } : {}), + ...(issue.stateType ? { stateType: issue.stateType } : {}), + ...(issue.stateName ? { stateName: issue.stateName } : {}), + }; +} + +export function LinearImportRouteScreen() { + const navigation = useNavigation(); + const { environments } = useEnvironments(); + const environmentId = environments[0]?.environmentId ?? null; + const projects = useProjects(); + const envProjects = useMemo( + () => projects.filter((project) => project.environmentId === environmentId), + [projects, environmentId], + ); + + const [projectId, setProjectId] = useState(null); + const activeProject: EnvironmentProject | null = + envProjects.find((project) => project.id === projectId) ?? envProjects[0] ?? null; + + const [query, setQuery] = useState(""); + const [debounced, setDebounced] = useState(""); + const [selected, setSelected] = useState>(() => new Set()); + const [perIssue, setPerIssue] = useState(true); + const [importing, setImporting] = useState(false); + + const iconColor = useThemeColor("--color-primary"); + const placeholderColor = useThemeColor("--color-icon-subtle"); + + const fetchIssues = useAtomCommand(linearEnvironment.fetchIssues, { reportFailure: false }); + const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); + + useEffect(() => { + const id = setTimeout(() => setDebounced(query), 220); + return () => clearTimeout(id); + }, [query]); + + const search = useEnvironmentQuery( + environmentId === null + ? null + : linearEnvironment.searchIssues({ + environmentId, + input: { query: debounced, limit: SEARCH_LIMIT }, + }), + ); + const issues = search.data?.issues ?? []; + + const toggle = useCallback((id: string) => { + setSelected((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + const handleImport = useCallback(async () => { + if (environmentId === null || activeProject === null || selected.size === 0 || importing) + return; + setImporting(true); + try { + const result = await fetchIssues({ environmentId, input: { ids: [...selected] } }); + if (result._tag !== "Success" || result.value.issues.length === 0) return; + const details = result.value.issues; + + const start = (text: string, linearIssue: LinearIssueLink | null) => { + const metadata = makeTurnCommandMetadata(); + return startTurn({ + environmentId, + input: buildProjectThreadStartTurnInput({ + projectId: activeProject.id, + projectCwd: activeProject.workspaceRoot, + threadId: metadata.threadId, + commandId: metadata.commandId, + messageId: metadata.messageId, + createdAt: metadata.createdAt, + text, + attachments: [], + modelSelection: IMPORT_MODEL, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + workspaceMode: "local", + branch: null, + worktreePath: null, + startFromOrigin: false, + worktreeBranchName: "", + linearIssue, + }), + }); + }; + + if (perIssue) { + for (const detail of details) { + await start(formatLinearIssues([detail], "combine"), issueLink(detail)); + } + } else { + await start(formatLinearIssues(details, "combine"), null); + } + navigation.goBack(); + } finally { + setImporting(false); + } + }, [ + activeProject, + environmentId, + fetchIssues, + importing, + navigation, + perIssue, + selected, + startTurn, + ]); + + const renderItem = useCallback( + ({ item }: LegendListRenderItemProps) => { + const isSelected = selected.has(item.id); + return ( + toggle(item.id)} + > + + + + {item.identifier} + + + {item.title} + + + {item.stateName || item.assigneeName ? ( + + {[item.stateName, item.assigneeName].filter(Boolean).join(" · ")} + + ) : null} + + + + ); + }, + [iconColor, placeholderColor, selected, toggle], + ); + + const connected = search.error === null; + + return ( + + + {envProjects.length > 1 ? ( + + {envProjects.map((project) => { + const isActive = (activeProject?.id ?? null) === project.id; + return ( + setProjectId(project.id)} + > + + {project.title} + + + ); + })} + + ) : null} + + + + + {search.isPending && issues.length === 0 ? ( + + + + ) : issues.length === 0 ? ( + + + + {connected ? "No issues found." : "Connect Linear in Settings to import issues."} + + + ) : ( + item.id} + renderItem={renderItem} + contentContainerStyle={{ paddingVertical: 8 }} + /> + )} + + + setPerIssue((value) => !value)} + > + + {perIssue ? "One thread per issue" : "Combine into one thread"} + + + + void handleImport()} + tone="primary" + /> + + + ); +} diff --git a/apps/mobile/src/features/settings/SettingsLinearRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsLinearRouteScreen.tsx new file mode 100644 index 00000000000..d143fe6aa56 --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsLinearRouteScreen.tsx @@ -0,0 +1,184 @@ +import { useNavigation } from "@react-navigation/native"; +import { useCallback, useState } from "react"; +import { Pressable, ScrollView, View } from "react-native"; + +import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { LinearIcon } from "../../components/LinearIcon"; +import { useEnvironments } from "../../state/environments"; +import { useEnvironmentServerConfig } from "../../state/entities"; +import { linearEnvironment } from "../../state/linear"; +import { useEnvironmentQuery } from "../../state/query"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { ConnectionSheetButton } from "../connection/ConnectionSheetButton"; +import { SettingsSection } from "./components/SettingsSection"; +import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; + +const LINEAR_KEY_HELP = "Linear → Settings → Security & access → Personal API keys"; + +export function SettingsLinearRouteScreen() { + const navigation = useNavigation(); + const { environments } = useEnvironments(); + const environmentId = environments[0]?.environmentId ?? null; + + const authQuery = useEnvironmentQuery( + environmentId === null ? null : linearEnvironment.authStatus({ environmentId, input: {} }), + ); + const setToken = useAtomCommand(linearEnvironment.setToken, { reportFailure: false }); + const clearToken = useAtomCommand(linearEnvironment.clearToken, { reportFailure: false }); + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false }); + + const config = useEnvironmentServerConfig(environmentId); + const linear = config?.settings?.linear; + + const [token, setTokenValue] = useState(""); + const [busy, setBusy] = useState(false); + + const connected = authQuery.data?.status === "authenticated"; + const account = authQuery.data?.account; + + const handleConnect = useCallback(async () => { + const trimmed = token.trim(); + if (environmentId === null || trimmed.length === 0 || busy) return; + setBusy(true); + try { + const result = await setToken({ environmentId, input: { token: trimmed } }); + if (result._tag === "Success" && result.value.status === "authenticated") { + setTokenValue(""); + authQuery.refresh(); + } + } finally { + setBusy(false); + } + }, [authQuery, busy, environmentId, setToken, token]); + + const handleDisconnect = useCallback(async () => { + if (environmentId === null || busy) return; + setBusy(true); + try { + await clearToken({ environmentId, input: {} }); + authQuery.refresh(); + } finally { + setBusy(false); + } + }, [authQuery, busy, clearToken, environmentId]); + + const setSyncFlag = useCallback( + (patch: Record) => { + if (environmentId === null) return; + void updateSettings({ environmentId, input: { patch: { linear: patch } } }); + }, + [environmentId, updateSettings], + ); + + return ( + + + + + + + + {connected + ? `Connected as ${account?.name ?? "Linear account"}${ + account?.email ? ` (${account.email})` : "" + }` + : "Not connected"} + + + {connected ? ( + void handleDisconnect()} + tone="danger" + /> + ) : ( + <> + + void handleConnect()} + tone="primary" + /> + + Create a personal API key in {LINEAR_KEY_HELP}. The key is stored securely on the + server. + + + )} + + + + {connected ? ( + + navigation.navigate("LinearImport")} + > + + Browse & import issues + + + + ) : null} + + + setSyncFlag({ autoSync: value })} + value={linear?.autoSync ?? true} + /> + setSyncFlag({ transitionOnStart: value })} + value={linear?.transitionOnStart ?? true} + /> + setSyncFlag({ transitionOnPrOpen: value })} + value={linear?.transitionOnPrOpen ?? true} + /> + setSyncFlag({ transitionOnMerge: value })} + value={linear?.transitionOnMerge ?? true} + /> + setSyncFlag({ postComments: value })} + value={linear?.postComments ?? false} + /> + + + + ); +} diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 11d363dbf69..fd59afcd376 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -94,6 +94,10 @@ function LocalSettingsRouteScreen() { + + + + @@ -411,6 +415,10 @@ function ConfiguredSettingsRouteScreen() { + + + + diff --git a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts index 7de54bceee5..0493392fcce 100644 --- a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts +++ b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts @@ -1 +1,5 @@ -export type SettingsSheetTarget = "SettingsEnvironments" | "SettingsArchive" | "SettingsAppearance"; +export type SettingsSheetTarget = + | "SettingsEnvironments" + | "SettingsArchive" + | "SettingsAppearance" + | "SettingsLinear"; diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 654be473b98..9efa1646c05 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -16,6 +16,8 @@ import { relativeTime } from "../../lib/time"; import { useThemeColor } from "../../lib/useThemeColor"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useThreadPr } from "../../state/use-thread-pr"; +import { linearEnvironment } from "../../state/linear"; +import { useAtomCommand } from "../../state/use-atom-command"; import type { HomeGroupDisplayAction } from "../home/homeListItems"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { resolveThreadStatus } from "./threadPresentation"; @@ -446,6 +448,24 @@ export const ThreadListRow = memo(function ThreadListRow(props: { const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); + const completeLinearIssue = useAtomCommand(linearEnvironment.completeThreadIssue, { + reportFailure: false, + }); + const linearIssue = thread.linearIssue ?? null; + const menuActions = useMemo( + () => + linearIssue + ? [ + { + id: "linear-done", + title: `Mark ${linearIssue.identifier} done`, + image: "checkmark.circle", + }, + ...THREAD_ROW_MENU_ACTIONS, + ] + : THREAD_ROW_MENU_ACTIONS, + [linearIssue], + ); const primaryAction = useMemo( () => ({ accessibilityLabel: `Archive ${thread.title}`, @@ -459,8 +479,14 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { if (nativeEvent.event === "archive") handleArchive(); if (nativeEvent.event === "delete") handleDelete(); + if (nativeEvent.event === "linear-done") { + void completeLinearIssue({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }); + } }, - [handleArchive, handleDelete], + [handleArchive, handleDelete, completeLinearIssue, thread.environmentId, thread.id], ); const statusPill = effectiveStatus ? ( @@ -474,6 +500,19 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ) : null; + const linearBadge = linearIssue ? ( + + + {linearIssue.identifier} + + + ) : null; + const subtitleRow = subtitleParts.length > 0 || pr !== null ? ( @@ -539,6 +578,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { {thread.title} + {linearBadge} {statusPill} + {linearBadge} {statusPill} diff --git a/apps/mobile/src/lib/projectThreadStartTurn.ts b/apps/mobile/src/lib/projectThreadStartTurn.ts index e3c2f744ada..fff1f3da5c8 100644 --- a/apps/mobile/src/lib/projectThreadStartTurn.ts +++ b/apps/mobile/src/lib/projectThreadStartTurn.ts @@ -2,6 +2,7 @@ import { CommandId, MessageId, ThreadId, + type LinearIssueLink, type ModelSelection, type ProjectId, type ProviderInteractionMode, @@ -38,6 +39,8 @@ export interface ProjectThreadStartTurnSpec { readonly startFromOrigin: boolean; /** Generated temp branch for worktree mode; unused for local mode. */ readonly worktreeBranchName: string; + /** Linear issue this thread is imported from, if any. */ + readonly linearIssue?: LinearIssueLink | null; } /** @@ -70,6 +73,7 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe interactionMode: spec.interactionMode, branch: spec.branch, worktreePath: isWorktree ? null : spec.worktreePath, + ...(spec.linearIssue ? { linearIssue: spec.linearIssue } : {}), createdAt: spec.createdAt, }, ...(isWorktree diff --git a/apps/mobile/src/state/linear.ts b/apps/mobile/src/state/linear.ts new file mode 100644 index 00000000000..8861cdcd3ea --- /dev/null +++ b/apps/mobile/src/state/linear.ts @@ -0,0 +1,5 @@ +import { createLinearEnvironmentAtoms } from "@t3tools/client-runtime/state/linear"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const linearEnvironment = createLinearEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index ebc4f984b86..04d0a13cfe2 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -52,6 +52,7 @@ import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; import { RuntimeReceiptBusTest } from "../src/orchestration/Layers/RuntimeReceiptBus.ts"; import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts"; +import { LinearSyncReactor } from "../src/orchestration/Services/LinearSyncReactor.ts"; import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionLive } from "../src/orchestration/Layers/ProviderRuntimeIngestion.ts"; import { @@ -366,6 +367,11 @@ export const makeOrchestrationIntegrationHarness = ( drain: Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(LinearSyncReactor, { + start: () => Effect.void, + }), + ), Layer.provideMerge( Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { publishThread: () => Effect.void, diff --git a/apps/server/src/linear/LinearApi.ts b/apps/server/src/linear/LinearApi.ts new file mode 100644 index 00000000000..4ce86e9ad91 --- /dev/null +++ b/apps/server/src/linear/LinearApi.ts @@ -0,0 +1,1033 @@ +import * as Config from "effect/Config"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { + LinearAuthError, + LinearRequestError, + LinearTokenStoreError, + type LinearApiOperation, + type LinearAttachment, + type LinearAuthStatus, + type LinearComment, + type LinearIssueDetail, + type LinearIssueFilter, + type LinearIssueSummary, + type LinearLabel, + type LinearLinkedPullRequest, + type LinearListIssuesResult, + type LinearMutationResult, + type LinearProject, + type LinearSubIssue, + type LinearTeam, + type LinearUser, + type LinearWorkflowState, + type LinearWorkflowStateType, +} from "@t3tools/contracts"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; + +const DEFAULT_API_BASE_URL = "https://api.linear.app/graphql"; + +/** Secret-store key holding the Linear personal access token. */ +export const LINEAR_API_TOKEN_SECRET = "linear.api-token"; + +/** How many linked issues/comments/attachments to request per issue. */ +const ISSUE_RELATION_LIMIT = 50; + +const LinearApiEnvConfig = Config.all({ + baseUrl: Config.string("T3CODE_LINEAR_API_BASE_URL").pipe( + Config.withDefault(DEFAULT_API_BASE_URL), + ), + envToken: Config.string("T3CODE_LINEAR_API_TOKEN").pipe(Config.option), +}); + +// ── GraphQL response schemas (partial — excess keys are ignored) ───── + +const GraphQlErrorEntry = Schema.Struct({ + message: Schema.optional(Schema.String), +}); + +const NamedNode = Schema.Struct({ name: Schema.optional(Schema.String) }); + +const StateNode = Schema.Struct({ + name: Schema.optional(Schema.String), + type: Schema.optional(Schema.String), +}); + +const TeamRef = Schema.Struct({ + id: Schema.optional(Schema.String), + key: Schema.optional(Schema.String), +}); + +const RawViewer = Schema.Struct({ + id: Schema.optional(Schema.String), + name: Schema.optional(Schema.String), + email: Schema.optional(Schema.String), +}); + +const RawIssueSummary = Schema.Struct({ + id: Schema.String, + identifier: Schema.String, + title: Schema.optional(Schema.String), + url: Schema.optional(Schema.String), + priorityLabel: Schema.optional(Schema.String), + state: Schema.optional(Schema.NullOr(StateNode)), + assignee: Schema.optional(Schema.NullOr(NamedNode)), + team: Schema.optional(Schema.NullOr(TeamRef)), +}); + +const RawIssueConnection = Schema.Struct({ + nodes: Schema.Array(RawIssueSummary), +}); + +const RawIssueDetail = Schema.Struct({ + id: Schema.String, + identifier: Schema.String, + title: Schema.optional(Schema.String), + url: Schema.optional(Schema.String), + description: Schema.optional(Schema.NullOr(Schema.String)), + priorityLabel: Schema.optional(Schema.String), + state: Schema.optional(Schema.NullOr(StateNode)), + assignee: Schema.optional(Schema.NullOr(NamedNode)), + team: Schema.optional(Schema.NullOr(TeamRef)), + labels: Schema.optional(Schema.NullOr(Schema.Struct({ nodes: Schema.Array(NamedNode) }))), + children: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + identifier: Schema.String, + title: Schema.optional(Schema.String), + state: Schema.optional(Schema.NullOr(NamedNode)), + }), + ), + }), + ), + ), + attachments: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + title: Schema.optional(Schema.String), + url: Schema.optional(Schema.String), + sourceType: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + }), + ), + ), + comments: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + body: Schema.optional(Schema.String), + createdAt: Schema.optional(Schema.String), + user: Schema.optional(Schema.NullOr(NamedNode)), + }), + ), + }), + ), + ), +}); + +const viewerEnvelope = Schema.Struct({ + data: Schema.optional( + Schema.NullOr(Schema.Struct({ viewer: Schema.optional(Schema.NullOr(RawViewer)) })), + ), + errors: Schema.optional(Schema.Array(GraphQlErrorEntry)), +}); + +const searchEnvelope = Schema.Struct({ + data: Schema.optional( + Schema.NullOr( + Schema.Struct({ + searchIssues: Schema.optional(Schema.NullOr(RawIssueConnection)), + issues: Schema.optional(Schema.NullOr(RawIssueConnection)), + }), + ), + ), + errors: Schema.optional(Schema.Array(GraphQlErrorEntry)), +}); + +const issueEnvelope = Schema.Struct({ + data: Schema.optional( + Schema.NullOr(Schema.Struct({ issue: Schema.optional(Schema.NullOr(RawIssueDetail)) })), + ), + errors: Schema.optional(Schema.Array(GraphQlErrorEntry)), +}); + +const RawPageInfo = Schema.Struct({ + hasNextPage: Schema.optional(Schema.Boolean), + endCursor: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawIssuePage = Schema.Struct({ + nodes: Schema.Array(RawIssueSummary), + pageInfo: Schema.optional(RawPageInfo), +}); + +const listEnvelope = Schema.Struct({ + data: Schema.optional( + Schema.NullOr(Schema.Struct({ issues: Schema.optional(Schema.NullOr(RawIssuePage)) })), + ), + errors: Schema.optional(Schema.Array(GraphQlErrorEntry)), +}); + +const RawTeam = Schema.Struct({ + id: Schema.String, + key: Schema.optional(Schema.String), + name: Schema.optional(Schema.String), +}); +const teamsEnvelope = Schema.Struct({ + data: Schema.optional( + Schema.NullOr( + Schema.Struct({ + teams: Schema.optional(Schema.NullOr(Schema.Struct({ nodes: Schema.Array(RawTeam) }))), + }), + ), + ), + errors: Schema.optional(Schema.Array(GraphQlErrorEntry)), +}); + +const RawState = Schema.Struct({ + id: Schema.String, + name: Schema.optional(Schema.String), + type: Schema.optional(Schema.String), + position: Schema.optional(Schema.Number), + color: Schema.optional(Schema.NullOr(Schema.String)), +}); +const statesEnvelope = Schema.Struct({ + data: Schema.optional( + Schema.NullOr( + Schema.Struct({ + team: Schema.optional( + Schema.NullOr( + Schema.Struct({ + states: Schema.optional( + Schema.NullOr(Schema.Struct({ nodes: Schema.Array(RawState) })), + ), + }), + ), + ), + }), + ), + ), + errors: Schema.optional(Schema.Array(GraphQlErrorEntry)), +}); + +const RawProject = Schema.Struct({ id: Schema.String, name: Schema.optional(Schema.String) }); +const projectsEnvelope = Schema.Struct({ + data: Schema.optional( + Schema.NullOr( + Schema.Struct({ + projects: Schema.optional( + Schema.NullOr(Schema.Struct({ nodes: Schema.Array(RawProject) })), + ), + }), + ), + ), + errors: Schema.optional(Schema.Array(GraphQlErrorEntry)), +}); + +const RawLabel = Schema.Struct({ + id: Schema.String, + name: Schema.optional(Schema.String), + color: Schema.optional(Schema.NullOr(Schema.String)), +}); +const labelsEnvelope = Schema.Struct({ + data: Schema.optional( + Schema.NullOr( + Schema.Struct({ + issueLabels: Schema.optional( + Schema.NullOr(Schema.Struct({ nodes: Schema.Array(RawLabel) })), + ), + }), + ), + ), + errors: Schema.optional(Schema.Array(GraphQlErrorEntry)), +}); + +const RawUser = Schema.Struct({ + id: Schema.String, + name: Schema.optional(Schema.String), + displayName: Schema.optional(Schema.String), + email: Schema.optional(Schema.String), +}); +const usersEnvelope = Schema.Struct({ + data: Schema.optional( + Schema.NullOr( + Schema.Struct({ + users: Schema.optional(Schema.NullOr(Schema.Struct({ nodes: Schema.Array(RawUser) }))), + viewer: Schema.optional( + Schema.NullOr(Schema.Struct({ id: Schema.optional(Schema.String) })), + ), + }), + ), + ), + errors: Schema.optional(Schema.Array(GraphQlErrorEntry)), +}); + +const SuccessNode = Schema.Struct({ success: Schema.optional(Schema.Boolean) }); +const mutationEnvelope = Schema.Struct({ + data: Schema.optional( + Schema.NullOr( + Schema.Struct({ + issueUpdate: Schema.optional(Schema.NullOr(SuccessNode)), + commentCreate: Schema.optional(Schema.NullOr(SuccessNode)), + attachmentCreate: Schema.optional(Schema.NullOr(SuccessNode)), + }), + ), + ), + errors: Schema.optional(Schema.Array(GraphQlErrorEntry)), +}); + +// ── GraphQL documents ──────────────────────────────────────────────── + +const SUMMARY_FIELDS = `id identifier title url priorityLabel state { name type } assignee { name } team { id key }`; + +const SEARCH_DOCUMENT = `query T3CodeLinearSearch($term: String!, $first: Int!) { + searchIssues(term: $term, first: $first) { nodes { ${SUMMARY_FIELDS} } } +}`; + +const RECENT_DOCUMENT = `query T3CodeLinearRecent($first: Int!) { + issues(first: $first, orderBy: updatedAt) { nodes { ${SUMMARY_FIELDS} } } +}`; + +const LIST_ISSUES_DOCUMENT = `query T3CodeLinearList($filter: IssueFilter, $first: Int!, $after: String) { + issues(filter: $filter, first: $first, after: $after, orderBy: updatedAt) { + nodes { ${SUMMARY_FIELDS} } + pageInfo { hasNextPage endCursor } + } +}`; + +const TEAMS_DOCUMENT = `query T3CodeLinearTeams { teams(first: 250) { nodes { id key name } } }`; + +const STATES_DOCUMENT = `query T3CodeLinearStates($teamId: String!) { + team(id: $teamId) { states { nodes { id name type position color } } } +}`; + +const PROJECTS_DOCUMENT = `query T3CodeLinearProjects { projects(first: 250) { nodes { id name } } }`; + +const LABELS_DOCUMENT = `query T3CodeLinearLabels { issueLabels(first: 250) { nodes { id name color } } }`; + +const USERS_DOCUMENT = `query T3CodeLinearUsers { users(first: 250) { nodes { id name displayName email } } viewer { id } }`; + +const ISSUE_DOCUMENT = `query T3CodeLinearIssue($id: String!, $relations: Int!) { + issue(id: $id) { + id identifier title url description priorityLabel + state { name type } + assignee { name } + team { id key } + labels(first: $relations) { nodes { name } } + children(first: $relations) { nodes { identifier title state { name } } } + attachments(first: $relations) { nodes { title url sourceType } } + comments(first: $relations) { nodes { body createdAt user { name } } } + } +}`; + +const VIEWER_DOCUMENT = `query T3CodeLinearViewer { viewer { id name email } }`; + +const UPDATE_ISSUE_STATE_DOCUMENT = `mutation T3CodeLinearUpdateState($id: String!, $stateId: String!) { + issueUpdate(id: $id, input: { stateId: $stateId }) { success } +}`; + +const CREATE_COMMENT_DOCUMENT = `mutation T3CodeLinearComment($issueId: String!, $body: String!) { + commentCreate(input: { issueId: $issueId, body: $body }) { success } +}`; + +const CREATE_ATTACHMENT_DOCUMENT = `mutation T3CodeLinearAttachment($input: AttachmentCreateInput!) { + attachmentCreate(input: $input) { success } +}`; + +// ── Helpers ────────────────────────────────────────────────────────── + +function clean(value: string | null | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed !== undefined && trimmed.length > 0 ? trimmed : undefined; +} + +const WORKFLOW_STATE_TYPES: ReadonlyArray = [ + "backlog", + "unstarted", + "started", + "completed", + "canceled", + "triage", +]; + +function coerceStateType(value: string | null | undefined): LinearWorkflowStateType | undefined { + return value != null && (WORKFLOW_STATE_TYPES as ReadonlyArray).includes(value) + ? (value as LinearWorkflowStateType) + : undefined; +} + +function bytesToString(value: Uint8Array): string { + return new TextDecoder().decode(value); +} + +function stringToBytes(value: string): Uint8Array { + return new TextEncoder().encode(value); +} + +const PULL_REQUEST_URL_PATTERN = + /(github\.com\/[^/]+\/[^/]+\/pull\/\d+)|(\/-\/merge_requests\/\d+)|(bitbucket\.org\/[^/]+\/[^/]+\/pull-requests\/\d+)/i; + +function isPullRequestAttachment(url: string | undefined): boolean { + // A Linear attachment is only treated as a linked PR when its URL matches a + // known pull/merge-request path. The `sourceType` (e.g. "github") is too + // broad on its own — it also covers commit and file links. + return url !== undefined && PULL_REQUEST_URL_PATTERN.test(url); +} + +function toSummary(raw: typeof RawIssueSummary.Type): LinearIssueSummary { + return { + id: raw.id, + identifier: raw.identifier, + title: clean(raw.title) ?? raw.identifier, + url: raw.url ?? "", + stateName: clean(raw.state?.name), + stateType: coerceStateType(raw.state?.type), + priorityLabel: clean(raw.priorityLabel), + assigneeName: clean(raw.assignee?.name), + teamKey: clean(raw.team?.key), + teamId: clean(raw.team?.id), + }; +} + +function toDetail(raw: typeof RawIssueDetail.Type): LinearIssueDetail { + const labels: Array = []; + for (const label of raw.labels?.nodes ?? []) { + const name = clean(label.name); + if (name !== undefined) labels.push(name); + } + + const subIssues: Array = (raw.children?.nodes ?? []).map((child) => ({ + identifier: child.identifier, + title: clean(child.title) ?? child.identifier, + stateName: clean(child.state?.name), + })); + + const attachments: Array = []; + const linkedPullRequests: Array = []; + for (const attachment of raw.attachments?.nodes ?? []) { + const url = clean(attachment.url); + if (url === undefined) continue; + const title = clean(attachment.title); + attachments.push({ url, title }); + if (isPullRequestAttachment(url)) { + linkedPullRequests.push({ url, title }); + } + } + + const comments: Array = (raw.comments?.nodes ?? []) + .map((comment) => ({ + author: clean(comment.user?.name), + body: comment.body ?? "", + createdAt: clean(comment.createdAt), + })) + .filter((comment) => comment.body.trim().length > 0); + + return { + id: raw.id, + identifier: raw.identifier, + title: clean(raw.title) ?? raw.identifier, + url: raw.url ?? "", + stateName: clean(raw.state?.name), + stateType: coerceStateType(raw.state?.type), + priorityLabel: clean(raw.priorityLabel), + assigneeName: clean(raw.assignee?.name), + teamKey: clean(raw.team?.key), + teamId: clean(raw.team?.id), + description: raw.description ?? "", + labels, + subIssues, + linkedPullRequests, + attachments, + comments, + }; +} + +function toTeam(raw: typeof RawTeam.Type): LinearTeam { + return { id: raw.id, key: clean(raw.key) ?? raw.id, name: clean(raw.name) ?? raw.id }; +} + +function toWorkflowState(raw: typeof RawState.Type, teamId: string): LinearWorkflowState { + return { + id: raw.id, + name: clean(raw.name) ?? raw.id, + type: coerceStateType(raw.type) ?? "unstarted", + position: raw.position ?? 0, + color: clean(raw.color), + teamId, + }; +} + +function toProject(raw: typeof RawProject.Type): LinearProject { + return { id: raw.id, name: clean(raw.name) ?? raw.id }; +} + +function toLabel(raw: typeof RawLabel.Type): LinearLabel { + return { id: raw.id, name: clean(raw.name) ?? raw.id, color: clean(raw.color) }; +} + +function toUser(raw: typeof RawUser.Type, viewerId: string | undefined): LinearUser { + return { + id: raw.id, + name: clean(raw.name) ?? clean(raw.displayName) ?? raw.id, + displayName: clean(raw.displayName), + email: clean(raw.email), + ...(viewerId !== undefined && raw.id === viewerId ? { isMe: true } : {}), + }; +} + +/** Build a Linear `IssueFilter` object from our filter contract. */ +function buildIssueFilter( + filter: LinearIssueFilter | undefined, +): Record | undefined { + if (filter === undefined) return undefined; + const out: Record = {}; + if (filter.teamId) out.team = { id: { eq: filter.teamId } }; + if (filter.assigneeId) out.assignee = { id: { eq: filter.assigneeId } }; + if (filter.stateId) out.state = { id: { eq: filter.stateId } }; + else if (filter.stateType) out.state = { type: { eq: filter.stateType } }; + if (filter.projectId) out.project = { id: { eq: filter.projectId } }; + if (filter.labelId) out.labels = { some: { id: { eq: filter.labelId } } }; + if (typeof filter.priority === "number") out.priority = { eq: filter.priority }; + const query = filter.query?.trim(); + if (query !== undefined && query.length > 0) { + out.or = [ + { title: { containsIgnoreCase: query } }, + { description: { containsIgnoreCase: query } }, + ]; + } + return Object.keys(out).length > 0 ? out : undefined; +} + +function firstGraphQlErrorMessage( + errors: ReadonlyArray | undefined, +): string | undefined { + for (const entry of errors ?? []) { + const message = clean(entry.message); + if (message !== undefined) return message; + } + return undefined; +} + +function isAuthMessage(message: string | undefined): boolean { + if (message === undefined) return false; + const lowered = message.toLowerCase(); + return ( + lowered.includes("authentication") || + lowered.includes("authorization") || + lowered.includes("unauthorized") || + lowered.includes("api key") || + lowered.includes("access token") + ); +} + +// ── Service ────────────────────────────────────────────────────────── + +export class LinearApi extends Context.Service< + LinearApi, + { + readonly probeAuth: Effect.Effect; + readonly searchIssues: (input: { + readonly query: string; + readonly limit: number; + }) => Effect.Effect< + { readonly issues: ReadonlyArray; readonly truncated: boolean }, + LinearAuthError | LinearRequestError | LinearTokenStoreError + >; + readonly fetchIssues: (input: { + readonly ids: ReadonlyArray; + }) => Effect.Effect< + ReadonlyArray, + LinearAuthError | LinearRequestError | LinearTokenStoreError + >; + readonly listIssues: (input: { + readonly filter?: LinearIssueFilter | undefined; + readonly first: number; + readonly after?: string | undefined; + }) => Effect.Effect< + LinearListIssuesResult, + LinearAuthError | LinearRequestError | LinearTokenStoreError + >; + readonly listTeams: Effect.Effect< + ReadonlyArray, + LinearAuthError | LinearRequestError | LinearTokenStoreError + >; + readonly listWorkflowStates: (input: { + readonly teamId: string; + }) => Effect.Effect< + ReadonlyArray, + LinearAuthError | LinearRequestError | LinearTokenStoreError + >; + readonly listProjects: Effect.Effect< + ReadonlyArray, + LinearAuthError | LinearRequestError | LinearTokenStoreError + >; + readonly listLabels: Effect.Effect< + ReadonlyArray, + LinearAuthError | LinearRequestError | LinearTokenStoreError + >; + readonly listUsers: Effect.Effect< + ReadonlyArray, + LinearAuthError | LinearRequestError | LinearTokenStoreError + >; + readonly updateIssueState: (input: { + readonly issueId: string; + readonly stateId: string; + }) => Effect.Effect< + LinearMutationResult, + LinearAuthError | LinearRequestError | LinearTokenStoreError + >; + readonly createComment: (input: { + readonly issueId: string; + readonly body: string; + }) => Effect.Effect< + LinearMutationResult, + LinearAuthError | LinearRequestError | LinearTokenStoreError + >; + readonly createAttachment: (input: { + readonly issueId: string; + readonly url: string; + readonly title?: string | undefined; + readonly subtitle?: string | undefined; + }) => Effect.Effect< + LinearMutationResult, + LinearAuthError | LinearRequestError | LinearTokenStoreError + >; + readonly setToken: (token: string) => Effect.Effect; + readonly clearToken: Effect.Effect; + } +>()("t3/linear/LinearApi") {} + +export const make = Effect.gen(function* () { + const config = yield* LinearApiEnvConfig; + const httpClient = yield* HttpClient.HttpClient; + const secrets = yield* ServerSecretStore.ServerSecretStore; + + const resolveToken = ( + operation: LinearApiOperation, + ): Effect.Effect, LinearTokenStoreError> => + secrets.get(LINEAR_API_TOKEN_SECRET).pipe( + Effect.mapError( + (cause) => + new LinearTokenStoreError({ + operation, + detail: "Failed to read the stored Linear token.", + cause, + }), + ), + Effect.map( + Option.match({ + onSome: (bytes) => { + const token = clean(bytesToString(bytes)); + return token !== undefined ? Option.some(token) : config.envToken; + }, + onNone: () => config.envToken, + }), + ), + ); + + const requireToken = (operation: LinearApiOperation) => + resolveToken(operation).pipe( + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new LinearAuthError({ + operation, + detail: "Connect Linear in Settings to continue.", + }), + ), + onSome: Effect.succeed, + }), + ), + ); + + const runGraphql = ( + operation: LinearApiOperation, + token: string, + document: string, + variables: Record, + envelopeSchema: S, + ): Effect.Effect => { + const request = HttpClientRequest.post(config.baseUrl).pipe( + HttpClientRequest.setHeader("authorization", token), + HttpClientRequest.acceptJson, + HttpClientRequest.bodyJsonUnsafe({ query: document, variables }), + ); + return httpClient.execute(request).pipe( + Effect.mapError( + (cause) => + new LinearRequestError({ + operation, + detail: "Failed to reach the Linear API.", + cause, + }), + ), + Effect.flatMap((response) => + HttpClientResponse.matchStatus({ + "2xx": (success) => + HttpClientResponse.schemaBodyJson(envelopeSchema)(success).pipe( + Effect.mapError( + (cause) => + new LinearRequestError({ + operation, + status: success.status, + detail: "Linear returned a response that could not be decoded.", + cause, + }), + ), + ), + orElse: (failed) => + failed.status === 401 || failed.status === 403 + ? Effect.fail( + new LinearAuthError({ + operation, + detail: "Linear rejected the API token.", + }), + ) + : Effect.fail( + new LinearRequestError({ + operation, + status: failed.status, + detail: `Linear returned HTTP ${failed.status}.`, + }), + ), + })(response), + ), + ); + }; + + const failFromGraphQlErrors = ( + operation: LinearApiOperation, + errors: ReadonlyArray | undefined, + ): Effect.Effect => { + const message = firstGraphQlErrorMessage(errors); + return isAuthMessage(message) + ? Effect.fail(new LinearAuthError({ operation, ...(message ? { detail: message } : {}) })) + : Effect.fail( + new LinearRequestError({ + operation, + detail: message ?? "Linear reported an error for the request.", + }), + ); + }; + + const probeAuth: LinearApi["Service"]["probeAuth"] = resolveToken("probeAuth").pipe( + Effect.flatMap( + Option.match({ + onNone: () => + Effect.succeed({ + status: "unauthenticated", + detail: "No Linear API token is configured.", + }), + onSome: (token) => + runGraphql("probeAuth", token, VIEWER_DOCUMENT, {}, viewerEnvelope).pipe( + Effect.flatMap((envelope): Effect.Effect => { + if (envelope.errors !== undefined && envelope.errors.length > 0) { + const message = firstGraphQlErrorMessage(envelope.errors); + // Only auth-related GraphQL errors mean a rejected token; + // other errors are real API/outage failures and propagate. + return isAuthMessage(message) + ? Effect.succeed({ + status: "unauthenticated", + detail: "The stored Linear token was rejected.", + }) + : Effect.fail( + new LinearRequestError({ + operation: "probeAuth", + detail: message ?? "Linear reported an error for the request.", + }), + ); + } + const viewer = envelope.data?.viewer ?? undefined; + const name = clean(viewer?.name); + return Effect.succeed({ + status: "authenticated", + account: { + name: name ?? "Linear account", + ...(clean(viewer?.email) ? { email: clean(viewer?.email) } : {}), + }, + }); + }), + // A rejected token → "unauthenticated"; genuine connectivity/API + // errors (LinearRequestError) stay in the channel so a transient + // outage isn't shown as "not connected". + Effect.catchTags({ + LinearAuthError: () => + Effect.succeed({ + status: "unauthenticated", + detail: "The stored Linear token was rejected.", + }), + }), + ), + }), + ), + ); + + const searchIssues: LinearApi["Service"]["searchIssues"] = (input) => { + const term = input.query.trim(); + return requireToken("searchIssues").pipe( + Effect.flatMap((token) => + term.length === 0 + ? runGraphql( + "searchIssues", + token, + RECENT_DOCUMENT, + { first: input.limit }, + searchEnvelope, + ) + : runGraphql( + "searchIssues", + token, + SEARCH_DOCUMENT, + { term, first: input.limit }, + searchEnvelope, + ), + ), + Effect.flatMap((envelope) => { + if (envelope.errors !== undefined && envelope.errors.length > 0) { + return failFromGraphQlErrors("searchIssues", envelope.errors); + } + const connection = envelope.data?.searchIssues ?? envelope.data?.issues ?? null; + const nodes = connection?.nodes ?? []; + return Effect.succeed({ + issues: nodes.map(toSummary), + truncated: nodes.length >= input.limit, + }); + }), + ); + }; + + const fetchIssue = ( + token: string, + id: string, + ): Effect.Effect => + runGraphql( + "fetchIssues", + token, + ISSUE_DOCUMENT, + { id, relations: ISSUE_RELATION_LIMIT }, + issueEnvelope, + ).pipe( + Effect.flatMap((envelope) => { + if (envelope.errors !== undefined && envelope.errors.length > 0) { + return failFromGraphQlErrors("fetchIssues", envelope.errors); + } + const issue = envelope.data?.issue ?? null; + return Effect.succeed(issue === null ? null : toDetail(issue)); + }), + ); + + const fetchIssues: LinearApi["Service"]["fetchIssues"] = (input) => + requireToken("fetchIssues").pipe( + Effect.flatMap((token) => + Effect.forEach(input.ids, (id) => fetchIssue(token, id), { concurrency: 4 }), + ), + Effect.map((results) => + results.filter((issue): issue is LinearIssueDetail => issue !== null), + ), + ); + + // Run a read query: resolve token → POST → fail on GraphQL errors → envelope. + const runReadQuery = ( + operation: LinearApiOperation, + document: string, + variables: Record, + envelope: S, + ): Effect.Effect< + S["Type"], + LinearAuthError | LinearRequestError | LinearTokenStoreError, + S["DecodingServices"] + > => + requireToken(operation).pipe( + Effect.flatMap((token) => runGraphql(operation, token, document, variables, envelope)), + Effect.flatMap((env) => { + const errs = (env as { errors?: ReadonlyArray }).errors; + return errs !== undefined && errs.length > 0 + ? failFromGraphQlErrors(operation, errs) + : Effect.succeed(env); + }), + ); + + const listIssues: LinearApi["Service"]["listIssues"] = (input) => + runReadQuery( + "listIssues", + LIST_ISSUES_DOCUMENT, + { + filter: buildIssueFilter(input.filter) ?? null, + first: input.first, + after: input.after ?? null, + }, + listEnvelope, + ).pipe( + Effect.map((envelope) => { + const page = envelope.data?.issues ?? null; + const nodes = page?.nodes ?? []; + return { + issues: nodes.map(toSummary), + pageInfo: { + hasNextPage: page?.pageInfo?.hasNextPage ?? false, + ...(clean(page?.pageInfo?.endCursor) + ? { endCursor: clean(page?.pageInfo?.endCursor)! } + : {}), + }, + }; + }), + ); + + const listTeams: LinearApi["Service"]["listTeams"] = runReadQuery( + "listTeams", + TEAMS_DOCUMENT, + {}, + teamsEnvelope, + ).pipe(Effect.map((envelope) => (envelope.data?.teams?.nodes ?? []).map(toTeam))); + + const listWorkflowStates: LinearApi["Service"]["listWorkflowStates"] = (input) => + runReadQuery( + "listWorkflowStates", + STATES_DOCUMENT, + { teamId: input.teamId }, + statesEnvelope, + ).pipe( + Effect.map((envelope) => + (envelope.data?.team?.states?.nodes ?? []) + .map((state) => toWorkflowState(state, input.teamId)) + .sort((a, b) => a.position - b.position), + ), + ); + + const listProjects: LinearApi["Service"]["listProjects"] = runReadQuery( + "listProjects", + PROJECTS_DOCUMENT, + {}, + projectsEnvelope, + ).pipe(Effect.map((envelope) => (envelope.data?.projects?.nodes ?? []).map(toProject))); + + const listLabels: LinearApi["Service"]["listLabels"] = runReadQuery( + "listLabels", + LABELS_DOCUMENT, + {}, + labelsEnvelope, + ).pipe(Effect.map((envelope) => (envelope.data?.issueLabels?.nodes ?? []).map(toLabel))); + + const listUsers: LinearApi["Service"]["listUsers"] = runReadQuery( + "listUsers", + USERS_DOCUMENT, + {}, + usersEnvelope, + ).pipe( + Effect.map((envelope) => { + const viewerId = clean(envelope.data?.viewer?.id); + return (envelope.data?.users?.nodes ?? []).map((user) => toUser(user, viewerId)); + }), + ); + + const mutationSucceeded = ( + node: { readonly success?: boolean | undefined } | null | undefined, + ): LinearMutationResult => ({ success: node?.success ?? false }); + + const updateIssueState: LinearApi["Service"]["updateIssueState"] = (input) => + runReadQuery( + "updateIssueState", + UPDATE_ISSUE_STATE_DOCUMENT, + { id: input.issueId, stateId: input.stateId }, + mutationEnvelope, + ).pipe(Effect.map((envelope) => mutationSucceeded(envelope.data?.issueUpdate))); + + const createComment: LinearApi["Service"]["createComment"] = (input) => + runReadQuery( + "createComment", + CREATE_COMMENT_DOCUMENT, + { issueId: input.issueId, body: input.body }, + mutationEnvelope, + ).pipe(Effect.map((envelope) => mutationSucceeded(envelope.data?.commentCreate))); + + const createAttachment: LinearApi["Service"]["createAttachment"] = (input) => + runReadQuery( + "createAttachment", + CREATE_ATTACHMENT_DOCUMENT, + { + input: { + issueId: input.issueId, + url: input.url, + title: input.title ?? "T3 Code", + ...(input.subtitle !== undefined ? { subtitle: input.subtitle } : {}), + }, + }, + mutationEnvelope, + ).pipe(Effect.map((envelope) => mutationSucceeded(envelope.data?.attachmentCreate))); + + const persistToken = ( + operation: LinearApiOperation, + token: string, + ): Effect.Effect => + secrets.set(LINEAR_API_TOKEN_SECRET, stringToBytes(token)).pipe( + Effect.mapError( + (cause) => + new LinearTokenStoreError({ + operation, + detail: "Failed to store the Linear token.", + cause, + }), + ), + ); + + // After a token write we surface the resulting auth status, but a probe that + // can't reach Linear (outage) must not turn the write itself into a failure — + // the standalone `probeAuth` RPC still reports connectivity errors. + const probeAuthLenient: Effect.Effect = probeAuth.pipe( + Effect.catchTags({ + LinearRequestError: () => + Effect.succeed({ + status: "unauthenticated", + detail: "Saved, but couldn't reach Linear to verify the token.", + }), + }), + ); + + const setToken: LinearApi["Service"]["setToken"] = (token) => + persistToken("setToken", token.trim()).pipe(Effect.flatMap(() => probeAuthLenient)); + + const clearToken: LinearApi["Service"]["clearToken"] = secrets + .remove(LINEAR_API_TOKEN_SECRET) + .pipe( + Effect.mapError( + (cause) => + new LinearTokenStoreError({ + operation: "clearToken", + detail: "Failed to remove the Linear token.", + cause, + }), + ), + Effect.flatMap(() => probeAuthLenient), + ); + + return LinearApi.of({ + probeAuth, + searchIssues, + fetchIssues, + listIssues, + listTeams, + listWorkflowStates, + listProjects, + listLabels, + listUsers, + updateIssueState, + createComment, + createAttachment, + setToken, + clearToken, + }); +}); + +export const layer = Layer.effect(LinearApi, make); diff --git a/apps/server/src/linear/linearStateMapping.test.ts b/apps/server/src/linear/linearStateMapping.test.ts new file mode 100644 index 00000000000..9dbaf6cdbbf --- /dev/null +++ b/apps/server/src/linear/linearStateMapping.test.ts @@ -0,0 +1,52 @@ +import type { LinearWorkflowState } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveTargetStateId } from "./linearStateMapping.ts"; + +const states: ReadonlyArray = [ + { id: "s-backlog", name: "Backlog", type: "backlog", position: 0 }, + { id: "s-todo", name: "Todo", type: "unstarted", position: 1 }, + { id: "s-progress", name: "In Progress", type: "started", position: 2 }, + { id: "s-review", name: "In Review", type: "started", position: 3 }, + { id: "s-done", name: "Done", type: "completed", position: 4 }, + { id: "s-canceled", name: "Canceled", type: "canceled", position: 5 }, +]; + +describe("resolveTargetStateId", () => { + it("maps started to the In Progress state", () => { + expect(resolveTargetStateId(states, undefined, "started")).toBe("s-progress"); + }); + + it("maps done to the completed state", () => { + expect(resolveTargetStateId(states, undefined, "done")).toBe("s-done"); + }); + + it("maps review to a state named In Review", () => { + expect(resolveTargetStateId(states, undefined, "review")).toBe("s-review"); + }); + + it("prefers a valid per-team override", () => { + expect(resolveTargetStateId(states, { started: "s-todo" }, "started")).toBe("s-todo"); + }); + + it("ignores an override that no longer exists", () => { + expect(resolveTargetStateId(states, { started: "s-missing" }, "started")).toBe("s-progress"); + }); + + it("falls back to the first started state when none is named In Progress", () => { + const renamed: ReadonlyArray = [ + { id: "s-doing", name: "Doing", type: "started", position: 1 }, + { id: "s-done", name: "Complete", type: "completed", position: 2 }, + ]; + expect(resolveTargetStateId(renamed, undefined, "started")).toBe("s-doing"); + expect(resolveTargetStateId(renamed, undefined, "done")).toBe("s-done"); + }); + + it("returns undefined for review when no review-like state exists", () => { + const noReview: ReadonlyArray = [ + { id: "s-progress", name: "In Progress", type: "started", position: 1 }, + { id: "s-done", name: "Done", type: "completed", position: 2 }, + ]; + expect(resolveTargetStateId(noReview, undefined, "review")).toBeUndefined(); + }); +}); diff --git a/apps/server/src/linear/linearStateMapping.ts b/apps/server/src/linear/linearStateMapping.ts new file mode 100644 index 00000000000..2818301e785 --- /dev/null +++ b/apps/server/src/linear/linearStateMapping.ts @@ -0,0 +1,39 @@ +import type { LinearTeamStateMapping, LinearWorkflowState } from "@t3tools/contracts"; + +export type LinearLifecycleStage = "started" | "review" | "done"; + +/** + * Resolve which Linear workflow-state id a lifecycle stage should move an issue + * to, for a given team. Precedence: + * 1. explicit per-team override (if it still exists in the team's states); + * 2. a sensibly-named state ("In Progress" / "In Review"); + * 3. the first state of the matching category `type`. + * Returns undefined when nothing sensible maps (the caller skips the transition). + */ +export function resolveTargetStateId( + states: ReadonlyArray, + mapping: LinearTeamStateMapping | undefined, + stage: LinearLifecycleStage, +): string | undefined { + const override = mapping?.[stage]; + if (override !== undefined && states.some((state) => state.id === override)) { + return override; + } + + const ordered = [...states].sort((a, b) => a.position - b.position); + const byType = (type: LinearWorkflowState["type"]) => + ordered.find((state) => state.type === type)?.id; + const byName = (needle: string) => + ordered.find((state) => state.name.toLowerCase().includes(needle))?.id; + + switch (stage) { + case "started": + return byName("in progress") ?? byType("started"); + case "review": + // Linear has no "review" category; teams model it as a custom started + // state (commonly "In Review"). Fall back to nothing so the caller skips. + return byName("in review") ?? byName("review"); + case "done": + return byType("completed"); + } +} diff --git a/apps/server/src/orchestration/Layers/LinearSyncReactor.ts b/apps/server/src/orchestration/Layers/LinearSyncReactor.ts new file mode 100644 index 00000000000..5dda5ba93c4 --- /dev/null +++ b/apps/server/src/orchestration/Layers/LinearSyncReactor.ts @@ -0,0 +1,199 @@ +import { + CommandId, + type LinearIssueLink, + type LinearWorkflowState, + type OrchestrationThreadShell, + type ServerSettings as ServerSettingsType, + type ThreadId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; + +import * as LinearApi from "../../linear/LinearApi.ts"; +import { + resolveTargetStateId, + type LinearLifecycleStage, +} from "../../linear/linearStateMapping.ts"; +import * as ServerSettings from "../../serverSettings.ts"; +import * as VcsStatusBroadcaster from "../../vcs/VcsStatusBroadcaster.ts"; +import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../Services/ProjectionSnapshotQuery.ts"; +import { LinearSyncReactor } from "../Services/LinearSyncReactor.ts"; + +const STAGE_RANK: Record = { started: 1, review: 2, done: 3 }; + +function transitionEnabled( + settings: ServerSettingsType["linear"], + stage: LinearLifecycleStage, +): boolean { + switch (stage) { + case "started": + return settings.transitionOnStart; + case "review": + return settings.transitionOnPrOpen; + case "done": + return settings.transitionOnMerge; + } +} + +const make = Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + const linear = yield* LinearApi.LinearApi; + const settingsService = yield* ServerSettings.ServerSettingsService; + const snapshot = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const vcs = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const crypto = yield* Crypto.Crypto; + + // Highest lifecycle stage already written per Linear issue id (never regress). + const appliedRank = new Map(); + // Threads whose PR status we're already watching. + const watchedThreads = new Set(); + // Cache team workflow states for the process lifetime. + const statesByTeam = new Map>(); + + const getTeamStates = (teamId: string) => + statesByTeam.has(teamId) + ? Effect.succeed(statesByTeam.get(teamId)!) + : linear.listWorkflowStates({ teamId }).pipe( + Effect.tap((states) => Effect.sync(() => statesByTeam.set(teamId, states))), + Effect.catchCause((cause) => + Effect.logDebug("linear sync could not load workflow states", { + teamId, + cause: Cause.pretty(cause), + }).pipe(Effect.as([] as ReadonlyArray)), + ), + ); + + const reflectState = (threadId: ThreadId, issue: LinearIssueLink, state: LinearWorkflowState) => + crypto.randomUUIDv4.pipe( + Effect.flatMap((uuid) => + engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make(uuid), + threadId, + linearIssue: { ...issue, stateName: state.name, stateType: state.type }, + }), + ), + Effect.catchCause((cause) => + Effect.logDebug("linear sync could not reflect state to thread", { + threadId, + cause: Cause.pretty(cause), + }), + ), + ); + + const applyStage = (thread: OrchestrationThreadShell, stage: LinearLifecycleStage) => + Effect.gen(function* () { + const issue = thread.linearIssue; + if (issue === null || issue === undefined) return; + const teamId = issue.teamId; + if (teamId === undefined) return; + + const settings = (yield* settingsService.getSettings).linear; + if (!settings.autoSync || !transitionEnabled(settings, stage)) return; + + const rank = STAGE_RANK[stage]; + if ((appliedRank.get(issue.id) ?? 0) >= rank) return; + + const states = yield* getTeamStates(teamId); + const stateId = resolveTargetStateId(states, settings.stateMappingByTeam[teamId], stage); + if (stateId === undefined) return; + + const result = yield* linear.updateIssueState({ issueId: issue.id, stateId }); + if (!result.success) return; + + if (stage === "started" && settings.postComments) { + yield* linear + .createComment({ + issueId: issue.id, + body: `T3 Code started working on this issue in thread “${thread.title}”.`, + }) + .pipe(Effect.ignore); + } + + const nextState = states.find((state) => state.id === stateId); + if (nextState !== undefined) { + yield* reflectState(thread.id, issue, nextState); + } + // Mark applied only after the write + best-effort badge reflect, so a + // failed issueUpdate can be retried on the next signal. + appliedRank.set(issue.id, rank); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("linear sync failed to apply stage", { + threadId: thread.id, + stage, + cause: Cause.pretty(cause), + }), + ), + ); + + const watchPullRequest = (thread: OrchestrationThreadShell) => + Effect.gen(function* () { + const worktreePath = thread.worktreePath; + if (worktreePath === null || thread.linearIssue == null) return; + // Key by worktree so a thread that later moves/creates its worktree gets + // a fresh watcher instead of being ignored on the stale path. + const watchKey = `${thread.id}:${worktreePath}`; + if (watchedThreads.has(watchKey)) return; + watchedThreads.add(watchKey); + + yield* Effect.forkScoped( + Stream.runForEach(vcs.streamStatus({ cwd: worktreePath }), (event) => { + const remote = event._tag === "localUpdated" ? null : event.remote; + const pr = remote?.pr ?? null; + if (pr === null) return Effect.void; + if (pr.state === "open") return applyStage(thread, "review"); + if (pr.state === "merged") return applyStage(thread, "done"); + return Effect.void; + }).pipe( + Effect.catchCause((cause) => + Effect.logDebug("linear sync PR watcher stopped", { + threadId: thread.id, + cause: Cause.pretty(cause), + }), + ), + ), + ); + }); + + const onThreadActivity = (threadId: ThreadId, markStarted: boolean) => + snapshot.getThreadShellById(threadId).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.void, + onSome: (thread) => + thread.linearIssue == null + ? Effect.void + : watchPullRequest(thread).pipe( + Effect.andThen(markStarted ? applyStage(thread, "started") : Effect.void), + ), + }), + ), + Effect.catchCause(() => Effect.void), + ); + + const start: LinearSyncReactor["Service"]["start"] = Effect.fn("start")(function* () { + yield* Effect.forkScoped( + Stream.runForEach(engine.streamDomainEvents, (event) => { + switch (event.type) { + case "thread.turn-start-requested": + return onThreadActivity(event.payload.threadId, true); + case "thread.created": + case "thread.meta-updated": + return onThreadActivity(event.payload.threadId, false); + default: + return Effect.void; + } + }), + ); + }); + + return { start } satisfies LinearSyncReactor["Service"]; +}); + +export const LinearSyncReactorLive = Layer.effect(LinearSyncReactor, make); diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index 300d1526bb9..8b636a7bbef 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -9,6 +9,7 @@ import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; +import { LinearSyncReactor } from "../Services/LinearSyncReactor.ts"; import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; @@ -64,6 +65,14 @@ describe("OrchestrationReactor", () => { drain: Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(LinearSyncReactor, { + start: () => { + started.push("linear-sync-reactor"); + return Effect.void; + }, + }), + ), Layer.provideMerge( Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { publishThread: () => Effect.void, @@ -85,6 +94,7 @@ describe("OrchestrationReactor", () => { "provider-command-reactor", "checkpoint-reactor", "thread-deletion-reactor", + "linear-sync-reactor", "agent-awareness-relay", ]); diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts index fb7543e31af..1af015bb847 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts @@ -9,6 +9,7 @@ import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; +import { LinearSyncReactor } from "../Services/LinearSyncReactor.ts"; import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; export const makeOrchestrationReactor = Effect.gen(function* () { @@ -16,6 +17,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { const providerCommandReactor = yield* ProviderCommandReactor; const checkpointReactor = yield* CheckpointReactor; const threadDeletionReactor = yield* ThreadDeletionReactor; + const linearSyncReactor = yield* LinearSyncReactor; const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () { @@ -23,6 +25,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { yield* providerCommandReactor.start(); yield* checkpointReactor.start(); yield* threadDeletionReactor.start(); + yield* linearSyncReactor.start(); yield* agentAwarenessRelay.start(); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index f12df850941..e05ee445b38 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -603,6 +603,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti interactionMode: event.payload.interactionMode, branch: event.payload.branch, worktreePath: event.payload.worktreePath, + linearIssue: event.payload.linearIssue ?? null, latestTurnId: null, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, @@ -662,6 +663,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...(event.payload.worktreePath !== undefined ? { worktreePath: event.payload.worktreePath } : {}), + ...(event.payload.linearIssue !== undefined + ? { linearIssue: event.payload.linearIssue } + : {}), updatedAt: event.payload.updatedAt, }); return; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index e36db35b107..cbd37d00b11 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -20,6 +20,7 @@ import { type OrchestrationSession, type OrchestrationThreadActivity, type OrchestrationThreadShell, + LinearIssueLink, ModelSelection, ProjectId, ThreadId, @@ -77,6 +78,7 @@ const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), + linearIssue: Schema.fromJsonString(Schema.NullOr(LinearIssueLink)), }), ); const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( @@ -329,6 +331,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + linked_linear_issue_json AS "linearIssue", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -357,6 +360,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + linked_linear_issue_json AS "linearIssue", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -387,6 +391,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + linked_linear_issue_json AS "linearIssue", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -749,6 +754,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + linked_linear_issue_json AS "linearIssue", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -1181,6 +1187,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + linearIssue: row.linearIssue ?? null, latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -1379,6 +1386,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + linearIssue: row.linearIssue ?? null, latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -1508,6 +1516,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + linearIssue: row.linearIssue ?? null, latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -1642,6 +1651,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + linearIssue: row.linearIssue ?? null, latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -1882,6 +1892,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: threadRow.value.interactionMode, branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, + linearIssue: threadRow.value.linearIssue ?? null, latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, @@ -1976,6 +1987,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: threadRow.value.interactionMode, branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, + linearIssue: threadRow.value.linearIssue ?? null, latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, diff --git a/apps/server/src/orchestration/Services/LinearSyncReactor.ts b/apps/server/src/orchestration/Services/LinearSyncReactor.ts new file mode 100644 index 00000000000..8d7a3cb3e11 --- /dev/null +++ b/apps/server/src/orchestration/Services/LinearSyncReactor.ts @@ -0,0 +1,16 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; + +/** + * LinearSyncReactor - background service that writes T3 Code thread progress + * back to linked Linear issues (In Progress on start → In Review on PR open → + * Done on merge). + */ +export class LinearSyncReactor extends Context.Service< + LinearSyncReactor, + { + /** Start reacting to lifecycle + VCS signals. Must be run in a scope. */ + readonly start: () => Effect.Effect; + } +>()("t3/orchestration/Services/LinearSyncReactor") {} diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 0d4af771ca8..c8bdda98b64 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -239,6 +239,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" interactionMode: command.interactionMode, branch: command.branch, worktreePath: command.worktreePath, + ...(command.linearIssue !== undefined ? { linearIssue: command.linearIssue } : {}), createdAt: command.createdAt, updatedAt: command.createdAt, }, @@ -335,6 +336,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" : {}), ...(command.branch !== undefined ? { branch: command.branch } : {}), ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}), + ...(command.linearIssue !== undefined ? { linearIssue: command.linearIssue } : {}), updatedAt: occurredAt, }, }; diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index fc6ab8f6fcf..167fbe8877a 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -282,6 +282,7 @@ export function projectEvent( interactionMode: payload.interactionMode, branch: payload.branch, worktreePath: payload.worktreePath, + linearIssue: payload.linearIssue ?? null, latestTurn: null, createdAt: payload.createdAt, updatedAt: payload.updatedAt, @@ -348,6 +349,7 @@ export function projectEvent( : {}), ...(payload.branch !== undefined ? { branch: payload.branch } : {}), ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), + ...(payload.linearIssue !== undefined ? { linearIssue: payload.linearIssue } : {}), updatedAt: payload.updatedAt, }), })), diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index a2069e62a14..dabb3e9a539 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -87,6 +87,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { interactionMode: "default", branch: null, worktreePath: null, + linearIssue: null, latestTurnId: null, createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-24T00:00:00.000Z", diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 1baeb375c15..86140566776 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -14,11 +14,12 @@ import { ProjectionThreadRepository, type ProjectionThreadRepositoryShape, } from "../Services/ProjectionThreads.ts"; -import { ModelSelection } from "@t3tools/contracts"; +import { LinearIssueLink, ModelSelection } from "@t3tools/contracts"; const ProjectionThreadDbRow = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), + linearIssue: Schema.fromJsonString(Schema.NullOr(LinearIssueLink)), }), ); type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type; @@ -39,6 +40,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode, branch, worktree_path, + linked_linear_issue_json, latest_turn_id, created_at, updated_at, @@ -58,6 +60,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.interactionMode}, ${row.branch}, ${row.worktreePath}, + ${JSON.stringify(row.linearIssue)}, ${row.latestTurnId}, ${row.createdAt}, ${row.updatedAt}, @@ -77,6 +80,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode = excluded.interaction_mode, branch = excluded.branch, worktree_path = excluded.worktree_path, + linked_linear_issue_json = excluded.linked_linear_issue_json, latest_turn_id = excluded.latest_turn_id, created_at = excluded.created_at, updated_at = excluded.updated_at, @@ -103,6 +107,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + linked_linear_issue_json AS "linearIssue", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -131,6 +136,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + linked_linear_issue_json AS "linearIssue", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index ba1131ee259..1a2f29e3e24 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -45,6 +45,7 @@ import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexe import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; +import Migration0033 from "./Migrations/033_ProjectionThreadsLinearIssue.ts"; /** * Migration loader with all migrations defined inline. @@ -89,6 +90,7 @@ export const migrationEntries = [ [30, "ProjectionThreadShellArchiveIndexes", Migration0030], [31, "AuthAuthorizationScopes", Migration0031], [32, "AuthPairingProofKeyThumbprint", Migration0032], + [33, "ProjectionThreadsLinearIssue", Migration0033], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/033_ProjectionThreadsLinearIssue.ts b/apps/server/src/persistence/Migrations/033_ProjectionThreadsLinearIssue.ts new file mode 100644 index 00000000000..740fadf4485 --- /dev/null +++ b/apps/server/src/persistence/Migrations/033_ProjectionThreadsLinearIssue.ts @@ -0,0 +1,20 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (columns.some((column) => column.name === "linked_linear_issue_json")) { + return; + } + + // Stores the JSON-encoded LinearIssueLink (or the string "null" when a thread + // is not linked to a Linear issue). Existing rows default to "null". + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN linked_linear_issue_json TEXT NOT NULL DEFAULT 'null' + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 44fdc147a4a..410107823ee 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -8,6 +8,7 @@ */ import { IsoDateTime, + LinearIssueLink, ModelSelection, NonNegativeInt, ProjectId, @@ -32,6 +33,7 @@ export const ProjectionThread = Schema.Struct({ interactionMode: ProviderInteractionMode, branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), + linearIssue: Schema.NullOr(LinearIssueLink), latestTurnId: Schema.NullOr(TurnId), createdAt: IsoDateTime, updatedAt: IsoDateTime, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 0c632d8486c..72dad534871 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -49,6 +49,8 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; +import { LinearSyncReactorLive } from "./orchestration/Layers/LinearSyncReactor.ts"; +import * as LinearApi from "./linear/LinearApi.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; @@ -162,6 +164,16 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(ProviderCommandReactorLive), Layer.provideMerge(CheckpointReactorLive), Layer.provideMerge(ThreadDeletionReactorLive), + Layer.provideMerge( + LinearSyncReactorLive.pipe( + Layer.provide( + LinearApi.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provide(FetchHttpClient.layer), + ), + ), + ), + ), Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))), Layer.provideMerge(RuntimeReceiptBusLive), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 9020e99f670..21cf24e85d6 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -56,7 +56,9 @@ import { type TerminalMetadataStreamEvent, WS_METHODS, WsRpcGroup, + LinearRequestError, } from "@t3tools/contracts"; +import { resolveTargetStateId } from "./linear/linearStateMapping.ts"; import { clamp } from "effect/Number"; import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; @@ -99,6 +101,7 @@ import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; +import * as LinearApi from "./linear/LinearApi.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; import * as GitHubCli from "./sourceControl/GitHubCli.ts"; @@ -299,6 +302,21 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.sourceControlLookupRepository, AuthOrchestrationReadScope], [WS_METHODS.sourceControlCloneRepository, AuthOrchestrationOperateScope], [WS_METHODS.sourceControlPublishRepository, AuthOrchestrationOperateScope], + [WS_METHODS.linearAuthStatus, AuthOrchestrationReadScope], + [WS_METHODS.linearSearchIssues, AuthOrchestrationReadScope], + [WS_METHODS.linearFetchIssues, AuthOrchestrationReadScope], + [WS_METHODS.linearListIssues, AuthOrchestrationReadScope], + [WS_METHODS.linearListTeams, AuthOrchestrationReadScope], + [WS_METHODS.linearListWorkflowStates, AuthOrchestrationReadScope], + [WS_METHODS.linearListProjects, AuthOrchestrationReadScope], + [WS_METHODS.linearListLabels, AuthOrchestrationReadScope], + [WS_METHODS.linearListUsers, AuthOrchestrationReadScope], + [WS_METHODS.linearUpdateIssueState, AuthOrchestrationOperateScope], + [WS_METHODS.linearCreateComment, AuthOrchestrationOperateScope], + [WS_METHODS.linearCreateAttachment, AuthOrchestrationOperateScope], + [WS_METHODS.linearCompleteThreadIssue, AuthOrchestrationOperateScope], + [WS_METHODS.linearSetToken, AuthOrchestrationOperateScope], + [WS_METHODS.linearClearToken, AuthOrchestrationOperateScope], [WS_METHODS.projectsListEntries, AuthOrchestrationReadScope], [WS_METHODS.projectsReadFile, AuthOrchestrationReadScope], [WS_METHODS.projectsSearchEntries, AuthOrchestrationReadScope], @@ -419,6 +437,7 @@ const makeWsRpcLayer = ( const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sourceControlDiscovery = yield* SourceControlDiscovery.SourceControlDiscovery; + const linear = yield* LinearApi.LinearApi; const automaticGitFetchInterval = serverSettings.getSettings.pipe( Effect.map((settings) => settings.automaticGitFetchInterval), Effect.catch((cause) => @@ -510,6 +529,81 @@ const makeWsRpcLayer = ( const serverCommandId = (tag: string) => randomUUID.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`))); + // Mark the Linear issue linked to a thread as done: resolve the team's + // completed state, write it back, and reflect the new state on the thread + // so its badge updates live. Non-Linear failures map to LinearRequestError. + const completeLinearThreadIssue = (input: { readonly threadId: ThreadId }) => + Effect.gen(function* () { + const shell = yield* projectionSnapshotQuery.getThreadShellById(input.threadId).pipe( + Effect.mapError( + (cause) => + new LinearRequestError({ + operation: "updateIssueState", + detail: "Failed to read the thread.", + cause, + }), + ), + ); + const issue = Option.isSome(shell) ? shell.value.linearIssue : null; + if (issue === null || issue === undefined || issue.teamId === undefined) { + return { success: false }; + } + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError( + (cause) => + new LinearRequestError({ + operation: "updateIssueState", + detail: "Failed to read settings.", + cause, + }), + ), + ); + const states = yield* linear.listWorkflowStates({ teamId: issue.teamId }); + const stateId = resolveTargetStateId( + states, + settings.linear.stateMappingByTeam[issue.teamId], + "done", + ); + if (stateId === undefined) { + return { success: false }; + } + const result = yield* linear.updateIssueState({ issueId: issue.id, stateId }); + if (!result.success) { + return { success: false }; + } + const nextState = states.find((state) => state.id === stateId); + if (nextState !== undefined) { + const commandId = yield* serverCommandId("linear-complete").pipe( + Effect.mapError( + (cause) => + new LinearRequestError({ + operation: "updateIssueState", + detail: "Failed to reflect the completed state.", + cause, + }), + ), + ); + yield* orchestrationEngine + .dispatch({ + type: "thread.meta.update", + commandId, + threadId: input.threadId, + linearIssue: { ...issue, stateName: nextState.name, stateType: nextState.type }, + }) + .pipe( + Effect.mapError( + (cause) => + new LinearRequestError({ + operation: "updateIssueState", + detail: "Failed to reflect the completed state.", + cause, + }), + ), + ); + } + return { success: true }; + }); + const loadAuthAccessSnapshot = () => Effect.all({ pairingLinks: serverAuth.listPairingLinks(), @@ -828,6 +922,9 @@ const makeWsRpcLayer = ( interactionMode: bootstrap.createThread.interactionMode, branch: bootstrap.createThread.branch, worktreePath: bootstrap.createThread.worktreePath, + ...(bootstrap.createThread.linearIssue !== undefined + ? { linearIssue: bootstrap.createThread.linearIssue } + : {}), createdAt: bootstrap.createThread.createdAt, }); createdThread = true; @@ -1322,6 +1419,78 @@ const makeWsRpcLayer = ( "rpc.aggregate": "source-control", }, ), + [WS_METHODS.linearAuthStatus]: (_input) => + observeRpcEffect(WS_METHODS.linearAuthStatus, linear.probeAuth, { + "rpc.aggregate": "linear", + }), + [WS_METHODS.linearSearchIssues]: (input) => + observeRpcEffect(WS_METHODS.linearSearchIssues, linear.searchIssues(input), { + "rpc.aggregate": "linear", + }), + [WS_METHODS.linearFetchIssues]: (input) => + observeRpcEffect( + WS_METHODS.linearFetchIssues, + linear.fetchIssues(input).pipe(Effect.map((issues) => ({ issues }))), + { "rpc.aggregate": "linear" }, + ), + [WS_METHODS.linearListIssues]: (input) => + observeRpcEffect(WS_METHODS.linearListIssues, linear.listIssues(input), { + "rpc.aggregate": "linear", + }), + [WS_METHODS.linearListTeams]: (_input) => + observeRpcEffect( + WS_METHODS.linearListTeams, + linear.listTeams.pipe(Effect.map((teams) => ({ teams }))), + { "rpc.aggregate": "linear" }, + ), + [WS_METHODS.linearListWorkflowStates]: (input) => + observeRpcEffect( + WS_METHODS.linearListWorkflowStates, + linear.listWorkflowStates(input).pipe(Effect.map((states) => ({ states }))), + { "rpc.aggregate": "linear" }, + ), + [WS_METHODS.linearListProjects]: (_input) => + observeRpcEffect( + WS_METHODS.linearListProjects, + linear.listProjects.pipe(Effect.map((projects) => ({ projects }))), + { "rpc.aggregate": "linear" }, + ), + [WS_METHODS.linearListLabels]: (_input) => + observeRpcEffect( + WS_METHODS.linearListLabels, + linear.listLabels.pipe(Effect.map((labels) => ({ labels }))), + { "rpc.aggregate": "linear" }, + ), + [WS_METHODS.linearListUsers]: (_input) => + observeRpcEffect( + WS_METHODS.linearListUsers, + linear.listUsers.pipe(Effect.map((users) => ({ users }))), + { "rpc.aggregate": "linear" }, + ), + [WS_METHODS.linearUpdateIssueState]: (input) => + observeRpcEffect(WS_METHODS.linearUpdateIssueState, linear.updateIssueState(input), { + "rpc.aggregate": "linear", + }), + [WS_METHODS.linearCreateComment]: (input) => + observeRpcEffect(WS_METHODS.linearCreateComment, linear.createComment(input), { + "rpc.aggregate": "linear", + }), + [WS_METHODS.linearCreateAttachment]: (input) => + observeRpcEffect(WS_METHODS.linearCreateAttachment, linear.createAttachment(input), { + "rpc.aggregate": "linear", + }), + [WS_METHODS.linearCompleteThreadIssue]: (input) => + observeRpcEffect(WS_METHODS.linearCompleteThreadIssue, completeLinearThreadIssue(input), { + "rpc.aggregate": "linear", + }), + [WS_METHODS.linearSetToken]: (input) => + observeRpcEffect(WS_METHODS.linearSetToken, linear.setToken(input.token), { + "rpc.aggregate": "linear", + }), + [WS_METHODS.linearClearToken]: (_input) => + observeRpcEffect(WS_METHODS.linearClearToken, linear.clearToken, { + "rpc.aggregate": "linear", + }), [WS_METHODS.projectsSearchEntries]: (input) => observeRpcEffect( WS_METHODS.projectsSearchEntries, @@ -1815,6 +1984,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( makeWsRpcLayer(session, previewAutomationBroker).pipe( Layer.provideMerge(RpcSerialization.layerJson), Layer.provide(ProviderMaintenanceRunner.layer), + Layer.provide(LinearApi.layer), Layer.provide( SourceControlDiscovery.layer.pipe( Layer.provide( diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 8dccf984457..eb888904946 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -108,7 +108,7 @@ import { } from "./CommandPalette.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteResults } from "./CommandPaletteResults"; -import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; +import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon, LinearIcon } from "./Icons"; import { ProjectFavicon } from "./ProjectFavicon"; import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; import { primaryServerKeybindingsAtom } from "../state/server"; @@ -1030,6 +1030,17 @@ function OpenCommandPaletteDialog(props: { }, }); + actionItems.push({ + kind: "action", + value: "action:linear-browse", + searchTerms: ["linear", "issues", "tickets", "browse", "import"], + title: "Browse Linear issues", + icon: , + run: async () => { + await navigate({ to: "/linear" }); + }, + }); + if (wslAddProjectEnvironmentOption) { actionItems.push({ kind: "action", diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 8ea38c51958..467833b701f 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -15,6 +15,15 @@ export const GitHubIcon: Icon = (props) => ( ); +export const LinearIcon: Icon = (props) => ( + + + +); + export const GitIcon: Icon = (props) => ( void; +}) { + return ( + + + + {issue.identifier} + + {issue.title} + + + {issue.stateName ? {issue.stateName} : null} + {issue.assigneeName ? · {issue.assigneeName} : null} + {issue.priorityLabel ? · {issue.priorityLabel} : null} + + + + ); +} + +export function LinearBrowsePopover({ + environmentId, + projectId, + projectName, +}: { + environmentId: EnvironmentId; + projectId: ProjectId; + projectName: string; +}) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [debounced, setDebounced] = useState(""); + const [selected, setSelected] = useState>(() => new Set()); + const [combine, setCombine] = useState(true); + const [importing, setImporting] = useState(false); + + const runImport = useLinearImport(); + const navigate = useNavigate(); + + useEffect(() => { + const id = setTimeout(() => setDebounced(query), SEARCH_DEBOUNCE_MS); + return () => clearTimeout(id); + }, [query]); + + const authQuery = useEnvironmentQuery( + open ? linearEnvironment.authStatus({ environmentId, input: {} }) : null, + ); + const connected = authQuery.data?.status === "authenticated"; + + const searchQuery = useEnvironmentQuery( + open && connected + ? linearEnvironment.searchIssues({ + environmentId, + input: { query: debounced, limit: SEARCH_LIMIT }, + }) + : null, + ); + + const issues = searchQuery.data?.issues ?? []; + const selectedCount = selected.size; + + // When a new result set arrives, drop selections that are no longer visible + // so Import can't act on issues that scrolled out of the current search. + useEffect(() => { + const data = searchQuery.data; + if (!data) return; + setSelected((current) => { + if (current.size === 0) return current; + const visible = new Set(data.issues.map((issue) => issue.id)); + const filtered = [...current].filter((id) => visible.has(id)); + return filtered.length === current.size ? current : new Set(filtered); + }); + }, [searchQuery.data]); + + const toggle = useCallback((id: string) => { + setSelected((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + const reset = useCallback(() => { + setQuery(""); + setDebounced(""); + setSelected(new Set()); + setCombine(true); + }, []); + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) reset(); + }, + [reset], + ); + + const handleImport = useCallback(async () => { + if (selectedCount === 0 || importing) return; + setImporting(true); + try { + const result = await runImport({ + target: { environmentId, projectId }, + ids: [...selected], + mode: combine ? "combine" : "perIssue", + }); + // Only keep failed issues that are still visible in the current results, + // so we never leave an un-clearable selection of off-screen rows. + const visibleIds = new Set(issues.map((issue) => issue.id)); + const retryable = (result.failedIds ?? []).filter((id) => visibleIds.has(id)); + if (result.ok) { + if (result.warning) { + toastManager.add({ + type: "warning", + title: "Imported with issues", + description: result.warning, + }); + } + if (retryable.length > 0) { + setSelected(new Set(retryable)); + } else { + handleOpenChange(false); + } + } else { + toastManager.add({ + type: "error", + title: "Linear import failed", + description: result.error ?? "The issues could not be imported.", + }); + // Only narrow the selection when the hook reported specific failures; + // a blanket failure (nothing imported) keeps the current selection so + // the user can retry it as-is. + if (result.failedIds !== undefined) setSelected(new Set(retryable)); + } + } finally { + setImporting(false); + } + }, [ + combine, + environmentId, + handleOpenChange, + importing, + issues, + projectId, + runImport, + selected, + selectedCount, + ]); + + const body = useMemo(() => { + if (authQuery.isPending && authQuery.data === null) { + return ( +
+ Checking Linear connection… +
+ ); + } + if (authQuery.error !== null) { + return ( + +

Couldn’t reach Linear

+

{authQuery.error}

+
+ ); + } + if (!connected) { + return ( + +

Linear isn’t connected

+

+ Add a personal API key in Settings → Linear to import issues. +

+
+ ); + } + if (searchQuery.error !== null) { + return ( + +

Couldn’t load issues

+

{searchQuery.error}

+
+ ); + } + if (searchQuery.isPending && issues.length === 0) { + return ( +
+ Searching… +
+ ); + } + if (issues.length === 0) { + return ( + +

No issues found

+

+ {debounced.trim().length > 0 + ? "Try a different search term." + : "No recent issues were returned."} +

+
+ ); + } + return ( + +
+ {issues.map((issue) => ( + toggle(issue.id)} + /> + ))} +
+
+ ); + }, [ + authQuery.data, + authQuery.error, + authQuery.isPending, + connected, + debounced, + issues, + searchQuery.error, + searchQuery.isPending, + selected, + toggle, + ]); + + return ( + +
+ + + + + } + /> + } + /> + Import from Linear + +
+ +
+
+ + Import from Linear + {selectedCount > 0 ? ( + + {selectedCount} selected + + ) : ( + + )} +
+ + {connected ? ( +
+ + setQuery(event.target.value)} + placeholder="Search issues…" + className="h-8 pl-8 text-[13px]" + aria-label="Search Linear issues" + /> +
+ ) : null} + + {body} + + {connected ? ( +
+ + +
+ ) : null} +
+
+
+ ); +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 21525b56b77..cf614cb8a40 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -22,6 +22,9 @@ import { ThreadWorktreeIndicator, } from "./ThreadStatusIndicators"; import { ProjectFavicon } from "./ProjectFavicon"; +import { LinearBrowsePopover } from "./LinearBrowsePopover"; +import { LinearIssueBadge } from "./linear/LinearIssueBadge"; +import { linearEnvironment } from "../state/linear"; import { useAtomValue } from "@effect/atom-react"; import { autoAnimate } from "@formkit/auto-animate"; import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; @@ -739,6 +742,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr )}
+ {thread.linearIssue ? : null} {discoveredPorts.length > 0 && ( ( (settings) => settings.sidebarThreadPreviewCount, @@ -2129,6 +2136,14 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec { id: "mark-unread", label: "Mark unread" }, { id: "copy-path", label: "Copy Path" }, { id: "copy-thread-id", label: "Copy Thread ID" }, + ...(thread.linearIssue + ? [ + { + id: "linear-complete", + label: `Mark ${thread.linearIssue.identifier} done in Linear`, + }, + ] + : []), { id: "delete", label: "Delete", destructive: true, icon: "trash" }, ], position, @@ -2161,6 +2176,34 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec copyThreadIdToClipboard(thread.id, { threadId: thread.id }); return; } + if (clicked === "linear-complete") { + const linearIssue = thread.linearIssue; + if (!linearIssue) return; + const result = await completeLinearIssue({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }); + const succeeded = result._tag === "Success" ? result.value.success : false; + toastManager.add( + stackedThreadToast( + succeeded + ? { + type: "success", + title: "Marked done in Linear", + description: `${linearIssue.identifier} moved to your team's completed state.`, + } + : { + type: "error", + title: "Couldn't complete the issue", + description: + result._tag === "Failure" + ? "Linear rejected the update — check the connection in Settings." + : "No completed state is configured for this team.", + }, + ), + ); + return; + } if (clicked !== "delete") return; if (appSettingsConfirmThreadDelete) { const confirmed = await api.dialogs.confirm( @@ -2187,6 +2230,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }, [ appSettingsConfirmThreadDelete, + completeLinearIssue, copyPathToClipboard, copyThreadIdToClipboard, deleteThread, @@ -2283,6 +2327,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec )} + {project.memberProjects[0] ? ( + + ) : null} void; + options: ReadonlyArray<{ value: string; label: string }>; + disabled?: boolean; +}) { + const selected = options.find((option) => option.value === value); + return ( + + ); +} + +function IssueTableRow({ + issue, + checked, + onToggle, +}: { + issue: LinearIssueSummary; + checked: boolean; + onToggle: () => void; +}) { + return ( + + + + + + {issue.identifier} + + + {issue.title} + + + {issue.stateName ?? "—"} + + + {issue.assigneeName ?? "—"} + + + {issue.priorityLabel ?? "—"} + + + ); +} + +export function LinearBrowser() { + const environmentId = usePrimaryEnvironmentId(); + const projects = useProjects(); + const navigate = useNavigate(); + const runImport = useLinearImport(); + + const [teamId, setTeamId] = useState(ALL); + const [assigneeId, setAssigneeId] = useState(ALL); + const [stateId, setStateId] = useState(ALL); + const [query, setQuery] = useState(""); + const [debounced, setDebounced] = useState(""); + const [cursor, setCursor] = useState(undefined); + const [rows, setRows] = useState>([]); + const [hasNext, setHasNext] = useState(false); + const [selected, setSelected] = useState>(() => new Set()); + const [perIssue, setPerIssue] = useState(true); + const [targetProjectId, setTargetProjectId] = useState(null); + const [importing, setImporting] = useState(false); + + useEffect(() => { + const id = setTimeout(() => setDebounced(query), 250); + return () => clearTimeout(id); + }, [query]); + + const filter = useMemo( + () => ({ + ...(teamId !== ALL ? { teamId } : {}), + ...(assigneeId !== ALL ? { assigneeId } : {}), + ...(stateId !== ALL ? { stateId } : {}), + ...(debounced.trim().length > 0 ? { query: debounced.trim() } : {}), + }), + [assigneeId, debounced, stateId, teamId], + ); + const filterKey = JSON.stringify(filter); + + const authQuery = useEnvironmentQuery( + environmentId ? linearEnvironment.authStatus({ environmentId, input: {} }) : null, + ); + const connected = authQuery.data?.status === "authenticated"; + + const teamsQuery = useEnvironmentQuery( + environmentId && connected ? linearEnvironment.teams({ environmentId, input: {} }) : null, + ); + const usersQuery = useEnvironmentQuery( + environmentId && connected ? linearEnvironment.users({ environmentId, input: {} }) : null, + ); + const statesQuery = useEnvironmentQuery( + environmentId && connected && teamId !== ALL + ? linearEnvironment.workflowStates({ environmentId, input: { teamId } }) + : null, + ); + const listQuery = useEnvironmentQuery( + environmentId && connected + ? linearEnvironment.listIssues({ + environmentId, + input: { filter, first: PAGE_SIZE, ...(cursor ? { after: cursor } : {}) }, + }) + : null, + ); + + // Which filter the accumulated rows belong to, so a page fetched under a + // previous filter can never merge into results for the current one. + const accumFilterKey = useRef(null); + + // Reset paging + selection whenever the filter changes. + useEffect(() => { + setCursor(undefined); + setSelected(new Set()); + }, [filterKey]); + + // Accumulate pages (dedupe by id); replace instead of append when the filter + // changed since the last accumulated page. + useEffect(() => { + const data = listQuery.data; + if (!data) return; + setRows((prev) => { + const base = accumFilterKey.current === filterKey ? prev : []; + const seen = new Set(base.map((issue) => issue.id)); + const merged = [...base]; + for (const issue of data.issues) if (!seen.has(issue.id)) merged.push(issue); + return merged; + }); + accumFilterKey.current = filterKey; + // Only offer "load more" when the API actually returned a cursor to advance. + setHasNext(data.pageInfo.hasNextPage && data.pageInfo.endCursor != null); + }, [listQuery.data, filterKey]); + + useEffect(() => { + if (targetProjectId === null && projects.length > 0) { + setTargetProjectId(projects[0]!.id); + } + }, [projects, targetProjectId]); + + const toggle = useCallback((id: string) => { + setSelected((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + const allSelected = rows.length > 0 && rows.every((issue) => selected.has(issue.id)); + const toggleAll = useCallback(() => { + setSelected((current) => + rows.every((issue) => current.has(issue.id)) + ? new Set() + : new Set(rows.map((issue) => issue.id)), + ); + }, [rows]); + + const teamOptions = (teamsQuery.data?.teams ?? []).map((team) => ({ + value: team.id, + label: `${team.key} · ${team.name}`, + })); + const userOptions = (usersQuery.data?.users ?? []).map((user) => ({ + value: user.id, + label: user.isMe ? `${user.name} (me)` : user.name, + })); + const stateOptions = (statesQuery.data?.states ?? []).map((state) => ({ + value: state.id, + label: state.name, + })); + + const targetProject = projects.find((project) => project.id === targetProjectId) ?? null; + + const handleImport = useCallback(async () => { + if (selected.size === 0 || importing) return; + if (!environmentId || !targetProject) { + toastManager.add({ + type: "error", + title: "Pick a destination folder", + description: "Choose which project to import into.", + }); + return; + } + setImporting(true); + try { + const result = await runImport({ + target: { + environmentId: targetProject.environmentId, + projectId: targetProject.id as ProjectId, + }, + ids: [...selected], + mode: perIssue ? "perIssue" : "combine", + }); + // Keep only failed issues still visible in the loaded rows selected, so a + // retry re-imports just those without stranding off-screen selections. + const visibleIds = new Set(rows.map((issue) => issue.id)); + const retryable = (result.failedIds ?? []).filter((id) => visibleIds.has(id)); + if (result.ok) { + toastManager.add( + result.warning + ? { type: "warning", title: "Imported with issues", description: result.warning } + : { + type: "success", + title: perIssue ? "Threads created" : "Draft ready", + description: perIssue + ? `Started ${selected.size} thread${selected.size === 1 ? "" : "s"} from Linear.` + : "Review the pre-filled composer and send.", + }, + ); + if (retryable.length > 0) { + // Stay on the browser with the failed rows selected for retry. + setSelected(new Set(retryable)); + } else { + setSelected(new Set()); + void navigate({ to: "/" }); + } + } else { + toastManager.add({ + type: "error", + title: "Linear import failed", + description: result.error ?? "The issues could not be imported.", + }); + // Keep the current selection on a blanket failure (nothing imported); + // only narrow it when specific issues were reported as failed. + if (result.failedIds !== undefined) setSelected(new Set(retryable)); + } + } finally { + setImporting(false); + } + }, [environmentId, importing, navigate, perIssue, rows, runImport, selected, targetProject]); + + let body: ReactNode; + if (!environmentId || (authQuery.isPending && authQuery.data === null)) { + body = ( +
+ Loading… +
+ ); + } else if (!connected) { + body = ( + +

Linear isn’t connected

+

+ Add a personal API key in Settings → Linear to browse and import issues. +

+ +
+ ); + } else if (listQuery.error !== null && rows.length === 0) { + body = ( + +

Couldn’t load issues

+

{listQuery.error}

+
+ ); + } else if (rows.length === 0 && !listQuery.isPending) { + body = ( + +

No issues match

+

Adjust the filters or search.

+
+ ); + } else { + body = ( + + + + + + + + ID + Title + Status + Assignee + Priority + + + + {rows.map((issue) => ( + toggle(issue.id)} + /> + ))} + +
+
+ {hasNext ? ( + + ) : ( + + {rows.length} issue{rows.length === 1 ? "" : "s"} + + )} +
+
+ ); + } + + return ( +
+
+ +

Linear issues

+
+ + {connected ? ( +
+
+ + setQuery(event.target.value)} + placeholder="Search…" + aria-label="Search Linear issues" + className="h-8 w-56 pl-8 text-[13px]" + /> +
+ { + setTeamId(value); + setStateId(ALL); + }} + options={teamOptions} + /> + + +
+ ) : null} + + {body} + + {connected && selected.size > 0 ? ( +
+ {selected.size} selected + +
+ + +
+
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/linear/LinearIssueBadge.tsx b/apps/web/src/components/linear/LinearIssueBadge.tsx new file mode 100644 index 00000000000..89396f4a02b --- /dev/null +++ b/apps/web/src/components/linear/LinearIssueBadge.tsx @@ -0,0 +1,60 @@ +import type { LinearIssueLink, LinearWorkflowStateType } from "@t3tools/contracts"; + +import { cn } from "../../lib/utils"; +import { LinearIcon } from "../Icons"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +/** Color the pill by workflow-state category so "Done" reads as complete. */ +function stateColorClass(stateType: LinearWorkflowStateType | undefined): string { + switch (stateType) { + case "completed": + return "text-violet-600 dark:text-violet-400"; + case "started": + return "text-amber-600 dark:text-amber-400"; + case "canceled": + return "text-muted-foreground/70 line-through"; + default: + return "text-muted-foreground"; + } +} + +/** + * Compact pill linking a thread to its Linear issue. Reflects the issue's live + * workflow state (advances In Progress → In Review → Done as the reactor writes + * status back). + */ +export function LinearIssueBadge({ + issue, + className, +}: { + issue: LinearIssueLink; + className?: string; +}) { + const stateLabel = issue.stateName ?? "Linear"; + return ( + + event.stopPropagation()} + className={cn( + "inline-flex shrink-0 items-center gap-1 rounded-sm px-1 font-mono text-[10px] leading-4 transition-colors hover:bg-accent", + stateColorClass(issue.stateType), + className, + )} + aria-label={`Linear ${issue.identifier} — ${stateLabel}`} + > + + {issue.identifier} + + } + /> + + {issue.identifier} · {stateLabel} + + + ); +} diff --git a/apps/web/src/components/settings/LinearSettings.tsx b/apps/web/src/components/settings/LinearSettings.tsx new file mode 100644 index 00000000000..5d0aedc168e --- /dev/null +++ b/apps/web/src/components/settings/LinearSettings.tsx @@ -0,0 +1,244 @@ +import { useCallback, useState } from "react"; +import { SquareKanbanIcon } from "lucide-react"; + +import { usePrimaryEnvironmentId } from "../../state/environments"; +import { usePrimarySettings } from "../../hooks/useSettings"; +import { linearEnvironment } from "../../state/linear"; +import { serverEnvironment } from "../../state/server"; +import { useEnvironmentQuery } from "../../state/query"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Switch } from "../ui/switch"; +import { toastManager } from "../ui/toast"; +import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; + +const LINEAR_TOKEN_HELP_URL = "https://linear.app/settings/account/security"; + +export function LinearSettingsPanel() { + const environmentId = usePrimaryEnvironmentId(); + const authQuery = useEnvironmentQuery( + environmentId === null ? null : linearEnvironment.authStatus({ environmentId, input: {} }), + ); + const setToken = useAtomCommand(linearEnvironment.setToken, "linear set token"); + const clearToken = useAtomCommand(linearEnvironment.clearToken, "linear clear token"); + + const [token, setTokenValue] = useState(""); + const [busy, setBusy] = useState(false); + + const status = authQuery.data; + const connected = status?.status === "authenticated"; + + const linear = usePrimarySettings((settings) => settings.linear); + const updateServerSettings = useAtomCommand( + serverEnvironment.updateSettings, + "linear settings update", + ); + // Send only the changed key(s). Sending the whole `linear` object would carry + // `stateMappingByTeam` on every toggle, which the whole-map replacement would + // then overwrite (wiping server-side per-team overrides). + const setLinear = useCallback( + (patch: { + autoSync?: boolean; + transitionOnStart?: boolean; + transitionOnPrOpen?: boolean; + transitionOnMerge?: boolean; + postComments?: boolean; + }) => { + if (environmentId === null) return; + void updateServerSettings({ environmentId, input: { patch: { linear: patch } } }); + }, + [environmentId, updateServerSettings], + ); + + const handleSave = useCallback(async () => { + const trimmed = token.trim(); + if (environmentId === null || trimmed.length === 0 || busy) return; + setBusy(true); + try { + const result = await setToken({ environmentId, input: { token: trimmed } }); + if (result._tag !== "Success") { + toastManager.add({ + type: "error", + title: "Could not save token", + description: "The token could not be saved.", + }); + return; + } + // The token was stored. Verification is a separate concern — a transient + // outage shouldn't read as "connect failed". + setTokenValue(""); + authQuery.refresh(); + if (result.value.status === "authenticated") { + toastManager.add({ + type: "success", + title: "Linear connected", + description: result.value.account + ? `Connected as ${result.value.account.name}.` + : "Linear is now connected.", + }); + } else { + toastManager.add({ + type: "warning", + title: "Token saved", + description: result.value.detail ?? "Saved, but Linear couldn’t verify it right now.", + }); + } + } finally { + setBusy(false); + } + }, [authQuery, busy, environmentId, setToken, token]); + + const handleDisconnect = useCallback(async () => { + if (environmentId === null || busy) return; + setBusy(true); + try { + await clearToken({ environmentId, input: {} }); + authQuery.refresh(); + toastManager.add({ type: "success", title: "Linear disconnected" }); + } finally { + setBusy(false); + } + }, [authQuery, busy, clearToken, environmentId]); + + const statusBadge = authQuery.isPending ? ( + Checking… + ) : connected ? ( + Connected + ) : ( + Not connected + ); + + return ( + + }> + + {statusBadge} + {connected ? ( + + ) : null} +
+ } + > + {connected ? null : ( +
+
+ setTokenValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void handleSave(); + } + }} + /> + +
+

+ Create a personal API key in{" "} + + Linear → Settings → Security & access + + . The key is stored securely on the server and never shown again. +

+
+ )} +
+ + + + setLinear({ autoSync: checked })} + aria-label="Auto-sync issue status" + /> + } + /> + setLinear({ transitionOnStart: checked })} + aria-label="Move to In Progress on start" + /> + } + /> + setLinear({ transitionOnPrOpen: checked })} + aria-label="Move to In Review on pull request" + /> + } + /> + setLinear({ transitionOnMerge: checked })} + aria-label="Move to Done on merge" + /> + } + /> + setLinear({ postComments: checked })} + aria-label="Post progress comments" + /> + } + /> + + + ); +} diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 6774b6f333f..88ce4bdc977 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -7,6 +7,7 @@ import { KeyboardIcon, Link2Icon, Settings2Icon, + SquareKanbanIcon, } from "lucide-react"; import { useCanGoBack, useNavigate } from "@tanstack/react-router"; @@ -27,6 +28,7 @@ export type SettingsSectionPath = | "/settings/keybindings" | "/settings/providers" | "/settings/source-control" + | "/settings/linear" | "/settings/connections" | "/settings/archived"; @@ -39,6 +41,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ { label: "Keybindings", to: "/settings/keybindings", icon: KeyboardIcon }, { label: "Providers", to: "/settings/providers", icon: BotIcon }, { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon }, + { label: "Linear", to: "/settings/linear", icon: SquareKanbanIcon }, { label: "Connections", to: "/settings/connections", icon: Link2Icon }, { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, ]; diff --git a/apps/web/src/hooks/useLinearImport.ts b/apps/web/src/hooks/useLinearImport.ts new file mode 100644 index 00000000000..3fad669c602 --- /dev/null +++ b/apps/web/src/hooks/useLinearImport.ts @@ -0,0 +1,196 @@ +import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { + DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + ProviderInstanceId, + type EnvironmentId, + type LinearIssueDetail, + type LinearIssueLink, + type ModelSelection, + type ProjectId, +} from "@t3tools/contracts"; +import { useCallback } from "react"; + +import { useComposerDraftStore } from "../composerDraftStore"; +import { formatLinearIssues } from "../lib/linearFormat"; +import { newMessageId, newThreadId } from "../lib/utils"; +import { + deriveLogicalProjectKeyFromSettings, + selectProjectGroupingSettings, +} from "../logicalProject"; +import { useProjects } from "../state/entities"; +import { linearEnvironment } from "../state/linear"; +import { threadEnvironment } from "../state/threads"; +import { useAtomCommand } from "../state/use-atom-command"; +import { useClientSettings } from "./useSettings"; +import { useNewThreadHandler } from "./useHandleNewThread"; + +export interface LinearImportTarget { + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; +} + +/** `combine` → one draft thread with all issues. `perIssue` → one started thread per issue. */ +export type LinearBulkImportMode = "combine" | "perIssue"; + +export interface LinearImportResult { + /** True when the import proceeded (at least one thread was created). */ + readonly ok: boolean; + /** Hard failure detail (shown as an error toast); only set when `ok` is false. */ + readonly error?: string; + /** Soft/partial detail (shown as a non-blocking notice) when `ok` is true. */ + readonly warning?: string; + /** Issue ids that failed to import or load, so the UI can keep them selected. */ + readonly failedIds?: ReadonlyArray; +} + +function issueTitle(issue: LinearIssueDetail): string { + const title = `${issue.identifier}: ${issue.title}`.trim(); + return title.length > 120 ? title.slice(0, 117) + "…" : title; +} + +function issueLink(issue: LinearIssueDetail): LinearIssueLink { + return { + id: issue.id, + identifier: issue.identifier, + title: issue.title, + url: issue.url, + ...(issue.teamId ? { teamId: issue.teamId } : {}), + ...(issue.stateType ? { stateType: issue.stateType } : {}), + ...(issue.stateName ? { stateName: issue.stateName } : {}), + }; +} + +/** + * Imports the selected Linear issues into T3 Code threads for `target`. + * `combine` pre-fills a single draft; `perIssue` creates and starts one linked + * thread per issue (leaning into parallel agents). + */ +export function useLinearImport() { + const projects = useProjects(); + const groupingSettings = useClientSettings(selectProjectGroupingSettings); + const newThread = useNewThreadHandler(); + const fetchIssues = useAtomCommand(linearEnvironment.fetchIssues, "linear fetch issues"); + const startTurn = useAtomCommand(threadEnvironment.startTurn, "linear import start turn"); + + return useCallback( + async (input: { + readonly target: LinearImportTarget; + readonly ids: ReadonlyArray; + readonly mode: LinearBulkImportMode; + }): Promise => { + if (input.ids.length === 0) { + return { ok: false, error: "Select at least one issue to import." }; + } + + const projectRef = scopeProjectRef(input.target.environmentId, input.target.projectId); + const project = projects.find( + (candidate) => + candidate.id === input.target.projectId && + candidate.environmentId === input.target.environmentId, + ); + + const result = await fetchIssues({ + environmentId: input.target.environmentId, + input: { ids: [...input.ids] }, + }); + if (result._tag !== "Success") { + return { ok: false, error: "Failed to load the selected Linear issues." }; + } + const issues = result.value.issues; + if (issues.length === 0) { + return { ok: false, error: "The selected issues could not be loaded." }; + } + + if (input.mode === "perIssue") { + // Imported issues default to Claude Opus 4.8 — the strongest coding + // model — rather than the project/Codex default. + const modelSelection: ModelSelection = { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-opus-4-8", + }; + // Attempt every issue; report a summary rather than bailing mid-loop and + // leaving the caller unsure which threads were actually created. + const failed: string[] = []; + const failedIds: string[] = []; + for (const issue of issues) { + const createdAt = new Date().toISOString(); + const title = issueTitle(issue); + const startResult = await startTurn({ + environmentId: input.target.environmentId, + input: { + threadId: newThreadId(), + message: { + messageId: newMessageId(), + role: "user", + text: formatLinearIssues([issue], "combine"), + attachments: [], + }, + modelSelection, + titleSeed: title, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + bootstrap: { + createThread: { + projectId: input.target.projectId, + title, + modelSelection, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: null, + worktreePath: null, + linearIssue: issueLink(issue), + createdAt, + }, + }, + createdAt, + }, + }); + if (startResult._tag !== "Success") { + failed.push(issue.identifier); + failedIds.push(issue.id); + } + } + // Selected ids Linear never returned (couldn't be loaded). + const returnedIds = new Set(issues.map((issue) => issue.id)); + const missingIds = input.ids.filter((id) => !returnedIds.has(id)); + const createdCount = issues.length - failed.length; + if (createdCount === 0) { + return { + ok: false, + error: "Failed to create any threads from Linear.", + failedIds: [...failedIds, ...missingIds], + }; + } + const problems: string[] = []; + if (failed.length > 0) problems.push(`failed: ${failed.join(", ")}`); + if (missingIds.length > 0) problems.push(`${missingIds.length} couldn't be loaded`); + // Partial success still created threads: succeed, but surface a notice + // and hand back the failed ids so the UI keeps them selected for retry. + if (problems.length > 0) { + return { + ok: true, + warning: `Created ${createdCount} of ${input.ids.length} threads (${problems.join("; ")}).`, + failedIds: [...failedIds, ...missingIds], + }; + } + return { ok: true }; + } + + // combine: pre-fill a single draft the user reviews before sending. + const markdown = formatLinearIssues(issues, "combine"); + await newThread(projectRef); + const logicalProjectKey = project + ? deriveLogicalProjectKeyFromSettings(project, groupingSettings) + : scopedProjectKey(projectRef); + const draft = useComposerDraftStore + .getState() + .getDraftSessionByLogicalProjectKey(logicalProjectKey); + if (draft) { + useComposerDraftStore.getState().setPrompt(draft.draftId, markdown); + } + return { ok: true }; + }, + [fetchIssues, groupingSettings, newThread, projects, startTurn], + ); +} diff --git a/apps/web/src/lib/linearFormat.test.ts b/apps/web/src/lib/linearFormat.test.ts new file mode 100644 index 00000000000..2ffb6c9eca9 --- /dev/null +++ b/apps/web/src/lib/linearFormat.test.ts @@ -0,0 +1,92 @@ +import type { LinearIssueDetail } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { formatLinearIssues } from "./linearFormat"; + +function makeIssue(overrides: Partial = {}): LinearIssueDetail { + return { + id: "id-1", + identifier: "ENG-1", + title: "Fix the thing", + url: "https://linear.app/acme/issue/ENG-1", + stateName: "In Progress", + priorityLabel: "High", + assigneeName: "Ada", + teamKey: "ENG", + description: "Do the work.\n\n- [ ] Step one\n- [ ] Step two", + labels: ["bug", "backend"], + subIssues: [{ identifier: "ENG-2", title: "Subtask", stateName: "Todo" }], + linkedPullRequests: [{ url: "https://github.com/acme/repo/pull/42", title: "PR 42" }], + attachments: [{ url: "https://example.com/design", title: "Design" }], + comments: [{ author: "Grace", body: "Looks good", createdAt: "2026-01-02" }], + ...overrides, + }; +} + +describe("formatLinearIssues", () => { + it("returns an empty string when there are no issues", () => { + expect(formatLinearIssues([], "combine")).toBe(""); + }); + + it("formats a single issue with all context", () => { + const output = formatLinearIssues([makeIssue()], "combine"); + expect(output).toContain("Work on this Linear issue:"); + expect(output).toContain("## ENG-1: Fix the thing"); + expect(output).toContain("Status: In Progress"); + expect(output).toContain("Priority: High"); + expect(output).toContain("Assignee: Ada"); + expect(output).toContain("Labels: bug, backend"); + expect(output).toContain("https://linear.app/acme/issue/ENG-1"); + // Acceptance-criteria checklist is preserved verbatim from the description. + expect(output).toContain("- [ ] Step one"); + expect(output).toContain("**Sub-issues**"); + expect(output).toContain("ENG-2: Subtask (Todo)"); + expect(output).toContain("**Linked pull requests**"); + expect(output).toContain("[PR 42](https://github.com/acme/repo/pull/42)"); + expect(output).toContain("**Attachments**"); + expect(output).toContain("**Comments**"); + expect(output).toContain("Grace"); + expect(output).toContain("Looks good"); + }); + + it("combines multiple issues under one task heading", () => { + const output = formatLinearIssues( + [makeIssue(), makeIssue({ id: "id-2", identifier: "ENG-9", title: "Second" })], + "combine", + ); + expect(output).toContain("Work on these Linear issues together as one task:"); + expect(output).toContain("## ENG-1: Fix the thing"); + expect(output).toContain("## ENG-9: Second"); + expect(output).not.toContain("Subtask 1"); + }); + + it("labels multiple issues as ordered subtasks", () => { + const output = formatLinearIssues( + [makeIssue(), makeIssue({ id: "id-2", identifier: "ENG-9", title: "Second" })], + "subtasks", + ); + expect(output).toContain("should be implemented as subtasks"); + expect(output).toContain("## Subtask 1 — ENG-1: Fix the thing"); + expect(output).toContain("## Subtask 2 — ENG-9: Second"); + }); + + it("omits sections that have no data", () => { + const output = formatLinearIssues( + [ + makeIssue({ + description: "", + labels: [], + subIssues: [], + linkedPullRequests: [], + attachments: [], + comments: [], + }), + ], + "combine", + ); + expect(output).not.toContain("**Sub-issues**"); + expect(output).not.toContain("**Linked pull requests**"); + expect(output).not.toContain("**Comments**"); + expect(output).not.toContain("**Description**"); + }); +}); diff --git a/apps/web/src/lib/linearFormat.ts b/apps/web/src/lib/linearFormat.ts new file mode 100644 index 00000000000..60cd1161ab1 --- /dev/null +++ b/apps/web/src/lib/linearFormat.ts @@ -0,0 +1,2 @@ +// Re-exported from the shared package so web and mobile format issues identically. +export * from "@t3tools/client-runtime/linear-format"; diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 3a9140e278c..705d6d910d4 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -15,11 +15,13 @@ import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' +import { Route as SettingsLinearRouteImport } from './routes/settings.linear' import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings' import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' +import { Route as ChatLinearRouteImport } from './routes/_chat.linear' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' @@ -52,6 +54,11 @@ const SettingsProvidersRoute = SettingsProvidersRouteImport.update({ path: '/providers', getParentRoute: () => SettingsRoute, } as any) +const SettingsLinearRoute = SettingsLinearRouteImport.update({ + id: '/linear', + path: '/linear', + getParentRoute: () => SettingsRoute, +} as any) const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({ id: '/keybindings', path: '/keybindings', @@ -77,6 +84,11 @@ const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ path: '/archived', getParentRoute: () => SettingsRoute, } as any) +const ChatLinearRoute = ChatLinearRouteImport.update({ + id: '/linear', + path: '/linear', + getParentRoute: () => ChatRoute, +} as any) const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({ id: '/draft/$draftId', path: '/draft/$draftId', @@ -93,11 +105,13 @@ export interface FileRoutesByFullPath { '/': typeof ChatIndexRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/linear': typeof ChatLinearRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/linear': typeof SettingsLinearRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute @@ -106,11 +120,13 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/linear': typeof ChatLinearRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/linear': typeof SettingsLinearRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/': typeof ChatIndexRoute @@ -122,11 +138,13 @@ export interface FileRoutesById { '/_chat': typeof ChatRouteWithChildren '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/_chat/linear': typeof ChatLinearRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/linear': typeof SettingsLinearRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/_chat/': typeof ChatIndexRoute @@ -139,11 +157,13 @@ export interface FileRouteTypes { | '/' | '/pair' | '/settings' + | '/linear' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' | '/settings/keybindings' + | '/settings/linear' | '/settings/providers' | '/settings/source-control' | '/$environmentId/$threadId' @@ -152,11 +172,13 @@ export interface FileRouteTypes { to: | '/pair' | '/settings' + | '/linear' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' | '/settings/keybindings' + | '/settings/linear' | '/settings/providers' | '/settings/source-control' | '/' @@ -167,11 +189,13 @@ export interface FileRouteTypes { | '/_chat' | '/pair' | '/settings' + | '/_chat/linear' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' | '/settings/keybindings' + | '/settings/linear' | '/settings/providers' | '/settings/source-control' | '/_chat/' @@ -229,6 +253,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsProvidersRouteImport parentRoute: typeof SettingsRoute } + '/settings/linear': { + id: '/settings/linear' + path: '/linear' + fullPath: '/settings/linear' + preLoaderRoute: typeof SettingsLinearRouteImport + parentRoute: typeof SettingsRoute + } '/settings/keybindings': { id: '/settings/keybindings' path: '/keybindings' @@ -264,6 +295,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsArchivedRouteImport parentRoute: typeof SettingsRoute } + '/_chat/linear': { + id: '/_chat/linear' + path: '/linear' + fullPath: '/linear' + preLoaderRoute: typeof ChatLinearRouteImport + parentRoute: typeof ChatRoute + } '/_chat/draft/$draftId': { id: '/_chat/draft/$draftId' path: '/draft/$draftId' @@ -282,12 +320,14 @@ declare module '@tanstack/react-router' { } interface ChatRouteChildren { + ChatLinearRoute: typeof ChatLinearRoute ChatIndexRoute: typeof ChatIndexRoute ChatEnvironmentIdThreadIdRoute: typeof ChatEnvironmentIdThreadIdRoute ChatDraftDraftIdRoute: typeof ChatDraftDraftIdRoute } const ChatRouteChildren: ChatRouteChildren = { + ChatLinearRoute: ChatLinearRoute, ChatIndexRoute: ChatIndexRoute, ChatEnvironmentIdThreadIdRoute: ChatEnvironmentIdThreadIdRoute, ChatDraftDraftIdRoute: ChatDraftDraftIdRoute, @@ -301,6 +341,7 @@ interface SettingsRouteChildren { SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute SettingsGeneralRoute: typeof SettingsGeneralRoute SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute + SettingsLinearRoute: typeof SettingsLinearRoute SettingsProvidersRoute: typeof SettingsProvidersRoute SettingsSourceControlRoute: typeof SettingsSourceControlRoute } @@ -311,6 +352,7 @@ const SettingsRouteChildren: SettingsRouteChildren = { SettingsDiagnosticsRoute: SettingsDiagnosticsRoute, SettingsGeneralRoute: SettingsGeneralRoute, SettingsKeybindingsRoute: SettingsKeybindingsRoute, + SettingsLinearRoute: SettingsLinearRoute, SettingsProvidersRoute: SettingsProvidersRoute, SettingsSourceControlRoute: SettingsSourceControlRoute, } diff --git a/apps/web/src/routes/_chat.linear.tsx b/apps/web/src/routes/_chat.linear.tsx new file mode 100644 index 00000000000..f25a77982b1 --- /dev/null +++ b/apps/web/src/routes/_chat.linear.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { LinearBrowser } from "../components/linear/LinearBrowser"; + +export const Route = createFileRoute("/_chat/linear")({ + component: LinearBrowser, +}); diff --git a/apps/web/src/routes/settings.linear.tsx b/apps/web/src/routes/settings.linear.tsx new file mode 100644 index 00000000000..8bc9fab4e9b --- /dev/null +++ b/apps/web/src/routes/settings.linear.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { LinearSettingsPanel } from "../components/settings/LinearSettings"; + +export const Route = createFileRoute("/settings/linear")({ + component: LinearSettingsPanel, +}); diff --git a/apps/web/src/state/linear.ts b/apps/web/src/state/linear.ts new file mode 100644 index 00000000000..8861cdcd3ea --- /dev/null +++ b/apps/web/src/state/linear.ts @@ -0,0 +1,5 @@ +import { createLinearEnvironmentAtoms } from "@t3tools/client-runtime/state/linear"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const linearEnvironment = createLinearEnvironmentAtoms(connectionAtomRuntime); diff --git a/docs/integrations/linear.md b/docs/integrations/linear.md new file mode 100644 index 00000000000..bb3f59bdbb7 --- /dev/null +++ b/docs/integrations/linear.md @@ -0,0 +1,50 @@ +# Linear Integration + +T3 Code connects to [Linear](https://linear.app) so you can turn an issue into a working thread +without copying context by hand. Browse and search your Linear issues straight from a folder in the +sidebar, select one or more, and a new thread opens with its full context already in the composer. + +## What You Can Do + +- **Import issues into threads** – Click the Linear icon on any folder row in the sidebar to browse + and search issues. Pick one or several, then import them into a new thread for that folder. +- **Bring the whole issue** – Title, description, acceptance criteria / checklists, labels, priority, + assignee, state, sub-issues, linked pull requests, comments, and attachment links are all pulled + into the composer as markdown. Review and edit before you send. +- **Combine or split** – When you select more than one issue, choose whether to **combine** them into + one task or treat them as **related subtasks**. +- **Browse in bulk** – Open the full Linear browser to filter by team, status, and assignee, page + through large backlogs, and multi-select issues to import as one thread per issue or a single + combined thread. +- **Status write-back** – As work progresses, T3 Code moves the linked issue through your workflow: + **In Progress** when the agent starts, **In Review** when a pull request opens, and **Done** when + it merges. States are mapped per team by their category (so renamed states still work). Toggle + each transition (and optional progress comments) in **Settings → Linear → Status write-back**. + +## Getting Started + +1. In Linear, open **Settings → Security & access → Personal API keys** and create a key. +2. In T3 Code, open **Settings → Linear** and paste the key, then **Connect**. The key is stored + securely on the server (via the encrypted secret store) and is never shown again. +3. The connection status shows the authenticated Linear account once the key is validated. + +Alternatively, set the `T3CODE_LINEAR_API_TOKEN` environment variable on the machine running T3 Code +(and optionally `T3CODE_LINEAR_API_BASE_URL` to override the API endpoint). A token stored through the +Settings UI takes precedence over the environment variable. + +## Importing + +1. Hover a folder in the sidebar and click the **Linear** icon (next to the new-thread button). +2. Search for issues, tick the ones you want, and choose **Combine into one task** or **As related + subtasks** when multiple are selected. +3. Click **Import**. A new draft thread opens for that folder with the issue context pre-filled — + review it and send to start working. + +## Requirements & Troubleshooting + +- **Not connected** – Add a personal API key in **Settings → Linear**, or set + `T3CODE_LINEAR_API_TOKEN` and restart T3 Code. +- **Token rejected** – Regenerate the key in Linear and reconnect; keys can be revoked from Linear’s + security settings. +- **No issues found** – Free-text search matches issue titles/identifiers; clear the search box to see + recent issues. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index d9e19889721..f30f4ededf6 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -31,6 +31,10 @@ "types": "./src/operations/projects.ts", "default": "./src/operations/projects.ts" }, + "./linear-format": { + "types": "./src/linearFormat.ts", + "default": "./src/linearFormat.ts" + }, "./platform": { "types": "./src/platform/index.ts", "default": "./src/platform/index.ts" @@ -115,6 +119,10 @@ "types": "./src/state/sourceControl.ts", "default": "./src/state/sourceControl.ts" }, + "./state/linear": { + "types": "./src/state/linear.ts", + "default": "./src/state/linear.ts" + }, "./state/terminal": { "types": "./src/state/terminal.ts", "default": "./src/state/terminal.ts" diff --git a/packages/client-runtime/src/linearFormat.ts b/packages/client-runtime/src/linearFormat.ts new file mode 100644 index 00000000000..1abd10cf4b9 --- /dev/null +++ b/packages/client-runtime/src/linearFormat.ts @@ -0,0 +1,118 @@ +import type { LinearIssueDetail } from "@t3tools/contracts"; + +/** + * How multiple imported Linear issues are laid out in the composer prompt. + * - `combine`: merge every issue into one comprehensive task. + * - `subtasks`: present each issue as an independent, ordered subtask. + */ +export type LinearImportMode = "combine" | "subtasks"; + +function metadataLine(issue: LinearIssueDetail): string | null { + const parts: Array = []; + if (issue.stateName) parts.push(`Status: ${issue.stateName}`); + if (issue.priorityLabel) parts.push(`Priority: ${issue.priorityLabel}`); + if (issue.assigneeName) parts.push(`Assignee: ${issue.assigneeName}`); + if (issue.teamKey) parts.push(`Team: ${issue.teamKey}`); + if (issue.labels.length > 0) parts.push(`Labels: ${issue.labels.join(", ")}`); + return parts.length > 0 ? parts.join(" · ") : null; +} + +function sectionList(title: string, lines: ReadonlyArray): ReadonlyArray { + if (lines.length === 0) return []; + return [`**${title}**`, ...lines.map((line) => `- ${line}`), ""]; +} + +/** Render a single issue as a self-contained markdown block under `heading`. */ +function formatIssueBlock(issue: LinearIssueDetail, heading: string): string { + const lines: Array = [heading, ""]; + + const meta = metadataLine(issue); + if (meta) { + lines.push(meta, ""); + } + if (issue.url) { + lines.push(`Linear: ${issue.url}`, ""); + } + + const description = issue.description.trim(); + if (description.length > 0) { + // The description carries any acceptance criteria / checklists inline as + // markdown; preserve it verbatim so checkboxes survive the import. + lines.push("**Description**", "", description, ""); + } + + lines.push( + ...sectionList( + "Sub-issues", + issue.subIssues.map((sub) => + sub.stateName + ? `${sub.identifier}: ${sub.title} (${sub.stateName})` + : `${sub.identifier}: ${sub.title}`, + ), + ), + ); + + lines.push( + ...sectionList( + "Linked pull requests", + issue.linkedPullRequests.map((pr) => (pr.title ? `[${pr.title}](${pr.url})` : pr.url)), + ), + ); + + lines.push( + ...sectionList( + "Attachments", + issue.attachments.map((attachment) => + attachment.title ? `[${attachment.title}](${attachment.url})` : attachment.url, + ), + ), + ); + + if (issue.comments.length > 0) { + lines.push("**Comments**", ""); + for (const comment of issue.comments) { + const author = comment.author ?? "Unknown"; + const when = comment.createdAt ? ` (${comment.createdAt})` : ""; + lines.push(`> **${author}**${when}:`); + for (const bodyLine of comment.body.trim().split("\n")) { + lines.push(`> ${bodyLine}`); + } + lines.push(""); + } + } + + return lines.join("\n").trimEnd(); +} + +/** + * Build the composer prompt for one or more imported Linear issues. + * Pure and deterministic — safe to unit test. + */ +export function formatLinearIssues( + issues: ReadonlyArray, + mode: LinearImportMode, +): string { + if (issues.length === 0) return ""; + + if (issues.length === 1) { + const issue = issues[0]!; + return `Work on this Linear issue:\n\n${formatIssueBlock( + issue, + `## ${issue.identifier}: ${issue.title}`, + )}\n`; + } + + if (mode === "subtasks") { + const blocks = issues.map((issue, index) => + formatIssueBlock(issue, `## Subtask ${index + 1} — ${issue.identifier}: ${issue.title}`), + ); + return `These related Linear issues should be implemented as subtasks:\n\n${blocks.join( + "\n\n", + )}\n`; + } + + const blocks = issues.map((issue) => + formatIssueBlock(issue, `## ${issue.identifier}: ${issue.title}`), + ); + return `Work on these Linear issues together as one task:\n\n${blocks.join("\n\n")}\n`; +} diff --git a/packages/client-runtime/src/state/linear.ts b/packages/client-runtime/src/state/linear.ts new file mode 100644 index 00000000000..6c6a0add283 --- /dev/null +++ b/packages/client-runtime/src/state/linear.ts @@ -0,0 +1,84 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { + createAtomCommandScheduler, + createEnvironmentRpcCommand, + createEnvironmentRpcQueryAtomFamily, +} from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; + +export function createLinearEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + const commandScheduler = createAtomCommandScheduler(); + return { + authStatus: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:linear:auth-status", + tag: WS_METHODS.linearAuthStatus, + }), + searchIssues: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:linear:search-issues", + tag: WS_METHODS.linearSearchIssues, + }), + listIssues: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:linear:list-issues", + tag: WS_METHODS.linearListIssues, + }), + teams: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:linear:teams", + tag: WS_METHODS.linearListTeams, + }), + workflowStates: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:linear:workflow-states", + tag: WS_METHODS.linearListWorkflowStates, + }), + projects: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:linear:projects", + tag: WS_METHODS.linearListProjects, + }), + labels: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:linear:labels", + tag: WS_METHODS.linearListLabels, + }), + users: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:linear:users", + tag: WS_METHODS.linearListUsers, + }), + fetchIssues: createEnvironmentRpcCommand(runtime, { + label: "environment-data:linear:fetch-issues", + tag: WS_METHODS.linearFetchIssues, + scheduler: commandScheduler, + }), + updateIssueState: createEnvironmentRpcCommand(runtime, { + label: "environment-data:linear:update-issue-state", + tag: WS_METHODS.linearUpdateIssueState, + scheduler: commandScheduler, + }), + createComment: createEnvironmentRpcCommand(runtime, { + label: "environment-data:linear:create-comment", + tag: WS_METHODS.linearCreateComment, + scheduler: commandScheduler, + }), + createAttachment: createEnvironmentRpcCommand(runtime, { + label: "environment-data:linear:create-attachment", + tag: WS_METHODS.linearCreateAttachment, + scheduler: commandScheduler, + }), + completeThreadIssue: createEnvironmentRpcCommand(runtime, { + label: "environment-data:linear:complete-thread-issue", + tag: WS_METHODS.linearCompleteThreadIssue, + scheduler: commandScheduler, + }), + setToken: createEnvironmentRpcCommand(runtime, { + label: "environment-data:linear:set-token", + tag: WS_METHODS.linearSetToken, + scheduler: commandScheduler, + }), + clearToken: createEnvironmentRpcCommand(runtime, { + label: "environment-data:linear:clear-token", + tag: WS_METHODS.linearClearToken, + scheduler: commandScheduler, + }), + }; +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 43270efdec7..3055ddf7f20 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -17,6 +17,7 @@ export * from "./settings.ts"; export * from "./git.ts"; export * from "./vcs.ts"; export * from "./sourceControl.ts"; +export * from "./linear.ts"; export * from "./orchestration.ts"; export * from "./editor.ts"; export * from "./project.ts"; diff --git a/packages/contracts/src/linear.ts b/packages/contracts/src/linear.ts new file mode 100644 index 00000000000..be7db956fa3 --- /dev/null +++ b/packages/contracts/src/linear.ts @@ -0,0 +1,329 @@ +import * as Schema from "effect/Schema"; +import { PositiveInt, ThreadId, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; + +const LINEAR_SEARCH_MAX_LIMIT = 50; +const LINEAR_LIST_MAX_LIMIT = 100; +const LINEAR_SEARCH_QUERY_MAX_LENGTH = 256; +const LINEAR_TOKEN_MAX_LENGTH = 512; + +// ── Auth status ────────────────────────────────────────────────────── + +export const LinearAuthStatusValue = Schema.Literals(["authenticated", "unauthenticated"]); +export type LinearAuthStatusValue = typeof LinearAuthStatusValue.Type; + +export const LinearAccount = Schema.Struct({ + name: TrimmedNonEmptyString, + email: Schema.optional(TrimmedNonEmptyString), +}); +export type LinearAccount = typeof LinearAccount.Type; + +export const LinearAuthStatus = Schema.Struct({ + status: LinearAuthStatusValue, + account: Schema.optional(LinearAccount), + detail: Schema.optional(TrimmedNonEmptyString), +}); +export type LinearAuthStatus = typeof LinearAuthStatus.Type; + +// ── Workflow states ────────────────────────────────────────────────── + +/** Linear workflow-state category. Stable across teams that rename states. */ +export const LinearWorkflowStateType = Schema.Literals([ + "backlog", + "unstarted", + "started", + "completed", + "canceled", + "triage", +]); +export type LinearWorkflowStateType = typeof LinearWorkflowStateType.Type; + +// ── Issue shapes ───────────────────────────────────────────────────── + +export const LinearIssueSummary = Schema.Struct({ + id: TrimmedNonEmptyString, + identifier: TrimmedNonEmptyString, + title: TrimmedNonEmptyString, + url: Schema.String, + stateName: Schema.optional(TrimmedNonEmptyString), + stateType: Schema.optional(LinearWorkflowStateType), + priorityLabel: Schema.optional(TrimmedNonEmptyString), + assigneeName: Schema.optional(TrimmedNonEmptyString), + teamKey: Schema.optional(TrimmedNonEmptyString), + teamId: Schema.optional(TrimmedNonEmptyString), +}); +export type LinearIssueSummary = typeof LinearIssueSummary.Type; + +export const LinearSubIssue = Schema.Struct({ + identifier: TrimmedNonEmptyString, + title: TrimmedNonEmptyString, + stateName: Schema.optional(TrimmedNonEmptyString), +}); +export type LinearSubIssue = typeof LinearSubIssue.Type; + +export const LinearLinkedPullRequest = Schema.Struct({ + url: Schema.String, + title: Schema.optional(TrimmedNonEmptyString), +}); +export type LinearLinkedPullRequest = typeof LinearLinkedPullRequest.Type; + +export const LinearComment = Schema.Struct({ + author: Schema.optional(TrimmedNonEmptyString), + body: Schema.String, + createdAt: Schema.optional(TrimmedNonEmptyString), +}); +export type LinearComment = typeof LinearComment.Type; + +export const LinearAttachment = Schema.Struct({ + title: Schema.optional(TrimmedNonEmptyString), + url: Schema.String, +}); +export type LinearAttachment = typeof LinearAttachment.Type; + +export const LinearIssueDetail = Schema.Struct({ + id: TrimmedNonEmptyString, + identifier: TrimmedNonEmptyString, + title: TrimmedNonEmptyString, + url: Schema.String, + stateName: Schema.optional(TrimmedNonEmptyString), + stateType: Schema.optional(LinearWorkflowStateType), + priorityLabel: Schema.optional(TrimmedNonEmptyString), + assigneeName: Schema.optional(TrimmedNonEmptyString), + teamKey: Schema.optional(TrimmedNonEmptyString), + teamId: Schema.optional(TrimmedNonEmptyString), + description: Schema.String, + labels: Schema.Array(TrimmedNonEmptyString), + subIssues: Schema.Array(LinearSubIssue), + linkedPullRequests: Schema.Array(LinearLinkedPullRequest), + attachments: Schema.Array(LinearAttachment), + comments: Schema.Array(LinearComment), +}); +export type LinearIssueDetail = typeof LinearIssueDetail.Type; + +// ── Filter metadata (teams / states / projects / labels / users) ───── + +export const LinearTeam = Schema.Struct({ + id: TrimmedNonEmptyString, + key: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, +}); +export type LinearTeam = typeof LinearTeam.Type; + +export const LinearWorkflowState = Schema.Struct({ + id: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, + type: LinearWorkflowStateType, + position: Schema.Number, + color: Schema.optional(TrimmedNonEmptyString), + teamId: Schema.optional(TrimmedNonEmptyString), +}); +export type LinearWorkflowState = typeof LinearWorkflowState.Type; + +export const LinearProject = Schema.Struct({ + id: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, +}); +export type LinearProject = typeof LinearProject.Type; + +export const LinearLabel = Schema.Struct({ + id: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, + color: Schema.optional(TrimmedNonEmptyString), +}); +export type LinearLabel = typeof LinearLabel.Type; + +export const LinearUser = Schema.Struct({ + id: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, + displayName: Schema.optional(TrimmedNonEmptyString), + email: Schema.optional(TrimmedNonEmptyString), + isMe: Schema.optional(Schema.Boolean), +}); +export type LinearUser = typeof LinearUser.Type; + +/** Persisted link from a T3 Code thread back to the Linear issue it came from. */ +export const LinearIssueLink = Schema.Struct({ + id: TrimmedNonEmptyString, + identifier: TrimmedNonEmptyString, + title: TrimmedNonEmptyString, + url: Schema.String, + teamId: Schema.optional(TrimmedNonEmptyString), + stateType: Schema.optional(LinearWorkflowStateType), + stateName: Schema.optional(TrimmedNonEmptyString), +}); +export type LinearIssueLink = typeof LinearIssueLink.Type; + +// ── Filter + pagination ────────────────────────────────────────────── + +export const LinearIssueFilter = Schema.Struct({ + teamId: Schema.optional(TrimmedNonEmptyString), + assigneeId: Schema.optional(TrimmedNonEmptyString), + stateType: Schema.optional(LinearWorkflowStateType), + stateId: Schema.optional(TrimmedNonEmptyString), + projectId: Schema.optional(TrimmedNonEmptyString), + labelId: Schema.optional(TrimmedNonEmptyString), + priority: Schema.optional(Schema.Int), + query: Schema.optional(TrimmedString.check(Schema.isMaxLength(LINEAR_SEARCH_QUERY_MAX_LENGTH))), +}); +export type LinearIssueFilter = typeof LinearIssueFilter.Type; + +export const LinearPageInfo = Schema.Struct({ + hasNextPage: Schema.Boolean, + endCursor: Schema.optional(TrimmedNonEmptyString), +}); +export type LinearPageInfo = typeof LinearPageInfo.Type; + +// ── RPC inputs / results ───────────────────────────────────────────── + +export const LinearListIssuesInput = Schema.Struct({ + filter: Schema.optional(LinearIssueFilter), + first: PositiveInt.check(Schema.isLessThanOrEqualTo(LINEAR_LIST_MAX_LIMIT)), + after: Schema.optional(TrimmedNonEmptyString), +}); +export type LinearListIssuesInput = typeof LinearListIssuesInput.Type; + +export const LinearListIssuesResult = Schema.Struct({ + issues: Schema.Array(LinearIssueSummary), + pageInfo: LinearPageInfo, +}); +export type LinearListIssuesResult = typeof LinearListIssuesResult.Type; + +export const LinearListTeamsResult = Schema.Struct({ teams: Schema.Array(LinearTeam) }); +export type LinearListTeamsResult = typeof LinearListTeamsResult.Type; + +export const LinearListWorkflowStatesInput = Schema.Struct({ teamId: TrimmedNonEmptyString }); +export type LinearListWorkflowStatesInput = typeof LinearListWorkflowStatesInput.Type; + +export const LinearListWorkflowStatesResult = Schema.Struct({ + states: Schema.Array(LinearWorkflowState), +}); +export type LinearListWorkflowStatesResult = typeof LinearListWorkflowStatesResult.Type; + +export const LinearListProjectsResult = Schema.Struct({ projects: Schema.Array(LinearProject) }); +export type LinearListProjectsResult = typeof LinearListProjectsResult.Type; + +export const LinearListLabelsResult = Schema.Struct({ labels: Schema.Array(LinearLabel) }); +export type LinearListLabelsResult = typeof LinearListLabelsResult.Type; + +export const LinearListUsersResult = Schema.Struct({ users: Schema.Array(LinearUser) }); +export type LinearListUsersResult = typeof LinearListUsersResult.Type; + +// ── Write mutations (Phase 3) ──────────────────────────────────────── + +export const LinearUpdateIssueStateInput = Schema.Struct({ + issueId: TrimmedNonEmptyString, + stateId: TrimmedNonEmptyString, +}); +export type LinearUpdateIssueStateInput = typeof LinearUpdateIssueStateInput.Type; + +export const LinearCreateCommentInput = Schema.Struct({ + issueId: TrimmedNonEmptyString, + body: Schema.String, +}); +export type LinearCreateCommentInput = typeof LinearCreateCommentInput.Type; + +export const LinearCreateAttachmentInput = Schema.Struct({ + issueId: TrimmedNonEmptyString, + url: Schema.String, + title: Schema.optional(TrimmedNonEmptyString), + subtitle: Schema.optional(TrimmedNonEmptyString), +}); +export type LinearCreateAttachmentInput = typeof LinearCreateAttachmentInput.Type; + +export const LinearMutationResult = Schema.Struct({ success: Schema.Boolean }); +export type LinearMutationResult = typeof LinearMutationResult.Type; + +/** Mark the Linear issue linked to a thread as done (right-click → thread menu). */ +export const LinearCompleteIssueInput = Schema.Struct({ threadId: ThreadId }); +export type LinearCompleteIssueInput = typeof LinearCompleteIssueInput.Type; + +export const LinearSearchIssuesInput = Schema.Struct({ + query: TrimmedString.check(Schema.isMaxLength(LINEAR_SEARCH_QUERY_MAX_LENGTH)), + limit: PositiveInt.check(Schema.isLessThanOrEqualTo(LINEAR_SEARCH_MAX_LIMIT)), +}); +export type LinearSearchIssuesInput = typeof LinearSearchIssuesInput.Type; + +export const LinearSearchIssuesResult = Schema.Struct({ + issues: Schema.Array(LinearIssueSummary), + truncated: Schema.Boolean, +}); +export type LinearSearchIssuesResult = typeof LinearSearchIssuesResult.Type; + +export const LinearFetchIssuesInput = Schema.Struct({ + ids: Schema.Array(TrimmedNonEmptyString), +}); +export type LinearFetchIssuesInput = typeof LinearFetchIssuesInput.Type; + +export const LinearFetchIssuesResult = Schema.Struct({ + issues: Schema.Array(LinearIssueDetail), +}); +export type LinearFetchIssuesResult = typeof LinearFetchIssuesResult.Type; + +export const LinearSetTokenInput = Schema.Struct({ + token: TrimmedNonEmptyString.check(Schema.isMaxLength(LINEAR_TOKEN_MAX_LENGTH)), +}); +export type LinearSetTokenInput = typeof LinearSetTokenInput.Type; + +// ── Errors ─────────────────────────────────────────────────────────── + +export const LinearApiOperation = Schema.Literals([ + "probeAuth", + "searchIssues", + "fetchIssues", + "listIssues", + "listTeams", + "listWorkflowStates", + "listProjects", + "listLabels", + "listUsers", + "updateIssueState", + "createComment", + "createAttachment", + "setToken", + "clearToken", +]); +export type LinearApiOperation = typeof LinearApiOperation.Type; + +export class LinearAuthError extends Schema.TaggedErrorClass()("LinearAuthError", { + operation: LinearApiOperation, + detail: Schema.optional(Schema.String), +}) { + override get message(): string { + const suffix = this.detail === undefined ? "" : ` ${this.detail}`; + return `Linear authentication required for ${this.operation}.${suffix}`; + } +} + +export class LinearRequestError extends Schema.TaggedErrorClass()( + "LinearRequestError", + { + operation: LinearApiOperation, + status: Schema.optional(Schema.Int), + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Linear API failed in ${this.operation}: ${this.detail}`; + } +} + +export class LinearTokenStoreError extends Schema.TaggedErrorClass()( + "LinearTokenStoreError", + { + operation: LinearApiOperation, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Linear token storage failed in ${this.operation}: ${this.detail}`; + } +} + +export const LinearError = Schema.Union([ + LinearAuthError, + LinearRequestError, + LinearTokenStoreError, +]); +export type LinearError = typeof LinearError.Type; +export const isLinearError = Schema.is(LinearError); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 623fed0917b..94cf81b6821 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -21,6 +21,7 @@ import { TurnId, } from "./baseSchemas.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; +import { LinearIssueLink } from "./linear.ts"; export const ORCHESTRATION_WS_METHODS = { dispatchCommand: "orchestration.dispatchCommand", @@ -352,6 +353,7 @@ export const OrchestrationThread = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + linearIssue: Schema.optional(Schema.NullOr(LinearIssueLink)), latestTurn: Schema.NullOr(OrchestrationLatestTurn), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -398,6 +400,7 @@ export const OrchestrationThreadShell = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + linearIssue: Schema.optional(Schema.NullOr(LinearIssueLink)), latestTurn: Schema.NullOr(OrchestrationLatestTurn), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -503,6 +506,7 @@ const ThreadCreateCommand = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + linearIssue: Schema.optional(Schema.NullOr(LinearIssueLink)), createdAt: IsoDateTime, }); @@ -532,6 +536,7 @@ const ThreadMetaUpdateCommand = Schema.Struct({ modelSelection: Schema.optional(ModelSelection), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + linearIssue: Schema.optional(Schema.NullOr(LinearIssueLink)), }); const ThreadRuntimeModeSetCommand = Schema.Struct({ @@ -558,6 +563,7 @@ const ThreadTurnStartBootstrapCreateThread = Schema.Struct({ interactionMode: ProviderInteractionMode, branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + linearIssue: Schema.optional(Schema.NullOr(LinearIssueLink)), createdAt: IsoDateTime, }); @@ -847,6 +853,7 @@ export const ThreadCreatedPayload = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + linearIssue: Schema.optional(Schema.NullOr(LinearIssueLink)), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); @@ -873,6 +880,7 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({ modelSelection: Schema.optional(ModelSelection), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + linearIssue: Schema.optional(Schema.NullOr(LinearIssueLink)), updatedAt: IsoDateTime, }); diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 48c5d9a774d..fdd3d33ab5c 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -143,6 +143,30 @@ import { SourceControlRepositoryLookupInput, } from "./sourceControl.ts"; import { VcsError } from "./vcs.ts"; +import { + LinearAuthError, + LinearAuthStatus, + LinearCompleteIssueInput, + LinearCreateAttachmentInput, + LinearCreateCommentInput, + LinearFetchIssuesInput, + LinearFetchIssuesResult, + LinearListIssuesInput, + LinearListIssuesResult, + LinearListLabelsResult, + LinearListProjectsResult, + LinearListTeamsResult, + LinearListUsersResult, + LinearListWorkflowStatesInput, + LinearListWorkflowStatesResult, + LinearMutationResult, + LinearRequestError, + LinearSearchIssuesInput, + LinearSearchIssuesResult, + LinearSetTokenInput, + LinearTokenStoreError, + LinearUpdateIssueStateInput, +} from "./linear.ts"; export const WS_METHODS = { // Project registry methods @@ -223,6 +247,23 @@ export const WS_METHODS = { sourceControlCloneRepository: "sourceControl.cloneRepository", sourceControlPublishRepository: "sourceControl.publishRepository", + // Linear methods + linearAuthStatus: "linear.authStatus", + linearSearchIssues: "linear.searchIssues", + linearFetchIssues: "linear.fetchIssues", + linearListIssues: "linear.listIssues", + linearListTeams: "linear.listTeams", + linearListWorkflowStates: "linear.listWorkflowStates", + linearListProjects: "linear.listProjects", + linearListLabels: "linear.listLabels", + linearListUsers: "linear.listUsers", + linearUpdateIssueState: "linear.updateIssueState", + linearCreateComment: "linear.createComment", + linearCreateAttachment: "linear.createAttachment", + linearCompleteThreadIssue: "linear.completeThreadIssue", + linearSetToken: "linear.setToken", + linearClearToken: "linear.clearToken", + // Streaming subscriptions subscribeVcsStatus: "subscribeVcsStatus", subscribeTerminalEvents: "subscribeTerminalEvents", @@ -354,6 +395,113 @@ export const WsSourceControlPublishRepositoryRpc = Rpc.make( }, ); +export const WsLinearAuthStatusRpc = Rpc.make(WS_METHODS.linearAuthStatus, { + payload: Schema.Struct({}), + success: LinearAuthStatus, + error: Schema.Union([LinearRequestError, LinearTokenStoreError, EnvironmentAuthorizationError]), +}); + +export const WsLinearSearchIssuesRpc = Rpc.make(WS_METHODS.linearSearchIssues, { + payload: LinearSearchIssuesInput, + success: LinearSearchIssuesResult, + error: Schema.Union([ + LinearAuthError, + LinearRequestError, + LinearTokenStoreError, + EnvironmentAuthorizationError, + ]), +}); + +export const WsLinearFetchIssuesRpc = Rpc.make(WS_METHODS.linearFetchIssues, { + payload: LinearFetchIssuesInput, + success: LinearFetchIssuesResult, + error: Schema.Union([ + LinearAuthError, + LinearRequestError, + LinearTokenStoreError, + EnvironmentAuthorizationError, + ]), +}); + +export const WsLinearSetTokenRpc = Rpc.make(WS_METHODS.linearSetToken, { + payload: LinearSetTokenInput, + success: LinearAuthStatus, + error: Schema.Union([LinearRequestError, LinearTokenStoreError, EnvironmentAuthorizationError]), +}); + +export const WsLinearClearTokenRpc = Rpc.make(WS_METHODS.linearClearToken, { + payload: Schema.Struct({}), + success: LinearAuthStatus, + error: Schema.Union([LinearTokenStoreError, EnvironmentAuthorizationError]), +}); + +const LinearReadError = Schema.Union([ + LinearAuthError, + LinearRequestError, + LinearTokenStoreError, + EnvironmentAuthorizationError, +]); + +export const WsLinearListIssuesRpc = Rpc.make(WS_METHODS.linearListIssues, { + payload: LinearListIssuesInput, + success: LinearListIssuesResult, + error: LinearReadError, +}); + +export const WsLinearListTeamsRpc = Rpc.make(WS_METHODS.linearListTeams, { + payload: Schema.Struct({}), + success: LinearListTeamsResult, + error: LinearReadError, +}); + +export const WsLinearListWorkflowStatesRpc = Rpc.make(WS_METHODS.linearListWorkflowStates, { + payload: LinearListWorkflowStatesInput, + success: LinearListWorkflowStatesResult, + error: LinearReadError, +}); + +export const WsLinearListProjectsRpc = Rpc.make(WS_METHODS.linearListProjects, { + payload: Schema.Struct({}), + success: LinearListProjectsResult, + error: LinearReadError, +}); + +export const WsLinearListLabelsRpc = Rpc.make(WS_METHODS.linearListLabels, { + payload: Schema.Struct({}), + success: LinearListLabelsResult, + error: LinearReadError, +}); + +export const WsLinearListUsersRpc = Rpc.make(WS_METHODS.linearListUsers, { + payload: Schema.Struct({}), + success: LinearListUsersResult, + error: LinearReadError, +}); + +export const WsLinearUpdateIssueStateRpc = Rpc.make(WS_METHODS.linearUpdateIssueState, { + payload: LinearUpdateIssueStateInput, + success: LinearMutationResult, + error: LinearReadError, +}); + +export const WsLinearCreateCommentRpc = Rpc.make(WS_METHODS.linearCreateComment, { + payload: LinearCreateCommentInput, + success: LinearMutationResult, + error: LinearReadError, +}); + +export const WsLinearCreateAttachmentRpc = Rpc.make(WS_METHODS.linearCreateAttachment, { + payload: LinearCreateAttachmentInput, + success: LinearMutationResult, + error: LinearReadError, +}); + +export const WsLinearCompleteThreadIssueRpc = Rpc.make(WS_METHODS.linearCompleteThreadIssue, { + payload: LinearCompleteIssueInput, + success: LinearMutationResult, + error: LinearReadError, +}); + export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, { payload: ProjectSearchEntriesInput, success: ProjectSearchEntriesResult, @@ -699,6 +847,21 @@ export const WsRpcGroup = RpcGroup.make( WsSourceControlLookupRepositoryRpc, WsSourceControlCloneRepositoryRpc, WsSourceControlPublishRepositoryRpc, + WsLinearAuthStatusRpc, + WsLinearSearchIssuesRpc, + WsLinearFetchIssuesRpc, + WsLinearListIssuesRpc, + WsLinearListTeamsRpc, + WsLinearListWorkflowStatesRpc, + WsLinearListProjectsRpc, + WsLinearListLabelsRpc, + WsLinearListUsersRpc, + WsLinearUpdateIssueStateRpc, + WsLinearCreateCommentRpc, + WsLinearCreateAttachmentRpc, + WsLinearCompleteThreadIssueRpc, + WsLinearSetTokenRpc, + WsLinearClearTokenRpc, WsProjectsListEntriesRpc, WsProjectsReadFileRpc, WsProjectsSearchEntriesRpc, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 6ccd65533dd..088d9fc87aa 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -361,6 +361,26 @@ export const ObservabilitySettings = Schema.Struct({ }); export type ObservabilitySettings = typeof ObservabilitySettings.Type; +/** Per-team override mapping lifecycle stages → specific Linear workflow-state ids. */ +export const LinearTeamStateMapping = Schema.Struct({ + started: Schema.optional(TrimmedNonEmptyString), + review: Schema.optional(TrimmedNonEmptyString), + done: Schema.optional(TrimmedNonEmptyString), +}); +export type LinearTeamStateMapping = typeof LinearTeamStateMapping.Type; + +export const LinearSyncSettings = Schema.Struct({ + autoSync: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + transitionOnStart: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + transitionOnPrOpen: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + transitionOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + postComments: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + stateMappingByTeam: Schema.Record(TrimmedNonEmptyString, LinearTeamStateMapping).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), +}); +export type LinearSyncSettings = typeof LinearSyncSettings.Type; + export const DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL = Duration.seconds(30); export const ServerSettings = Schema.Struct({ @@ -409,6 +429,7 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed({})), ), observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + linear: LinearSyncSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }); export type ServerSettings = typeof ServerSettings.Type; @@ -530,6 +551,18 @@ export const ServerSettingsPatch = Schema.Struct({ // patches risk leaving driver-specific config in a half-merged state. // The web UI sends a fully-formed map every time it edits this field. providerInstances: Schema.optionalKey(Schema.Record(ProviderInstanceId, ProviderInstanceConfig)), + linear: Schema.optionalKey( + Schema.Struct({ + autoSync: Schema.optionalKey(Schema.Boolean), + transitionOnStart: Schema.optionalKey(Schema.Boolean), + transitionOnPrOpen: Schema.optionalKey(Schema.Boolean), + transitionOnMerge: Schema.optionalKey(Schema.Boolean), + postComments: Schema.optionalKey(Schema.Boolean), + stateMappingByTeam: Schema.optionalKey( + Schema.Record(TrimmedNonEmptyString, LinearTeamStateMapping), + ), + }), + ), }); export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 1bbf466f60b..74fbaa2532b 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -83,6 +83,11 @@ export function applyServerSettingsPatch( ...(patch.providerInstances !== undefined ? { providerInstances: patch.providerInstances } : {}), + // Whole-map replacement so sending an empty map clears stale team overrides + // (deepMerge would otherwise keep removed keys). + ...(patch.linear?.stateMappingByTeam !== undefined + ? { linear: { ...next.linear, stateMappingByTeam: patch.linear.stateMappingByTeam } } + : {}), ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}), }; if (!selectionPatch) {