diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index daad92af..e52bcb1e 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -49,6 +49,7 @@ import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider'; import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { useUIStore } from '@/stores/useUIStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; +import { useLinearAuthStore } from '@/stores/useLinearAuthStore'; import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; import type { RuntimeAPIs } from '@/lib/api/types'; import { TooltipProvider } from '@/components/ui/tooltip'; @@ -247,6 +248,7 @@ function App({ apis }: AppProps) { const isSwitchingDirectory = useDirectoryStore((state) => state.isSwitchingDirectory); const [showMemoryDebug, setShowMemoryDebug] = React.useState(false); const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus); + const refreshLinearAuthStatus = useLinearAuthStore((state) => state.refreshStatus); const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState(() => apis.runtime.isVSCode); // Embedded chats start inactive until the parent panel identifies the active // tab. Otherwise a newly loaded background tab can focus its composer first @@ -345,7 +347,8 @@ function App({ apis }: AppProps) { } void refreshGitHubAuthStatus(apis.github, { force: true }); - }, [apis.github, embeddedSessionChat, refreshGitHubAuthStatus]); + void refreshLinearAuthStatus(apis.linear, { force: true }); + }, [apis.github, apis.linear, embeddedSessionChat, refreshGitHubAuthStatus, refreshLinearAuthStatus]); useAppFontEffects(); diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index 9178e36b..234df4b7 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -35,6 +35,7 @@ import { useConfigStore } from '@/stores/useConfigStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; +import { useLinearAuthStore } from '@/stores/useLinearAuthStore'; import { useGitStore } from '@/stores/useGitStore'; import { useMcpConfigStore, type McpDraft } from '@/stores/useMcpConfigStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; @@ -630,6 +631,7 @@ export function MobileApp({ apis }: MobileAppProps) { const clearError = useSessionUIStore((state) => state.clearError); const setIsMobile = useUIStore((state) => state.setIsMobile); const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus); + const refreshLinearAuthStatus = useLinearAuthStore((state) => state.refreshStatus); const setPlanModeEnabled = useFeatureFlagsStore((state) => state.setPlanModeEnabled); const projects = useProjectsStore((state) => state.projects); const [connectionEpoch, setConnectionEpoch] = React.useState(0); @@ -678,6 +680,7 @@ export function MobileApp({ apis }: MobileAppProps) { const refreshInPlace = () => { void initializeApp(); void refreshGitHubAuthStatus(apis.github, { force: true }); + void refreshLinearAuthStatus(apis.linear, { force: true }); if (providersCount === 0) void loadProviders({ source: 'mobileApp:nativeResume' }); if (agentsCount === 0) void loadAgents({ source: 'mobileApp:nativeResume' }); }; @@ -746,7 +749,7 @@ export function MobileApp({ apis }: MobileAppProps) { lastNativeResumeSyncEventAtRef.current = now; window.dispatchEvent(new Event('openchamber:system-resume')); } - }, [agentsCount, apis.github, initializeApp, loadAgents, loadProviders, providersCount, refreshGitHubAuthStatus]); + }, [agentsCount, apis.github, apis.linear, initializeApp, loadAgents, loadProviders, providersCount, refreshGitHubAuthStatus, refreshLinearAuthStatus]); useNativeMobileChrome(); useNativeMobileLifecycle(handleNativeResume); @@ -1031,7 +1034,8 @@ export function MobileApp({ apis }: MobileAppProps) { React.useEffect(() => { if (!isConnected) return; void refreshGitHubAuthStatus(apis.github, { force: true }); - }, [apis.github, isConnected, refreshGitHubAuthStatus]); + void refreshLinearAuthStatus(apis.linear, { force: true }); + }, [apis.github, apis.linear, isConnected, refreshGitHubAuthStatus, refreshLinearAuthStatus]); // Discover all worktrees for every known project so the draft session's // worktree/branch dropdown can list every available branch — not only the diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index ec34510f..4220e54e 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -17,7 +17,7 @@ import { } from '@/sync/attachment-files'; import type { AttachedFile } from '@/stores/types/sessionTypes'; import * as sessionActions from '@/sync/session-actions'; -import { buildLinkedIssue } from '@/lib/linkedIssues'; +import { buildLinkedIssue, buildLinkedLinearIssue } from '@/lib/linkedIssues'; import { useUserMessageHistory } from "@/sync/sync-context"; import { getInlineCommentDraftKey, useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore'; import { useSnippetsStore } from '@/stores/useSnippetsStore'; @@ -66,6 +66,7 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { GitHubIssuePickerDialog } from '@/components/session/GitHubIssuePickerDialog'; import { GitHubPrPickerDialog } from '@/components/session/GitHubPrPickerDialog'; +import { LinearIssuePickerDialog } from '@/components/session/LinearIssuePickerDialog'; import { Icon } from "@/components/icon/Icon"; import { DraftPresetChips } from './DraftPresetChips'; import { useChatSearchDirectory } from '@/hooks/useChatSearchDirectory'; @@ -426,7 +427,7 @@ const ChatInputComponent: React.FC = ({ const isExpandedInput = useUIStore((state) => state.isExpandedInput); const setExpandedInput = useUIStore((state) => state.setExpandedInput); const setTimelineDialogOpen = useUIStore((state) => state.setTimelineDialogOpen); - const { git: runtimeGit, vscode: vscodeApi } = useRuntimeAPIs(); + const { git: runtimeGit, vscode: vscodeApi, linear: runtimeLinear } = useRuntimeAPIs(); const cycleAgentShortcutOverride = useUIStore((state) => state.shortcutOverrides.cycle_agent); const cycleAgentShortcut = React.useMemo(() => ( getEffectiveShortcutCombo('cycle_agent', cycleAgentShortcutOverride ? { cycle_agent: cycleAgentShortcutOverride } : undefined) @@ -722,6 +723,7 @@ const ChatInputComponent: React.FC = ({ // Issue linking state const [issuePickerOpen, setIssuePickerOpen] = React.useState(false); const [prPickerOpen, setPrPickerOpen] = React.useState(false); + const [linearPickerOpen, setLinearPickerOpen] = React.useState(false); const [linkedIssue, setLinkedIssue] = React.useState<{ number: number; title: string; @@ -740,6 +742,13 @@ const ChatInputComponent: React.FC = ({ contextText: string; author?: { login: string; avatarUrl?: string }; } | null>(null); + const [linkedLinearIssue, setLinkedLinearIssue] = React.useState<{ + identifier: string; + title: string; + url: string; + contextText: string; + author?: { login: string; avatarUrl?: string }; + } | null>(null); // Message queue const messageQueueTarget = currentSessionId @@ -972,6 +981,10 @@ const ChatInputComponent: React.FC = ({ setPrPickerOpen(true); }, []); + const openLinearPicker = React.useCallback(() => { + setLinearPickerOpen(true); + }, []); + const getSubmitErrorMessage = (error: unknown, fallback: string) => { const message = error instanceof Error ? error.message : ''; return message.toLowerCase().includes('runtime changed') @@ -1158,6 +1171,9 @@ const ChatInputComponent: React.FC = ({ linkedPr: linkedPr ? { number: linkedPr.number, title: linkedPr.title, url: linkedPr.url, instructions: linkedPr.instructionsText, context: linkedPr.contextText } : null, + linkedLinearIssue: linkedLinearIssue + ? { identifier: linkedLinearIssue.identifier, title: linkedLinearIssue.title, url: linkedLinearIssue.url, contextText: linkedLinearIssue.contextText } + : null, }, { parseAgentMention: (text) => { const { sanitizedText, mention } = parseAgentMentions(text, agents); @@ -1395,6 +1411,20 @@ const ChatInputComponent: React.FC = ({ true, ).catch(() => undefined); } + if (linkedLinearIssue && linkTargetSessionId) { + void sessionActions.setLinkedIssue( + linkTargetSessionId, + linkTargetDirectory, + buildLinkedLinearIssue({ + identifier: linkedLinearIssue.identifier, + title: linkedLinearIssue.title, + url: linkedLinearIssue.url, + author: linkedLinearIssue.author, + linkedAt: Date.now(), + }), + true, + ).catch(() => undefined); + } // Clear linked issue after successful message send if (linkedIssue) { @@ -1403,6 +1433,9 @@ const ChatInputComponent: React.FC = ({ if (linkedPr) { setLinkedPr(null); } + if (linkedLinearIssue) { + setLinkedLinearIssue(null); + } }).catch((error: unknown) => { const rawMessage = error instanceof Error @@ -2528,6 +2561,7 @@ const ChatInputComponent: React.FC = ({ const footerGapClass = 'gap-x-1.5 gap-y-0'; const isVSCode = isVSCodeRuntime(); + const showLinearPicker = Boolean(runtimeLinear) && !isVSCode; // The work-status panel carries the agent's todos and the changed-file // count, but only on the desktop/web layout — VS Code and mobile have no // panel, so these keep their place above the composer there. @@ -2608,6 +2642,7 @@ const ChatInputComponent: React.FC = ({ draftPickerOpen: mobileDraftPicker !== null, issuePickerOpen, prPickerOpen, + linearPickerOpen, isDragging, }, }); @@ -2778,6 +2813,18 @@ const ChatInputComponent: React.FC = ({ onRemove={() => setLinkedPr(null)} /> ) : null} + {linkedLinearIssue && !isVSCode ? ( + setLinearPickerOpen(true)} + onRemove={() => setLinkedLinearIssue(null)} + /> + ) : null} = ({ onPickLocalFiles={handlePickLocalFiles} onOpenIssuePicker={openIssuePicker} onOpenPrPicker={openPrPicker} + showLinearPicker={showLinearPicker} + onOpenLinearPicker={openLinearPicker} onOpenAttachSheet={openMobileAttachSheet} onStartDictation={toggleDictation} onAbort={handleAbort} @@ -3023,6 +3072,8 @@ const ChatInputComponent: React.FC = ({ onPickLocalFiles={handlePickLocalFiles} onOpenIssuePicker={openIssuePicker} onOpenPrPicker={openPrPicker} + showLinearPicker={showLinearPicker} + onOpenLinearPicker={openLinearPicker} onOpenAttachSheet={openMobileAttachSheet} onToggleExpandedInput={handleToggleExpandedInput} onTogglePermissionAutoAccept={handlePermissionAutoAcceptToggle} @@ -3087,6 +3138,7 @@ const ChatInputComponent: React.FC = ({ onSelect={(issue) => { setLinkedIssue(issue); setLinkedPr(null); + setLinkedLinearIssue(null); }} /> = ({ onSelect={(pr) => { setLinkedPr(pr); setLinkedIssue(null); + setLinkedLinearIssue(null); + }} + /> + { + setLinkedLinearIssue(issue); + setLinkedIssue(null); + setLinkedPr(null); }} /> = ({ {t('chat.chatInput.actions.linkGithubPr')} + {showLinearPicker ? ( + + ) : null} ) : null} diff --git a/packages/ui/src/components/chat/FileAttachment.tsx b/packages/ui/src/components/chat/FileAttachment.tsx index 7f1d4f26..13b24224 100644 --- a/packages/ui/src/components/chat/FileAttachment.tsx +++ b/packages/ui/src/components/chat/FileAttachment.tsx @@ -558,17 +558,29 @@ interface FilePart { const GITHUB_ISSUE_LINK_MIME = 'application/vnd.github.issue-link'; const GITHUB_PR_LINK_MIME = 'application/vnd.github.pull-request-link'; +const LINEAR_ISSUE_LINK_MIME = 'application/vnd.openchamber.linear-issue-link'; -const getGitHubLinkKind = (file: FilePart): 'issue' | 'pr' | null => { +type IssueLinkKind = 'github-issue' | 'github-pr' | 'linear-issue'; + +const getIssueLinkKind = (file: FilePart): IssueLinkKind | null => { if (file.mime === GITHUB_ISSUE_LINK_MIME) { - return 'issue'; + return 'github-issue'; } if (file.mime === GITHUB_PR_LINK_MIME) { - return 'pr'; + return 'github-pr'; + } + if (file.mime === LINEAR_ISSUE_LINK_MIME) { + return 'linear-issue'; } return null; }; +const issueLinkIcon = (kind: IssueLinkKind): 'github' | 'git-pull-request' | 'linear' => { + if (kind === 'github-pr') return 'git-pull-request'; + if (kind === 'linear-issue') return 'linear'; + return 'github'; +}; + interface MessageFilesDisplayProps { files: FilePart[]; onShowPopup?: (content: ToolPopupContent) => void; @@ -591,7 +603,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false } }; const resolveDisplayName = React.useCallback((file: FilePart): string => { - const isGitHubLink = getGitHubLinkKind(file) !== null; + const isGitHubLink = getIssueLinkKind(file) !== null; if (isGitHubLink && typeof file.filename === 'string' && file.filename.trim().length > 0) { return file.filename.trim(); } @@ -665,11 +677,11 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false } const fileName = resolveDisplayName(file); const ext = fileName.split('.').pop() || ''; const sizeText = formatFileSize(file.size); - const githubLinkKind = getGitHubLinkKind(file); + const issueLinkKind = getIssueLinkKind(file); return ( - {githubLinkKind && file.url ? ( + {issueLinkKind && file.url ? ( + + ); + })} + + + ) : null} +
+ + +
+ + ) : isWaiting ? ( +
+ + {t('settings.integrations.linear.flow.waiting')} + + +
+ ) : ( + + )} + + + + + + ); +}; diff --git a/packages/ui/src/components/sections/integrations/ThirdPartyIntegrationsSection.tsx b/packages/ui/src/components/sections/integrations/ThirdPartyIntegrationsSection.tsx index e9cf51bf..542f765b 100644 --- a/packages/ui/src/components/sections/integrations/ThirdPartyIntegrationsSection.tsx +++ b/packages/ui/src/components/sections/integrations/ThirdPartyIntegrationsSection.tsx @@ -414,6 +414,12 @@ export const ThirdPartyIntegrationsSection: React.FC +
+ +

+ {t('settings.integrations.experimentalWarning')} +

+
{THIRD_PARTY_PLUGINS.map(renderPlugin)} diff --git a/packages/ui/src/components/sections/magic-prompts/MagicPromptsPage.tsx b/packages/ui/src/components/sections/magic-prompts/MagicPromptsPage.tsx index 1b1792c6..0f6b7ec3 100644 --- a/packages/ui/src/components/sections/magic-prompts/MagicPromptsPage.tsx +++ b/packages/ui/src/components/sections/magic-prompts/MagicPromptsPage.tsx @@ -61,6 +61,14 @@ const PROMPT_PAGE_MAP: Record = { { id: 'github.issue.review.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' }, ], }, + 'linear.issue.review': { + titleKey: 'settings.magicPrompts.page.group.linearIssueReview.title', + descriptionKey: 'settings.magicPrompts.page.group.linearIssueReview.description', + blocks: [ + { id: 'linear.issue.review.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' }, + { id: 'linear.issue.review.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' }, + ], + }, 'github.pr.checks.review': { titleKey: 'settings.magicPrompts.page.group.githubPrFailedChecksReview.title', descriptionKey: 'settings.magicPrompts.page.group.githubPrFailedChecksReview.description', diff --git a/packages/ui/src/components/sections/magic-prompts/MagicPromptsSidebar.tsx b/packages/ui/src/components/sections/magic-prompts/MagicPromptsSidebar.tsx index c25105ef..2ea33ba1 100644 --- a/packages/ui/src/components/sections/magic-prompts/MagicPromptsSidebar.tsx +++ b/packages/ui/src/components/sections/magic-prompts/MagicPromptsSidebar.tsx @@ -35,6 +35,12 @@ export const MagicPromptsSidebar: React.FC = ({ onItem { id: 'github.pr.comment.single', titleKey: 'settings.magicPrompts.sidebar.item.githubSinglePrCommentReview' }, ], }, + { + groupKey: 'settings.magicPrompts.sidebar.group.linear', + items: [ + { id: 'linear.issue.review', titleKey: 'settings.magicPrompts.sidebar.item.linearIssueReview' }, + ], + }, { groupKey: 'settings.magicPrompts.sidebar.group.planning', items: [ diff --git a/packages/ui/src/components/session/LinearIssuePickerDialog.tsx b/packages/ui/src/components/session/LinearIssuePickerDialog.tsx new file mode 100644 index 00000000..9bf7c0e7 --- /dev/null +++ b/packages/ui/src/components/session/LinearIssuePickerDialog.tsx @@ -0,0 +1,492 @@ +import React from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; +import { toast } from '@/components/ui'; +import { Icon } from '@/components/icon/Icon'; +import { cn } from '@/lib/utils'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { useUIStore } from '@/stores/useUIStore'; +import { useLinearAuthStore } from '@/stores/useLinearAuthStore'; +import { useDebouncedValue } from '@/hooks/useDebouncedValue'; +import { useDeviceInfo } from '@/lib/device'; +import { buildIssueContextText, startLinearIssueSession } from '@/lib/linearStartSession'; +import type { LinearIssueSummary, LinearMappingResult } from '@/lib/api/types'; +import { useI18n } from '@/lib/i18n'; + +const parseLinearIssueQuery = (value: string): string | null => { + const trimmed = value.trim(); + if (!trimmed) return null; + const urlMatch = trimmed.match(/linear\.app\/(?:[^/]+\/)?issue\/([A-Za-z][A-Za-z0-9]*-\d+)/i); + if (urlMatch) return urlMatch[1].toUpperCase(); + if (/^[A-Za-z][A-Za-z0-9]*-\d+$/.test(trimmed)) return trimmed.toUpperCase(); + return null; +}; + +export function LinearIssuePickerDialog({ + open, + onOpenChange, + mode = 'select', + onSelect, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + mode?: 'createSession' | 'select'; + onSelect?: (issue: { + identifier: string; + title: string; + url: string; + contextText: string; + author?: { login: string; avatarUrl?: string }; + }) => void; +}) { + const { t } = useI18n(); + const { linear } = useRuntimeAPIs(); + const linearAuthStatus = useLinearAuthStore((state) => state.status); + const linearAuthChecked = useLinearAuthStore((state) => state.hasChecked); + const refreshStatus = useLinearAuthStore((state) => state.refreshStatus); + const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); + const setSettingsPage = useUIStore((state) => state.setSettingsPage); + const isMobile = useUIStore((state) => state.isMobile); + const { isTablet } = useDeviceInfo(); + const alwaysShowActions = isMobile || isTablet; + + const [query, setQuery] = React.useState(''); + const [issues, setIssues] = React.useState([]); + const [cursor, setCursor] = React.useState(null); + const [hasMore, setHasMore] = React.useState(false); + const [connected, setConnected] = React.useState(true); + const [startingIssueKey, setStartingIssueKey] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(false); + const [isLoadingMore, setIsLoadingMore] = React.useState(false); + const [error, setError] = React.useState(null); + const [createInWorktree, setCreateInWorktree] = React.useState(false); + const [mapping, setMapping] = React.useState(null); + const [mappingError, setMappingError] = React.useState(null); + const listRequestId = React.useRef(0); + + const directIdentifier = React.useMemo(() => parseLinearIssueQuery(query), [query]); + const debouncedQuery = useDebouncedValue(query, 350); + + const refresh = React.useCallback(async (search = '') => { + if (linearAuthChecked && linearAuthStatus?.connected === false) { + setConnected(false); + setIssues([]); + setHasMore(false); + setCursor(null); + setError(null); + return; + } + if (!linear?.issuesList) { + setConnected(true); + setError(t('session.linearIssuePicker.error.runtimeUnavailable')); + return; + } + + const requestId = listRequestId.current + 1; + listRequestId.current = requestId; + setIsLoading(true); + setError(null); + try { + const next = await linear.issuesList(search ? { query: search } : undefined); + if (requestId !== listRequestId.current) return; + setConnected(next.connected !== false); + setIssues(next.issues ?? []); + setCursor(next.cursor ?? null); + setHasMore(Boolean(next.hasMore)); + } catch (e) { + if (requestId !== listRequestId.current) return; + setError(e instanceof Error ? e.message : String(e)); + } finally { + if (requestId === listRequestId.current) { + setIsLoading(false); + } + } + }, [linear, linearAuthChecked, linearAuthStatus, t]); + + const refreshMapping = React.useCallback(async () => { + if (mode !== 'createSession') { + setMapping(null); + setMappingError(null); + return; + } + if (!linear?.mappingGet) { + setMapping(null); + setMappingError(t('session.linearIssuePicker.error.runtimeUnavailable')); + return; + } + try { + const next = await linear.mappingGet(); + setMapping(next); + setMappingError(null); + } catch (e) { + setMapping(null); + setMappingError(e instanceof Error ? e.message : String(e)); + } + }, [linear, mode, t]); + + React.useEffect(() => { + if (!open) { + setQuery(''); + setStartingIssueKey(null); + setError(null); + setIssues([]); + setCursor(null); + setHasMore(false); + setIsLoading(false); + setConnected(true); + setCreateInWorktree(false); + setMapping(null); + setMappingError(null); + return; + } + if (linear && !linearAuthChecked) { + void refreshStatus(linear); + } + }, [open, linear, linearAuthChecked, refreshStatus]); + + React.useEffect(() => { + if (!open) return; + void refresh(debouncedQuery.trim()); + }, [open, debouncedQuery, refresh]); + + React.useEffect(() => { + if (!open) return; + void refreshMapping(); + }, [open, refreshMapping]); + + const loadMore = React.useCallback(async () => { + if (!linear?.issuesList) return; + if (isLoadingMore || isLoading) return; + if (!hasMore || !cursor) return; + + const requestId = listRequestId.current + 1; + listRequestId.current = requestId; + setIsLoadingMore(true); + try { + const search = debouncedQuery.trim(); + const next = await linear.issuesList({ + query: search || undefined, + cursor, + }); + if (requestId !== listRequestId.current) return; + setConnected(next.connected !== false); + setIssues((prev) => [...prev, ...(next.issues ?? [])]); + setCursor(next.cursor ?? null); + setHasMore(Boolean(next.hasMore)); + } catch (e) { + if (requestId !== listRequestId.current) return; + const message = e instanceof Error ? e.message : String(e); + toast.error(t('session.linearIssuePicker.toast.loadMoreFailed'), { description: message }); + } finally { + if (requestId === listRequestId.current) { + setIsLoadingMore(false); + } + } + }, [cursor, debouncedQuery, hasMore, isLoading, isLoadingMore, linear, t]); + + const openLinearSettings = React.useCallback(() => { + setSettingsPage('integrations'); + setSettingsDialogOpen(true); + }, [setSettingsDialogOpen, setSettingsPage]); + + const selectIssue = React.useCallback(async (issueKey: string) => { + if (!linear?.issueGet) { + toast.error(t('session.linearIssuePicker.error.runtimeUnavailable')); + return; + } + if (startingIssueKey) return; + setStartingIssueKey(issueKey); + try { + const issueRes = await linear.issueGet(issueKey); + if (issueRes.connected === false) { + toast.error(t('session.linearIssuePicker.error.notConnected')); + return; + } + const issue = issueRes.issue; + if (!issue) { + toast.error(t('session.linearIssuePicker.error.issueNotFound')); + return; + } + const comments = issue.comments ?? []; + const login = issue.assignee?.displayName || issue.assignee?.name; + onSelect?.({ + identifier: issue.identifier, + title: issue.title, + url: issue.url, + contextText: buildIssueContextText({ issue, comments }), + author: login + ? { login, avatarUrl: issue.assignee?.avatarUrl || undefined } + : undefined, + }); + onOpenChange(false); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error(t('session.linearIssuePicker.toast.loadIssueDetailsFailed'), { description: message }); + } finally { + setStartingIssueKey(null); + } + }, [linear, onOpenChange, onSelect, startingIssueKey, t]); + + const startSession = React.useCallback(async (issueKey: string) => { + if (startingIssueKey) return; + setStartingIssueKey(issueKey); + try { + await startLinearIssueSession({ + linear, + issueKey, + createInWorktree, + mapping, + onMappingLoaded: (next) => { + setMapping(next); + setMappingError(null); + }, + onSessionCreated: () => onOpenChange(false), + t, + }); + } finally { + setStartingIssueKey(null); + } + }, [createInWorktree, linear, mapping, onOpenChange, startingIssueKey, t]); + + const handleIssue = React.useCallback((issueKey: string) => { + if (mode === 'select') { + void selectIssue(issueKey); + return; + } + void startSession(issueKey); + }, [mode, selectIssue, startSession]); + + const title = mode === 'select' + ? t('session.linearIssuePicker.title') + : t('session.linearIssuePicker.title.createSession'); + const description = mode === 'select' + ? t('session.linearIssuePicker.description') + : t('session.linearIssuePicker.description.createSession'); + const showDisconnected = linearAuthChecked && connected === false; + const runtimeMissing = !linear; + + const content = ( + <> +
+ + setQuery(e.target.value)} + className="pl-9 w-full" + /> +
+ +
+ {runtimeMissing ? ( +
{t('session.linearIssuePicker.empty.runtimeUnavailable')}
+ ) : null} + + {mode === 'createSession' && mappingError ? ( +
{mappingError}
+ ) : null} + + {isLoading ? ( +
+ + {t('session.linearIssuePicker.loading.issues')} +
+ ) : null} + + {showDisconnected ? ( +
+
{t('session.linearIssuePicker.empty.notConnected')}
+
+ +
+
+ ) : null} + + {error ? ( +
{error}
+ ) : null} + + {directIdentifier && linear && connected ? ( +
handleIssue(directIdentifier)} + > + + {directIdentifier} + +

+ {t('session.linearIssuePicker.actions.useIssue', { identifier: directIdentifier })} +

+
+ {startingIssueKey === directIdentifier ? ( + + ) : null} +
+
+ ) : null} + + {issues.length === 0 && !isLoading && connected && linear ? ( +
+ {debouncedQuery.trim() + ? t('session.linearIssuePicker.empty.noIssuesFound') + : t('session.linearIssuePicker.empty.noOpenIssuesFound')} +
+ ) : null} + + {issues.map((issue) => ( +
+ ))} + + {hasMore && connected && linear ? ( +
+ +
+ ) : null} +
+ + {mode !== 'select' ? ( +
+

{t('session.linearIssuePicker.actions.sectionTitle')}

+
+
setCreateInWorktree((value) => !value)} + onKeyDown={(event) => { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + setCreateInWorktree((value) => !value); + } + }} + > + + {t('session.linearIssuePicker.actions.createInWorktree')} +
+
+ +
+
+ ) : null} + + ); + + if (isMobile) { + return ( + onOpenChange(false)} + renderHeader={(closeButton) => ( +
+
+

{title}

+ {closeButton} +
+

{description}

+
+ )} + > + {content} +
+ ); + } + + return ( + + + + + + {title} + + + {description} + + + {content} + + + ); +} diff --git a/packages/ui/src/components/session/NewWorktreeDialog.tsx b/packages/ui/src/components/session/NewWorktreeDialog.tsx index 2912c7d8..c07ec407 100644 --- a/packages/ui/src/components/session/NewWorktreeDialog.tsx +++ b/packages/ui/src/components/session/NewWorktreeDialog.tsx @@ -27,11 +27,12 @@ import { cn } from '@/lib/utils'; import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; +import { useLinearAuthStore } from '@/stores/useLinearAuthStore'; import { useUIStore } from '@/stores/useUIStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSelectionStore } from '@/sync/selection-store'; import * as sessionActions from '@/sync/session-actions'; -import { buildLinkedIssue } from '@/lib/linkedIssues'; +import { buildLinkedIssue, buildLinkedLinearIssue } from '@/lib/linkedIssues'; import { useConfigStore } from '@/stores/useConfigStore'; import { validateWorktreeCreate, createWorktree } from '@/lib/worktrees/worktreeManager'; import { withWorktreeUpstreamDefaults } from '@/lib/worktrees/worktreeCreate'; @@ -40,6 +41,7 @@ import { getWorktreeSetupCommands, getWorktreeSetupWaitEnabled } from '@/lib/ope import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { generateBranchSlug } from '@/lib/git/branchNameGenerator'; import { renderMagicPrompt } from '@/lib/magicPrompts'; +import { postLinearSessionStarted } from '@/lib/linearSessionStatus'; import { parseModelIdentifier } from '@/lib/modelIdentifier'; import { rankBranchesForQuery } from '@/lib/worktrees/branchSearch'; import { @@ -50,6 +52,7 @@ import { import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useGitBranches, useGitStore, useGitLoadingBranches } from '@/stores/useGitStore'; import { GitHubIntegrationDialog } from './GitHubIntegrationDialog'; +import { LinearIssuePickerDialog } from './LinearIssuePickerDialog'; import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { Icon } from "@/components/icon/Icon"; @@ -59,6 +62,8 @@ import type { GitHubIssuesListResult, GitHubPullRequestContextResult, GitHubPullRequestSummary, + LinearIssue, + LinearIssueComment, } from '@/lib/api/types'; import type { ProjectRef } from '@/lib/worktrees/worktreeManager'; import { useI18n } from '@/lib/i18n'; @@ -72,6 +77,13 @@ interface ValidationState { touched: boolean; } +type LinkedLinearWorktreeIssue = { + identifier: string; + title: string; + url: string; + author?: { login: string; avatarUrl?: string }; +}; + // State for New Branch mode interface NewBranchState { branchName: string; @@ -80,6 +92,7 @@ interface NewBranchState { sourceBranch: string; linkedIssue: GitHubIssue | null; linkedPr: GitHubPullRequestSummary | null; + linkedLinearIssue: LinkedLinearWorktreeIssue | null; includePrDiff: boolean; } @@ -209,16 +222,29 @@ const buildPullRequestContextText = (payload: GitHubPullRequestContextResult) => return `GitHub pull request context (JSON)\n${JSON.stringify(payload, null, 2)}`; }; +const buildLinearIssueContextText = (args: { + issue: LinearIssue; + comments: LinearIssueComment[]; +}) => { + const payload = { + issue: args.issue, + comments: args.comments, + }; + return `Linear issue context (JSON)\n${JSON.stringify(payload, null, 2)}`; +}; + export function NewWorktreeDialog({ open, onOpenChange, onWorktreeCreated, }: NewWorktreeDialogProps) { const { t } = useI18n(); - const { github, git } = useRuntimeAPIs(); + const { github, git, linear } = useRuntimeAPIs(); const isMobile = useUIStore((state) => state.isMobile); const githubAuthStatus = useGitHubAuthStore((state) => state.status); const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked); + const linearAuthStatus = useLinearAuthStore((state) => state.status); + const linearAuthChecked = useLinearAuthStore((state) => state.hasChecked); const activeProject = useProjectsStore((state) => state.getActiveProject()); const projectDirectory = activeProject?.path ?? null; @@ -240,6 +266,7 @@ export function NewWorktreeDialog({ sourceBranch: '', linkedIssue: null, linkedPr: null, + linkedLinearIssue: null, includePrDiff: false, }); @@ -290,6 +317,7 @@ export function NewWorktreeDialog({ }, [existingWorktreeNames]); const [githubDialogOpen, setGithubDialogOpen] = React.useState(false); + const [linearDialogOpen, setLinearDialogOpen] = React.useState(false); // Desktop branch picker states const [existingBranchDropdownOpen, setExistingBranchDropdownOpen] = React.useState(false); @@ -480,12 +508,9 @@ export function NewWorktreeDialog({ directory: string; issue: GitHubIssue | null; pr: GitHubPullRequestSummary | null; + linearIssue: LinkedLinearWorktreeIssue | null; includeDiff: boolean; }) => { - if (!projectDirectory || !github) { - return; - } - const configState = useConfigStore.getState(); const lastUsedProvider = useSelectionStore.getState().lastUsedProvider; const defaultModel = resolveDefaultModelSelection(); @@ -500,6 +525,69 @@ export function NewWorktreeDialog({ const variant = resolveDefaultVariant(providerID, modelID); + if (args.linearIssue) { + if (!linear?.issueGet) { + return; + } + + const issueRes = await linear.issueGet(args.linearIssue.identifier); + if (issueRes.connected === false || !issueRes.issue) { + throw new Error('Failed to load issue context'); + } + + const issue = issueRes.issue; + const comments = issue.comments ?? []; + const login = issue.assignee?.displayName || issue.assignee?.name; + const visiblePromptText = await renderMagicPrompt('linear.issue.review.visible', { + identifier: issue.identifier, + }); + const instructionsText = await renderMagicPrompt('linear.issue.review.instructions'); + const contextText = buildLinearIssueContextText({ issue, comments }); + + postLinearSessionStarted(linear, { + sessionId: args.sessionId, + issueIdentifier: issue.identifier, + }); + + await useSessionUIStore.getState().sendMessage( + visiblePromptText, + providerID, + modelID, + agentName, + undefined, + undefined, + [ + { text: instructionsText, synthetic: true }, + { text: contextText, synthetic: true }, + ], + variant, + undefined, + { sessionId: args.sessionId, directory: args.directory }, + ); + + void sessionActions.setLinkedIssue( + args.sessionId, + args.directory, + buildLinkedLinearIssue({ + identifier: issue.identifier, + title: issue.title, + url: issue.url, + author: login + ? { login, avatarUrl: issue.assignee?.avatarUrl || undefined } + : args.linearIssue.author, + linkedAt: Date.now(), + }), + true, + ).catch(() => undefined); + + toast.success(t('session.newWorktree.toast.sessionFromIssue')); + return; + } + + if (!projectDirectory || !github) { + return; + } + if (args.issue) { if (!github.issueGet || !github.issueComments) { return; @@ -615,6 +703,7 @@ export function NewWorktreeDialog({ } }, [ github, + linear, projectDirectory, resolveDefaultAgentName, resolveDefaultModelSelection, @@ -702,6 +791,7 @@ export function NewWorktreeDialog({ sourceBranch: '', linkedIssue: null, linkedPr: null, + linkedLinearIssue: null, includePrDiff: false, }); }, [open, generateUniqueSlug]); @@ -862,9 +952,10 @@ export function NewWorktreeDialog({ try { const linkedPr = mode === 'new-branch' ? newBranchState.linkedPr : null; const linkedIssue = mode === 'new-branch' ? newBranchState.linkedIssue : null; + const linkedLinearIssue = mode === 'new-branch' ? newBranchState.linkedLinearIssue : null; const linkedPrState = mode === 'new-branch' ? newBranchState.linkedPr : null; const includePrDiff = mode === 'new-branch' ? newBranchState.includePrDiff : false; - const shouldCreateSession = Boolean(linkedIssue || linkedPrState); + const shouldCreateSession = Boolean(linkedIssue || linkedPrState || linkedLinearIssue); const setupCommands = await getWorktreeSetupCommands(projectRef); const sourceBranch = newBranchState.sourceBranch; @@ -914,7 +1005,9 @@ export function NewWorktreeDialog({ await waitForWorktreeBootstrap(metadata.path); } - const sessionTitle = linkedIssue + const sessionTitle = linkedLinearIssue + ? `${linkedLinearIssue.identifier} ${linkedLinearIssue.title}`.trim() + : linkedIssue ? `#${linkedIssue.number} ${linkedIssue.title}`.trim() : linkedPrState ? `#${linkedPrState.number} ${linkedPrState.title}`.trim() @@ -966,10 +1059,14 @@ export function NewWorktreeDialog({ directory: metadata.path, issue: linkedIssue, pr: linkedPrState, + linearIssue: linkedLinearIssue, includeDiff: includePrDiff, }).catch((error) => { - const message = error instanceof Error ? error.message : t('session.newWorktree.error.sendGitHubContextFailed'); - toast.error(t('session.newWorktree.error.sendGitHubContextFailed'), { description: message }); + const fallback = linkedLinearIssue + ? t('session.newWorktree.error.sendLinearContextFailed') + : t('session.newWorktree.error.sendGitHubContextFailed'); + const message = error instanceof Error ? error.message : fallback; + toast.error(fallback, { description: message }); }); } else { onWorktreeCreated?.(metadata.path); @@ -999,6 +1096,7 @@ export function NewWorktreeDialog({ ...prev, linkedIssue: null, linkedPr: null, + linkedLinearIssue: null, includePrDiff: false, branchName: '', })); @@ -1012,6 +1110,7 @@ export function NewWorktreeDialog({ ...prev, linkedIssue: issue, linkedPr: null, + linkedLinearIssue: null, includePrDiff: false, branchName: newBranchName, worktreeName: slugifyWorktreeName(newBranchName), @@ -1023,6 +1122,7 @@ export function NewWorktreeDialog({ ...prev, linkedPr: pr, linkedIssue: null, + linkedLinearIssue: null, includePrDiff: result.includeDiff ?? false, branchName: pr.head, worktreeName: slugifyWorktreeName(pr.head), @@ -1031,8 +1131,33 @@ export function NewWorktreeDialog({ } }; + const handleLinearSelect = (issue: { + identifier: string; + title: string; + url: string; + author?: { login: string; avatarUrl?: string }; + }) => { + const newBranchName = `issue-${issue.identifier}-${generateBranchSlug()}`; + setNewBranchState(prev => ({ + ...prev, + linkedLinearIssue: { + identifier: issue.identifier, + title: issue.title, + url: issue.url, + author: issue.author, + }, + linkedIssue: null, + linkedPr: null, + includePrDiff: false, + branchName: newBranchName, + worktreeName: slugifyWorktreeName(newBranchName), + isSyncingWorktreeName: true, + })); + }; + // GitHub connection check const isGitHubConnected = githubAuthChecked && githubAuthStatus?.connected === true; + const isLinearConnected = Boolean(linear) && linearAuthChecked && linearAuthStatus?.connected === true; // Check if form is valid for submission const isFormValid = mode === 'existing-branch' @@ -1046,12 +1171,42 @@ export function NewWorktreeDialog({ ...prev, linkedIssue: null, linkedPr: null, + linkedLinearIssue: null, branchName: '', includePrDiff: false, isSyncingWorktreeName: true, })); }; + const startFromIssueButtons = mode === 'new-branch' && (isGitHubConnected || isLinearConnected) ? ( +
+ {isGitHubConnected && ( + + )} + {isLinearConnected && ( + + )} +
+ ) : null; + // Footer content const footerContent = (
@@ -1277,21 +1432,11 @@ export function NewWorktreeDialog({
) : (
-
-
)} + {newBranchState.linkedLinearIssue && ( +
+ + + {t('session.newWorktree.fromLinearIssue', { + identifier: newBranchState.linkedLinearIssue.identifier, + title: newBranchState.linkedLinearIssue.title, + })} + +
+ )}
)} @@ -1527,12 +1684,23 @@ export function NewWorktreeDialog({ )} {/* Linked Item Preview - Two row minimal display */} - {(newBranchState.linkedIssue || newBranchState.linkedPr) && mode === 'new-branch' && ( + {(newBranchState.linkedIssue || newBranchState.linkedPr || newBranchState.linkedLinearIssue) && mode === 'new-branch' && (
{/* Row 1: Type, number, title, actions */}
- + + {newBranchState.linkedLinearIssue && ( + + {newBranchState.linkedLinearIssue.identifier} + + )} {newBranchState.linkedIssue && ( {t('session.newWorktree.issueNumber', { number: newBranchState.linkedIssue.number })} @@ -1545,11 +1713,11 @@ export function NewWorktreeDialog({ )} - {newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title} + {newBranchState.linkedLinearIssue?.title || newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title} ) : (
-
-
)} + {newBranchState.linkedLinearIssue && ( +
+ + + {t('session.newWorktree.fromLinearIssue', { + identifier: newBranchState.linkedLinearIssue.identifier, + title: newBranchState.linkedLinearIssue.title, + })} + +
+ )}
)} @@ -1968,12 +2138,23 @@ export function NewWorktreeDialog({ )} {/* Linked Item Preview - Two row minimal display */} - {(newBranchState.linkedIssue || newBranchState.linkedPr) && mode === 'new-branch' && ( + {(newBranchState.linkedIssue || newBranchState.linkedPr || newBranchState.linkedLinearIssue) && mode === 'new-branch' && (
{/* Row 1: Type, number, title, actions */}
- + + {newBranchState.linkedLinearIssue && ( + + {newBranchState.linkedLinearIssue.identifier} + + )} {newBranchState.linkedIssue && ( {t('session.newWorktree.issueNumber', { number: newBranchState.linkedIssue.number })} @@ -1986,11 +2167,11 @@ export function NewWorktreeDialog({ )} - {newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title} + {newBranchState.linkedLinearIssue?.title || newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title} + ); } diff --git a/packages/ui/src/components/views/LinearIssuesView.tsx b/packages/ui/src/components/views/LinearIssuesView.tsx new file mode 100644 index 00000000..8454e81c --- /dev/null +++ b/packages/ui/src/components/views/LinearIssuesView.tsx @@ -0,0 +1,1168 @@ +import React from 'react'; +import { Icon } from '@/components/icon/Icon'; +import type { IconName } from '@/components/icon/icons'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; +import { ScrollShadow } from '@/components/ui/ScrollShadow'; +import { toast } from '@/components/ui'; +import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; +import { cn } from '@/lib/utils'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { useDebouncedValue } from '@/hooks/useDebouncedValue'; +import { useLinearAuthStore } from '@/stores/useLinearAuthStore'; +import { useUIStore, LINEAR_ISSUE_LIST_ALL_TEAMS } from '@/stores/useUIStore'; +import { useI18n } from '@/lib/i18n'; +import { formatDateTimeForPreference } from '@/lib/timeFormat'; +import { openExternalUrl } from '@/lib/url'; +import { startLinearIssueSession } from '@/lib/linearStartSession'; +import type { + LinearIssue, + LinearIssueLabel, + LinearIssueListPriority, + LinearIssueListStatus, + LinearIssueSummary, + LinearTeamMapping, + LinearWorkflowState, +} from '@/lib/api/types'; + +const LINEAR_MARKDOWN_CLASS = '[&_img]:max-w-full [&_img]:h-auto'; +const FILTER_TRIGGER_CLASS = 'flex h-8 min-w-0 flex-1 items-center gap-1.5 rounded-md px-2 typography-ui-label font-semibold text-foreground outline-none hover:bg-interactive-hover focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50'; +const FILTER_COMPACT_TRIGGER_CLASS = 'flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-foreground outline-none hover:bg-interactive-hover focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50'; +// The Linear rail is 380–600px. Below this, four or five flex pickers squeeze +// labels to an ellipsis. Status keeps its label (the filter people use most); +// search and the other filters drop to icons that already identify them. +// Walkthrough uses the same icon-only idea at 680px for a wider header. +const FILTER_COMPACT_WIDTH = 520; + +const workspaceLabel = (workspace: { name: string | null; urlKey: string | null; id: string }) => ( + workspace.name?.trim() || workspace.urlKey?.trim() || workspace.id +); + +const LINEAR_PRIORITY_KEYS = { + 0: 'contextPanel.linear.priority.none', + 1: 'contextPanel.linear.priority.urgent', + 2: 'contextPanel.linear.priority.high', + 3: 'contextPanel.linear.priority.medium', + 4: 'contextPanel.linear.priority.low', +} as const; + +const LINEAR_WORKFLOW_TYPE_RANK = { + triage: 0, + backlog: 1, + unstarted: 2, + started: 3, + completed: 4, + canceled: 5, +} as const; + +const linearWorkflowTypeRank = (type: string | null): number => { + if ( + type === 'triage' + || type === 'backlog' + || type === 'unstarted' + || type === 'started' + || type === 'completed' + || type === 'canceled' + ) { + return LINEAR_WORKFLOW_TYPE_RANK[type]; + } + return 99; +}; + +const compareLinearWorkflowStates = (left: LinearWorkflowState, right: LinearWorkflowState): number => { + const typeDelta = linearWorkflowTypeRank(left.type) - linearWorkflowTypeRank(right.type); + if (typeDelta !== 0) return typeDelta; + if (left.position !== right.position) return left.position - right.position; + return left.name.localeCompare(right.name); +}; + +const linearPriorityMessageKey = (priority: number | null | undefined) => { + if (priority !== 0 && priority !== 1 && priority !== 2 && priority !== 3 && priority !== 4) { + return null; + } + return LINEAR_PRIORITY_KEYS[priority]; +}; + +const STATUS_FILTER_ITEMS = [ + { value: 'all', labelKey: 'contextPanel.linear.filter.status.all' }, + { value: 'backlog', labelKey: 'contextPanel.linear.filter.status.backlog' }, + { value: 'todo', labelKey: 'contextPanel.linear.filter.status.todo' }, + { value: 'started', labelKey: 'contextPanel.linear.filter.status.started' }, + { value: 'inReview', labelKey: 'contextPanel.linear.filter.status.inReview' }, + { value: 'completed', labelKey: 'contextPanel.linear.filter.status.completed' }, + { value: 'canceled', labelKey: 'contextPanel.linear.filter.status.canceled' }, + { value: 'duplicate', labelKey: 'contextPanel.linear.filter.status.duplicate' }, +] as const; + +const isLinearIssueListStatus = (value: string): value is LinearIssueListStatus => ( + STATUS_FILTER_ITEMS.some((item) => item.value === value) +); + +const PRIORITY_FILTER_ITEMS = [ + { value: 'all', labelKey: 'contextPanel.linear.filter.priority.all' }, + { value: 'urgent', labelKey: 'contextPanel.linear.priority.urgent' }, + { value: 'high', labelKey: 'contextPanel.linear.priority.high' }, + { value: 'medium', labelKey: 'contextPanel.linear.priority.medium' }, + { value: 'low', labelKey: 'contextPanel.linear.priority.low' }, + { value: 'none', labelKey: 'contextPanel.linear.priority.none' }, +] as const; + +const isLinearIssueListPriority = (value: string): value is LinearIssueListPriority => ( + PRIORITY_FILTER_ITEMS.some((item) => item.value === value) +); + +const labelChipStyle = (color: string | null): React.CSSProperties | undefined => { + if (!color) { + return { backgroundColor: 'color-mix(in srgb, var(--surface-mutedForeground) 12%, transparent)' }; + } + return { + color, + backgroundColor: `color-mix(in srgb, ${color} 18%, transparent)`, + }; +}; + +const LinearIssueLabelChips: React.FC<{ labels: LinearIssueLabel[] }> = ({ labels }) => { + if (labels.length === 0) return null; + return ( +
+ {labels.map((label) => ( + + {label.name} + + ))} +
+ ); +}; + +const LinearFilterMenu: React.FC<{ + icon: IconName; + label: string; + ariaLabel: string; + value: string; + items: Array<{ value: string; label: string }>; + disabled?: boolean; + compact?: boolean; + active?: boolean; + onValueChange: (value: string) => void; +}> = ({ icon, label, ariaLabel, value, items, disabled, compact, active, onValueChange }) => { + const [open, setOpen] = React.useState(false); + + return ( + { + if (!disabled) setOpen(next); + }} + > + + + + + { + onValueChange(next); + setOpen(false); + }} + > + {items.map((item) => ( + + {item.label} + + ))} + + + + ); +}; + +const parseLinearIssueQuery = (value: string): string | null => { + const trimmed = value.trim(); + if (!trimmed) return null; + const urlMatch = trimmed.match(/linear\.app\/(?:[^/]+\/)?issue\/([A-Za-z][A-Za-z0-9]*-\d+)/i); + if (urlMatch) return urlMatch[1].toUpperCase(); + if (/^[A-Za-z][A-Za-z0-9]*-\d+$/.test(trimmed)) return trimmed.toUpperCase(); + return null; +}; + +const toIssueSummary = (issue: LinearIssue): LinearIssueSummary => ({ + id: issue.id, + identifier: issue.identifier, + title: issue.title, + url: issue.url, + state: issue.state, + assignee: issue.assignee, + team: issue.team, + priority: issue.priority, + labels: issue.labels, +}); + +const patchIssueInList = (issues: LinearIssueSummary[], next: LinearIssue): LinearIssueSummary[] => { + const summary = toIssueSummary(next); + return issues.map((issue) => (issue.id === next.id ? summary : issue)); +}; + +export const LinearIssuesView: React.FC = () => { + const { t } = useI18n(); + const { linear } = useRuntimeAPIs(); + const linearAuthStatus = useLinearAuthStore((state) => state.status); + const linearAuthChecked = useLinearAuthStore((state) => state.hasChecked); + const refreshStatus = useLinearAuthStore((state) => state.refreshStatus); + const setLinearAuthStatus = useLinearAuthStore((state) => state.setStatus); + const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); + const setSettingsPage = useUIStore((state) => state.setSettingsPage); + const listStatus = useUIStore((state) => state.linearIssueListStatus); + const listAssignee = useUIStore((state) => state.linearIssueListAssignee); + const listTeamId = useUIStore((state) => state.linearIssueListTeamId); + const listPriority = useUIStore((state) => state.linearIssueListPriority); + const linearIssueFocus = useUIStore((state) => state.linearIssueFocus); + const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); + const setListStatus = useUIStore((state) => state.setLinearIssueListStatus); + const setListAssignee = useUIStore((state) => state.setLinearIssueListAssignee); + const setListTeamId = useUIStore((state) => state.setLinearIssueListTeamId); + const setListPriority = useUIStore((state) => state.setLinearIssueListPriority); + const resetListFilters = useUIStore((state) => state.resetLinearIssueListFilters); + const setLinearIssueFocus = useUIStore((state) => state.setLinearIssueFocus); + + const [query, setQuery] = React.useState(''); + const [searchOpen, setSearchOpen] = React.useState(false); + const [issues, setIssues] = React.useState([]); + const [cursor, setCursor] = React.useState(null); + const [hasMore, setHasMore] = React.useState(false); + const [connected, setConnected] = React.useState(true); + const [isLoading, setIsLoading] = React.useState(false); + const [isLoadingMore, setIsLoadingMore] = React.useState(false); + const [error, setError] = React.useState(null); + const [selectedIssueId, setSelectedIssueId] = React.useState(null); + const [selectedIssue, setSelectedIssue] = React.useState(null); + const [workflowStates, setWorkflowStates] = React.useState([]); + const [isLoadingIssue, setIsLoadingIssue] = React.useState(false); + const [isUpdating, setIsUpdating] = React.useState(false); + const [isStarting, setIsStarting] = React.useState(false); + const [createInWorktree, setCreateInWorktree] = React.useState(false); + const [teams, setTeams] = React.useState([]); + const [isSwitchingWorkspace, setIsSwitchingWorkspace] = React.useState(false); + const listRequestId = React.useRef(0); + const listRootRef = React.useRef(null); + const searchInputRef = React.useRef(null); + const [panelWidth, setPanelWidth] = React.useState(0); + + const directIdentifier = React.useMemo(() => parseLinearIssueQuery(query), [query]); + const debouncedQuery = useDebouncedValue(query, 350); + + // Same shape the pull request panel uses, so both context surfaces read alike. + const formatCommentTimestamp = React.useCallback((value: string | null) => { + if (!value) return ''; + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) return ''; + return formatDateTimeForPreference(timestamp, timeFormatPreference, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + }, [timeFormatPreference]); + + const openLinearSettings = React.useCallback(() => { + setSettingsPage('integrations'); + setSettingsDialogOpen(true); + }, [setSettingsDialogOpen, setSettingsPage]); + + const listQuery = React.useMemo(() => ({ + query: debouncedQuery.trim() || undefined, + status: listStatus, + assignee: listAssignee, + teamId: listTeamId === LINEAR_ISSUE_LIST_ALL_TEAMS ? undefined : listTeamId, + priority: listPriority === 'all' ? undefined : listPriority, + }), [debouncedQuery, listAssignee, listPriority, listStatus, listTeamId]); + + const workspaces = linearAuthStatus?.workspaces ?? []; + const currentWorkspaceId = workspaces.find((workspace) => workspace.current)?.id + || linearAuthStatus?.organization?.id + || ''; + + const refresh = React.useCallback(async () => { + if (linearAuthChecked && linearAuthStatus?.connected === false) { + setConnected(false); + setIssues([]); + setHasMore(false); + setCursor(null); + setError(null); + return; + } + if (!linear?.issuesList) { + setConnected(true); + setError(t('session.linearIssuePicker.error.runtimeUnavailable')); + return; + } + + const requestId = listRequestId.current + 1; + listRequestId.current = requestId; + setIsLoading(true); + setError(null); + try { + const next = await linear.issuesList(listQuery); + if (requestId !== listRequestId.current) return; + setConnected(next.connected !== false); + if (next.connected === false) { + setIssues([]); + setHasMore(false); + setCursor(null); + return; + } + setIssues(next.issues ?? []); + setCursor(next.cursor ?? null); + setHasMore(Boolean(next.hasMore)); + } catch (e) { + if (requestId !== listRequestId.current) return; + setError(e instanceof Error ? e.message : String(e)); + } finally { + if (requestId === listRequestId.current) { + setIsLoading(false); + } + } + }, [linear, linearAuthChecked, linearAuthStatus, listQuery, t]); + + React.useEffect(() => { + if (linear && !linearAuthChecked) { + void refreshStatus(linear); + } + }, [linear, linearAuthChecked, refreshStatus]); + + React.useEffect(() => { + void refresh(); + }, [refresh]); + + React.useEffect(() => { + if (!linear?.mappingGet || !connected) { + setTeams([]); + return; + } + let cancelled = false; + void linear.mappingGet().then((mapping) => { + if (cancelled) return; + if (mapping.connected === false) { + setTeams([]); + return; + } + setTeams(mapping.teams ?? []); + }).catch(() => { + if (!cancelled) { + setTeams([]); + } + }); + return () => { + cancelled = true; + }; + }, [connected, currentWorkspaceId, linear]); + + React.useEffect(() => { + if (listTeamId === LINEAR_ISSUE_LIST_ALL_TEAMS || teams.length === 0) { + return; + } + if (!teams.some((team) => team.id === listTeamId)) { + setListTeamId(LINEAR_ISSUE_LIST_ALL_TEAMS); + } + }, [listTeamId, setListTeamId, teams]); + + const loadMore = React.useCallback(async () => { + if (!linear?.issuesList) return; + if (isLoadingMore || isLoading) return; + if (!hasMore || !cursor) return; + + const requestId = listRequestId.current + 1; + listRequestId.current = requestId; + setIsLoadingMore(true); + try { + const next = await linear.issuesList({ + ...listQuery, + cursor, + }); + if (requestId !== listRequestId.current) return; + setConnected(next.connected !== false); + if (next.connected === false) { + return; + } + setIssues((prev) => [...prev, ...(next.issues ?? [])]); + setCursor(next.cursor ?? null); + setHasMore(Boolean(next.hasMore)); + } catch (e) { + if (requestId !== listRequestId.current) return; + const message = e instanceof Error ? e.message : String(e); + toast.error(t('session.linearIssuePicker.toast.loadMoreFailed'), { description: message }); + } finally { + if (requestId === listRequestId.current) { + setIsLoadingMore(false); + } + } + }, [cursor, hasMore, isLoading, isLoadingMore, linear, listQuery, t]); + + React.useEffect(() => { + if (!selectedIssueId || !linear?.issueGet) { + return; + } + let cancelled = false; + setIsLoadingIssue(true); + setSelectedIssue(null); + setWorkflowStates([]); + void (async () => { + try { + const issueRes = await linear.issueGet(selectedIssueId); + if (cancelled) return; + if (issueRes.connected === false) { + setConnected(false); + setSelectedIssueId(null); + return; + } + const issue = issueRes.issue; + if (!issue) { + toast.error(t('session.linearIssuePicker.error.issueNotFound')); + setSelectedIssueId(null); + return; + } + setSelectedIssue(issue); + const teamId = issue.team?.id; + if (!teamId || !linear.issueStates) { + return; + } + try { + const statesRes = await linear.issueStates(teamId); + if (cancelled) return; + if (statesRes.connected === false) { + setConnected(false); + return; + } + setWorkflowStates(statesRes.states ?? []); + } catch (e) { + if (cancelled) return; + const message = e instanceof Error ? e.message : String(e); + toast.error(t('session.linearIssuePicker.toast.loadIssueDetailsFailed'), { description: message }); + } + } catch (e) { + if (cancelled) return; + const message = e instanceof Error ? e.message : String(e); + toast.error(t('session.linearIssuePicker.toast.loadIssueDetailsFailed'), { description: message }); + setSelectedIssueId(null); + } finally { + if (!cancelled) { + setIsLoadingIssue(false); + } + } + })(); + return () => { + cancelled = true; + }; + }, [linear, selectedIssueId, t]); + + React.useEffect(() => { + if (!linearIssueFocus) return; + setSelectedIssueId(linearIssueFocus); + setLinearIssueFocus(null); + }, [linearIssueFocus, setLinearIssueFocus]); + + const applyUpdatedIssue = React.useCallback((issue: LinearIssue) => { + setSelectedIssue(issue); + setIssues((prev) => patchIssueInList(prev, issue)); + }, []); + + const updateIssueState = React.useCallback(async (stateId: string, failedKey: 'contextPanel.linear.toast.statusUpdateFailed' | 'contextPanel.linear.toast.closeFailed') => { + if (!linear?.issueUpdate || !selectedIssue || isUpdating) { + return; + } + if (selectedIssue.state?.id === stateId) { + return; + } + setIsUpdating(true); + try { + const result = await linear.issueUpdate({ id: selectedIssue.id, stateId }); + if (result.connected === false) { + setConnected(false); + toast.error(t(failedKey)); + return; + } + if (!result.issue) { + toast.error(t(failedKey)); + return; + } + applyUpdatedIssue(result.issue); + toast.success(t('contextPanel.linear.toast.statusUpdated')); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error(t(failedKey), { description: message }); + } finally { + setIsUpdating(false); + } + }, [applyUpdatedIssue, isUpdating, linear, selectedIssue, t]); + + const closeIssue = React.useCallback(() => { + const completed = workflowStates.find((state) => state.type === 'completed'); + if (!completed) { + toast.error(t('contextPanel.linear.error.noCompletedState')); + return; + } + void updateIssueState(completed.id, 'contextPanel.linear.toast.closeFailed'); + }, [t, updateIssueState, workflowStates]); + + const startSession = React.useCallback(async () => { + if (!selectedIssue || isStarting) return; + setIsStarting(true); + try { + await startLinearIssueSession({ + linear, + issueKey: selectedIssue.id, + createInWorktree, + t, + }); + } finally { + setIsStarting(false); + } + }, [createInWorktree, isStarting, linear, selectedIssue, t]); + + const switchWorkspace = React.useCallback(async (organizationId: string) => { + if (!linear?.authActivate || !organizationId || organizationId === currentWorkspaceId || isSwitchingWorkspace) { + return; + } + setIsSwitchingWorkspace(true); + try { + const payload = await linear.authActivate(organizationId); + setLinearAuthStatus(payload); + setSelectedIssueId(null); + setSelectedIssue(null); + setWorkflowStates([]); + setListTeamId(LINEAR_ISSUE_LIST_ALL_TEAMS); + toast.success(t('contextPanel.linear.toast.workspaceSwitched')); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error(t('contextPanel.linear.toast.workspaceSwitchFailed'), { description: message }); + } finally { + setIsSwitchingWorkspace(false); + } + }, [currentWorkspaceId, isSwitchingWorkspace, linear, setLinearAuthStatus, setListTeamId, t]); + + const statusOptions = React.useMemo(() => { + const byId = new Map(workflowStates.map((state) => [state.id, state])); + const currentId = selectedIssue?.state?.id; + const currentName = selectedIssue?.state?.name; + const states = currentId && currentName && !byId.has(currentId) + ? [ + { + id: currentId, + name: currentName, + type: selectedIssue.state?.type ?? null, + position: 0, + }, + ...workflowStates, + ] + : workflowStates; + return [...states].sort(compareLinearWorkflowStates); + }, [selectedIssue, workflowStates]); + + const completedState = workflowStates.find((state) => state.type === 'completed'); + const alreadyCompleted = selectedIssue?.state?.type === 'completed'; + const showDisconnected = linearAuthChecked && connected === false; + const runtimeMissing = !linear; + const showingDetail = Boolean(selectedIssueId); + const usingDefaultFilters = listStatus === 'all' && listAssignee === 'any' && listTeamId === LINEAR_ISSUE_LIST_ALL_TEAMS && listPriority === 'all'; + const canUseListControls = Boolean(linear) && connected && !showDisconnected; + const filtersDisabled = !canUseListControls || isSwitchingWorkspace; + // Zero means the observer has not reported yet; assume there is room rather + // than rendering a compact filter row for one frame on every open. + const compactFilters = panelWidth > 0 && panelWidth < FILTER_COMPACT_WIDTH; + const searchActive = query.trim().length > 0; + const hasActiveFilters = !usingDefaultFilters || searchActive; + const showSearchField = !compactFilters || searchOpen || searchActive; + + const closeCompactSearch = React.useCallback(() => { + setQuery(''); + setSearchOpen(false); + }, []); + + React.useEffect(() => { + const element = listRootRef.current; + if (!element || !globalThis.ResizeObserver) return; + const observer = new ResizeObserver((entries) => { + setPanelWidth(entries[0]?.contentRect.width ?? 0); + }); + observer.observe(element); + return () => observer.disconnect(); + }, [showingDetail]); + + React.useEffect(() => { + if (compactFilters && searchOpen) { + searchInputRef.current?.focus(); + } + }, [compactFilters, searchOpen]); + + const worktreeToggle = ( +
setCreateInWorktree((value) => !value)} + onKeyDown={(event) => { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + setCreateInWorktree((value) => !value); + } + }} + > + + {t('session.linearIssuePicker.actions.createInWorktree')} +
+ ); + + const renderIssueRow = (issue: LinearIssueSummary) => ( +
setSelectedIssueId(issue.id)} + > + + {issue.identifier} + +

+ {issue.title} +

+
+ +
+
+ ); + + if (showingDetail) { + const assigneeName = selectedIssue?.assignee?.displayName || selectedIssue?.assignee?.name; + const comments = selectedIssue?.comments ?? []; + const description = selectedIssue?.description?.trim() || ''; + const statusValue = selectedIssue?.state?.id || ''; + const priorityKey = linearPriorityMessageKey(selectedIssue?.priority); + const labels = selectedIssue?.labels ?? []; + + return ( +
+
+ + {selectedIssue ? ( + + ) : null} +
+ + {isLoadingIssue && !selectedIssue ? ( +
+ + {t('contextPanel.linear.loading.issue')} +
+ ) : null} + + {selectedIssue ? ( + + + {t('contextPanel.linear.loading.issue')} +
+ }> +
+
+
{selectedIssue.identifier}
+

{selectedIssue.title}

+
+ +
+ {statusOptions.length > 0 && statusValue ? ( + + ) : selectedIssue.state?.name ? ( + {selectedIssue.state.name} + ) : null} + + {completedState && !alreadyCompleted ? ( + + ) : null} +
+ +
+ {selectedIssue.team?.name ? ( + <> +
{t('contextPanel.linear.label.team')}
+
{selectedIssue.team.name}
+ + ) : null} +
{t('contextPanel.linear.label.assignee')}
+
+ {assigneeName || t('contextPanel.linear.label.unassigned')} +
+ {priorityKey ? ( + <> +
{t('contextPanel.linear.label.priority')}
+
+ {t(priorityKey)} +
+ + ) : null} + {labels.length > 0 ? ( + <> +
{t('contextPanel.linear.label.labels')}
+
+ +
+ + ) : null} +
+ +
+ {description ? ( + + ) : ( +

{t('contextPanel.linear.empty.noDescription')}

+ )} +
+ +
+

{t('contextPanel.linear.label.comments')}

+ {comments.length === 0 ? ( +

{t('contextPanel.linear.empty.noComments')}

+ ) : ( +
+ {comments.map((comment, index) => { + const author = comment.user?.displayName + || comment.user?.name + || t('contextPanel.linear.label.unassigned'); + const avatarUrl = comment.user?.avatarUrl || null; + const initial = author.charAt(0).toUpperCase(); + const isLast = index === comments.length - 1; + const createdLabel = formatCommentTimestamp(comment.createdAt); + return ( +
+ {!isLast ? ( +
+ ) : null} +
+ {avatarUrl ? ( + {author} + ) : ( + {initial} + )} +
+
+
+ {author} + {createdLabel ? {createdLabel} : null} +
+ {comment.body.trim() ? ( + + ) : null} +
+
+ ); + })} +
+ )} +
+
+ + ) : null} + + {selectedIssue ? ( +
+ {worktreeToggle} + +
+ ) : null} +
+ ); + } + + return ( +
+
+ {showSearchField ? ( +
+ + setQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Escape' && compactFilters) { + event.preventDefault(); + closeCompactSearch(); + } + }} + className={cn('pl-9 w-full', compactFilters && 'pr-9')} + /> + {compactFilters ? ( + + ) : null} +
+ ) : null} + + {canUseListControls || (compactFilters && !showSearchField) ? ( +
+ {canUseListControls ? ( + <> + item.value === listStatus) ?? STATUS_FILTER_ITEMS[0]).labelKey)} + ariaLabel={t('contextPanel.linear.filter.statusAria')} + value={listStatus} + active={listStatus !== 'all'} + disabled={filtersDisabled} + items={STATUS_FILTER_ITEMS.map((item) => ({ + value: item.value, + label: t(item.labelKey), + }))} + onValueChange={(value) => { + if (isLinearIssueListStatus(value)) { + setListStatus(value); + } + }} + /> + + item.value === listPriority) ?? PRIORITY_FILTER_ITEMS[0]).labelKey)} + ariaLabel={t('contextPanel.linear.filter.priorityAria')} + value={listPriority} + active={listPriority !== 'all'} + disabled={filtersDisabled} + items={PRIORITY_FILTER_ITEMS.map((item) => ({ + value: item.value, + label: t(item.labelKey), + }))} + onValueChange={(value) => { + if (isLinearIssueListPriority(value)) { + setListPriority(value); + } + }} + /> + + { + if (value === 'any' || value === 'me') { + setListAssignee(value); + } + }} + /> + + {teams.length > 0 ? ( + team.id === listTeamId)?.name ?? listTeamId) + } + ariaLabel={t('contextPanel.linear.filter.teamAria')} + value={listTeamId} + active={listTeamId !== LINEAR_ISSUE_LIST_ALL_TEAMS} + disabled={filtersDisabled} + items={[ + { value: LINEAR_ISSUE_LIST_ALL_TEAMS, label: t('contextPanel.linear.filter.team.all') }, + ...teams.map((team) => ({ value: team.id, label: team.name })), + ]} + onValueChange={setListTeamId} + /> + ) : null} + + {workspaces.length > 1 && currentWorkspaceId ? ( + workspace.id === currentWorkspaceId) ?? { id: currentWorkspaceId, name: null, urlKey: null })} + ariaLabel={t('contextPanel.linear.label.workspaceAria')} + value={currentWorkspaceId} + disabled={isSwitchingWorkspace} + items={workspaces.map((workspace) => ({ + value: workspace.id, + label: workspaceLabel(workspace), + }))} + onValueChange={(value) => { + void switchWorkspace(value); + }} + /> + ) : null} + + {hasActiveFilters ? ( + + ) : null} + + ) : null} + + {compactFilters && !showSearchField ? ( + + ) : null} +
+ ) : null} +
+ + + {runtimeMissing ? ( +
{t('session.linearIssuePicker.empty.runtimeUnavailable')}
+ ) : null} + + {isLoading && issues.length === 0 ? ( +
+ + {t('session.linearIssuePicker.loading.issues')} +
+ ) : null} + + {showDisconnected ? ( +
+
{t('session.linearIssuePicker.empty.notConnected')}
+
+ +
+
+ ) : null} + + {error ? ( +
{error}
+ ) : null} + + {directIdentifier && linear && connected ? ( +
setSelectedIssueId(directIdentifier)} + > + + {directIdentifier} + +

+ {t('session.linearIssuePicker.actions.useIssue', { identifier: directIdentifier })} +

+
+ ) : null} + + {issues.length === 0 && !isLoading && connected && linear ? ( +
+ {debouncedQuery.trim() + ? t('session.linearIssuePicker.empty.noIssuesFound') + : usingDefaultFilters + ? t('session.linearIssuePicker.empty.noOpenIssuesFound') + : t('contextPanel.linear.empty.noMatchingIssues')} +
+ ) : null} + + {issues.map(renderIssueRow)} + + {hasMore && connected && linear ? ( +
+ +
+ ) : null} +
+
+ ); +}; diff --git a/packages/ui/src/components/views/SettingsView.tsx b/packages/ui/src/components/views/SettingsView.tsx index f8f61a4c..f5995a77 100644 --- a/packages/ui/src/components/views/SettingsView.tsx +++ b/packages/ui/src/components/views/SettingsView.tsx @@ -964,7 +964,7 @@ export const SettingsView: React.FC = ({ onClose, forceMobile : } {getPageTitle(page.slug)} - {(page.slug === 'tunnel' || page.slug === 'integrations') && ( + {page.slug === 'tunnel' && ( {t('settings.view.badge.beta')} diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index d29dce96..3eff8676 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -30,6 +30,7 @@ import { readEmbeddedThemeSearchParams } from '@/contexts/theme-embedded-bootstr import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useLinearAuthStore } from '@/stores/useLinearAuthStore'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { getCycledPrimaryAgentName } from '@/components/chat/mobileControlsUtils'; import { focusChatInput } from '@/components/chat/composer/editor/dom'; @@ -505,6 +506,7 @@ export const useKeyboardShortcuts = () => { isVSCode: isVSCodeRuntime(), screenWidth: window.innerWidth, tabs: panel?.tabs ?? [], + linearConnected: useLinearAuthStore.getState().status?.connected === true, }); const target = visibleSurfaces[switchSurfaceDigit - 1]; if (target) { diff --git a/packages/ui/src/hooks/useRouter.ts b/packages/ui/src/hooks/useRouter.ts index 2379f9be..6ca3ae8a 100644 --- a/packages/ui/src/hooks/useRouter.ts +++ b/packages/ui/src/hooks/useRouter.ts @@ -2,6 +2,7 @@ import React from 'react'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useUIStore, type ContextPanelMode } from '@/stores/useUIStore'; import { parseRoute, updateBrowserURL, hasRouteParams } from '@/lib/router'; +import { openSessionFromRoute } from '@/lib/router/openSessionFromRoute'; import type { RouteState, AppRouteState } from '@/lib/router'; import { resolveSettingsSlug } from '@/lib/settings/metadata'; import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat'; @@ -48,7 +49,6 @@ export function useRouter(): void { const isApplyingRouteRef = React.useRef(false); // Get store actions (stable references) - const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const setSettingsPage = useUIStore((state) => state.setSettingsPage); const navigateToDiff = useUIStore((state) => state.navigateToDiff); @@ -67,11 +67,7 @@ export function useRouter(): void { try { // 1. Apply session first (may trigger async operations) if (route.sessionId) { - const currentSessionId = useSessionUIStore.getState().currentSessionId; - if (route.sessionId !== currentSessionId) { - const directoryHint = useSessionUIStore.getState().getDirectoryForSession(route.sessionId); - setCurrentSession(route.sessionId, directoryHint); - } + await openSessionFromRoute(route.sessionId); } // 2. Handle settings first because it is a full-screen overlay. @@ -107,7 +103,7 @@ export function useRouter(): void { isApplyingRouteRef.current = false; } }, - [setCurrentSession, setSettingsDialogOpen, setSettingsPage, navigateToDiff] + [setSettingsDialogOpen, setSettingsPage, navigateToDiff] ); /** diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 51761520..d1b70aa4 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -1145,6 +1145,199 @@ export type GitHubDeviceFlowComplete = | { connected: true; user: GitHubUserSummary; scope?: string } | { connected: false; status?: string; error?: string }; +export type LinearUserSummary = { + id: string; + name: string | null; + displayName: string | null; + email: string | null; + avatarUrl: string | null; +}; + +export type LinearOrganizationSummary = { + id: string; + name: string; + urlKey: string | null; +}; + +export type LinearWorkspaceSummary = { + id: string; + name: string | null; + urlKey: string | null; + current: boolean; + user?: LinearUserSummary | null; + authorizedAt?: number | null; +}; + +export type LinearAuthStatus = { + connected: boolean; + user?: LinearUserSummary | null; + organization?: LinearOrganizationSummary | null; + scope?: string; + workspaces?: LinearWorkspaceSummary[]; +}; + +export type LinearAuthStart = { + authorizationUrl: string; + expiresIn: number; + scope: string; +}; + +export type LinearAuthOrigin = 'desktop' | 'web'; + +export type LinearIssueState = { + id: string | null; + name: string | null; + type: string | null; +}; + +export type LinearWorkflowState = { + id: string; + name: string; + type: string | null; + position: number; +}; + +export type LinearIssueAssignee = { + name: string | null; + displayName: string | null; + avatarUrl: string | null; +}; + +export type LinearIssueTeam = { + id: string; + key: string; + name: string; +}; + +export type LinearIssuePriority = 0 | 1 | 2 | 3 | 4; + +export type LinearIssueLabel = { + id: string; + name: string; + color: string | null; +}; + +export type LinearIssueSummary = { + id: string; + identifier: string; + title: string; + url: string; + state?: LinearIssueState | null; + assignee?: LinearIssueAssignee | null; + team?: LinearIssueTeam | null; + priority?: LinearIssuePriority | null; + labels?: LinearIssueLabel[]; +}; + +export type LinearIssueComment = { + id: string; + body: string; + createdAt: string | null; + user?: { name: string | null; displayName: string | null; avatarUrl?: string | null } | null; +}; + +export type LinearIssue = LinearIssueSummary & { + description?: string | null; + comments?: LinearIssueComment[]; +}; + +export type LinearIssueListStatus = 'all' | 'backlog' | 'todo' | 'started' | 'inReview' | 'completed' | 'canceled' | 'duplicate'; +export type LinearIssueListAssignee = 'any' | 'me'; +export type LinearIssueListPriority = 'all' | 'none' | 'urgent' | 'high' | 'medium' | 'low'; + +export type LinearIssuesListOptions = { + query?: string; + cursor?: string; + status?: LinearIssueListStatus; + assignee?: LinearIssueListAssignee; + teamId?: string; + priority?: LinearIssueListPriority; +}; + +export type LinearIssuesListResult = { + connected: boolean; + issues?: LinearIssueSummary[]; + cursor?: string | null; + hasMore?: boolean; +}; + +export type LinearIssueGetResult = { + connected: boolean; + issue?: LinearIssue | null; +}; + +export type LinearIssueStatesResult = { + connected: boolean; + states?: LinearWorkflowState[]; +}; + +export type LinearIssueUpdateInput = { + id: string; + stateId: string; +}; + +export type LinearIssueUpdateResult = { + connected: boolean; + issue?: LinearIssue | null; +}; + +export type LinearTeamMapping = { + id: string; + key: string; + name: string; + projectPath: string | null; +}; + +export type LinearMappingResult = { + connected: boolean; + defaultProjectPath?: string | null; + teams?: LinearTeamMapping[]; +}; + +export type LinearMappingWrite = { + defaultProjectPath: string | null; + teamProjectPaths: { [teamId: string]: string }; +}; + +export type LinearSessionStatusKind = 'started' | 'completed' | 'failure'; + +export type LinearSessionStatusPostInput = { + kind: LinearSessionStatusKind; + sessionId: string; + issueIdentifier?: string; + sessionOrigin?: string; +}; + +export type LinearSessionStatusPostResult = + | { connected: false } + | { connected: true; posted: true; commentId: string | null } + | { + connected: true; + posted: false; + skipped: 'already-posted' | 'issue-not-found' | 'not-started' | 'disabled' | 'origin-not-public'; + }; + +export type LinearPreferences = { + /** Status comments are off until the user opts in. */ + sessionComments: boolean; +}; + +export interface LinearAPI { + authStatus(): Promise; + authStart(origin?: LinearAuthOrigin): Promise; + authDisconnect(): Promise<{ removed: boolean }>; + authActivate(organizationId: string): Promise; + issuesList(options?: LinearIssuesListOptions): Promise; + issueGet(id: string): Promise; + issueStates(teamId: string): Promise; + issueUpdate(input: LinearIssueUpdateInput): Promise; + mappingGet(): Promise; + mappingSet(mapping: LinearMappingWrite): Promise; + sessionStatusPost(input: LinearSessionStatusPostInput): Promise; + preferencesGet(): Promise; + preferencesSet(preferences: LinearPreferences): Promise; +} + export interface GitHubAPI { authStatus(): Promise; authStart(): Promise; @@ -1269,6 +1462,7 @@ export interface RuntimeAPIs { permissions: PermissionsAPI; notifications: NotificationsAPI; github?: GitHubAPI; + linear?: LinearAPI; push?: PushAPI; diagnostics?: DiagnosticsAPI; clientAuth?: ClientAuthAPI; diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index 1302d87a..75d596f9 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -1,3 +1,4 @@ +import { linearIntegrationI18n } from './linear-integration.i18n'; import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'OpenCode Go Nutzungsverfolgung', @@ -2218,5 +2219,6 @@ export const settingsDict = { 'settings.openchamber.visual.option.themeMode.light.description': 'Immer helles Erscheinungsbild verwenden', 'settings.openchamber.visual.option.themeMode.dark.description': 'Immer dunkles Erscheinungsbild verwenden', 'chat.message.userText.collapseAria': 'Benutzernachricht einklappen', + ...linearIntegrationI18n.de, ...thirdPartyIntegrationI18n.de, }; diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index cc6f59a1..f8f2a907 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -1,7 +1,11 @@ import { settingsDict } from './de.settings'; +import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; +import { linearPanelI18n } from './linear-panel.i18n'; export const dict = { ...settingsDict, + ...linearIssuePickerI18n.de, + ...linearPanelI18n.de, 'common.language.german': 'Deutsch', 'common.loading': 'Wird geladen...', 'common.unavailable': 'Nicht verfügbar', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 29f19526..95620f19 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1,3 +1,4 @@ +import { linearIntegrationI18n } from './linear-integration.i18n'; import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'OpenCode Go usage tracking', @@ -2217,5 +2218,6 @@ export const settingsDict = { 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', + ...linearIntegrationI18n.en, ...thirdPartyIntegrationI18n.en, } as const; diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 506a7a2c..5b4e7c5c 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1,7 +1,11 @@ import { settingsDict } from './en.settings'; +import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; +import { linearPanelI18n } from './linear-panel.i18n'; export const dict = { ...settingsDict, + ...linearIssuePickerI18n.en, + ...linearPanelI18n.en, 'terminalView.actions.attachSelection': 'Attach selected output', 'terminalView.actions.restart': 'Restart terminal', 'chat.message.terminalContext': '{terminal}, lines {start}-{end}', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index e5125e1a..fa40f214 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1,3 +1,4 @@ +import { linearIntegrationI18n } from './linear-integration.i18n'; import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'Seguimiento de uso de OpenCode Go', @@ -2227,5 +2228,6 @@ export const settingsDict = { "settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.", "settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer", "settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue", + ...linearIntegrationI18n.es, ...thirdPartyIntegrationI18n.es, } as const; diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index c5175e3d..d4af1600 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1,8 +1,12 @@ import type { I18nKey } from './en'; import { settingsDict } from './es.settings'; +import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; +import { linearPanelI18n } from './linear-panel.i18n'; export const dict: Record = { ...settingsDict, + ...linearIssuePickerI18n.es, + ...linearPanelI18n.es, 'terminalView.actions.attachSelection': 'Adjuntar salida seleccionada', 'terminalView.actions.restart': 'Reiniciar terminal', 'chat.message.terminalContext': '{terminal}, líneas {start}-{end}', diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 7d374725..432d536d 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1,3 +1,4 @@ +import { linearIntegrationI18n } from './linear-integration.i18n'; import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'Suivi de l’utilisation d’OpenCode Go', @@ -2227,5 +2228,6 @@ export const settingsDict = { 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', + ...linearIntegrationI18n.fr, ...thirdPartyIntegrationI18n.fr, } as const; diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 4575aa2b..646a3714 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1,7 +1,11 @@ import { settingsDict } from './fr.settings'; +import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; +import { linearPanelI18n } from './linear-panel.i18n'; export const dict = { ...settingsDict, + ...linearIssuePickerI18n.fr, + ...linearPanelI18n.fr, 'terminalView.actions.attachSelection': 'Joindre la sortie sélectionnée', 'terminalView.actions.restart': 'Redémarrer le terminal', 'chat.message.terminalContext': '{terminal}, lignes {start}-{end}', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 1dcf5291..87b35b08 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1,3 +1,4 @@ +import { linearIntegrationI18n } from './linear-integration.i18n'; import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'OpenCode Go 使用量追跡', @@ -2227,5 +2228,6 @@ export const settingsDict = { 'settings.openchamber.visual.field.followUpBehaviorDescription': 'エージェントが応答している間にフォローアップメッセージで Enter を押したときの動作を選択します。', 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'ステア', 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'キュー', + ...linearIntegrationI18n.ja, ...thirdPartyIntegrationI18n.ja, } as const; diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 9a666ec0..72643f20 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1,8 +1,12 @@ import type { I18nKey } from './en'; import { settingsDict } from './ja.settings'; +import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; +import { linearPanelI18n } from './linear-panel.i18n'; export const dict: Record = { ...settingsDict, + ...linearIssuePickerI18n.ja, + ...linearPanelI18n.ja, 'terminalView.actions.attachSelection': '選択した出力を添付', 'terminalView.actions.restart': 'ターミナルを再起動', 'chat.message.terminalContext': '{terminal}、{start}〜{end}行', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index ef0d8e6a..08ac89c7 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1,3 +1,4 @@ +import { linearIntegrationI18n } from './linear-integration.i18n'; import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'OpenCode Go 사용량 추적', @@ -2227,5 +2228,6 @@ export const settingsDict = { 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', + ...linearIntegrationI18n.ko, ...thirdPartyIntegrationI18n.ko, } as const; diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 786dd155..8baeb2c3 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1,8 +1,12 @@ import type { I18nKey } from './en'; import { settingsDict } from './ko.settings'; +import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; +import { linearPanelI18n } from './linear-panel.i18n'; export const dict: Record = { ...settingsDict, + ...linearIssuePickerI18n.ko, + ...linearPanelI18n.ko, 'terminalView.actions.attachSelection': '선택한 출력 첨부', 'terminalView.actions.restart': '터미널 다시 시작', 'chat.message.terminalContext': '{terminal}, {start}-{end}행', diff --git a/packages/ui/src/lib/i18n/messages/linear-integration.i18n.test.ts b/packages/ui/src/lib/i18n/messages/linear-integration.i18n.test.ts new file mode 100644 index 00000000..0fae64bb --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/linear-integration.i18n.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from 'bun:test'; +import { linearIntegrationI18n } from './linear-integration.i18n'; + +const locales = ['en', 'de', 'fr', 'es', 'ja', 'pt-BR', 'uk', 'ko', 'pl', 'zh-CN', 'zh-TW', 'tr'] as const; + +const requiredKeys = [ + 'settings.integrations.firstParty.title', + 'settings.integrations.firstParty.info', + 'settings.integrations.linear.title', + 'settings.integrations.linear.description', + 'settings.integrations.linear.info', + 'settings.integrations.linear.status.notConnected', + 'settings.integrations.linear.status.connected', + 'settings.integrations.linear.status.waiting', + 'settings.integrations.linear.actions.connect', + 'settings.integrations.linear.actions.disconnect', + 'settings.integrations.linear.actions.addWorkspace', + 'settings.integrations.linear.actions.switchTo', + 'settings.integrations.linear.label.otherWorkspaces', + 'settings.integrations.linear.flow.title', + 'settings.integrations.linear.flow.description', + 'settings.integrations.linear.flow.waiting', + 'settings.integrations.linear.toast.connected', + 'settings.integrations.linear.toast.disconnected', + 'settings.integrations.linear.toast.workspaceSwitched', + 'settings.integrations.linear.toast.workspaceSwitchFailed', + 'settings.integrations.linear.toast.startConnectFailed', + 'settings.integrations.linear.toast.disconnectFailed', + 'settings.integrations.linear.toast.authorizationFailed', + 'settings.integrations.linear.avatarAlt.withName', + 'settings.integrations.linear.avatarAlt.fallback', + 'settings.integrations.linear.label.unknownUser', + 'settings.integrations.linear.mapping.defaultProject', + 'settings.integrations.linear.mapping.defaultProject.info', + 'settings.integrations.linear.mapping.defaultProject.placeholder', + 'settings.integrations.linear.mapping.defaultProject.aria', + 'settings.integrations.linear.mapping.teams', + 'settings.integrations.linear.mapping.teams.info', + 'settings.integrations.linear.mapping.teams.useDefault', + 'settings.integrations.linear.mapping.teams.aria', + 'settings.integrations.linear.mapping.emptyProjects', + 'settings.integrations.linear.mapping.emptyTeams', + 'settings.integrations.linear.mapping.loadFailed', + 'settings.integrations.linear.sessionComments.label', + 'settings.integrations.linear.sessionComments.info', + 'settings.integrations.linear.sessionComments.aria', + 'settings.integrations.linear.sessionComments.loadFailed', + 'settings.magicPrompts.sidebar.group.linear', + 'settings.magicPrompts.sidebar.item.linearIssueReview', + 'settings.magicPrompts.page.group.linearIssueReview.title', + 'settings.magicPrompts.page.group.linearIssueReview.description', +] as const; + +describe('linear integration translations', () => { + test('provides every required key in every supported locale', () => { + const english = linearIntegrationI18n.en; + for (const locale of locales) { + for (const key of requiredKeys) { + const value = linearIntegrationI18n[locale][key]; + expect(value).toBeTruthy(); + if ( + locale !== 'en' + && key !== 'settings.integrations.linear.title' + && key !== 'settings.magicPrompts.sidebar.group.linear' + ) { + expect(value).not.toBe(english[key]); + } + } + } + }); +}); diff --git a/packages/ui/src/lib/i18n/messages/linear-integration.i18n.ts b/packages/ui/src/lib/i18n/messages/linear-integration.i18n.ts new file mode 100644 index 00000000..529a6aba --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/linear-integration.i18n.ts @@ -0,0 +1,567 @@ +/** Linear first-party integration settings strings — merged into each locale's settings dictionary. */ +export const linearIntegrationI18n = { + en: { + 'settings.integrations.firstParty.title': 'Built-in integrations', + 'settings.integrations.firstParty.info': 'Sign-ins for services that ship with OpenChamber. The login stays on this computer so web, desktop, and a paired phone share it.', + 'settings.integrations.linear.title': 'Linear', + 'settings.integrations.linear.description': 'Connect Linear workspaces on this OpenChamber server.', + 'settings.integrations.linear.info': 'Connect one or more Linear workspaces. OpenChamber stores the logins on this computer so web, desktop, and a paired phone share them.', + 'settings.integrations.linear.status.notConnected': 'Not connected', + 'settings.integrations.linear.status.connected': 'Connected', + 'settings.integrations.linear.status.waiting': 'Waiting', + 'settings.integrations.linear.actions.connect': 'Connect', + 'settings.integrations.linear.actions.disconnect': 'Disconnect', + 'settings.integrations.linear.actions.addWorkspace': 'Add workspace', + 'settings.integrations.linear.actions.switchTo': 'Switch to', + 'settings.integrations.linear.label.otherWorkspaces': 'Other workspaces', + 'settings.integrations.linear.flow.title': 'Waiting for Linear', + 'settings.integrations.linear.flow.description': 'Finish signing in in the browser tab that just opened.', + 'settings.integrations.linear.flow.waiting': 'Waiting for authorization…', + 'settings.integrations.linear.toast.connected': 'Linear connected', + 'settings.integrations.linear.toast.disconnected': 'Linear disconnected', + 'settings.integrations.linear.toast.workspaceSwitched': 'Switched Linear workspace', + 'settings.integrations.linear.toast.workspaceSwitchFailed': 'Could not switch Linear workspace', + 'settings.integrations.linear.toast.startConnectFailed': 'Could not start Linear sign-in', + 'settings.integrations.linear.toast.disconnectFailed': 'Could not disconnect Linear', + 'settings.integrations.linear.toast.authorizationFailed': 'Linear authorization timed out. Click Connect to try again.', + 'settings.integrations.linear.avatarAlt.withName': 'Linear avatar for {name}', + 'settings.integrations.linear.avatarAlt.fallback': 'Linear avatar', + 'settings.integrations.linear.label.unknownUser': 'Unknown user', + 'settings.integrations.linear.mapping.defaultProject': 'Default project', + 'settings.integrations.linear.mapping.defaultProject.info': 'New sessions from Linear issues use this project unless the issue\'s team has its own mapping.', + 'settings.integrations.linear.mapping.defaultProject.placeholder': 'None', + 'settings.integrations.linear.mapping.defaultProject.aria': 'Default project for Linear issues', + 'settings.integrations.linear.mapping.teams': 'Team projects', + 'settings.integrations.linear.mapping.teams.info': 'Optional. An issue from a mapped team opens in that project instead of the default.', + 'settings.integrations.linear.mapping.teams.useDefault': 'Use default', + 'settings.integrations.linear.mapping.teams.aria': 'Project for Linear team {team}', + 'settings.integrations.linear.mapping.emptyProjects': 'Add a project first, then map Linear teams to it.', + 'settings.integrations.linear.mapping.emptyTeams': 'This Linear workspace has no teams.', + 'settings.integrations.linear.mapping.loadFailed': 'Could not load Linear project mapping.', + 'settings.integrations.linear.sessionComments.label': 'Session comments', + 'settings.integrations.linear.sessionComments.info': 'Adds a comment to the issue when a session starts, finishes, or fails. Comments are only posted when this server has a public address, so the link opens the session for everyone on the issue.', + 'settings.integrations.linear.sessionComments.aria': 'Post session status comments to Linear', + 'settings.integrations.linear.sessionComments.loadFailed': 'Could not load Linear comment settings.', + 'settings.magicPrompts.sidebar.group.linear': 'Linear', + 'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue Review', + 'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue Review', + 'settings.magicPrompts.page.group.linearIssueReview.description': 'Prompts used when starting a session from a Linear issue: visible user message + hidden instructions.', + }, + de: { + 'settings.integrations.firstParty.title': 'Eingebaute Integrationen', + 'settings.integrations.firstParty.info': 'Anmeldungen für Dienste, die mit OpenChamber mitgeliefert werden. Die Anmeldung bleibt auf diesem Computer, damit Web, Desktop und ein gekoppeltes Telefon sie teilen.', + 'settings.integrations.linear.title': 'Linear', + 'settings.integrations.linear.description': 'Verbinde Linear-Workspaces mit diesem OpenChamber-Server.', + 'settings.integrations.linear.info': 'Verbinde einen oder mehrere Linear-Workspaces. OpenChamber speichert die Anmeldungen auf diesem Computer, damit Web, Desktop und ein gekoppeltes Telefon sie teilen.', + 'settings.integrations.linear.status.notConnected': 'Nicht verbunden', + 'settings.integrations.linear.status.connected': 'Verbunden', + 'settings.integrations.linear.status.waiting': 'Warten', + 'settings.integrations.linear.actions.connect': 'Verbinden', + 'settings.integrations.linear.actions.disconnect': 'Trennen', + 'settings.integrations.linear.actions.addWorkspace': 'Workspace hinzufügen', + 'settings.integrations.linear.actions.switchTo': 'Wechseln zu', + 'settings.integrations.linear.label.otherWorkspaces': 'Andere Workspaces', + 'settings.integrations.linear.flow.title': 'Warte auf Linear', + 'settings.integrations.linear.flow.description': 'Schließe die Anmeldung im gerade geöffneten Browser-Tab ab.', + 'settings.integrations.linear.flow.waiting': 'Warte auf die Autorisierung…', + 'settings.integrations.linear.toast.connected': 'Linear verbunden', + 'settings.integrations.linear.toast.disconnected': 'Linear getrennt', + 'settings.integrations.linear.toast.workspaceSwitched': 'Linear-Workspace gewechselt', + 'settings.integrations.linear.toast.workspaceSwitchFailed': 'Linear-Workspace konnte nicht gewechselt werden', + 'settings.integrations.linear.toast.startConnectFailed': 'Linear-Anmeldung konnte nicht gestartet werden', + 'settings.integrations.linear.toast.disconnectFailed': 'Linear konnte nicht getrennt werden', + 'settings.integrations.linear.toast.authorizationFailed': 'Die Linear-Autorisierung ist abgelaufen. Klicke auf Verbinden, um es erneut zu versuchen.', + 'settings.integrations.linear.avatarAlt.withName': 'Linear-Avatar für {name}', + 'settings.integrations.linear.avatarAlt.fallback': 'Linear-Avatar', + 'settings.integrations.linear.label.unknownUser': 'Unbekannter Benutzer', + 'settings.integrations.linear.mapping.defaultProject': 'Standardprojekt', + 'settings.integrations.linear.mapping.defaultProject.info': 'Neue Sitzungen aus Linear-Issues nutzen dieses Projekt, sofern das Team des Issues keine eigene Zuordnung hat.', + 'settings.integrations.linear.mapping.defaultProject.placeholder': 'Keines', + 'settings.integrations.linear.mapping.defaultProject.aria': 'Standardprojekt für Linear-Issues', + 'settings.integrations.linear.mapping.teams': 'Team-Projekte', + 'settings.integrations.linear.mapping.teams.info': 'Optional. Ein Issue eines zugeordneten Teams öffnet sich in diesem Projekt statt im Standard.', + 'settings.integrations.linear.mapping.teams.useDefault': 'Standard verwenden', + 'settings.integrations.linear.mapping.teams.aria': 'Projekt für Linear-Team {team}', + 'settings.integrations.linear.mapping.emptyProjects': 'Füge zuerst ein Projekt hinzu und ordne dann Linear-Teams zu.', + 'settings.integrations.linear.mapping.emptyTeams': 'Dieser Linear-Workspace hat keine Teams.', + 'settings.integrations.linear.mapping.loadFailed': 'Linear-Projektzuordnung konnte nicht geladen werden.', + 'settings.integrations.linear.sessionComments.label': 'Sitzungskommentare', + 'settings.integrations.linear.sessionComments.info': 'Kommentiert das Issue, wenn eine Sitzung startet, endet oder fehlschlägt. Kommentare werden nur gepostet, wenn dieser Server eine öffentliche Adresse hat, damit der Link die Sitzung für alle Beteiligten öffnet.', + 'settings.integrations.linear.sessionComments.aria': 'Statuskommentare zu Sitzungen in Linear posten', + 'settings.integrations.linear.sessionComments.loadFailed': 'Linear-Kommentareinstellungen konnten nicht geladen werden.', + 'settings.magicPrompts.sidebar.group.linear': 'Linear', + 'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue-Review', + 'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue-Review', + 'settings.magicPrompts.page.group.linearIssueReview.description': 'Eingabeaufforderungen beim Start einer Sitzung aus einem Linear-Issue: sichtbare Benutzernachricht + versteckte Anweisungen.', + }, + fr: { + 'settings.integrations.firstParty.title': 'Intégrations natives', + 'settings.integrations.firstParty.info': 'Connexions aux services fournis avec OpenChamber. La connexion reste sur cet ordinateur pour que le web, le bureau et un téléphone apparié la partagent.', + 'settings.integrations.linear.title': 'Linear', + 'settings.integrations.linear.description': 'Connectez des espaces Linear à ce serveur OpenChamber.', + 'settings.integrations.linear.info': 'Connectez un ou plusieurs espaces Linear. OpenChamber enregistre les connexions sur cet ordinateur pour que le web, le bureau et un téléphone apparié les partagent.', + 'settings.integrations.linear.status.notConnected': 'Non connecté', + 'settings.integrations.linear.status.connected': 'Connecté', + 'settings.integrations.linear.status.waiting': 'En attente', + 'settings.integrations.linear.actions.connect': 'Connecter', + 'settings.integrations.linear.actions.disconnect': 'Déconnecter', + 'settings.integrations.linear.actions.addWorkspace': 'Ajouter un workspace', + 'settings.integrations.linear.actions.switchTo': 'Basculer vers', + 'settings.integrations.linear.label.otherWorkspaces': 'Autres workspaces', + 'settings.integrations.linear.flow.title': 'En attente de Linear', + 'settings.integrations.linear.flow.description': 'Terminez la connexion dans l’onglet du navigateur qui vient de s’ouvrir.', + 'settings.integrations.linear.flow.waiting': 'En attente de l’autorisation…', + 'settings.integrations.linear.toast.connected': 'Linear connecté', + 'settings.integrations.linear.toast.disconnected': 'Linear déconnecté', + 'settings.integrations.linear.toast.workspaceSwitched': 'Workspace Linear modifié', + 'settings.integrations.linear.toast.workspaceSwitchFailed': 'Impossible de changer de workspace Linear', + 'settings.integrations.linear.toast.startConnectFailed': 'Impossible de démarrer la connexion Linear', + 'settings.integrations.linear.toast.disconnectFailed': 'Impossible de déconnecter Linear', + 'settings.integrations.linear.toast.authorizationFailed': 'L’autorisation Linear a expiré. Cliquez sur Connecter pour réessayer.', + 'settings.integrations.linear.avatarAlt.withName': 'Avatar Linear de {name}', + 'settings.integrations.linear.avatarAlt.fallback': 'Avatar Linear', + 'settings.integrations.linear.label.unknownUser': 'Utilisateur inconnu', + 'settings.integrations.linear.mapping.defaultProject': 'Projet par défaut', + 'settings.integrations.linear.mapping.defaultProject.info': 'Les nouvelles sessions depuis des tickets Linear utilisent ce projet, sauf si l’équipe du ticket a sa propre association.', + 'settings.integrations.linear.mapping.defaultProject.placeholder': 'Aucun', + 'settings.integrations.linear.mapping.defaultProject.aria': 'Projet par défaut pour les tickets Linear', + 'settings.integrations.linear.mapping.teams': 'Projets par équipe', + 'settings.integrations.linear.mapping.teams.info': 'Facultatif. Un ticket d’une équipe associée s’ouvre dans ce projet plutôt que dans le projet par défaut.', + 'settings.integrations.linear.mapping.teams.useDefault': 'Utiliser le défaut', + 'settings.integrations.linear.mapping.teams.aria': 'Projet pour l’équipe Linear {team}', + 'settings.integrations.linear.mapping.emptyProjects': 'Ajoutez d’abord un projet, puis associez les équipes Linear.', + 'settings.integrations.linear.mapping.emptyTeams': 'Cet espace Linear n’a aucune équipe.', + 'settings.integrations.linear.mapping.loadFailed': 'Impossible de charger l’association des projets Linear.', + 'settings.integrations.linear.sessionComments.label': 'Commentaires de session', + 'settings.integrations.linear.sessionComments.info': 'Ajoute un commentaire au ticket quand une session démarre, se termine ou échoue. Les commentaires ne sont publiés que si ce serveur a une adresse publique, afin que le lien ouvre la session pour tout le monde.', + 'settings.integrations.linear.sessionComments.aria': 'Publier les commentaires d’état de session dans Linear', + 'settings.integrations.linear.sessionComments.loadFailed': 'Impossible de charger les réglages de commentaires Linear.', + 'settings.magicPrompts.sidebar.group.linear': 'Linear', + 'settings.magicPrompts.sidebar.item.linearIssueReview': 'Revue d’issue', + 'settings.magicPrompts.page.group.linearIssueReview.title': 'Revue d’issue', + 'settings.magicPrompts.page.group.linearIssueReview.description': 'Prompts utilisés au démarrage d’une session depuis un ticket Linear : message utilisateur visible + instructions masquées.', + }, + es: { + 'settings.integrations.firstParty.title': 'Integraciones nativas', + 'settings.integrations.firstParty.info': 'Inicios de sesión de los servicios incluidos en OpenChamber. El inicio de sesión se guarda en este ordenador para que la web, el escritorio y un teléfono emparejado lo compartan.', + 'settings.integrations.linear.title': 'Linear', + 'settings.integrations.linear.description': 'Conecta espacios de Linear a este servidor de OpenChamber.', + 'settings.integrations.linear.info': 'Conecta uno o más espacios de Linear. OpenChamber guarda los inicios de sesión en este ordenador para que la web, el escritorio y un teléfono emparejado los compartan.', + 'settings.integrations.linear.status.notConnected': 'No conectado', + 'settings.integrations.linear.status.connected': 'Conectado', + 'settings.integrations.linear.status.waiting': 'Esperando', + 'settings.integrations.linear.actions.connect': 'Conectar', + 'settings.integrations.linear.actions.disconnect': 'Desconectar', + 'settings.integrations.linear.actions.addWorkspace': 'Añadir workspace', + 'settings.integrations.linear.actions.switchTo': 'Cambiar a', + 'settings.integrations.linear.label.otherWorkspaces': 'Otros workspaces', + 'settings.integrations.linear.flow.title': 'Esperando a Linear', + 'settings.integrations.linear.flow.description': 'Termina de iniciar sesión en la pestaña del navegador que acaba de abrirse.', + 'settings.integrations.linear.flow.waiting': 'Esperando la autorización…', + 'settings.integrations.linear.toast.connected': 'Linear conectado', + 'settings.integrations.linear.toast.disconnected': 'Linear desconectado', + 'settings.integrations.linear.toast.workspaceSwitched': 'Workspace de Linear cambiado', + 'settings.integrations.linear.toast.workspaceSwitchFailed': 'No se pudo cambiar el workspace de Linear', + 'settings.integrations.linear.toast.startConnectFailed': 'No se pudo iniciar la conexión con Linear', + 'settings.integrations.linear.toast.disconnectFailed': 'No se pudo desconectar Linear', + 'settings.integrations.linear.toast.authorizationFailed': 'La autorización de Linear ha caducado. Haz clic en Conectar para intentarlo de nuevo.', + 'settings.integrations.linear.avatarAlt.withName': 'Avatar de Linear de {name}', + 'settings.integrations.linear.avatarAlt.fallback': 'Avatar de Linear', + 'settings.integrations.linear.label.unknownUser': 'Usuario desconocido', + 'settings.integrations.linear.mapping.defaultProject': 'Proyecto predeterminado', + 'settings.integrations.linear.mapping.defaultProject.info': 'Las sesiones nuevas desde issues de Linear usan este proyecto, salvo que el equipo del issue tenga su propia asignación.', + 'settings.integrations.linear.mapping.defaultProject.placeholder': 'Ninguno', + 'settings.integrations.linear.mapping.defaultProject.aria': 'Proyecto predeterminado para issues de Linear', + 'settings.integrations.linear.mapping.teams': 'Proyectos por equipo', + 'settings.integrations.linear.mapping.teams.info': 'Opcional. Un issue de un equipo asignado se abre en ese proyecto en lugar del predeterminado.', + 'settings.integrations.linear.mapping.teams.useDefault': 'Usar el predeterminado', + 'settings.integrations.linear.mapping.teams.aria': 'Proyecto para el equipo de Linear {team}', + 'settings.integrations.linear.mapping.emptyProjects': 'Añade primero un proyecto y luego asigna equipos de Linear.', + 'settings.integrations.linear.mapping.emptyTeams': 'Este espacio de Linear no tiene equipos.', + 'settings.integrations.linear.mapping.loadFailed': 'No se pudo cargar la asignación de proyectos de Linear.', + 'settings.integrations.linear.sessionComments.label': 'Comentarios de sesión', + 'settings.integrations.linear.sessionComments.info': 'Añade un comentario a la incidencia cuando una sesión empieza, termina o falla. Los comentarios solo se publican si este servidor tiene una dirección pública, para que el enlace abra la sesión a todos.', + 'settings.integrations.linear.sessionComments.aria': 'Publicar comentarios de estado de sesión en Linear', + 'settings.integrations.linear.sessionComments.loadFailed': 'No se pudieron cargar los ajustes de comentarios de Linear.', + 'settings.magicPrompts.sidebar.group.linear': 'Linear', + 'settings.magicPrompts.sidebar.item.linearIssueReview': 'Revisión de issue', + 'settings.magicPrompts.page.group.linearIssueReview.title': 'Revisión de issue', + 'settings.magicPrompts.page.group.linearIssueReview.description': 'Prompts usados al iniciar una sesión desde un issue de Linear: mensaje visible del usuario e instrucciones ocultas.', + }, + ja: { + 'settings.integrations.firstParty.title': '標準連携', + 'settings.integrations.firstParty.info': 'OpenChamber に同梱されているサービスのログインです。このコンピュータに保存され、Web、デスクトップ、ペアリングしたスマホで共有されます。', + 'settings.integrations.linear.title': 'Linear', + 'settings.integrations.linear.description': 'この OpenChamber サーバーに Linear ワークスペースを接続します。複数接続できます。', + 'settings.integrations.linear.info': 'Linear ワークスペースを1つ以上接続します。ログインはこのコンピュータに保存され、Web、デスクトップ、ペアリングしたスマホで共有されます。', + 'settings.integrations.linear.status.notConnected': '未接続', + 'settings.integrations.linear.status.connected': '接続済み', + 'settings.integrations.linear.status.waiting': '待機中', + 'settings.integrations.linear.actions.connect': '接続', + 'settings.integrations.linear.actions.disconnect': '切断', + 'settings.integrations.linear.actions.addWorkspace': 'ワークスペースを追加', + 'settings.integrations.linear.actions.switchTo': '切り替える', + 'settings.integrations.linear.label.otherWorkspaces': '他のワークスペース', + 'settings.integrations.linear.flow.title': 'Linear を待っています', + 'settings.integrations.linear.flow.description': '開いたブラウザタブでサインインを完了してください。', + 'settings.integrations.linear.flow.waiting': '認可を待っています…', + 'settings.integrations.linear.toast.connected': 'Linear に接続しました', + 'settings.integrations.linear.toast.disconnected': 'Linear を切断しました', + 'settings.integrations.linear.toast.workspaceSwitched': 'Linear ワークスペースを切り替えました', + 'settings.integrations.linear.toast.workspaceSwitchFailed': 'Linear ワークスペースを切り替えられませんでした', + 'settings.integrations.linear.toast.startConnectFailed': 'Linear のサインインを開始できませんでした', + 'settings.integrations.linear.toast.disconnectFailed': 'Linear を切断できませんでした', + 'settings.integrations.linear.toast.authorizationFailed': 'Linear の認可がタイムアウトしました。接続をもう一度押してください。', + 'settings.integrations.linear.avatarAlt.withName': '{name} の Linear アバター', + 'settings.integrations.linear.avatarAlt.fallback': 'Linear アバター', + 'settings.integrations.linear.label.unknownUser': '不明なユーザー', + 'settings.integrations.linear.mapping.defaultProject': 'デフォルトのプロジェクト', + 'settings.integrations.linear.mapping.defaultProject.info': 'Linear Issueから作る新しいセッションはこのプロジェクトを使います。チームに個別の割り当てがある場合はそちらを使います。', + 'settings.integrations.linear.mapping.defaultProject.placeholder': 'なし', + 'settings.integrations.linear.mapping.defaultProject.aria': 'Linear Issueのデフォルトプロジェクト', + 'settings.integrations.linear.mapping.teams': 'チームのプロジェクト', + 'settings.integrations.linear.mapping.teams.info': '任意。割り当てたチームのIssueは、デフォルトではなくそのプロジェクトで開きます。', + 'settings.integrations.linear.mapping.teams.useDefault': 'デフォルトを使う', + 'settings.integrations.linear.mapping.teams.aria': 'Linearチーム {team} のプロジェクト', + 'settings.integrations.linear.mapping.emptyProjects': '先にプロジェクトを追加してから、Linearチームを割り当ててください。', + 'settings.integrations.linear.mapping.emptyTeams': 'このLinearワークスペースにはチームがありません。', + 'settings.integrations.linear.mapping.loadFailed': 'Linearのプロジェクト割り当てを読み込めませんでした。', + 'settings.integrations.linear.sessionComments.label': 'セッションのコメント', + 'settings.integrations.linear.sessionComments.info': 'セッションの開始・完了・失敗時にイシューへコメントします。リンクを誰でも開けるよう、このサーバーが公開アドレスを持つ場合のみ投稿します。', + 'settings.integrations.linear.sessionComments.aria': 'セッション状態のコメントを Linear に投稿', + 'settings.integrations.linear.sessionComments.loadFailed': 'Linear のコメント設定を読み込めませんでした。', + 'settings.magicPrompts.sidebar.group.linear': 'Linear', + 'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue レビュー', + 'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue レビュー', + 'settings.magicPrompts.page.group.linearIssueReview.description': 'Linear の Issue からセッションを開始するときに使うプロンプト: 表示ユーザーメッセージ + 非表示の指示。', + }, + ko: { + 'settings.integrations.firstParty.title': '기본 제공 통합', + 'settings.integrations.firstParty.info': 'OpenChamber에 포함된 서비스 로그인입니다. 이 컴퓨터에 저장되며 웹, 데스크톱, 페어링된 휴대폰이 공유합니다.', + 'settings.integrations.linear.title': 'Linear', + 'settings.integrations.linear.description': '이 OpenChamber 서버에 Linear 워크스페이스를 연결하세요. 여러 개를 연결할 수 있습니다.', + 'settings.integrations.linear.info': 'Linear 워크스페이스를 하나 이상 연결하세요. 로그인은 이 컴퓨터에 저장되며 웹, 데스크톱, 페어링된 휴대폰이 공유합니다.', + 'settings.integrations.linear.status.notConnected': '연결되지 않음', + 'settings.integrations.linear.status.connected': '연결됨', + 'settings.integrations.linear.status.waiting': '대기 중', + 'settings.integrations.linear.actions.connect': '연결', + 'settings.integrations.linear.actions.disconnect': '연결 해제', + 'settings.integrations.linear.actions.addWorkspace': '워크스페이스 추가', + 'settings.integrations.linear.actions.switchTo': '전환', + 'settings.integrations.linear.label.otherWorkspaces': '다른 워크스페이스', + 'settings.integrations.linear.flow.title': 'Linear 대기 중', + 'settings.integrations.linear.flow.description': '방금 열린 브라우저 탭에서 로그인을 완료하세요.', + 'settings.integrations.linear.flow.waiting': '권한 부여를 기다리는 중…', + 'settings.integrations.linear.toast.connected': 'Linear가 연결됨', + 'settings.integrations.linear.toast.disconnected': 'Linear 연결이 해제됨', + 'settings.integrations.linear.toast.workspaceSwitched': 'Linear 워크스페이스를 전환했습니다', + 'settings.integrations.linear.toast.workspaceSwitchFailed': 'Linear 워크스페이스를 전환하지 못했습니다', + 'settings.integrations.linear.toast.startConnectFailed': 'Linear 로그인을 시작하지 못했습니다', + 'settings.integrations.linear.toast.disconnectFailed': 'Linear 연결을 해제하지 못했습니다', + 'settings.integrations.linear.toast.authorizationFailed': 'Linear 권한 부여가 시간 초과되었습니다. 연결을 다시 누르세요.', + 'settings.integrations.linear.avatarAlt.withName': '{name}의 Linear 아바타', + 'settings.integrations.linear.avatarAlt.fallback': 'Linear 아바타', + 'settings.integrations.linear.label.unknownUser': '알 수 없는 사용자', + 'settings.integrations.linear.mapping.defaultProject': '기본 프로젝트', + 'settings.integrations.linear.mapping.defaultProject.info': 'Linear 이슈에서 만드는 새 세션은 이 프로젝트를 사용합니다. 해당 팀에 별도 연결이 있으면 그쪽을 씁니다.', + 'settings.integrations.linear.mapping.defaultProject.placeholder': '없음', + 'settings.integrations.linear.mapping.defaultProject.aria': 'Linear 이슈의 기본 프로젝트', + 'settings.integrations.linear.mapping.teams': '팀 프로젝트', + 'settings.integrations.linear.mapping.teams.info': '선택 사항입니다. 연결한 팀의 이슈는 기본값 대신 그 프로젝트에서 열립니다.', + 'settings.integrations.linear.mapping.teams.useDefault': '기본값 사용', + 'settings.integrations.linear.mapping.teams.aria': 'Linear 팀 {team}의 프로젝트', + 'settings.integrations.linear.mapping.emptyProjects': '먼저 프로젝트를 추가한 다음 Linear 팀을 연결하세요.', + 'settings.integrations.linear.mapping.emptyTeams': '이 Linear 워크스페이스에는 팀이 없습니다.', + 'settings.integrations.linear.mapping.loadFailed': 'Linear 프로젝트 연결을 불러오지 못했습니다.', + 'settings.integrations.linear.sessionComments.label': '세션 댓글', + 'settings.integrations.linear.sessionComments.info': '세션이 시작, 완료, 실패할 때 이슈에 댓글을 남깁니다. 링크를 모두가 열 수 있도록 이 서버에 공개 주소가 있을 때만 게시합니다.', + 'settings.integrations.linear.sessionComments.aria': '세션 상태 댓글을 Linear에 게시', + 'settings.integrations.linear.sessionComments.loadFailed': 'Linear 댓글 설정을 불러오지 못했습니다.', + 'settings.magicPrompts.sidebar.group.linear': 'Linear', + 'settings.magicPrompts.sidebar.item.linearIssueReview': '이슈 리뷰', + 'settings.magicPrompts.page.group.linearIssueReview.title': '이슈 리뷰', + 'settings.magicPrompts.page.group.linearIssueReview.description': 'Linear 이슈로 세션을 시작할 때 쓰는 프롬프트입니다: 표시 사용자 메시지 + 숨겨진 지침.', + }, + pl: { + 'settings.integrations.firstParty.title': 'Wbudowane integracje', + 'settings.integrations.firstParty.info': 'Logowania do usług dostarczanych z OpenChamber. Zapisujemy je na tym komputerze, żeby przeglądarka, aplikacja desktopowa i sparowany telefon z nich korzystały.', + 'settings.integrations.linear.title': 'Linear', + 'settings.integrations.linear.description': 'Połącz przestrzenie Linear z tym serwerem OpenChamber.', + 'settings.integrations.linear.info': 'Połącz jedną lub kilka przestrzeni Linear. OpenChamber zapisuje logowania na tym komputerze, żeby przeglądarka, aplikacja desktopowa i sparowany telefon z nich korzystały.', + 'settings.integrations.linear.status.notConnected': 'Nie połączono', + 'settings.integrations.linear.status.connected': 'Połączono', + 'settings.integrations.linear.status.waiting': 'Oczekiwanie', + 'settings.integrations.linear.actions.connect': 'Połącz', + 'settings.integrations.linear.actions.disconnect': 'Rozłącz', + 'settings.integrations.linear.actions.addWorkspace': 'Dodaj workspace', + 'settings.integrations.linear.actions.switchTo': 'Przełącz na', + 'settings.integrations.linear.label.otherWorkspaces': 'Inne przestrzenie', + 'settings.integrations.linear.flow.title': 'Oczekiwanie na Linear', + 'settings.integrations.linear.flow.description': 'Dokończ logowanie w karcie przeglądarki, która właśnie się otworzyła.', + 'settings.integrations.linear.flow.waiting': 'Oczekiwanie na autoryzację…', + 'settings.integrations.linear.toast.connected': 'Połączono z Linear', + 'settings.integrations.linear.toast.disconnected': 'Rozłączono Linear', + 'settings.integrations.linear.toast.workspaceSwitched': 'Przełączono workspace Linear', + 'settings.integrations.linear.toast.workspaceSwitchFailed': 'Nie udało się przełączyć workspace Linear', + 'settings.integrations.linear.toast.startConnectFailed': 'Nie udało się rozpocząć logowania do Linear', + 'settings.integrations.linear.toast.disconnectFailed': 'Nie udało się rozłączyć Linear', + 'settings.integrations.linear.toast.authorizationFailed': 'Autoryzacja Linear wygasła. Kliknij Połącz, aby spróbować ponownie.', + 'settings.integrations.linear.avatarAlt.withName': 'Awatar Linear użytkownika {name}', + 'settings.integrations.linear.avatarAlt.fallback': 'Awatar Linear', + 'settings.integrations.linear.label.unknownUser': 'Nieznany użytkownik', + 'settings.integrations.linear.mapping.defaultProject': 'Domyślny projekt', + 'settings.integrations.linear.mapping.defaultProject.info': 'Nowe sesje ze zgłoszeń Linear używają tego projektu, chyba że zespół zgłoszenia ma własne przypisanie.', + 'settings.integrations.linear.mapping.defaultProject.placeholder': 'Brak', + 'settings.integrations.linear.mapping.defaultProject.aria': 'Domyślny projekt dla zgłoszeń Linear', + 'settings.integrations.linear.mapping.teams': 'Projekty zespołów', + 'settings.integrations.linear.mapping.teams.info': 'Opcjonalnie. Zgłoszenie z przypisanego zespołu otworzy się w tym projekcie zamiast w domyślnym.', + 'settings.integrations.linear.mapping.teams.useDefault': 'Użyj domyślnego', + 'settings.integrations.linear.mapping.teams.aria': 'Projekt dla zespołu Linear {team}', + 'settings.integrations.linear.mapping.emptyProjects': 'Najpierw dodaj projekt, a potem przypisz zespoły Linear.', + 'settings.integrations.linear.mapping.emptyTeams': 'Ten obszar Linear nie ma zespołów.', + 'settings.integrations.linear.mapping.loadFailed': 'Nie udało się wczytać przypisania projektów Linear.', + 'settings.integrations.linear.sessionComments.label': 'Komentarze o sesji', + 'settings.integrations.linear.sessionComments.info': 'Dodaje komentarz do zgłoszenia, gdy sesja się zaczyna, kończy lub kończy błędem. Komentarze pojawiają się tylko wtedy, gdy ten serwer ma publiczny adres, żeby link otwierał sesję każdemu.', + 'settings.integrations.linear.sessionComments.aria': 'Publikuj komentarze o stanie sesji w Linear', + 'settings.integrations.linear.sessionComments.loadFailed': 'Nie udało się wczytać ustawień komentarzy Linear.', + 'settings.magicPrompts.sidebar.group.linear': 'Linear', + 'settings.magicPrompts.sidebar.item.linearIssueReview': 'Przegląd zgłoszenia', + 'settings.magicPrompts.page.group.linearIssueReview.title': 'Przegląd zgłoszenia', + 'settings.magicPrompts.page.group.linearIssueReview.description': 'Prompty używane przy starcie sesji ze zgłoszenia Linear: widoczna wiadomość użytkownika i ukryte instrukcje.', + }, + 'pt-BR': { + 'settings.integrations.firstParty.title': 'Integrações nativas', + 'settings.integrations.firstParty.info': 'Logins dos serviços inclusos no OpenChamber. O login fica neste computador para que a web, o app desktop e um celular emparelhado o compartilhem.', + 'settings.integrations.linear.title': 'Linear', + 'settings.integrations.linear.description': 'Conecte espaços do Linear a este servidor OpenChamber.', + 'settings.integrations.linear.info': 'Conecte um ou mais espaços do Linear. O OpenChamber guarda os logins neste computador para que a web, o app desktop e um celular emparelhado os compartilhem.', + 'settings.integrations.linear.status.notConnected': 'Não conectado', + 'settings.integrations.linear.status.connected': 'Conectado', + 'settings.integrations.linear.status.waiting': 'Aguardando', + 'settings.integrations.linear.actions.connect': 'Conectar', + 'settings.integrations.linear.actions.disconnect': 'Desconectar', + 'settings.integrations.linear.actions.addWorkspace': 'Adicionar workspace', + 'settings.integrations.linear.actions.switchTo': 'Alternar para', + 'settings.integrations.linear.label.otherWorkspaces': 'Outros workspaces', + 'settings.integrations.linear.flow.title': 'Aguardando o Linear', + 'settings.integrations.linear.flow.description': 'Conclua o login na aba do navegador que acabou de abrir.', + 'settings.integrations.linear.flow.waiting': 'Aguardando autorização…', + 'settings.integrations.linear.toast.connected': 'Linear conectado', + 'settings.integrations.linear.toast.disconnected': 'Linear desconectado', + 'settings.integrations.linear.toast.workspaceSwitched': 'Workspace do Linear alterado', + 'settings.integrations.linear.toast.workspaceSwitchFailed': 'Não foi possível alternar o workspace do Linear', + 'settings.integrations.linear.toast.startConnectFailed': 'Não foi possível iniciar o login no Linear', + 'settings.integrations.linear.toast.disconnectFailed': 'Não foi possível desconectar o Linear', + 'settings.integrations.linear.toast.authorizationFailed': 'A autorização do Linear expirou. Clique em Conectar para tentar de novo.', + 'settings.integrations.linear.avatarAlt.withName': 'Avatar do Linear de {name}', + 'settings.integrations.linear.avatarAlt.fallback': 'Avatar do Linear', + 'settings.integrations.linear.label.unknownUser': 'Usuário desconhecido', + 'settings.integrations.linear.mapping.defaultProject': 'Projeto padrão', + 'settings.integrations.linear.mapping.defaultProject.info': 'Novas sessões a partir de issues do Linear usam este projeto, a menos que a equipe da issue tenha o próprio mapeamento.', + 'settings.integrations.linear.mapping.defaultProject.placeholder': 'Nenhum', + 'settings.integrations.linear.mapping.defaultProject.aria': 'Projeto padrão para issues do Linear', + 'settings.integrations.linear.mapping.teams': 'Projetos por equipe', + 'settings.integrations.linear.mapping.teams.info': 'Opcional. Uma issue de uma equipe mapeada abre nesse projeto em vez do padrão.', + 'settings.integrations.linear.mapping.teams.useDefault': 'Usar o padrão', + 'settings.integrations.linear.mapping.teams.aria': 'Projeto para a equipe do Linear {team}', + 'settings.integrations.linear.mapping.emptyProjects': 'Adicione um projeto primeiro e depois mapeie as equipes do Linear.', + 'settings.integrations.linear.mapping.emptyTeams': 'Este espaço do Linear não tem equipes.', + 'settings.integrations.linear.mapping.loadFailed': 'Não foi possível carregar o mapeamento de projetos do Linear.', + 'settings.integrations.linear.sessionComments.label': 'Comentários de sessão', + 'settings.integrations.linear.sessionComments.info': 'Comenta na issue quando uma sessão começa, termina ou falha. Os comentários só são publicados se este servidor tiver um endereço público, para que o link abra a sessão para todos.', + 'settings.integrations.linear.sessionComments.aria': 'Publicar comentários de status de sessão no Linear', + 'settings.integrations.linear.sessionComments.loadFailed': 'Não foi possível carregar as configurações de comentários do Linear.', + 'settings.magicPrompts.sidebar.group.linear': 'Linear', + 'settings.magicPrompts.sidebar.item.linearIssueReview': 'Revisão de issue', + 'settings.magicPrompts.page.group.linearIssueReview.title': 'Revisão de issue', + 'settings.magicPrompts.page.group.linearIssueReview.description': 'Prompts usados ao iniciar uma sessão a partir de uma issue do Linear: mensagem visível do usuário e instruções ocultas.', + }, + uk: { + 'settings.integrations.firstParty.title': 'Вбудовані інтеграції', + 'settings.integrations.firstParty.info': 'Входи до сервісів, що входять до OpenChamber. Логін лишається на цьому комп’ютері, тож веб, десктоп і спарений телефон користуються одним обліковим записом.', + 'settings.integrations.linear.title': 'Linear', + 'settings.integrations.linear.description': 'Підключіть Linear workspace до цього сервера OpenChamber. Можна кілька.', + 'settings.integrations.linear.info': 'Підключіть один або кілька Linear workspace. OpenChamber зберігає входи на цьому комп’ютері, тож веб, десктоп і спарений телефон користуються ними.', + 'settings.integrations.linear.status.notConnected': 'Не підключено', + 'settings.integrations.linear.status.connected': 'Підключено', + 'settings.integrations.linear.status.waiting': 'Очікування', + 'settings.integrations.linear.actions.connect': 'Підключити', + 'settings.integrations.linear.actions.disconnect': 'Відключити', + 'settings.integrations.linear.actions.addWorkspace': 'Додати workspace', + 'settings.integrations.linear.actions.switchTo': 'Перемкнути на', + 'settings.integrations.linear.label.otherWorkspaces': 'Інші workspace', + 'settings.integrations.linear.flow.title': 'Очікування Linear', + 'settings.integrations.linear.flow.description': 'Завершіть вхід у вкладці браузера, яка щойно відкрилась.', + 'settings.integrations.linear.flow.waiting': 'Очікування авторизації…', + 'settings.integrations.linear.toast.connected': 'Linear підключено', + 'settings.integrations.linear.toast.disconnected': 'Linear відключено', + 'settings.integrations.linear.toast.workspaceSwitched': 'Перемкнуто Linear workspace', + 'settings.integrations.linear.toast.workspaceSwitchFailed': 'Не вдалося перемкнути Linear workspace', + 'settings.integrations.linear.toast.startConnectFailed': 'Не вдалося почати вхід у Linear', + 'settings.integrations.linear.toast.disconnectFailed': 'Не вдалося відключити Linear', + 'settings.integrations.linear.toast.authorizationFailed': 'Авторизація Linear завершилась за часом. Натисніть Підключити ще раз.', + 'settings.integrations.linear.avatarAlt.withName': 'Аватар Linear для {name}', + 'settings.integrations.linear.avatarAlt.fallback': 'Аватар Linear', + 'settings.integrations.linear.label.unknownUser': 'Невідомий користувач', + 'settings.integrations.linear.mapping.defaultProject': 'Проєкт за замовчуванням', + 'settings.integrations.linear.mapping.defaultProject.info': 'Нові сесії з Linear issue використовують цей проєкт, якщо в команди issue немає власної прив’язки.', + 'settings.integrations.linear.mapping.defaultProject.placeholder': 'Немає', + 'settings.integrations.linear.mapping.defaultProject.aria': 'Проєкт за замовчуванням для Linear issue', + 'settings.integrations.linear.mapping.teams': 'Проєкти команд', + 'settings.integrations.linear.mapping.teams.info': 'Не обов’язково. Issue з прив’язаної команди відкриється в цьому проєкті, а не в типовому.', + 'settings.integrations.linear.mapping.teams.useDefault': 'Використати типовий', + 'settings.integrations.linear.mapping.teams.aria': 'Проєкт для команди Linear {team}', + 'settings.integrations.linear.mapping.emptyProjects': 'Спочатку додайте проєкт, потім прив’яжіть команди Linear.', + 'settings.integrations.linear.mapping.emptyTeams': 'У цьому робочому просторі Linear немає команд.', + 'settings.integrations.linear.mapping.loadFailed': 'Не вдалося завантажити прив’язку проєктів Linear.', + 'settings.integrations.linear.sessionComments.label': 'Коментарі про сесію', + 'settings.integrations.linear.sessionComments.info': 'Додає коментар до тікета, коли сесія починається, завершується або падає. Коментарі публікуються, лише якщо цей сервер має публічну адресу, щоб посилання відкривало сесію для всіх.', + 'settings.integrations.linear.sessionComments.aria': 'Публікувати коментарі про стан сесії в Linear', + 'settings.integrations.linear.sessionComments.loadFailed': 'Не вдалося завантажити налаштування коментарів Linear.', + 'settings.magicPrompts.sidebar.group.linear': 'Linear', + 'settings.magicPrompts.sidebar.item.linearIssueReview': 'Огляд issue', + 'settings.magicPrompts.page.group.linearIssueReview.title': 'Огляд issue', + 'settings.magicPrompts.page.group.linearIssueReview.description': 'Промпти для старту сесії з Linear issue: видиме повідомлення користувача та приховані інструкції.', + }, + 'zh-CN': { + 'settings.integrations.firstParty.title': '内置集成', + 'settings.integrations.firstParty.info': 'OpenChamber 自带服务的登录。登录保存在这台电脑上,网页、桌面应用和已配对的手机会共用它。', + 'settings.integrations.linear.title': 'Linear', + 'settings.integrations.linear.description': '将 Linear 工作区连接到此 OpenChamber 服务器。可以连接多个。', + 'settings.integrations.linear.info': '连接一个或多个 Linear 工作区。OpenChamber 把登录保存在这台电脑上,网页、桌面应用和已配对的手机会共用它们。', + 'settings.integrations.linear.status.notConnected': '未连接', + 'settings.integrations.linear.status.connected': '已连接', + 'settings.integrations.linear.status.waiting': '等待中', + 'settings.integrations.linear.actions.connect': '连接', + 'settings.integrations.linear.actions.disconnect': '断开', + 'settings.integrations.linear.actions.addWorkspace': '添加工作区', + 'settings.integrations.linear.actions.switchTo': '切换到', + 'settings.integrations.linear.label.otherWorkspaces': '其他工作区', + 'settings.integrations.linear.flow.title': '正在等待 Linear', + 'settings.integrations.linear.flow.description': '请在刚打开的浏览器标签页中完成登录。', + 'settings.integrations.linear.flow.waiting': '正在等待授权…', + 'settings.integrations.linear.toast.connected': '已连接 Linear', + 'settings.integrations.linear.toast.disconnected': '已断开 Linear', + 'settings.integrations.linear.toast.workspaceSwitched': '已切换 Linear 工作区', + 'settings.integrations.linear.toast.workspaceSwitchFailed': '无法切换 Linear 工作区', + 'settings.integrations.linear.toast.startConnectFailed': '无法开始 Linear 登录', + 'settings.integrations.linear.toast.disconnectFailed': '无法断开 Linear', + 'settings.integrations.linear.toast.authorizationFailed': 'Linear 授权已超时。请再次点击连接。', + 'settings.integrations.linear.avatarAlt.withName': '{name} 的 Linear 头像', + 'settings.integrations.linear.avatarAlt.fallback': 'Linear 头像', + 'settings.integrations.linear.label.unknownUser': '未知用户', + 'settings.integrations.linear.mapping.defaultProject': '默认项目', + 'settings.integrations.linear.mapping.defaultProject.info': '从 Linear Issue 新建的会话会使用此项目,除非该 Issue 所属团队有单独映射。', + 'settings.integrations.linear.mapping.defaultProject.placeholder': '无', + 'settings.integrations.linear.mapping.defaultProject.aria': 'Linear Issue 的默认项目', + 'settings.integrations.linear.mapping.teams': '团队项目', + 'settings.integrations.linear.mapping.teams.info': '可选。来自已映射团队的 Issue 会在该项目中打开,而不是默认项目。', + 'settings.integrations.linear.mapping.teams.useDefault': '使用默认', + 'settings.integrations.linear.mapping.teams.aria': 'Linear 团队 {team} 的项目', + 'settings.integrations.linear.mapping.emptyProjects': '请先添加一个项目,再映射 Linear 团队。', + 'settings.integrations.linear.mapping.emptyTeams': '此 Linear 工作区没有团队。', + 'settings.integrations.linear.mapping.loadFailed': '无法加载 Linear 项目映射。', + 'settings.integrations.linear.sessionComments.label': '会话评论', + 'settings.integrations.linear.sessionComments.info': '会话开始、完成或失败时在议题下留言。仅当此服务器拥有公网地址时才发布,这样链接才能让所有人打开该会话。', + 'settings.integrations.linear.sessionComments.aria': '将会话状态评论发布到 Linear', + 'settings.integrations.linear.sessionComments.loadFailed': '无法加载 Linear 评论设置。', + 'settings.magicPrompts.sidebar.group.linear': 'Linear', + 'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue 审查', + 'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue 审查', + 'settings.magicPrompts.page.group.linearIssueReview.description': '从 Linear Issue 开始会话时使用的提示词:可见用户消息 + 隐藏指令。', + }, + 'zh-TW': { + 'settings.integrations.firstParty.title': '內建整合', + 'settings.integrations.firstParty.info': 'OpenChamber 內建服務的登入。登入保存在這台電腦上,網頁、桌面應用程式和已配對的手機會共用它。', + 'settings.integrations.linear.title': 'Linear', + 'settings.integrations.linear.description': '將 Linear 工作區連線到此 OpenChamber 伺服器。可以連線多個。', + 'settings.integrations.linear.info': '連接一個或多個 Linear 工作區。OpenChamber 把登入保存在這台電腦上,網頁、桌面應用程式和已配對的手機會共用它們。', + 'settings.integrations.linear.status.notConnected': '未連線', + 'settings.integrations.linear.status.connected': '已連線', + 'settings.integrations.linear.status.waiting': '等待中', + 'settings.integrations.linear.actions.connect': '連線', + 'settings.integrations.linear.actions.disconnect': '中斷連線', + 'settings.integrations.linear.actions.addWorkspace': '新增工作區', + 'settings.integrations.linear.actions.switchTo': '切換到', + 'settings.integrations.linear.label.otherWorkspaces': '其他工作區', + 'settings.integrations.linear.flow.title': '正在等待 Linear', + 'settings.integrations.linear.flow.description': '請在剛開啟的瀏覽器分頁中完成登入。', + 'settings.integrations.linear.flow.waiting': '正在等待授權…', + 'settings.integrations.linear.toast.connected': '已連線 Linear', + 'settings.integrations.linear.toast.disconnected': '已中斷 Linear', + 'settings.integrations.linear.toast.workspaceSwitched': '已切換 Linear 工作區', + 'settings.integrations.linear.toast.workspaceSwitchFailed': '無法切換 Linear 工作區', + 'settings.integrations.linear.toast.startConnectFailed': '無法開始 Linear 登入', + 'settings.integrations.linear.toast.disconnectFailed': '無法中斷 Linear', + 'settings.integrations.linear.toast.authorizationFailed': 'Linear 授權已逾時。請再次按連線。', + 'settings.integrations.linear.avatarAlt.withName': '{name} 的 Linear 頭像', + 'settings.integrations.linear.avatarAlt.fallback': 'Linear 頭像', + 'settings.integrations.linear.label.unknownUser': '未知使用者', + 'settings.integrations.linear.mapping.defaultProject': '預設專案', + 'settings.integrations.linear.mapping.defaultProject.info': '從 Linear Issue 新增的會話會使用此專案,除非該 Issue 所屬團隊有單獨對應。', + 'settings.integrations.linear.mapping.defaultProject.placeholder': '無', + 'settings.integrations.linear.mapping.defaultProject.aria': 'Linear Issue 的預設專案', + 'settings.integrations.linear.mapping.teams': '團隊專案', + 'settings.integrations.linear.mapping.teams.info': '選用。來自已對應團隊的 Issue 會在該專案中開啟,而不是預設專案。', + 'settings.integrations.linear.mapping.teams.useDefault': '使用預設', + 'settings.integrations.linear.mapping.teams.aria': 'Linear 團隊 {team} 的專案', + 'settings.integrations.linear.mapping.emptyProjects': '請先新增一個專案,再對應 Linear 團隊。', + 'settings.integrations.linear.mapping.emptyTeams': '此 Linear 工作區沒有團隊。', + 'settings.integrations.linear.mapping.loadFailed': '無法載入 Linear 專案對應。', + 'settings.integrations.linear.sessionComments.label': '工作階段留言', + 'settings.integrations.linear.sessionComments.info': '工作階段開始、完成或失敗時在議題留言。僅在這台伺服器有公開位址時才發布,這樣連結才能讓所有人開啟該工作階段。', + 'settings.integrations.linear.sessionComments.aria': '將工作階段狀態留言發布到 Linear', + 'settings.integrations.linear.sessionComments.loadFailed': '無法載入 Linear 留言設定。', + 'settings.magicPrompts.sidebar.group.linear': 'Linear', + 'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue 審查', + 'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue 審查', + 'settings.magicPrompts.page.group.linearIssueReview.description': '從 Linear Issue 開始會話時使用的提示詞:可見使用者訊息 + 隱藏指令。', + }, + tr: { + 'settings.integrations.firstParty.title': 'Yerleşik entegrasyonlar', + 'settings.integrations.firstParty.info': 'OpenChamber ile gelen hizmetlerin oturumları. Giriş bu bilgisayarda kalır; web, masaüstü ve eşlenen telefon paylaşır.', + 'settings.integrations.linear.title': 'Linear', + 'settings.integrations.linear.description': 'Linear çalışma alanlarını bu OpenChamber sunucusuna bağla.', + 'settings.integrations.linear.info': 'Bir veya daha fazla Linear çalışma alanı bağla. OpenChamber girişleri bu bilgisayarda tutar; web, masaüstü ve eşlenen telefon paylaşır.', + 'settings.integrations.linear.status.notConnected': 'Bağlı değil', + 'settings.integrations.linear.status.connected': 'Bağlı', + 'settings.integrations.linear.status.waiting': 'Bekleniyor', + 'settings.integrations.linear.actions.connect': 'Bağlan', + 'settings.integrations.linear.actions.disconnect': 'Bağlantıyı kes', + 'settings.integrations.linear.actions.addWorkspace': 'Çalışma alanı ekle', + 'settings.integrations.linear.actions.switchTo': 'Şuna geç', + 'settings.integrations.linear.label.otherWorkspaces': 'Diğer çalışma alanları', + 'settings.integrations.linear.flow.title': 'Linear bekleniyor', + 'settings.integrations.linear.flow.description': 'Az önce açılan tarayıcı sekmesinde girişi bitir.', + 'settings.integrations.linear.flow.waiting': 'Yetkilendirme bekleniyor…', + 'settings.integrations.linear.toast.connected': 'Linear bağlandı', + 'settings.integrations.linear.toast.disconnected': 'Linear bağlantısı kesildi', + 'settings.integrations.linear.toast.workspaceSwitched': 'Linear çalışma alanı değiştirildi', + 'settings.integrations.linear.toast.workspaceSwitchFailed': 'Linear çalışma alanı değiştirilemedi', + 'settings.integrations.linear.toast.startConnectFailed': 'Linear girişi başlatılamadı', + 'settings.integrations.linear.toast.disconnectFailed': 'Linear bağlantısı kesilemedi', + 'settings.integrations.linear.toast.authorizationFailed': "Linear yetkilendirmesi zaman aşımına uğradı. Yeniden bağlanmak için Bağlan'a bas.", + 'settings.integrations.linear.avatarAlt.withName': '{name} için Linear avatarı', + 'settings.integrations.linear.avatarAlt.fallback': 'Linear avatarı', + 'settings.integrations.linear.label.unknownUser': 'Bilinmeyen kullanıcı', + 'settings.integrations.linear.mapping.defaultProject': 'Varsayılan proje', + 'settings.integrations.linear.mapping.defaultProject.info': "Linear issue'larından yeni session'lar, ekibin kendi eşlemesi yoksa bu projeyi kullanır.", + 'settings.integrations.linear.mapping.defaultProject.placeholder': 'Yok', + 'settings.integrations.linear.mapping.defaultProject.aria': "Linear issue'ları için varsayılan proje", + 'settings.integrations.linear.mapping.teams': 'Ekip projeleri', + 'settings.integrations.linear.mapping.teams.info': 'İsteğe bağlı. Eşlenen bir ekipten gelen issue varsayılan yerine o projede açılır.', + 'settings.integrations.linear.mapping.teams.useDefault': 'Varsayılanı kullan', + 'settings.integrations.linear.mapping.teams.aria': 'Linear ekibi {team} için proje', + 'settings.integrations.linear.mapping.emptyProjects': 'Önce bir proje ekle, sonra Linear ekiplerini ona eşle.', + 'settings.integrations.linear.mapping.emptyTeams': 'Bu Linear çalışma alanında ekip yok.', + 'settings.integrations.linear.mapping.loadFailed': 'Linear proje eşlemesi yüklenemedi.', + 'settings.integrations.linear.sessionComments.label': 'Oturum yorumları', + 'settings.integrations.linear.sessionComments.info': 'Bir oturum başladığında, bittiğinde veya başarısız olduğunda göreve yorum ekler. Bağlantının herkeste açılabilmesi için yorumlar yalnızca bu sunucunun genel bir adresi varsa gönderilir.', + 'settings.integrations.linear.sessionComments.aria': 'Oturum durumu yorumlarını Linear’a gönder', + 'settings.integrations.linear.sessionComments.loadFailed': 'Linear yorum ayarları yüklenemedi.', + 'settings.magicPrompts.sidebar.group.linear': 'Linear', + 'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue incelemesi', + 'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue incelemesi', + 'settings.magicPrompts.page.group.linearIssueReview.description': "Linear issue'dan session başlatırken kullanılan prompt'lar: görünen kullanıcı mesajı + gizli talimatlar.", + }, +} as const; diff --git a/packages/ui/src/lib/i18n/messages/linear-issue-picker.i18n.test.ts b/packages/ui/src/lib/i18n/messages/linear-issue-picker.i18n.test.ts new file mode 100644 index 00000000..7fc0303e --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/linear-issue-picker.i18n.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from 'bun:test'; +import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; + +const locales = ['en', 'de', 'fr', 'es', 'ja', 'pt-BR', 'uk', 'ko', 'pl', 'zh-CN', 'zh-TW', 'tr'] as const; + +const requiredKeys = [ + 'chat.chatInput.actions.linkLinearIssue', + 'chat.chatInput.linked.linearIssue.openInBrowserAria', + 'chat.chatInput.linked.linearIssue.removeAria', + 'session.linearIssuePicker.title', + 'session.linearIssuePicker.description', + 'session.linearIssuePicker.searchPlaceholder', + 'session.linearIssuePicker.empty.notConnected', + 'session.linearIssuePicker.empty.runtimeUnavailable', + 'session.linearIssuePicker.empty.noIssuesFound', + 'session.linearIssuePicker.empty.noOpenIssuesFound', + 'session.linearIssuePicker.loading.issues', + 'session.linearIssuePicker.loading.more', + 'session.linearIssuePicker.actions.openSettings', + 'session.linearIssuePicker.actions.useIssue', + 'session.linearIssuePicker.actions.loadMore', + 'session.linearIssuePicker.actions.openInLinearAria', + 'session.linearIssuePicker.toast.loadMoreFailed', + 'session.linearIssuePicker.toast.loadIssueDetailsFailed', + 'session.linearIssuePicker.error.notConnected', + 'session.linearIssuePicker.error.runtimeUnavailable', + 'session.linearIssuePicker.error.issueNotFound', + 'chat.chatInput.actions.newSessionFromLinearIssue', + 'session.linearIssuePicker.title.createSession', + 'session.linearIssuePicker.description.createSession', + 'session.linearIssuePicker.error.noMappedProject', + 'session.linearIssuePicker.error.noModelSelected', + 'session.linearIssuePicker.toast.sendContextFailed', + 'session.linearIssuePicker.toast.sessionCreated', + 'session.linearIssuePicker.toast.startSessionFailed', + 'session.linearIssuePicker.actions.sectionTitle', + 'session.linearIssuePicker.actions.toggleWorktreeAria', + 'session.linearIssuePicker.actions.createInWorktree', + 'session.linearIssuePicker.actions.refresh', + 'chat.workStatus.linkedIssues.openLinear', + 'session.newWorktree.actions.startFromLinearIssue', + 'session.newWorktree.fromLinearIssue', + 'session.newWorktree.error.sendLinearContextFailed', +] as const; + +describe('linear issue picker translations', () => { + test('provides every required key in every supported locale', () => { + const english = linearIssuePickerI18n.en; + for (const locale of locales) { + for (const key of requiredKeys) { + const value = linearIssuePickerI18n[locale][key]; + expect(value).toBeTruthy(); + if (locale !== 'en') { + expect(value).not.toBe(english[key]); + } + } + } + }); +}); diff --git a/packages/ui/src/lib/i18n/messages/linear-issue-picker.i18n.ts b/packages/ui/src/lib/i18n/messages/linear-issue-picker.i18n.ts new file mode 100644 index 00000000..4a7e70bb --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/linear-issue-picker.i18n.ts @@ -0,0 +1,471 @@ +/** Linear issue picker / composer strings — merged into each locale's main dictionary. */ +export const linearIssuePickerI18n = { + en: { + 'chat.chatInput.actions.linkLinearIssue': 'Link Linear Issue', + 'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Open issue in Linear', + 'chat.chatInput.linked.linearIssue.removeAria': 'Remove linked Linear issue', + 'session.linearIssuePicker.title': 'Link Linear Issue', + 'session.linearIssuePicker.description': 'Select an issue from your connected Linear workspace.', + 'session.linearIssuePicker.searchPlaceholder': 'Search by title, identifier, or Linear URL', + 'session.linearIssuePicker.empty.notConnected': 'Linear is not connected. Connect it in Settings → Integrations.', + 'session.linearIssuePicker.empty.runtimeUnavailable': 'Linear is not available in this app.', + 'session.linearIssuePicker.empty.noIssuesFound': 'No issues found', + 'session.linearIssuePicker.empty.noOpenIssuesFound': 'No open issues found', + 'session.linearIssuePicker.loading.issues': 'Loading issues...', + 'session.linearIssuePicker.loading.more': 'Loading...', + 'session.linearIssuePicker.actions.openSettings': 'Open settings', + 'session.linearIssuePicker.actions.useIssue': 'Use {identifier}', + 'session.linearIssuePicker.actions.loadMore': 'Load more', + 'session.linearIssuePicker.actions.openInLinearAria': 'Open in Linear', + 'session.linearIssuePicker.toast.loadMoreFailed': 'Failed to load more issues', + 'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Failed to load issue details', + 'session.linearIssuePicker.error.notConnected': 'Linear not connected', + 'session.linearIssuePicker.error.runtimeUnavailable': 'Linear is not available in this app', + 'session.linearIssuePicker.error.issueNotFound': 'Issue not found', + 'chat.chatInput.actions.newSessionFromLinearIssue': 'New Session From Linear Issue', + 'session.linearIssuePicker.title.createSession': 'New Session From Linear Issue', + 'session.linearIssuePicker.description.createSession': 'Creates a session in the project mapped to this Linear team, with the issue as the first prompt.', + 'session.linearIssuePicker.error.noMappedProject': 'Map this Linear team to a project in Settings → Integrations', + 'session.linearIssuePicker.error.noModelSelected': 'No model selected', + 'session.linearIssuePicker.toast.sendContextFailed': 'Failed to send issue context', + 'session.linearIssuePicker.toast.sessionCreated': 'Session created from issue', + 'session.linearIssuePicker.toast.startSessionFailed': 'Failed to start session', + 'session.linearIssuePicker.actions.sectionTitle': 'Actions', + 'session.linearIssuePicker.actions.toggleWorktreeAria': 'Toggle worktree', + 'session.linearIssuePicker.actions.createInWorktree': 'Create in worktree', + 'session.linearIssuePicker.actions.refresh': 'Refresh', + 'chat.workStatus.linkedIssues.openLinear': 'Open {identifier} in Linear', + 'session.newWorktree.actions.startFromLinearIssue': 'Start from Linear Issue', + 'session.newWorktree.fromLinearIssue': 'From {identifier}: {title}', + 'session.newWorktree.error.sendLinearContextFailed': 'Failed to send Linear context', + }, + de: { + 'chat.chatInput.actions.linkLinearIssue': 'Linear-Issue verknüpfen', + 'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Issue in Linear öffnen', + 'chat.chatInput.linked.linearIssue.removeAria': 'Verknüpftes Linear-Issue entfernen', + 'session.linearIssuePicker.title': 'Linear-Issue verknüpfen', + 'session.linearIssuePicker.description': 'Wähle ein Issue aus deinem verbundenen Linear-Workspace.', + 'session.linearIssuePicker.searchPlaceholder': 'Nach Titel, Kennung oder Linear-URL suchen', + 'session.linearIssuePicker.empty.notConnected': 'Linear ist nicht verbunden. Verbinde es unter Einstellungen → Integrationen.', + 'session.linearIssuePicker.empty.runtimeUnavailable': 'Linear ist in dieser App nicht verfügbar.', + 'session.linearIssuePicker.empty.noIssuesFound': 'Keine Issues gefunden', + 'session.linearIssuePicker.empty.noOpenIssuesFound': 'Keine offenen Issues gefunden', + 'session.linearIssuePicker.loading.issues': 'Issues werden geladen...', + 'session.linearIssuePicker.loading.more': 'Wird geladen...', + 'session.linearIssuePicker.actions.openSettings': 'Einstellungen öffnen', + 'session.linearIssuePicker.actions.useIssue': '{identifier} verwenden', + 'session.linearIssuePicker.actions.loadMore': 'Mehr laden', + 'session.linearIssuePicker.actions.openInLinearAria': 'In Linear öffnen', + 'session.linearIssuePicker.toast.loadMoreFailed': 'Weitere Issues konnten nicht geladen werden', + 'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Issue-Details konnten nicht geladen werden', + 'session.linearIssuePicker.error.notConnected': 'Linear nicht verbunden', + 'session.linearIssuePicker.error.runtimeUnavailable': 'Linear ist in dieser App nicht verfügbar', + 'session.linearIssuePicker.error.issueNotFound': 'Issue nicht gefunden', + 'chat.chatInput.actions.newSessionFromLinearIssue': 'Neue Sitzung aus Linear-Issue', + 'session.linearIssuePicker.title.createSession': 'Neue Sitzung aus Linear-Issue', + 'session.linearIssuePicker.description.createSession': 'Erstellt eine Sitzung im diesem Linear-Team zugeordneten Projekt, mit dem Issue als erstem Prompt.', + 'session.linearIssuePicker.error.noMappedProject': 'Ordne dieses Linear-Team in Einstellungen → Integrationen einem Projekt zu', + 'session.linearIssuePicker.error.noModelSelected': 'Kein Modell ausgewählt', + 'session.linearIssuePicker.toast.sendContextFailed': 'Issue-Kontext konnte nicht gesendet werden', + 'session.linearIssuePicker.toast.sessionCreated': 'Sitzung aus Issue erstellt', + 'session.linearIssuePicker.toast.startSessionFailed': 'Sitzung konnte nicht gestartet werden', + 'session.linearIssuePicker.actions.sectionTitle': 'Aktionen', + 'session.linearIssuePicker.actions.toggleWorktreeAria': 'Worktree umschalten', + 'session.linearIssuePicker.actions.createInWorktree': 'In Worktree erstellen', + 'session.linearIssuePicker.actions.refresh': 'Aktualisieren', + 'chat.workStatus.linkedIssues.openLinear': '{identifier} in Linear öffnen', + 'session.newWorktree.actions.startFromLinearIssue': 'Von Linear-Issue starten', + 'session.newWorktree.fromLinearIssue': 'Von {identifier}: {title}', + 'session.newWorktree.error.sendLinearContextFailed': 'Linear-Kontext konnte nicht gesendet werden', + }, + fr: { + 'chat.chatInput.actions.linkLinearIssue': 'Lier un ticket Linear', + 'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Ouvrir le ticket dans Linear', + 'chat.chatInput.linked.linearIssue.removeAria': 'Retirer le ticket Linear lié', + 'session.linearIssuePicker.title': 'Lier un ticket Linear', + 'session.linearIssuePicker.description': 'Choisissez un ticket dans votre espace Linear connecté.', + 'session.linearIssuePicker.searchPlaceholder': 'Rechercher par titre, identifiant ou URL Linear', + 'session.linearIssuePicker.empty.notConnected': 'Linear n’est pas connecté. Connectez-le dans Paramètres → Intégrations.', + 'session.linearIssuePicker.empty.runtimeUnavailable': 'Linear n’est pas disponible dans cette application.', + 'session.linearIssuePicker.empty.noIssuesFound': 'Aucun ticket trouvé', + 'session.linearIssuePicker.empty.noOpenIssuesFound': 'Aucun ticket ouvert trouvé', + 'session.linearIssuePicker.loading.issues': 'Chargement des tickets...', + 'session.linearIssuePicker.loading.more': 'Chargement...', + 'session.linearIssuePicker.actions.openSettings': 'Ouvrir les paramètres', + 'session.linearIssuePicker.actions.useIssue': 'Utiliser {identifier}', + 'session.linearIssuePicker.actions.loadMore': 'Charger plus', + 'session.linearIssuePicker.actions.openInLinearAria': 'Ouvrir dans Linear', + 'session.linearIssuePicker.toast.loadMoreFailed': 'Impossible de charger d’autres tickets', + 'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Impossible de charger les détails du ticket', + 'session.linearIssuePicker.error.notConnected': 'Linear non connecté', + 'session.linearIssuePicker.error.runtimeUnavailable': 'Linear n’est pas disponible dans cette application', + 'session.linearIssuePicker.error.issueNotFound': 'Ticket introuvable', + 'chat.chatInput.actions.newSessionFromLinearIssue': 'Nouvelle session depuis un ticket Linear', + 'session.linearIssuePicker.title.createSession': 'Nouvelle session depuis un ticket Linear', + 'session.linearIssuePicker.description.createSession': 'Crée une session dans le projet associé à cette équipe Linear, avec le ticket comme premier message.', + 'session.linearIssuePicker.error.noMappedProject': 'Associez cette équipe Linear à un projet dans Paramètres → Intégrations', + 'session.linearIssuePicker.error.noModelSelected': 'Aucun modèle sélectionné', + 'session.linearIssuePicker.toast.sendContextFailed': 'Impossible d’envoyer le contexte du ticket', + 'session.linearIssuePicker.toast.sessionCreated': 'Session créée depuis le ticket', + 'session.linearIssuePicker.toast.startSessionFailed': 'Impossible de démarrer la session', + 'session.linearIssuePicker.actions.sectionTitle': 'Actions disponibles', + 'session.linearIssuePicker.actions.toggleWorktreeAria': 'Activer ou désactiver le worktree', + 'session.linearIssuePicker.actions.createInWorktree': 'Créer dans un worktree', + 'session.linearIssuePicker.actions.refresh': 'Actualiser', + 'chat.workStatus.linkedIssues.openLinear': 'Ouvrir {identifier} dans Linear', + 'session.newWorktree.actions.startFromLinearIssue': 'Démarrer depuis un ticket Linear', + 'session.newWorktree.fromLinearIssue': 'Depuis {identifier} : {title}', + 'session.newWorktree.error.sendLinearContextFailed': 'Impossible d’envoyer le contexte Linear', + }, + es: { + 'chat.chatInput.actions.linkLinearIssue': 'Vincular issue de Linear', + 'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Abrir issue en Linear', + 'chat.chatInput.linked.linearIssue.removeAria': 'Quitar issue de Linear vinculado', + 'session.linearIssuePicker.title': 'Vincular issue de Linear', + 'session.linearIssuePicker.description': 'Elige un issue del espacio de Linear conectado.', + 'session.linearIssuePicker.searchPlaceholder': 'Buscar por título, identificador o URL de Linear', + 'session.linearIssuePicker.empty.notConnected': 'Linear no está conectado. Conéctalo en Ajustes → Integraciones.', + 'session.linearIssuePicker.empty.runtimeUnavailable': 'Linear no está disponible en esta aplicación.', + 'session.linearIssuePicker.empty.noIssuesFound': 'No se encontraron issues', + 'session.linearIssuePicker.empty.noOpenIssuesFound': 'No se encontraron issues abiertos', + 'session.linearIssuePicker.loading.issues': 'Cargando issues...', + 'session.linearIssuePicker.loading.more': 'Cargando...', + 'session.linearIssuePicker.actions.openSettings': 'Abrir ajustes', + 'session.linearIssuePicker.actions.useIssue': 'Usar {identifier}', + 'session.linearIssuePicker.actions.loadMore': 'Cargar más', + 'session.linearIssuePicker.actions.openInLinearAria': 'Abrir en Linear', + 'session.linearIssuePicker.toast.loadMoreFailed': 'No se pudieron cargar más issues', + 'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'No se pudieron cargar los detalles del issue', + 'session.linearIssuePicker.error.notConnected': 'Linear no conectado', + 'session.linearIssuePicker.error.runtimeUnavailable': 'Linear no está disponible en esta aplicación', + 'session.linearIssuePicker.error.issueNotFound': 'Issue no encontrado', + 'chat.chatInput.actions.newSessionFromLinearIssue': 'Nueva sesión desde un issue de Linear', + 'session.linearIssuePicker.title.createSession': 'Nueva sesión desde un issue de Linear', + 'session.linearIssuePicker.description.createSession': 'Crea una sesión en el proyecto asignado a este equipo de Linear, con el issue como primer mensaje.', + 'session.linearIssuePicker.error.noMappedProject': 'Asigna este equipo de Linear a un proyecto en Ajustes → Integraciones', + 'session.linearIssuePicker.error.noModelSelected': 'Ningún modelo seleccionado', + 'session.linearIssuePicker.toast.sendContextFailed': 'No se pudo enviar el contexto del issue', + 'session.linearIssuePicker.toast.sessionCreated': 'Sesión creada desde el issue', + 'session.linearIssuePicker.toast.startSessionFailed': 'No se pudo iniciar la sesión', + 'session.linearIssuePicker.actions.sectionTitle': 'Acciones', + 'session.linearIssuePicker.actions.toggleWorktreeAria': 'Activar o desactivar worktree', + 'session.linearIssuePicker.actions.createInWorktree': 'Crear en worktree', + 'session.linearIssuePicker.actions.refresh': 'Actualizar', + 'chat.workStatus.linkedIssues.openLinear': 'Abrir {identifier} en Linear', + 'session.newWorktree.actions.startFromLinearIssue': 'Empezar desde un issue de Linear', + 'session.newWorktree.fromLinearIssue': 'Desde {identifier}: {title}', + 'session.newWorktree.error.sendLinearContextFailed': 'No se pudo enviar el contexto de Linear', + }, + ja: { + 'chat.chatInput.actions.linkLinearIssue': 'Linear Issueをリンク', + 'chat.chatInput.linked.linearIssue.openInBrowserAria': 'LinearでIssueを開く', + 'chat.chatInput.linked.linearIssue.removeAria': 'リンクしたLinear Issueを削除', + 'session.linearIssuePicker.title': 'Linear Issueをリンク', + 'session.linearIssuePicker.description': '接続中のLinearワークスペースからIssueを選びます。', + 'session.linearIssuePicker.searchPlaceholder': 'タイトル、識別子、またはLinearのURLで検索', + 'session.linearIssuePicker.empty.notConnected': 'Linearは未接続です。設定 → 連携 で接続してください。', + 'session.linearIssuePicker.empty.runtimeUnavailable': 'このアプリではLinearを利用できません。', + 'session.linearIssuePicker.empty.noIssuesFound': 'Issueが見つかりません', + 'session.linearIssuePicker.empty.noOpenIssuesFound': '未完了のIssueはありません', + 'session.linearIssuePicker.loading.issues': 'Issueを読み込み中...', + 'session.linearIssuePicker.loading.more': '読み込み中...', + 'session.linearIssuePicker.actions.openSettings': '設定を開く', + 'session.linearIssuePicker.actions.useIssue': '{identifier} を使う', + 'session.linearIssuePicker.actions.loadMore': 'さらに読み込む', + 'session.linearIssuePicker.actions.openInLinearAria': 'Linearで開く', + 'session.linearIssuePicker.toast.loadMoreFailed': 'これ以上のIssueを読み込めませんでした', + 'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Issueの詳細を読み込めませんでした', + 'session.linearIssuePicker.error.notConnected': 'Linear未接続', + 'session.linearIssuePicker.error.runtimeUnavailable': 'このアプリではLinearを利用できません', + 'session.linearIssuePicker.error.issueNotFound': 'Issueが見つかりません', + 'chat.chatInput.actions.newSessionFromLinearIssue': 'Linear Issueから新しいセッション', + 'session.linearIssuePicker.title.createSession': 'Linear Issueから新しいセッション', + 'session.linearIssuePicker.description.createSession': 'このLinearチームに割り当てたプロジェクトでセッションを作り、Issueを最初のプロンプトにします。', + 'session.linearIssuePicker.error.noMappedProject': '設定 → 連携 でこのLinearチームをプロジェクトに割り当ててください', + 'session.linearIssuePicker.error.noModelSelected': 'モデルが選択されていません', + 'session.linearIssuePicker.toast.sendContextFailed': 'Issueのコンテキストを送信できませんでした', + 'session.linearIssuePicker.toast.sessionCreated': 'Issueからセッションを作成しました', + 'session.linearIssuePicker.toast.startSessionFailed': 'セッションを開始できませんでした', + 'session.linearIssuePicker.actions.sectionTitle': '操作', + 'session.linearIssuePicker.actions.toggleWorktreeAria': 'ワークツリーを切り替え', + 'session.linearIssuePicker.actions.createInWorktree': 'ワークツリーで作成', + 'session.linearIssuePicker.actions.refresh': '更新', + 'chat.workStatus.linkedIssues.openLinear': 'Linearで {identifier} を開く', + 'session.newWorktree.actions.startFromLinearIssue': 'Linear Issueから開始', + 'session.newWorktree.fromLinearIssue': '{identifier}: {title}から', + 'session.newWorktree.error.sendLinearContextFailed': 'Linearのコンテキストを送信できませんでした', + }, + 'pt-BR': { + 'chat.chatInput.actions.linkLinearIssue': 'Vincular issue do Linear', + 'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Abrir issue no Linear', + 'chat.chatInput.linked.linearIssue.removeAria': 'Remover issue do Linear vinculada', + 'session.linearIssuePicker.title': 'Vincular issue do Linear', + 'session.linearIssuePicker.description': 'Selecione uma issue do espaço Linear conectado.', + 'session.linearIssuePicker.searchPlaceholder': 'Buscar por título, identificador ou URL do Linear', + 'session.linearIssuePicker.empty.notConnected': 'O Linear não está conectado. Conecte em Configurações → Integrações.', + 'session.linearIssuePicker.empty.runtimeUnavailable': 'O Linear não está disponível neste app.', + 'session.linearIssuePicker.empty.noIssuesFound': 'Nenhuma issue encontrada', + 'session.linearIssuePicker.empty.noOpenIssuesFound': 'Nenhuma issue aberta encontrada', + 'session.linearIssuePicker.loading.issues': 'Carregando issues...', + 'session.linearIssuePicker.loading.more': 'Carregando...', + 'session.linearIssuePicker.actions.openSettings': 'Abrir configurações', + 'session.linearIssuePicker.actions.useIssue': 'Usar {identifier}', + 'session.linearIssuePicker.actions.loadMore': 'Carregar mais', + 'session.linearIssuePicker.actions.openInLinearAria': 'Abrir no Linear', + 'session.linearIssuePicker.toast.loadMoreFailed': 'Não foi possível carregar mais issues', + 'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Não foi possível carregar os detalhes da issue', + 'session.linearIssuePicker.error.notConnected': 'Linear não conectado', + 'session.linearIssuePicker.error.runtimeUnavailable': 'O Linear não está disponível neste app', + 'session.linearIssuePicker.error.issueNotFound': 'Issue não encontrada', + 'chat.chatInput.actions.newSessionFromLinearIssue': 'Nova sessão a partir de uma issue do Linear', + 'session.linearIssuePicker.title.createSession': 'Nova sessão a partir de uma issue do Linear', + 'session.linearIssuePicker.description.createSession': 'Cria uma sessão no projeto associado a esta equipe do Linear, com a issue como o primeiro prompt.', + 'session.linearIssuePicker.error.noMappedProject': 'Associe esta equipe do Linear a um projeto em Configurações → Integrações', + 'session.linearIssuePicker.error.noModelSelected': 'Nenhum modelo selecionado', + 'session.linearIssuePicker.toast.sendContextFailed': 'Não foi possível enviar o contexto da issue', + 'session.linearIssuePicker.toast.sessionCreated': 'Sessão criada a partir da issue', + 'session.linearIssuePicker.toast.startSessionFailed': 'Não foi possível iniciar a sessão', + 'session.linearIssuePicker.actions.sectionTitle': 'Ações', + 'session.linearIssuePicker.actions.toggleWorktreeAria': 'Ativar ou desativar worktree', + 'session.linearIssuePicker.actions.createInWorktree': 'Criar em worktree', + 'session.linearIssuePicker.actions.refresh': 'Atualizar', + 'chat.workStatus.linkedIssues.openLinear': 'Abrir {identifier} no Linear', + 'session.newWorktree.actions.startFromLinearIssue': 'Começar a partir de uma issue do Linear', + 'session.newWorktree.fromLinearIssue': 'De {identifier}: {title}', + 'session.newWorktree.error.sendLinearContextFailed': 'Não foi possível enviar o contexto do Linear', + }, + uk: { + 'chat.chatInput.actions.linkLinearIssue': 'Прив’язати Linear issue', + 'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Відкрити issue в Linear', + 'chat.chatInput.linked.linearIssue.removeAria': 'Прибрати прив’язаний Linear issue', + 'session.linearIssuePicker.title': 'Прив’язати Linear issue', + 'session.linearIssuePicker.description': 'Оберіть issue з підключеного робочого простору Linear.', + 'session.linearIssuePicker.searchPlaceholder': 'Пошук за назвою, ідентифікатором або URL Linear', + 'session.linearIssuePicker.empty.notConnected': 'Linear не підключено. Підключіть його в Налаштуваннях → Інтеграції.', + 'session.linearIssuePicker.empty.runtimeUnavailable': 'Linear недоступний у цьому застосунку.', + 'session.linearIssuePicker.empty.noIssuesFound': 'Issue не знайдено', + 'session.linearIssuePicker.empty.noOpenIssuesFound': 'Відкритих issue немає', + 'session.linearIssuePicker.loading.issues': 'Завантаження issue...', + 'session.linearIssuePicker.loading.more': 'Завантаження...', + 'session.linearIssuePicker.actions.openSettings': 'Відкрити налаштування', + 'session.linearIssuePicker.actions.useIssue': 'Використати {identifier}', + 'session.linearIssuePicker.actions.loadMore': 'Завантажити ще', + 'session.linearIssuePicker.actions.openInLinearAria': 'Відкрити в Linear', + 'session.linearIssuePicker.toast.loadMoreFailed': 'Не вдалося завантажити більше issue', + 'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Не вдалося завантажити деталі issue', + 'session.linearIssuePicker.error.notConnected': 'Linear не підключено', + 'session.linearIssuePicker.error.runtimeUnavailable': 'Linear недоступний у цьому застосунку', + 'session.linearIssuePicker.error.issueNotFound': 'Issue не знайдено', + 'chat.chatInput.actions.newSessionFromLinearIssue': 'Нова сесія з Linear issue', + 'session.linearIssuePicker.title.createSession': 'Нова сесія з Linear issue', + 'session.linearIssuePicker.description.createSession': 'Створює сесію в проєкті, прив’язаному до цієї команди Linear, з issue як першим запитом.', + 'session.linearIssuePicker.error.noMappedProject': 'Прив’яжіть цю команду Linear до проєкту в Налаштуваннях → Інтеграції', + 'session.linearIssuePicker.error.noModelSelected': 'Модель не вибрано', + 'session.linearIssuePicker.toast.sendContextFailed': 'Не вдалося надіслати контекст issue', + 'session.linearIssuePicker.toast.sessionCreated': 'Сесію створено з issue', + 'session.linearIssuePicker.toast.startSessionFailed': 'Не вдалося почати сесію', + 'session.linearIssuePicker.actions.sectionTitle': 'Дії', + 'session.linearIssuePicker.actions.toggleWorktreeAria': 'Перемкнути worktree', + 'session.linearIssuePicker.actions.createInWorktree': 'Створити у worktree', + 'session.linearIssuePicker.actions.refresh': 'Оновити', + 'chat.workStatus.linkedIssues.openLinear': 'Відкрити {identifier} у Linear', + 'session.newWorktree.actions.startFromLinearIssue': 'Почати з Linear issue', + 'session.newWorktree.fromLinearIssue': 'З {identifier}: {title}', + 'session.newWorktree.error.sendLinearContextFailed': 'Не вдалося надіслати контекст Linear', + }, + ko: { + 'chat.chatInput.actions.linkLinearIssue': 'Linear 이슈 연결', + 'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Linear에서 이슈 열기', + 'chat.chatInput.linked.linearIssue.removeAria': '연결된 Linear 이슈 제거', + 'session.linearIssuePicker.title': 'Linear 이슈 연결', + 'session.linearIssuePicker.description': '연결된 Linear 워크스페이스에서 이슈를 선택하세요.', + 'session.linearIssuePicker.searchPlaceholder': '제목, 식별자 또는 Linear URL로 검색', + 'session.linearIssuePicker.empty.notConnected': 'Linear가 연결되어 있지 않습니다. 설정 → 연동에서 연결하세요.', + 'session.linearIssuePicker.empty.runtimeUnavailable': '이 앱에서는 Linear를 사용할 수 없습니다.', + 'session.linearIssuePicker.empty.noIssuesFound': '이슈를 찾을 수 없습니다', + 'session.linearIssuePicker.empty.noOpenIssuesFound': '열린 이슈가 없습니다', + 'session.linearIssuePicker.loading.issues': '이슈를 불러오는 중...', + 'session.linearIssuePicker.loading.more': '불러오는 중...', + 'session.linearIssuePicker.actions.openSettings': '설정 열기', + 'session.linearIssuePicker.actions.useIssue': '{identifier} 사용', + 'session.linearIssuePicker.actions.loadMore': '더 보기', + 'session.linearIssuePicker.actions.openInLinearAria': 'Linear에서 열기', + 'session.linearIssuePicker.toast.loadMoreFailed': '이슈를 더 불러오지 못했습니다', + 'session.linearIssuePicker.toast.loadIssueDetailsFailed': '이슈 세부 정보를 불러오지 못했습니다', + 'session.linearIssuePicker.error.notConnected': 'Linear가 연결되지 않음', + 'session.linearIssuePicker.error.runtimeUnavailable': '이 앱에서는 Linear를 사용할 수 없습니다', + 'session.linearIssuePicker.error.issueNotFound': '이슈를 찾을 수 없습니다', + 'chat.chatInput.actions.newSessionFromLinearIssue': 'Linear 이슈로 새 세션 만들기', + 'session.linearIssuePicker.title.createSession': 'Linear 이슈로 새 세션 만들기', + 'session.linearIssuePicker.description.createSession': '이 Linear 팀에 연결한 프로젝트에서 세션을 만들고, 이슈를 첫 프롬프트로 넣습니다.', + 'session.linearIssuePicker.error.noMappedProject': '설정 → 연동에서 이 Linear 팀을 프로젝트에 연결하세요', + 'session.linearIssuePicker.error.noModelSelected': '모델이 선택되지 않았습니다', + 'session.linearIssuePicker.toast.sendContextFailed': '이슈 컨텍스트를 보내지 못했습니다', + 'session.linearIssuePicker.toast.sessionCreated': '이슈에서 세션을 만들었습니다', + 'session.linearIssuePicker.toast.startSessionFailed': '세션을 시작하지 못했습니다', + 'session.linearIssuePicker.actions.sectionTitle': '작업', + 'session.linearIssuePicker.actions.toggleWorktreeAria': '워크트리 전환', + 'session.linearIssuePicker.actions.createInWorktree': '워크트리에서 만들기', + 'session.linearIssuePicker.actions.refresh': '새로고침', + 'chat.workStatus.linkedIssues.openLinear': 'Linear에서 {identifier} 열기', + 'session.newWorktree.actions.startFromLinearIssue': 'Linear 이슈에서 시작', + 'session.newWorktree.fromLinearIssue': '{identifier}: {title}에서', + 'session.newWorktree.error.sendLinearContextFailed': 'Linear 컨텍스트를 보내지 못했습니다', + }, + pl: { + 'chat.chatInput.actions.linkLinearIssue': 'Powiąż zgłoszenie Linear', + 'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Otwórz zgłoszenie w Linear', + 'chat.chatInput.linked.linearIssue.removeAria': 'Usuń powiązane zgłoszenie Linear', + 'session.linearIssuePicker.title': 'Powiąż zgłoszenie Linear', + 'session.linearIssuePicker.description': 'Wybierz zgłoszenie z połączonego obszaru Linear.', + 'session.linearIssuePicker.searchPlaceholder': 'Szukaj po tytule, identyfikatorze lub adresie URL Linear', + 'session.linearIssuePicker.empty.notConnected': 'Linear nie jest połączony. Połącz go w Ustawieniach → Integracje.', + 'session.linearIssuePicker.empty.runtimeUnavailable': 'Linear jest niedostępny w tej aplikacji.', + 'session.linearIssuePicker.empty.noIssuesFound': 'Nie znaleziono zgłoszeń', + 'session.linearIssuePicker.empty.noOpenIssuesFound': 'Nie znaleziono otwartych zgłoszeń', + 'session.linearIssuePicker.loading.issues': 'Ładowanie zgłoszeń...', + 'session.linearIssuePicker.loading.more': 'Ładowanie...', + 'session.linearIssuePicker.actions.openSettings': 'Otwórz ustawienia', + 'session.linearIssuePicker.actions.useIssue': 'Użyj {identifier}', + 'session.linearIssuePicker.actions.loadMore': 'Załaduj więcej', + 'session.linearIssuePicker.actions.openInLinearAria': 'Otwórz w Linear', + 'session.linearIssuePicker.toast.loadMoreFailed': 'Nie udało się załadować kolejnych zgłoszeń', + 'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Nie udało się załadować szczegółów zgłoszenia', + 'session.linearIssuePicker.error.notConnected': 'Linear niepołączony', + 'session.linearIssuePicker.error.runtimeUnavailable': 'Linear jest niedostępny w tej aplikacji', + 'session.linearIssuePicker.error.issueNotFound': 'Nie znaleziono zgłoszenia', + 'chat.chatInput.actions.newSessionFromLinearIssue': 'Nowa sesja ze zgłoszenia Linear', + 'session.linearIssuePicker.title.createSession': 'Nowa sesja ze zgłoszenia Linear', + 'session.linearIssuePicker.description.createSession': 'Tworzy sesję w projekcie przypisanym do tego zespołu Linear, ze zgłoszeniem jako pierwszym poleceniem.', + 'session.linearIssuePicker.error.noMappedProject': 'Przypisz ten zespół Linear do projektu w Ustawieniach → Integracje', + 'session.linearIssuePicker.error.noModelSelected': 'Nie wybrano modelu', + 'session.linearIssuePicker.toast.sendContextFailed': 'Nie udało się wysłać kontekstu zgłoszenia', + 'session.linearIssuePicker.toast.sessionCreated': 'Utworzono sesję ze zgłoszenia', + 'session.linearIssuePicker.toast.startSessionFailed': 'Nie udało się rozpocząć sesji', + 'session.linearIssuePicker.actions.sectionTitle': 'Czynności', + 'session.linearIssuePicker.actions.toggleWorktreeAria': 'Przełącz worktree', + 'session.linearIssuePicker.actions.createInWorktree': 'Utwórz w worktree', + 'session.linearIssuePicker.actions.refresh': 'Odśwież', + 'chat.workStatus.linkedIssues.openLinear': 'Otwórz {identifier} w Linear', + 'session.newWorktree.actions.startFromLinearIssue': 'Zacznij od zgłoszenia Linear', + 'session.newWorktree.fromLinearIssue': 'Z {identifier}: {title}', + 'session.newWorktree.error.sendLinearContextFailed': 'Nie udało się wysłać kontekstu Linear', + }, + 'zh-CN': { + 'chat.chatInput.actions.linkLinearIssue': '关联 Linear Issue', + 'chat.chatInput.linked.linearIssue.openInBrowserAria': '在 Linear 中打开 Issue', + 'chat.chatInput.linked.linearIssue.removeAria': '移除已关联的 Linear Issue', + 'session.linearIssuePicker.title': '关联 Linear Issue', + 'session.linearIssuePicker.description': '从已连接的 Linear 工作区选择一个 Issue。', + 'session.linearIssuePicker.searchPlaceholder': '按标题、标识符或 Linear 链接搜索', + 'session.linearIssuePicker.empty.notConnected': '尚未连接 Linear。请到设置 → 集成 中连接。', + 'session.linearIssuePicker.empty.runtimeUnavailable': '此应用中无法使用 Linear。', + 'session.linearIssuePicker.empty.noIssuesFound': '未找到 Issue', + 'session.linearIssuePicker.empty.noOpenIssuesFound': '没有未完成的 Issue', + 'session.linearIssuePicker.loading.issues': '正在加载 Issue...', + 'session.linearIssuePicker.loading.more': '正在加载...', + 'session.linearIssuePicker.actions.openSettings': '打开设置', + 'session.linearIssuePicker.actions.useIssue': '使用 {identifier}', + 'session.linearIssuePicker.actions.loadMore': '加载更多', + 'session.linearIssuePicker.actions.openInLinearAria': '在 Linear 中打开', + 'session.linearIssuePicker.toast.loadMoreFailed': '无法加载更多 Issue', + 'session.linearIssuePicker.toast.loadIssueDetailsFailed': '无法加载 Issue 详情', + 'session.linearIssuePicker.error.notConnected': '未连接 Linear', + 'session.linearIssuePicker.error.runtimeUnavailable': '此应用中无法使用 Linear', + 'session.linearIssuePicker.error.issueNotFound': '未找到 Issue', + 'chat.chatInput.actions.newSessionFromLinearIssue': '从 Linear Issue 新建会话', + 'session.linearIssuePicker.title.createSession': '从 Linear Issue 新建会话', + 'session.linearIssuePicker.description.createSession': '在映射到此 Linear 团队的项目中创建会话,并以该 Issue 作为第一条提示。', + 'session.linearIssuePicker.error.noMappedProject': '请在设置 → 集成 中将此 Linear 团队映射到一个项目', + 'session.linearIssuePicker.error.noModelSelected': '未选择模型', + 'session.linearIssuePicker.toast.sendContextFailed': '无法发送 Issue 上下文', + 'session.linearIssuePicker.toast.sessionCreated': '已从 Issue 创建会话', + 'session.linearIssuePicker.toast.startSessionFailed': '无法开始会话', + 'session.linearIssuePicker.actions.sectionTitle': '操作', + 'session.linearIssuePicker.actions.toggleWorktreeAria': '切换 worktree', + 'session.linearIssuePicker.actions.createInWorktree': '在 worktree 中创建', + 'session.linearIssuePicker.actions.refresh': '刷新', + 'chat.workStatus.linkedIssues.openLinear': '在 Linear 中打开 {identifier}', + 'session.newWorktree.actions.startFromLinearIssue': '从 Linear Issue 开始', + 'session.newWorktree.fromLinearIssue': '来自 {identifier}:{title}', + 'session.newWorktree.error.sendLinearContextFailed': '无法发送 Linear 上下文', + }, + 'zh-TW': { + 'chat.chatInput.actions.linkLinearIssue': '關聯 Linear Issue', + 'chat.chatInput.linked.linearIssue.openInBrowserAria': '在 Linear 中開啟 Issue', + 'chat.chatInput.linked.linearIssue.removeAria': '移除已關聯的 Linear Issue', + 'session.linearIssuePicker.title': '關聯 Linear Issue', + 'session.linearIssuePicker.description': '從已連線的 Linear 工作區選擇一個 Issue。', + 'session.linearIssuePicker.searchPlaceholder': '依標題、識別碼或 Linear 網址搜尋', + 'session.linearIssuePicker.empty.notConnected': '尚未連線 Linear。請到設定 → 整合 中連線。', + 'session.linearIssuePicker.empty.runtimeUnavailable': '此應用程式無法使用 Linear。', + 'session.linearIssuePicker.empty.noIssuesFound': '找不到 Issue', + 'session.linearIssuePicker.empty.noOpenIssuesFound': '沒有未完成的 Issue', + 'session.linearIssuePicker.loading.issues': '正在載入 Issue...', + 'session.linearIssuePicker.loading.more': '正在載入...', + 'session.linearIssuePicker.actions.openSettings': '開啟設定', + 'session.linearIssuePicker.actions.useIssue': '使用 {identifier}', + 'session.linearIssuePicker.actions.loadMore': '載入更多', + 'session.linearIssuePicker.actions.openInLinearAria': '在 Linear 中開啟', + 'session.linearIssuePicker.toast.loadMoreFailed': '無法載入更多 Issue', + 'session.linearIssuePicker.toast.loadIssueDetailsFailed': '無法載入 Issue 詳細資料', + 'session.linearIssuePicker.error.notConnected': '未連線 Linear', + 'session.linearIssuePicker.error.runtimeUnavailable': '此應用程式無法使用 Linear', + 'session.linearIssuePicker.error.issueNotFound': '找不到 Issue', + 'chat.chatInput.actions.newSessionFromLinearIssue': '從 Linear Issue 新增會話', + 'session.linearIssuePicker.title.createSession': '從 Linear Issue 新增會話', + 'session.linearIssuePicker.description.createSession': '在對應到此 Linear 團隊的專案中建立會話,並以該 Issue 作為第一則提示。', + 'session.linearIssuePicker.error.noMappedProject': '請在設定 → 整合 中將此 Linear 團隊對應到一個專案', + 'session.linearIssuePicker.error.noModelSelected': '尚未選擇模型', + 'session.linearIssuePicker.toast.sendContextFailed': '無法傳送 Issue 內容', + 'session.linearIssuePicker.toast.sessionCreated': '已從 Issue 建立會話', + 'session.linearIssuePicker.toast.startSessionFailed': '無法開始會話', + 'session.linearIssuePicker.actions.sectionTitle': '操作', + 'session.linearIssuePicker.actions.toggleWorktreeAria': '切換 worktree', + 'session.linearIssuePicker.actions.createInWorktree': '在 worktree 中建立', + 'session.linearIssuePicker.actions.refresh': '重新整理', + 'chat.workStatus.linkedIssues.openLinear': '在 Linear 中開啟 {identifier}', + 'session.newWorktree.actions.startFromLinearIssue': '從 Linear Issue 開始', + 'session.newWorktree.fromLinearIssue': '來自 {identifier}:{title}', + 'session.newWorktree.error.sendLinearContextFailed': '無法傳送 Linear 內容', + }, + tr: { + 'chat.chatInput.actions.linkLinearIssue': 'Linear Issue bağla', + 'chat.chatInput.linked.linearIssue.openInBrowserAria': "Issue'u Linear'da aç", + 'chat.chatInput.linked.linearIssue.removeAria': "Bağlı Linear issue'u kaldır", + 'session.linearIssuePicker.title': 'Linear Issue bağla', + 'session.linearIssuePicker.description': 'Bağlı Linear çalışma alanından bir issue seç.', + 'session.linearIssuePicker.searchPlaceholder': "Başlığa, tanımlayıcıya veya Linear URL'sine göre ara", + 'session.linearIssuePicker.empty.notConnected': "Linear bağlı değil. Ayarlar → Entegrasyonlar'dan bağla.", + 'session.linearIssuePicker.empty.runtimeUnavailable': 'Linear bu uygulamada kullanılamıyor.', + 'session.linearIssuePicker.empty.noIssuesFound': 'Issue bulunamadı', + 'session.linearIssuePicker.empty.noOpenIssuesFound': 'Açık issue bulunamadı', + 'session.linearIssuePicker.loading.issues': "Issue'lar yükleniyor...", + 'session.linearIssuePicker.loading.more': 'Yükleniyor...', + 'session.linearIssuePicker.actions.openSettings': 'Ayarları aç', + 'session.linearIssuePicker.actions.useIssue': '{identifier} kullan', + 'session.linearIssuePicker.actions.loadMore': 'Daha fazla yükle', + 'session.linearIssuePicker.actions.openInLinearAria': "Linear'da aç", + 'session.linearIssuePicker.toast.loadMoreFailed': 'Daha fazla issue yüklenemedi', + 'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Issue ayrıntıları yüklenemedi', + 'session.linearIssuePicker.error.notConnected': 'Linear bağlı değil', + 'session.linearIssuePicker.error.runtimeUnavailable': 'Linear bu uygulamada kullanılamıyor', + 'session.linearIssuePicker.error.issueNotFound': 'Issue bulunamadı', + 'chat.chatInput.actions.newSessionFromLinearIssue': "Linear Issue'dan yeni session", + 'session.linearIssuePicker.title.createSession': "Linear Issue'dan yeni session", + 'session.linearIssuePicker.description.createSession': 'Bu Linear ekibine eşlenen projede bir session oluşturur; ilk prompt issue olur.', + 'session.linearIssuePicker.error.noMappedProject': "Bu Linear ekibini Ayarlar → Entegrasyonlar'da bir projeye eşle", + 'session.linearIssuePicker.error.noModelSelected': 'Model seçilmedi', + 'session.linearIssuePicker.toast.sendContextFailed': 'Issue bağlamı gönderilemedi', + 'session.linearIssuePicker.toast.sessionCreated': "Issue'dan session oluşturuldu", + 'session.linearIssuePicker.toast.startSessionFailed': 'Session başlatılamadı', + 'session.linearIssuePicker.actions.sectionTitle': 'İşlemler', + 'session.linearIssuePicker.actions.toggleWorktreeAria': "Worktree'yi aç veya kapat", + 'session.linearIssuePicker.actions.createInWorktree': "Worktree'de oluştur", + 'session.linearIssuePicker.actions.refresh': 'Yenile', + 'chat.workStatus.linkedIssues.openLinear': "{identifier} issue'unu Linear'da aç", + 'session.newWorktree.actions.startFromLinearIssue': "Linear Issue'dan başla", + 'session.newWorktree.fromLinearIssue': '{identifier}: {title}', + 'session.newWorktree.error.sendLinearContextFailed': 'Linear bağlamı gönderilemedi', + }, +} as const; diff --git a/packages/ui/src/lib/i18n/messages/linear-panel.i18n.test.ts b/packages/ui/src/lib/i18n/messages/linear-panel.i18n.test.ts new file mode 100644 index 00000000..ed6424f5 --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/linear-panel.i18n.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from 'bun:test'; +import { linearPanelI18n } from './linear-panel.i18n'; + +const locales = ['en', 'de', 'fr', 'es', 'ja', 'pt-BR', 'uk', 'ko', 'pl', 'zh-CN', 'zh-TW', 'tr'] as const; + +const requiredKeys = [ + 'contextPanel.mode.linear', + 'contextRail.surface.linear.description', + 'contextPanel.linear.actions.backToList', + 'contextPanel.linear.actions.startSession', + 'contextPanel.linear.actions.closeIssue', + 'contextPanel.linear.actions.closeSearch', + 'contextPanel.linear.label.status', + 'contextPanel.linear.label.team', + 'contextPanel.linear.label.assignee', + 'contextPanel.linear.label.unassigned', + 'contextPanel.linear.label.priority', + 'contextPanel.linear.label.labels', + 'contextPanel.linear.priority.none', + 'contextPanel.linear.priority.urgent', + 'contextPanel.linear.priority.high', + 'contextPanel.linear.priority.medium', + 'contextPanel.linear.priority.low', + 'contextPanel.linear.label.comments', + 'contextPanel.linear.label.statusAria', + 'contextPanel.linear.label.workspace', + 'contextPanel.linear.label.workspaceAria', + 'contextPanel.linear.filter.statusAria', + 'contextPanel.linear.filter.assigneeAria', + 'contextPanel.linear.filter.teamAria', + 'contextPanel.linear.filter.priorityAria', + 'contextPanel.linear.filter.searchAria', + 'contextPanel.linear.filter.clear', + 'contextPanel.linear.filter.clearAria', + 'contextPanel.linear.filter.status.all', + 'contextPanel.linear.filter.status.backlog', + 'contextPanel.linear.filter.status.todo', + 'contextPanel.linear.filter.status.started', + 'contextPanel.linear.filter.status.inReview', + 'contextPanel.linear.filter.status.completed', + 'contextPanel.linear.filter.status.canceled', + 'contextPanel.linear.filter.status.duplicate', + 'contextPanel.linear.filter.assignee.any', + 'contextPanel.linear.filter.assignee.me', + 'contextPanel.linear.filter.team.all', + 'contextPanel.linear.filter.priority.all', + 'contextPanel.linear.empty.noDescription', + 'contextPanel.linear.empty.noComments', + 'contextPanel.linear.empty.noMatchingIssues', + 'contextPanel.linear.loading.issue', + 'contextPanel.linear.toast.statusUpdated', + 'contextPanel.linear.toast.statusUpdateFailed', + 'contextPanel.linear.toast.closeFailed', + 'contextPanel.linear.toast.workspaceSwitched', + 'contextPanel.linear.toast.workspaceSwitchFailed', + 'contextPanel.linear.error.noCompletedState', +] as const; + +const matchingEnglishAllowed = new Set([ + 'contextPanel.mode.linear', + 'contextPanel.linear.label.status', + 'contextPanel.linear.label.team', +]); + +describe('linear panel translations', () => { + test('provides every required key in every supported locale', () => { + const english = linearPanelI18n.en; + for (const locale of locales) { + for (const key of requiredKeys) { + const value = linearPanelI18n[locale][key]; + expect(value).toBeTruthy(); + if (locale !== 'en' && !matchingEnglishAllowed.has(key)) { + expect(value).not.toBe(english[key]); + } + } + } + }); +}); diff --git a/packages/ui/src/lib/i18n/messages/linear-panel.i18n.ts b/packages/ui/src/lib/i18n/messages/linear-panel.i18n.ts new file mode 100644 index 00000000..1774acaf --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/linear-panel.i18n.ts @@ -0,0 +1,627 @@ +/** Linear context-rail panel strings — merged into each locale's main dictionary. */ +export const linearPanelI18n = { + en: { + 'contextPanel.mode.linear': 'Linear', + 'contextRail.surface.linear.description': 'Browse Linear issues, change status, and start a session', + 'contextPanel.linear.actions.backToList': 'Back to issues', + 'contextPanel.linear.actions.startSession': 'Start session', + 'contextPanel.linear.actions.closeIssue': 'Close issue', + 'contextPanel.linear.actions.closeSearch': 'Close search', + 'contextPanel.linear.label.status': 'Status', + 'contextPanel.linear.label.team': 'Team', + 'contextPanel.linear.label.assignee': 'Assignee', + 'contextPanel.linear.label.unassigned': 'Unassigned', + 'contextPanel.linear.label.priority': 'Priority', + 'contextPanel.linear.label.labels': 'Labels', + 'contextPanel.linear.priority.none': 'No priority', + 'contextPanel.linear.priority.urgent': 'Urgent', + 'contextPanel.linear.priority.high': 'High', + 'contextPanel.linear.priority.medium': 'Medium', + 'contextPanel.linear.priority.low': 'Low', + 'contextPanel.linear.label.comments': 'Comments', + 'contextPanel.linear.label.statusAria': 'Linear issue status', + 'contextPanel.linear.label.workspace': 'Workspace', + 'contextPanel.linear.label.workspaceAria': 'Linear workspace', + 'contextPanel.linear.filter.statusAria': 'Filter issues by status', + 'contextPanel.linear.filter.assigneeAria': 'Filter issues by assignee', + 'contextPanel.linear.filter.teamAria': 'Filter issues by team', + 'contextPanel.linear.filter.priorityAria': 'Filter issues by priority', + 'contextPanel.linear.filter.searchAria': 'Search issues', + 'contextPanel.linear.filter.clear': 'Clear', + 'contextPanel.linear.filter.clearAria': 'Clear issue filters', + 'contextPanel.linear.filter.status.all': 'All', + 'contextPanel.linear.filter.status.backlog': 'Backlog', + 'contextPanel.linear.filter.status.todo': 'To Do', + 'contextPanel.linear.filter.status.started': 'In Progress', + 'contextPanel.linear.filter.status.inReview': 'In Review', + 'contextPanel.linear.filter.status.completed': 'Done', + 'contextPanel.linear.filter.status.canceled': 'Canceled', + 'contextPanel.linear.filter.status.duplicate': 'Duplicate', + 'contextPanel.linear.filter.assignee.any': 'Anyone', + 'contextPanel.linear.filter.assignee.me': 'Assigned to me', + 'contextPanel.linear.filter.team.all': 'All teams', + 'contextPanel.linear.filter.priority.all': 'All priorities', + 'contextPanel.linear.empty.noDescription': 'No description', + 'contextPanel.linear.empty.noComments': 'No comments', + 'contextPanel.linear.empty.noMatchingIssues': 'No issues match these filters', + 'contextPanel.linear.loading.issue': 'Loading issue…', + 'contextPanel.linear.toast.statusUpdated': 'Issue status updated', + 'contextPanel.linear.toast.statusUpdateFailed': 'Could not update issue status', + 'contextPanel.linear.toast.closeFailed': 'Could not close issue', + 'contextPanel.linear.toast.workspaceSwitched': 'Switched Linear workspace', + 'contextPanel.linear.toast.workspaceSwitchFailed': 'Could not switch Linear workspace', + 'contextPanel.linear.error.noCompletedState': 'This team has no completed status', + }, + de: { + 'contextPanel.mode.linear': 'Linear', + 'contextRail.surface.linear.description': 'Linear-Issues durchsuchen, Status ändern und eine Sitzung starten', + 'contextPanel.linear.actions.backToList': 'Zurück zu den Issues', + 'contextPanel.linear.actions.startSession': 'Sitzung starten', + 'contextPanel.linear.actions.closeIssue': 'Issue schließen', + 'contextPanel.linear.actions.closeSearch': 'Suche schließen', + 'contextPanel.linear.label.status': 'Status', + 'contextPanel.linear.label.team': 'Team', + 'contextPanel.linear.label.assignee': 'Zugewiesen', + 'contextPanel.linear.label.unassigned': 'Nicht zugewiesen', + 'contextPanel.linear.label.priority': 'Priorität', + 'contextPanel.linear.label.labels': 'Kennzeichnungen', + 'contextPanel.linear.priority.none': 'Keine Priorität', + 'contextPanel.linear.priority.urgent': 'Dringend', + 'contextPanel.linear.priority.high': 'Hoch', + 'contextPanel.linear.priority.medium': 'Mittel', + 'contextPanel.linear.priority.low': 'Niedrig', + 'contextPanel.linear.label.comments': 'Kommentare', + 'contextPanel.linear.label.statusAria': 'Status des Linear-Issues', + 'contextPanel.linear.label.workspace': 'Arbeitsbereich', + 'contextPanel.linear.label.workspaceAria': 'Linear-Workspace', + 'contextPanel.linear.filter.statusAria': 'Issues nach Status filtern', + 'contextPanel.linear.filter.assigneeAria': 'Issues nach Zuweisung filtern', + 'contextPanel.linear.filter.teamAria': 'Issues nach Team filtern', + 'contextPanel.linear.filter.priorityAria': 'Issues nach Priorität filtern', + 'contextPanel.linear.filter.searchAria': 'Issues durchsuchen', + 'contextPanel.linear.filter.clear': 'Zurücksetzen', + 'contextPanel.linear.filter.clearAria': 'Issue-Filter zurücksetzen', + 'contextPanel.linear.filter.status.all': 'Alle', + 'contextPanel.linear.filter.status.backlog': 'Warteliste', + 'contextPanel.linear.filter.status.todo': 'Zu tun', + 'contextPanel.linear.filter.status.started': 'In Bearbeitung', + 'contextPanel.linear.filter.status.inReview': 'In Prüfung', + 'contextPanel.linear.filter.status.completed': 'Erledigt', + 'contextPanel.linear.filter.status.canceled': 'Abgebrochen', + 'contextPanel.linear.filter.status.duplicate': 'Duplikat', + 'contextPanel.linear.filter.assignee.any': 'Alle Personen', + 'contextPanel.linear.filter.assignee.me': 'Mir zugewiesen', + 'contextPanel.linear.filter.team.all': 'Alle Teams', + 'contextPanel.linear.filter.priority.all': 'Alle Prioritäten', + 'contextPanel.linear.empty.noDescription': 'Keine Beschreibung', + 'contextPanel.linear.empty.noComments': 'Keine Kommentare', + 'contextPanel.linear.empty.noMatchingIssues': 'Keine Issues passen zu diesen Filtern', + 'contextPanel.linear.loading.issue': 'Issue wird geladen…', + 'contextPanel.linear.toast.statusUpdated': 'Issue-Status aktualisiert', + 'contextPanel.linear.toast.statusUpdateFailed': 'Issue-Status konnte nicht aktualisiert werden', + 'contextPanel.linear.toast.closeFailed': 'Issue konnte nicht geschlossen werden', + 'contextPanel.linear.toast.workspaceSwitched': 'Linear-Workspace gewechselt', + 'contextPanel.linear.toast.workspaceSwitchFailed': 'Linear-Workspace konnte nicht gewechselt werden', + 'contextPanel.linear.error.noCompletedState': 'Dieses Team hat keinen erledigten Status', + }, + fr: { + 'contextPanel.mode.linear': 'Linear', + 'contextRail.surface.linear.description': 'Parcourir les tickets Linear, changer le statut et démarrer une session', + 'contextPanel.linear.actions.backToList': 'Retour aux tickets', + 'contextPanel.linear.actions.startSession': 'Démarrer une session', + 'contextPanel.linear.actions.closeIssue': 'Fermer le ticket', + 'contextPanel.linear.actions.closeSearch': 'Fermer la recherche', + 'contextPanel.linear.label.status': 'Statut', + 'contextPanel.linear.label.team': 'Équipe', + 'contextPanel.linear.label.assignee': 'Assigné', + 'contextPanel.linear.label.unassigned': 'Non assigné', + 'contextPanel.linear.label.priority': 'Priorité', + 'contextPanel.linear.label.labels': 'Libellés', + 'contextPanel.linear.priority.none': 'Sans priorité', + 'contextPanel.linear.priority.urgent': 'Urgente', + 'contextPanel.linear.priority.high': 'Haute', + 'contextPanel.linear.priority.medium': 'Moyenne', + 'contextPanel.linear.priority.low': 'Basse', + 'contextPanel.linear.label.comments': 'Commentaires', + 'contextPanel.linear.label.statusAria': 'Statut du ticket Linear', + 'contextPanel.linear.label.workspace': 'Espace de travail', + 'contextPanel.linear.label.workspaceAria': 'Espace de travail Linear', + 'contextPanel.linear.filter.statusAria': 'Filtrer les tickets par statut', + 'contextPanel.linear.filter.assigneeAria': 'Filtrer les tickets par assigné', + 'contextPanel.linear.filter.teamAria': 'Filtrer les tickets par équipe', + 'contextPanel.linear.filter.priorityAria': 'Filtrer les tickets par priorité', + 'contextPanel.linear.filter.searchAria': 'Rechercher des tickets', + 'contextPanel.linear.filter.clear': 'Effacer', + 'contextPanel.linear.filter.clearAria': 'Effacer les filtres des tickets', + 'contextPanel.linear.filter.status.all': 'Tous', + 'contextPanel.linear.filter.status.backlog': 'Liste d’attente', + 'contextPanel.linear.filter.status.todo': 'À faire', + 'contextPanel.linear.filter.status.started': 'En cours', + 'contextPanel.linear.filter.status.inReview': 'En revue', + 'contextPanel.linear.filter.status.completed': 'Terminé', + 'contextPanel.linear.filter.status.canceled': 'Annulé', + 'contextPanel.linear.filter.status.duplicate': 'Doublon', + 'contextPanel.linear.filter.assignee.any': 'Tout le monde', + 'contextPanel.linear.filter.assignee.me': 'Assignés à moi', + 'contextPanel.linear.filter.team.all': 'Toutes les équipes', + 'contextPanel.linear.filter.priority.all': 'Toutes les priorités', + 'contextPanel.linear.empty.noDescription': 'Aucune description', + 'contextPanel.linear.empty.noComments': 'Aucun commentaire', + 'contextPanel.linear.empty.noMatchingIssues': 'Aucun ticket ne correspond à ces filtres', + 'contextPanel.linear.loading.issue': 'Chargement du ticket…', + 'contextPanel.linear.toast.statusUpdated': 'Statut du ticket mis à jour', + 'contextPanel.linear.toast.statusUpdateFailed': 'Impossible de mettre à jour le statut du ticket', + 'contextPanel.linear.toast.closeFailed': 'Impossible de fermer le ticket', + 'contextPanel.linear.toast.workspaceSwitched': 'Workspace Linear modifié', + 'contextPanel.linear.toast.workspaceSwitchFailed': 'Impossible de changer de workspace Linear', + 'contextPanel.linear.error.noCompletedState': 'Cette équipe n’a pas de statut terminé', + }, + es: { + 'contextPanel.mode.linear': 'Linear', + 'contextRail.surface.linear.description': 'Explora issues de Linear, cambia el estado e inicia una sesión', + 'contextPanel.linear.actions.backToList': 'Volver a los issues', + 'contextPanel.linear.actions.startSession': 'Iniciar sesión', + 'contextPanel.linear.actions.closeIssue': 'Cerrar issue', + 'contextPanel.linear.actions.closeSearch': 'Cerrar búsqueda', + 'contextPanel.linear.label.status': 'Estado', + 'contextPanel.linear.label.team': 'Equipo', + 'contextPanel.linear.label.assignee': 'Asignado', + 'contextPanel.linear.label.unassigned': 'Sin asignar', + 'contextPanel.linear.label.priority': 'Prioridad', + 'contextPanel.linear.label.labels': 'Etiquetas', + 'contextPanel.linear.priority.none': 'Sin prioridad', + 'contextPanel.linear.priority.urgent': 'Urgente', + 'contextPanel.linear.priority.high': 'Alta', + 'contextPanel.linear.priority.medium': 'Media', + 'contextPanel.linear.priority.low': 'Baja', + 'contextPanel.linear.label.comments': 'Comentarios', + 'contextPanel.linear.label.statusAria': 'Estado del issue de Linear', + 'contextPanel.linear.label.workspace': 'Espacio de trabajo', + 'contextPanel.linear.label.workspaceAria': 'Espacio de trabajo de Linear', + 'contextPanel.linear.filter.statusAria': 'Filtrar issues por estado', + 'contextPanel.linear.filter.assigneeAria': 'Filtrar issues por asignado', + 'contextPanel.linear.filter.teamAria': 'Filtrar issues por equipo', + 'contextPanel.linear.filter.priorityAria': 'Filtrar issues por prioridad', + 'contextPanel.linear.filter.searchAria': 'Buscar issues', + 'contextPanel.linear.filter.clear': 'Borrar', + 'contextPanel.linear.filter.clearAria': 'Borrar filtros de issues', + 'contextPanel.linear.filter.status.all': 'Todos', + 'contextPanel.linear.filter.status.backlog': 'Lista de espera', + 'contextPanel.linear.filter.status.todo': 'Por hacer', + 'contextPanel.linear.filter.status.started': 'En curso', + 'contextPanel.linear.filter.status.inReview': 'En revisión', + 'contextPanel.linear.filter.status.completed': 'Hecho', + 'contextPanel.linear.filter.status.canceled': 'Cancelado', + 'contextPanel.linear.filter.status.duplicate': 'Duplicado', + 'contextPanel.linear.filter.assignee.any': 'Cualquiera', + 'contextPanel.linear.filter.assignee.me': 'Asignados a mí', + 'contextPanel.linear.filter.team.all': 'Todos los equipos', + 'contextPanel.linear.filter.priority.all': 'Todas las prioridades', + 'contextPanel.linear.empty.noDescription': 'Sin descripción', + 'contextPanel.linear.empty.noComments': 'Sin comentarios', + 'contextPanel.linear.empty.noMatchingIssues': 'Ningún issue coincide con estos filtros', + 'contextPanel.linear.loading.issue': 'Cargando issue…', + 'contextPanel.linear.toast.statusUpdated': 'Estado del issue actualizado', + 'contextPanel.linear.toast.statusUpdateFailed': 'No se pudo actualizar el estado del issue', + 'contextPanel.linear.toast.closeFailed': 'No se pudo cerrar el issue', + 'contextPanel.linear.toast.workspaceSwitched': 'Workspace de Linear cambiado', + 'contextPanel.linear.toast.workspaceSwitchFailed': 'No se pudo cambiar el workspace de Linear', + 'contextPanel.linear.error.noCompletedState': 'Este equipo no tiene un estado completado', + }, + ja: { + 'contextPanel.mode.linear': 'Linear', + 'contextRail.surface.linear.description': 'Linear の Issue を一覧し、状態を変えてセッションを開始します', + 'contextPanel.linear.actions.backToList': 'Issue 一覧に戻る', + 'contextPanel.linear.actions.startSession': 'セッションを開始', + 'contextPanel.linear.actions.closeIssue': 'Issue をクローズ', + 'contextPanel.linear.actions.closeSearch': '検索を閉じる', + 'contextPanel.linear.label.status': '状態', + 'contextPanel.linear.label.team': 'チーム', + 'contextPanel.linear.label.assignee': '担当者', + 'contextPanel.linear.label.unassigned': '未割り当て', + 'contextPanel.linear.label.priority': '優先度', + 'contextPanel.linear.label.labels': 'ラベル', + 'contextPanel.linear.priority.none': '優先度なし', + 'contextPanel.linear.priority.urgent': '緊急', + 'contextPanel.linear.priority.high': '高', + 'contextPanel.linear.priority.medium': '中', + 'contextPanel.linear.priority.low': '低', + 'contextPanel.linear.label.comments': 'コメント', + 'contextPanel.linear.label.statusAria': 'Linear Issue の状態', + 'contextPanel.linear.label.workspace': 'ワークスペース', + 'contextPanel.linear.label.workspaceAria': 'Linear ワークスペース', + 'contextPanel.linear.filter.statusAria': '状態で Issue を絞り込む', + 'contextPanel.linear.filter.assigneeAria': '担当者で Issue を絞り込む', + 'contextPanel.linear.filter.teamAria': 'チームで Issue を絞り込む', + 'contextPanel.linear.filter.priorityAria': '優先度で Issue を絞り込む', + 'contextPanel.linear.filter.searchAria': 'Issue を検索', + 'contextPanel.linear.filter.clear': 'クリア', + 'contextPanel.linear.filter.clearAria': 'Issue フィルターをクリア', + 'contextPanel.linear.filter.status.all': 'すべて', + 'contextPanel.linear.filter.status.backlog': 'バックログ', + 'contextPanel.linear.filter.status.todo': '未着手', + 'contextPanel.linear.filter.status.started': '進行中', + 'contextPanel.linear.filter.status.inReview': 'レビュー中', + 'contextPanel.linear.filter.status.completed': '完了', + 'contextPanel.linear.filter.status.canceled': 'キャンセル', + 'contextPanel.linear.filter.status.duplicate': '重複', + 'contextPanel.linear.filter.assignee.any': '全員', + 'contextPanel.linear.filter.assignee.me': '自分に割り当て', + 'contextPanel.linear.filter.team.all': 'すべてのチーム', + 'contextPanel.linear.filter.priority.all': 'すべての優先度', + 'contextPanel.linear.empty.noDescription': '説明はありません', + 'contextPanel.linear.empty.noComments': 'コメントはありません', + 'contextPanel.linear.empty.noMatchingIssues': 'この条件に合う Issue はありません', + 'contextPanel.linear.loading.issue': 'Issue を読み込み中…', + 'contextPanel.linear.toast.statusUpdated': 'Issue の状態を更新しました', + 'contextPanel.linear.toast.statusUpdateFailed': 'Issue の状態を更新できませんでした', + 'contextPanel.linear.toast.closeFailed': 'Issue をクローズできませんでした', + 'contextPanel.linear.toast.workspaceSwitched': 'Linear ワークスペースを切り替えました', + 'contextPanel.linear.toast.workspaceSwitchFailed': 'Linear ワークスペースを切り替えられませんでした', + 'contextPanel.linear.error.noCompletedState': 'このチームには完了ステータスがありません', + }, + ko: { + 'contextPanel.mode.linear': 'Linear', + 'contextRail.surface.linear.description': 'Linear 이슈를 보고 상태를 바꾼 뒤 세션을 시작합니다', + 'contextPanel.linear.actions.backToList': '이슈 목록으로', + 'contextPanel.linear.actions.startSession': '세션 시작', + 'contextPanel.linear.actions.closeIssue': '이슈 닫기', + 'contextPanel.linear.actions.closeSearch': '검색 닫기', + 'contextPanel.linear.label.status': '상태', + 'contextPanel.linear.label.team': '팀', + 'contextPanel.linear.label.assignee': '담당자', + 'contextPanel.linear.label.unassigned': '담당자 없음', + 'contextPanel.linear.label.priority': '우선순위', + 'contextPanel.linear.label.labels': '레이블', + 'contextPanel.linear.priority.none': '우선순위 없음', + 'contextPanel.linear.priority.urgent': '긴급', + 'contextPanel.linear.priority.high': '높음', + 'contextPanel.linear.priority.medium': '보통', + 'contextPanel.linear.priority.low': '낮음', + 'contextPanel.linear.label.comments': '댓글', + 'contextPanel.linear.label.statusAria': 'Linear 이슈 상태', + 'contextPanel.linear.label.workspace': '워크스페이스', + 'contextPanel.linear.label.workspaceAria': 'Linear 워크스페이스', + 'contextPanel.linear.filter.statusAria': '상태로 이슈 필터', + 'contextPanel.linear.filter.assigneeAria': '담당자로 이슈 필터', + 'contextPanel.linear.filter.teamAria': '팀으로 이슈 필터', + 'contextPanel.linear.filter.priorityAria': '우선순위로 이슈 필터', + 'contextPanel.linear.filter.searchAria': '이슈 검색', + 'contextPanel.linear.filter.clear': '지우기', + 'contextPanel.linear.filter.clearAria': '이슈 필터 지우기', + 'contextPanel.linear.filter.status.all': '전체', + 'contextPanel.linear.filter.status.backlog': '백로그', + 'contextPanel.linear.filter.status.todo': '할 일', + 'contextPanel.linear.filter.status.started': '작업 중', + 'contextPanel.linear.filter.status.inReview': '검토 중', + 'contextPanel.linear.filter.status.completed': '완료', + 'contextPanel.linear.filter.status.canceled': '취소됨', + 'contextPanel.linear.filter.status.duplicate': '중복', + 'contextPanel.linear.filter.assignee.any': '누구나', + 'contextPanel.linear.filter.assignee.me': '내게 할당됨', + 'contextPanel.linear.filter.team.all': '모든 팀', + 'contextPanel.linear.filter.priority.all': '모든 우선순위', + 'contextPanel.linear.empty.noDescription': '설명이 없습니다', + 'contextPanel.linear.empty.noComments': '댓글이 없습니다', + 'contextPanel.linear.empty.noMatchingIssues': '이 필터에 맞는 이슈가 없습니다', + 'contextPanel.linear.loading.issue': '이슈를 불러오는 중…', + 'contextPanel.linear.toast.statusUpdated': '이슈 상태를 업데이트했습니다', + 'contextPanel.linear.toast.statusUpdateFailed': '이슈 상태를 업데이트하지 못했습니다', + 'contextPanel.linear.toast.closeFailed': '이슈를 닫지 못했습니다', + 'contextPanel.linear.toast.workspaceSwitched': 'Linear 워크스페이스를 전환했습니다', + 'contextPanel.linear.toast.workspaceSwitchFailed': 'Linear 워크스페이스를 전환하지 못했습니다', + 'contextPanel.linear.error.noCompletedState': '이 팀에는 완료 상태가 없습니다', + }, + pl: { + 'contextPanel.mode.linear': 'Linear', + 'contextRail.surface.linear.description': 'Przeglądaj zgłoszenia Linear, zmieniaj status i uruchamiaj sesję', + 'contextPanel.linear.actions.backToList': 'Wróć do zgłoszeń', + 'contextPanel.linear.actions.startSession': 'Uruchom sesję', + 'contextPanel.linear.actions.closeIssue': 'Zamknij zgłoszenie', + 'contextPanel.linear.actions.closeSearch': 'Zamknij wyszukiwanie', + 'contextPanel.linear.label.status': 'Status', + 'contextPanel.linear.label.team': 'Zespół', + 'contextPanel.linear.label.assignee': 'Przypisane', + 'contextPanel.linear.label.unassigned': 'Nieprzypisane', + 'contextPanel.linear.label.priority': 'Priorytet', + 'contextPanel.linear.label.labels': 'Etykiety', + 'contextPanel.linear.priority.none': 'Brak priorytetu', + 'contextPanel.linear.priority.urgent': 'Pilne', + 'contextPanel.linear.priority.high': 'Wysoki', + 'contextPanel.linear.priority.medium': 'Średni', + 'contextPanel.linear.priority.low': 'Niski', + 'contextPanel.linear.label.comments': 'Komentarze', + 'contextPanel.linear.label.statusAria': 'Status zgłoszenia Linear', + 'contextPanel.linear.label.workspace': 'Obszar roboczy', + 'contextPanel.linear.label.workspaceAria': 'Workspace Linear', + 'contextPanel.linear.filter.statusAria': 'Filtruj zgłoszenia według statusu', + 'contextPanel.linear.filter.assigneeAria': 'Filtruj zgłoszenia według osoby', + 'contextPanel.linear.filter.teamAria': 'Filtruj zgłoszenia według zespołu', + 'contextPanel.linear.filter.priorityAria': 'Filtruj zgłoszenia według priorytetu', + 'contextPanel.linear.filter.searchAria': 'Szukaj zgłoszeń', + 'contextPanel.linear.filter.clear': 'Wyczyść', + 'contextPanel.linear.filter.clearAria': 'Wyczyść filtry zgłoszeń', + 'contextPanel.linear.filter.status.all': 'Wszystkie', + 'contextPanel.linear.filter.status.backlog': 'Lista oczekujących', + 'contextPanel.linear.filter.status.todo': 'Do zrobienia', + 'contextPanel.linear.filter.status.started': 'W toku', + 'contextPanel.linear.filter.status.inReview': 'W recenzji', + 'contextPanel.linear.filter.status.completed': 'Ukończone', + 'contextPanel.linear.filter.status.canceled': 'Anulowane', + 'contextPanel.linear.filter.status.duplicate': 'Duplikat', + 'contextPanel.linear.filter.assignee.any': 'Ktokolwiek', + 'contextPanel.linear.filter.assignee.me': 'Przypisane do mnie', + 'contextPanel.linear.filter.team.all': 'Wszystkie zespoły', + 'contextPanel.linear.filter.priority.all': 'Wszystkie priorytety', + 'contextPanel.linear.empty.noDescription': 'Brak opisu', + 'contextPanel.linear.empty.noComments': 'Brak komentarzy', + 'contextPanel.linear.empty.noMatchingIssues': 'Żadne zgłoszenie nie pasuje do tych filtrów', + 'contextPanel.linear.loading.issue': 'Wczytywanie zgłoszenia…', + 'contextPanel.linear.toast.statusUpdated': 'Zaktualizowano status zgłoszenia', + 'contextPanel.linear.toast.statusUpdateFailed': 'Nie udało się zaktualizować statusu zgłoszenia', + 'contextPanel.linear.toast.closeFailed': 'Nie udało się zamknąć zgłoszenia', + 'contextPanel.linear.toast.workspaceSwitched': 'Przełączono workspace Linear', + 'contextPanel.linear.toast.workspaceSwitchFailed': 'Nie udało się przełączyć workspace Linear', + 'contextPanel.linear.error.noCompletedState': 'Ten zespół nie ma statusu ukończenia', + }, + 'pt-BR': { + 'contextPanel.mode.linear': 'Linear', + 'contextRail.surface.linear.description': 'Navegue pelas issues do Linear, altere o status e inicie uma sessão', + 'contextPanel.linear.actions.backToList': 'Voltar às issues', + 'contextPanel.linear.actions.startSession': 'Iniciar sessão', + 'contextPanel.linear.actions.closeIssue': 'Fechar issue', + 'contextPanel.linear.actions.closeSearch': 'Fechar pesquisa', + 'contextPanel.linear.label.status': 'Status', + 'contextPanel.linear.label.team': 'Equipe', + 'contextPanel.linear.label.assignee': 'Responsável', + 'contextPanel.linear.label.unassigned': 'Sem responsável', + 'contextPanel.linear.label.priority': 'Prioridade', + 'contextPanel.linear.label.labels': 'Etiquetas', + 'contextPanel.linear.priority.none': 'Sem prioridade', + 'contextPanel.linear.priority.urgent': 'Urgente', + 'contextPanel.linear.priority.high': 'Alta', + 'contextPanel.linear.priority.medium': 'Média', + 'contextPanel.linear.priority.low': 'Baixa', + 'contextPanel.linear.label.comments': 'Comentários', + 'contextPanel.linear.label.statusAria': 'Status da issue do Linear', + 'contextPanel.linear.label.workspace': 'Espaço de trabalho', + 'contextPanel.linear.label.workspaceAria': 'Workspace do Linear', + 'contextPanel.linear.filter.statusAria': 'Filtrar issues por status', + 'contextPanel.linear.filter.assigneeAria': 'Filtrar issues por responsável', + 'contextPanel.linear.filter.teamAria': 'Filtrar issues por equipe', + 'contextPanel.linear.filter.priorityAria': 'Filtrar issues por prioridade', + 'contextPanel.linear.filter.searchAria': 'Pesquisar issues', + 'contextPanel.linear.filter.clear': 'Limpar', + 'contextPanel.linear.filter.clearAria': 'Limpar filtros de issues', + 'contextPanel.linear.filter.status.all': 'Todas', + 'contextPanel.linear.filter.status.backlog': 'Lista de espera', + 'contextPanel.linear.filter.status.todo': 'A fazer', + 'contextPanel.linear.filter.status.started': 'Em andamento', + 'contextPanel.linear.filter.status.inReview': 'Em revisão', + 'contextPanel.linear.filter.status.completed': 'Concluído', + 'contextPanel.linear.filter.status.canceled': 'Cancelado', + 'contextPanel.linear.filter.status.duplicate': 'Duplicado', + 'contextPanel.linear.filter.assignee.any': 'Qualquer pessoa', + 'contextPanel.linear.filter.assignee.me': 'Atribuídas a mim', + 'contextPanel.linear.filter.team.all': 'Todas as equipes', + 'contextPanel.linear.filter.priority.all': 'Todas as prioridades', + 'contextPanel.linear.empty.noDescription': 'Sem descrição', + 'contextPanel.linear.empty.noComments': 'Sem comentários', + 'contextPanel.linear.empty.noMatchingIssues': 'Nenhuma issue corresponde a estes filtros', + 'contextPanel.linear.loading.issue': 'Carregando issue…', + 'contextPanel.linear.toast.statusUpdated': 'Status da issue atualizado', + 'contextPanel.linear.toast.statusUpdateFailed': 'Não foi possível atualizar o status da issue', + 'contextPanel.linear.toast.closeFailed': 'Não foi possível fechar a issue', + 'contextPanel.linear.toast.workspaceSwitched': 'Workspace do Linear alterado', + 'contextPanel.linear.toast.workspaceSwitchFailed': 'Não foi possível alternar o workspace do Linear', + 'contextPanel.linear.error.noCompletedState': 'Esta equipe não tem um status de concluído', + }, + uk: { + 'contextPanel.mode.linear': 'Linear', + 'contextRail.surface.linear.description': 'Переглядайте Linear issue, змінюйте статус і запускайте сесію', + 'contextPanel.linear.actions.backToList': 'Назад до issues', + 'contextPanel.linear.actions.startSession': 'Почати сесію', + 'contextPanel.linear.actions.closeIssue': 'Закрити issue', + 'contextPanel.linear.actions.closeSearch': 'Закрити пошук', + 'contextPanel.linear.label.status': 'Статус', + 'contextPanel.linear.label.team': 'Команда', + 'contextPanel.linear.label.assignee': 'Виконавець', + 'contextPanel.linear.label.unassigned': 'Не призначено', + 'contextPanel.linear.label.priority': 'Пріоритет', + 'contextPanel.linear.label.labels': 'Мітки', + 'contextPanel.linear.priority.none': 'Без пріоритету', + 'contextPanel.linear.priority.urgent': 'Терміновий', + 'contextPanel.linear.priority.high': 'Високий', + 'contextPanel.linear.priority.medium': 'Середній', + 'contextPanel.linear.priority.low': 'Низький', + 'contextPanel.linear.label.comments': 'Коментарі', + 'contextPanel.linear.label.statusAria': 'Статус Linear issue', + 'contextPanel.linear.label.workspace': 'Робочий простір', + 'contextPanel.linear.label.workspaceAria': 'Робочий простір Linear', + 'contextPanel.linear.filter.statusAria': 'Фільтрувати issues за статусом', + 'contextPanel.linear.filter.assigneeAria': 'Фільтрувати issues за виконавцем', + 'contextPanel.linear.filter.teamAria': 'Фільтрувати issues за командою', + 'contextPanel.linear.filter.priorityAria': 'Фільтрувати issues за пріоритетом', + 'contextPanel.linear.filter.searchAria': 'Шукати issues', + 'contextPanel.linear.filter.clear': 'Скинути', + 'contextPanel.linear.filter.clearAria': 'Скинути фільтри issues', + 'contextPanel.linear.filter.status.all': 'Усі', + 'contextPanel.linear.filter.status.backlog': 'Беклог', + 'contextPanel.linear.filter.status.todo': 'До виконання', + 'contextPanel.linear.filter.status.started': 'У роботі', + 'contextPanel.linear.filter.status.inReview': 'На перегляді', + 'contextPanel.linear.filter.status.completed': 'Готово', + 'contextPanel.linear.filter.status.canceled': 'Скасовано', + 'contextPanel.linear.filter.status.duplicate': 'Дублікат', + 'contextPanel.linear.filter.assignee.any': 'Будь-хто', + 'contextPanel.linear.filter.assignee.me': 'Призначені мені', + 'contextPanel.linear.filter.team.all': 'Усі команди', + 'contextPanel.linear.filter.priority.all': 'Усі пріоритети', + 'contextPanel.linear.empty.noDescription': 'Немає опису', + 'contextPanel.linear.empty.noComments': 'Немає коментарів', + 'contextPanel.linear.empty.noMatchingIssues': 'Немає issues за цими фільтрами', + 'contextPanel.linear.loading.issue': 'Завантаження issue…', + 'contextPanel.linear.toast.statusUpdated': 'Статус issue оновлено', + 'contextPanel.linear.toast.statusUpdateFailed': 'Не вдалося оновити статус issue', + 'contextPanel.linear.toast.closeFailed': 'Не вдалося закрити issue', + 'contextPanel.linear.toast.workspaceSwitched': 'Перемкнуто Linear workspace', + 'contextPanel.linear.toast.workspaceSwitchFailed': 'Не вдалося перемкнути Linear workspace', + 'contextPanel.linear.error.noCompletedState': 'У цієї команди немає статусу completed', + }, + 'zh-CN': { + 'contextPanel.mode.linear': 'Linear', + 'contextRail.surface.linear.description': '浏览 Linear Issue、更改状态并开始会话', + 'contextPanel.linear.actions.backToList': '返回 Issue 列表', + 'contextPanel.linear.actions.startSession': '开始会话', + 'contextPanel.linear.actions.closeIssue': '关闭 Issue', + 'contextPanel.linear.actions.closeSearch': '关闭搜索', + 'contextPanel.linear.label.status': '状态', + 'contextPanel.linear.label.team': '团队', + 'contextPanel.linear.label.assignee': '负责人', + 'contextPanel.linear.label.unassigned': '未指派', + 'contextPanel.linear.label.priority': '优先级', + 'contextPanel.linear.label.labels': '标签', + 'contextPanel.linear.priority.none': '无优先级', + 'contextPanel.linear.priority.urgent': '紧急', + 'contextPanel.linear.priority.high': '高', + 'contextPanel.linear.priority.medium': '中', + 'contextPanel.linear.priority.low': '低', + 'contextPanel.linear.label.comments': '评论', + 'contextPanel.linear.label.statusAria': 'Linear Issue 状态', + 'contextPanel.linear.label.workspace': '工作区', + 'contextPanel.linear.label.workspaceAria': 'Linear 工作区', + 'contextPanel.linear.filter.statusAria': '按状态筛选 Issue', + 'contextPanel.linear.filter.assigneeAria': '按负责人筛选 Issue', + 'contextPanel.linear.filter.teamAria': '按团队筛选 Issue', + 'contextPanel.linear.filter.priorityAria': '按优先级筛选 Issue', + 'contextPanel.linear.filter.searchAria': '搜索 Issue', + 'contextPanel.linear.filter.clear': '清除', + 'contextPanel.linear.filter.clearAria': '清除 Issue 筛选', + 'contextPanel.linear.filter.status.all': '全部', + 'contextPanel.linear.filter.status.backlog': '待办池', + 'contextPanel.linear.filter.status.todo': '待办', + 'contextPanel.linear.filter.status.started': '进行中', + 'contextPanel.linear.filter.status.inReview': '审核中', + 'contextPanel.linear.filter.status.completed': '已完成', + 'contextPanel.linear.filter.status.canceled': '已取消', + 'contextPanel.linear.filter.status.duplicate': '重复', + 'contextPanel.linear.filter.assignee.any': '任何人', + 'contextPanel.linear.filter.assignee.me': '指派给我', + 'contextPanel.linear.filter.team.all': '所有团队', + 'contextPanel.linear.filter.priority.all': '所有优先级', + 'contextPanel.linear.empty.noDescription': '没有描述', + 'contextPanel.linear.empty.noComments': '没有评论', + 'contextPanel.linear.empty.noMatchingIssues': '没有符合这些筛选条件的 Issue', + 'contextPanel.linear.loading.issue': '正在加载 Issue…', + 'contextPanel.linear.toast.statusUpdated': '已更新 Issue 状态', + 'contextPanel.linear.toast.statusUpdateFailed': '无法更新 Issue 状态', + 'contextPanel.linear.toast.closeFailed': '无法关闭 Issue', + 'contextPanel.linear.toast.workspaceSwitched': '已切换 Linear 工作区', + 'contextPanel.linear.toast.workspaceSwitchFailed': '无法切换 Linear 工作区', + 'contextPanel.linear.error.noCompletedState': '此团队没有已完成状态', + }, + 'zh-TW': { + 'contextPanel.mode.linear': 'Linear', + 'contextRail.surface.linear.description': '瀏覽 Linear Issue、變更狀態並開始會話', + 'contextPanel.linear.actions.backToList': '返回 Issue 列表', + 'contextPanel.linear.actions.startSession': '開始會話', + 'contextPanel.linear.actions.closeIssue': '關閉 Issue', + 'contextPanel.linear.actions.closeSearch': '關閉搜尋', + 'contextPanel.linear.label.status': '狀態', + 'contextPanel.linear.label.team': '團隊', + 'contextPanel.linear.label.assignee': '負責人', + 'contextPanel.linear.label.unassigned': '未指派', + 'contextPanel.linear.label.priority': '優先級', + 'contextPanel.linear.label.labels': '標籤', + 'contextPanel.linear.priority.none': '無優先級', + 'contextPanel.linear.priority.urgent': '緊急', + 'contextPanel.linear.priority.high': '高', + 'contextPanel.linear.priority.medium': '中', + 'contextPanel.linear.priority.low': '低', + 'contextPanel.linear.label.comments': '留言', + 'contextPanel.linear.label.statusAria': 'Linear Issue 狀態', + 'contextPanel.linear.label.workspace': '工作區', + 'contextPanel.linear.label.workspaceAria': 'Linear 工作區', + 'contextPanel.linear.filter.statusAria': '依狀態篩選 Issue', + 'contextPanel.linear.filter.assigneeAria': '依負責人篩選 Issue', + 'contextPanel.linear.filter.teamAria': '依團隊篩選 Issue', + 'contextPanel.linear.filter.priorityAria': '依優先級篩選 Issue', + 'contextPanel.linear.filter.searchAria': '搜尋 Issue', + 'contextPanel.linear.filter.clear': '清除', + 'contextPanel.linear.filter.clearAria': '清除 Issue 篩選', + 'contextPanel.linear.filter.status.all': '全部', + 'contextPanel.linear.filter.status.backlog': '待辦池', + 'contextPanel.linear.filter.status.todo': '待辦', + 'contextPanel.linear.filter.status.started': '進行中', + 'contextPanel.linear.filter.status.inReview': '審核中', + 'contextPanel.linear.filter.status.completed': '已完成', + 'contextPanel.linear.filter.status.canceled': '已取消', + 'contextPanel.linear.filter.status.duplicate': '重複', + 'contextPanel.linear.filter.assignee.any': '任何人', + 'contextPanel.linear.filter.assignee.me': '指派給我', + 'contextPanel.linear.filter.team.all': '所有團隊', + 'contextPanel.linear.filter.priority.all': '所有優先級', + 'contextPanel.linear.empty.noDescription': '沒有描述', + 'contextPanel.linear.empty.noComments': '沒有留言', + 'contextPanel.linear.empty.noMatchingIssues': '沒有符合這些篩選條件的 Issue', + 'contextPanel.linear.loading.issue': '正在載入 Issue…', + 'contextPanel.linear.toast.statusUpdated': '已更新 Issue 狀態', + 'contextPanel.linear.toast.statusUpdateFailed': '無法更新 Issue 狀態', + 'contextPanel.linear.toast.closeFailed': '無法關閉 Issue', + 'contextPanel.linear.toast.workspaceSwitched': '已切換 Linear 工作區', + 'contextPanel.linear.toast.workspaceSwitchFailed': '無法切換 Linear 工作區', + 'contextPanel.linear.error.noCompletedState': '此團隊沒有已完成狀態', + }, + tr: { + 'contextPanel.mode.linear': 'Linear', + 'contextRail.surface.linear.description': "Linear issue'larını incele, durumu değiştir ve session başlat", + 'contextPanel.linear.actions.backToList': 'Issue listesine dön', + 'contextPanel.linear.actions.startSession': 'Session başlat', + 'contextPanel.linear.actions.closeIssue': "Issue'u kapat", + 'contextPanel.linear.actions.closeSearch': 'Aramayı kapat', + 'contextPanel.linear.label.status': 'Durum', + 'contextPanel.linear.label.team': 'Ekip', + 'contextPanel.linear.label.assignee': 'Atanan', + 'contextPanel.linear.label.unassigned': 'Atanmamış', + 'contextPanel.linear.label.priority': 'Öncelik', + 'contextPanel.linear.label.labels': 'Etiketler', + 'contextPanel.linear.priority.none': 'Öncelik yok', + 'contextPanel.linear.priority.urgent': 'Acil', + 'contextPanel.linear.priority.high': 'Yüksek', + 'contextPanel.linear.priority.medium': 'Orta', + 'contextPanel.linear.priority.low': 'Düşük', + 'contextPanel.linear.label.comments': 'Yorumlar', + 'contextPanel.linear.label.statusAria': 'Linear issue durumu', + 'contextPanel.linear.label.workspace': 'Çalışma alanı', + 'contextPanel.linear.label.workspaceAria': 'Linear çalışma alanı', + 'contextPanel.linear.filter.statusAria': "Issue'ları duruma göre süz", + 'contextPanel.linear.filter.assigneeAria': "Issue'ları atanan kişiye göre süz", + 'contextPanel.linear.filter.teamAria': "Issue'ları ekibe göre süz", + 'contextPanel.linear.filter.priorityAria': "Issue'ları önceliğe göre süz", + 'contextPanel.linear.filter.searchAria': "Issue'larda ara", + 'contextPanel.linear.filter.clear': 'Temizle', + 'contextPanel.linear.filter.clearAria': "Issue filtrelerini temizle", + 'contextPanel.linear.filter.status.all': 'Tümü', + 'contextPanel.linear.filter.status.backlog': 'Bekleme listesi', + 'contextPanel.linear.filter.status.todo': 'Yapılacak', + 'contextPanel.linear.filter.status.started': 'Devam ediyor', + 'contextPanel.linear.filter.status.inReview': 'İncelemede', + 'contextPanel.linear.filter.status.completed': 'Bitti', + 'contextPanel.linear.filter.status.canceled': 'İptal', + 'contextPanel.linear.filter.status.duplicate': 'Yinelenen', + 'contextPanel.linear.filter.assignee.any': 'Herkes', + 'contextPanel.linear.filter.assignee.me': 'Bana atananlar', + 'contextPanel.linear.filter.team.all': 'Tüm ekipler', + 'contextPanel.linear.filter.priority.all': 'Tüm öncelikler', + 'contextPanel.linear.empty.noDescription': 'Açıklama yok', + 'contextPanel.linear.empty.noComments': 'Yorum yok', + 'contextPanel.linear.empty.noMatchingIssues': 'Bu süzgeçlere uyan issue yok', + 'contextPanel.linear.loading.issue': 'Issue yükleniyor…', + 'contextPanel.linear.toast.statusUpdated': 'Issue durumu güncellendi', + 'contextPanel.linear.toast.statusUpdateFailed': 'Issue durumu güncellenemedi', + 'contextPanel.linear.toast.closeFailed': 'Issue kapatılamadı', + 'contextPanel.linear.toast.workspaceSwitched': 'Linear çalışma alanı değiştirildi', + 'contextPanel.linear.toast.workspaceSwitchFailed': 'Linear çalışma alanı değiştirilemedi', + 'contextPanel.linear.error.noCompletedState': 'Bu ekibin tamamlandı durumu yok', + }, +} as const; diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index b6953d2b..ac8d5357 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1,3 +1,4 @@ +import { linearIntegrationI18n } from './linear-integration.i18n'; import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'Śledzenie użycia OpenCode Go', @@ -2220,5 +2221,6 @@ export const settingsDict = { 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', + ...linearIntegrationI18n.pl, ...thirdPartyIntegrationI18n.pl, }; diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 5e6ee9f6..abe5287a 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1,8 +1,12 @@ import type { I18nKey } from './en'; import { settingsDict } from './pl.settings'; +import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; +import { linearPanelI18n } from './linear-panel.i18n'; export const dict: Record = { ...settingsDict, + ...linearIssuePickerI18n.pl, + ...linearPanelI18n.pl, 'terminalView.actions.attachSelection': 'Dołącz zaznaczone dane wyjściowe', 'terminalView.actions.restart': 'Uruchom terminal ponownie', 'chat.message.terminalContext': '{terminal}, wiersze {start}-{end}', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 276df6df..fb5fa2c1 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1,3 +1,4 @@ +import { linearIntegrationI18n } from './linear-integration.i18n'; import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'Monitoramento de uso do OpenCode Go', @@ -2227,5 +2228,6 @@ export const settingsDict = { "settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.", "settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer", "settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue", + ...linearIntegrationI18n['pt-BR'], ...thirdPartyIntegrationI18n['pt-BR'], } as const; diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 581f7450..b2719031 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1,8 +1,12 @@ import type { I18nKey } from './en'; import { settingsDict } from './pt-BR.settings'; +import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; +import { linearPanelI18n } from './linear-panel.i18n'; export const dict: Record = { ...settingsDict, + ...linearIssuePickerI18n['pt-BR'], + ...linearPanelI18n['pt-BR'], 'terminalView.actions.attachSelection': 'Anexar saída selecionada', 'terminalView.actions.restart': 'Reiniciar terminal', 'chat.message.terminalContext': '{terminal}, linhas {start}-{end}', diff --git a/packages/ui/src/lib/i18n/messages/tr.settings.ts b/packages/ui/src/lib/i18n/messages/tr.settings.ts index 34125588..c4ce2859 100644 --- a/packages/ui/src/lib/i18n/messages/tr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/tr.settings.ts @@ -1,3 +1,4 @@ +import { linearIntegrationI18n } from './linear-integration.i18n'; import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'OpenCode Go kullanım takibi', @@ -2218,4 +2219,5 @@ export const settingsDict = { 'settings.openchamber.visual.field.sessionTabsAria': 'Başlıktaki session sekmelerini aç/kapat', 'settings.openchamber.visual.field.sessionTabsInfo': 'Açtığınız session\'lar başlıkta sekmeler olarak dizilir. Kapatırsanız düz session başlığına döner.', ...thirdPartyIntegrationI18n.tr, + ...linearIntegrationI18n.tr, }; diff --git a/packages/ui/src/lib/i18n/messages/tr.ts b/packages/ui/src/lib/i18n/messages/tr.ts index 6812353d..593255c9 100644 --- a/packages/ui/src/lib/i18n/messages/tr.ts +++ b/packages/ui/src/lib/i18n/messages/tr.ts @@ -1,7 +1,11 @@ import { settingsDict } from './tr.settings'; +import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; +import { linearPanelI18n } from './linear-panel.i18n'; export const dict = { ...settingsDict, + ...linearIssuePickerI18n.tr, + ...linearPanelI18n.tr, 'terminalView.actions.attachSelection': 'Seçili çıktıyı ekle', 'terminalView.actions.restart': 'Terminali yeniden başlat', 'chat.message.terminalContext': '{terminal}, {start}-{end}. satırlar', diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 34cdc172..d65facf2 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1,3 +1,4 @@ +import { linearIntegrationI18n } from './linear-integration.i18n'; import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'Відстеження використання OpenCode Go', @@ -2227,5 +2228,6 @@ export const settingsDict = { "settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.", "settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer", "settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue", + ...linearIntegrationI18n.uk, ...thirdPartyIntegrationI18n.uk, } as const; diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index d11b878b..adf207ff 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1,8 +1,12 @@ import type { I18nKey } from './en'; import { settingsDict } from './uk.settings'; +import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; +import { linearPanelI18n } from './linear-panel.i18n'; export const dict: Record = { ...settingsDict, + ...linearIssuePickerI18n.uk, + ...linearPanelI18n.uk, 'terminalView.actions.attachSelection': 'Прикріпити вибраний вивід', 'terminalView.actions.restart': 'Перезапустити термінал', 'chat.message.terminalContext': '{terminal}, рядки {start}-{end}', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index f910b3d2..c7f1f622 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1,3 +1,4 @@ +import { linearIntegrationI18n } from './linear-integration.i18n'; import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'OpenCode Go 用量跟踪', @@ -2227,5 +2228,6 @@ export const settingsDict = { 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', + ...linearIntegrationI18n['zh-CN'], ...thirdPartyIntegrationI18n['zh-CN'], } as const; diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 184213b0..7dcf3db8 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1,8 +1,12 @@ import type { I18nKey } from './en'; import { settingsDict } from './zh-CN.settings'; +import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; +import { linearPanelI18n } from './linear-panel.i18n'; export const dict: Record = { ...settingsDict, + ...linearIssuePickerI18n['zh-CN'], + ...linearPanelI18n['zh-CN'], 'terminalView.actions.attachSelection': '附加所选输出', 'terminalView.actions.restart': '重启终端', 'chat.message.terminalContext': '{terminal},第 {start}-{end} 行', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index fb6e7243..e24521ae 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1,3 +1,4 @@ +import { linearIntegrationI18n } from './linear-integration.i18n'; import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'OpenCode Go 用量追蹤', @@ -2227,5 +2228,6 @@ export const settingsDict = { 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', + ...linearIntegrationI18n['zh-TW'], ...thirdPartyIntegrationI18n['zh-TW'], } as const; diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 2f6e5d7d..d537d9f1 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1,8 +1,12 @@ import type { I18nKey } from './en'; import { settingsDict } from './zh-TW.settings'; +import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; +import { linearPanelI18n } from './linear-panel.i18n'; export const dict: Record = { ...settingsDict, + ...linearIssuePickerI18n['zh-TW'], + ...linearPanelI18n['zh-TW'], 'terminalView.actions.attachSelection': '附加所選輸出', 'terminalView.actions.restart': '重新啟動終端', 'chat.message.terminalContext': '{terminal},第 {start}-{end} 行', diff --git a/packages/ui/src/lib/linearProjectMapping.test.ts b/packages/ui/src/lib/linearProjectMapping.test.ts new file mode 100644 index 00000000..8f5b64d9 --- /dev/null +++ b/packages/ui/src/lib/linearProjectMapping.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from 'bun:test'; +import { resolveLinearMappedProjectPath } from './linearProjectMapping'; +import type { LinearMappingResult } from './api/types'; + +const mapping = (): LinearMappingResult => ({ + connected: true, + defaultProjectPath: '/default', + teams: [ + { id: 'team-eng', key: 'ENG', name: 'Engineering', projectPath: '/eng' }, + { id: 'team-des', key: 'DES', name: 'Design', projectPath: null }, + ], +}); + +describe('resolveLinearMappedProjectPath', () => { + test('prefers the team path over the default', () => { + expect(resolveLinearMappedProjectPath(mapping(), { id: 'team-eng', key: 'ENG', name: 'Engineering' })) + .toBe('/eng'); + }); + + test('falls back to the default when the team has no path', () => { + expect(resolveLinearMappedProjectPath(mapping(), { id: 'team-des', key: 'DES', name: 'Design' })) + .toBe('/default'); + }); + + test('matches a team by key when the id is missing', () => { + expect(resolveLinearMappedProjectPath(mapping(), { id: '', key: 'ENG', name: 'Engineering' })) + .toBe('/eng'); + }); + + test('returns null when Linear is disconnected or unmapped', () => { + expect(resolveLinearMappedProjectPath({ connected: false }, { id: 'team-eng', key: 'ENG', name: 'Engineering' })) + .toBeNull(); + expect(resolveLinearMappedProjectPath({ + connected: true, + defaultProjectPath: null, + teams: [{ id: 'team-des', key: 'DES', name: 'Design', projectPath: null }], + }, { id: 'team-des', key: 'DES', name: 'Design' })).toBeNull(); + }); +}); diff --git a/packages/ui/src/lib/linearProjectMapping.ts b/packages/ui/src/lib/linearProjectMapping.ts new file mode 100644 index 00000000..c8fb2ae5 --- /dev/null +++ b/packages/ui/src/lib/linearProjectMapping.ts @@ -0,0 +1,24 @@ +import type { LinearIssueTeam, LinearMappingResult } from '@/lib/api/types'; + +export function resolveLinearMappedProjectPath( + mapping: LinearMappingResult | null | undefined, + team: LinearIssueTeam | null | undefined, +): string | null { + if (!mapping || mapping.connected === false) { + return null; + } + const teams = mapping.teams ?? []; + if (team?.id) { + const byId = teams.find((entry) => entry.id === team.id); + if (byId?.projectPath) { + return byId.projectPath; + } + } + if (team?.key) { + const byKey = teams.find((entry) => entry.key === team.key); + if (byKey?.projectPath) { + return byKey.projectPath; + } + } + return mapping.defaultProjectPath?.trim() || null; +} diff --git a/packages/ui/src/lib/linearSessionStatus.test.ts b/packages/ui/src/lib/linearSessionStatus.test.ts new file mode 100644 index 00000000..672fb3f5 --- /dev/null +++ b/packages/ui/src/lib/linearSessionStatus.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, test } from 'bun:test'; + +import { resolveLinearSessionOrigin } from './linearSessionStatus'; + +describe('resolveLinearSessionOrigin', () => { + const originalWindow = globalThis.window; + + afterEach(() => { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: originalWindow, + }); + }); + + test('uses the page origin on web', () => { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + location: { origin: 'https://app.example.com' }, + }, + }); + expect(resolveLinearSessionOrigin()).toBe('https://app.example.com'); + }); + + test('uses the desktop loopback origin instead of the packaged UI scheme', () => { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + location: { origin: 'openchamber-ui://app' }, + __OPENCHAMBER_ELECTRON__: { runtime: 'electron' }, + __OPENCHAMBER_LOCAL_ORIGIN__: 'http://127.0.0.1:3001', + }, + }); + expect(resolveLinearSessionOrigin()).toBe('http://127.0.0.1:3001'); + }); + + test('reports no origin when the desktop shell has no http loopback', () => { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + location: { origin: 'openchamber-ui://app' }, + __OPENCHAMBER_ELECTRON__: { runtime: 'electron' }, + __OPENCHAMBER_LOCAL_ORIGIN__: 'openchamber-ui://app', + }, + }); + // A deep link is unopenable for everyone but this machine, so the server + // gets no origin and posts no comment. + expect(resolveLinearSessionOrigin()).toBe(undefined); + }); +}); diff --git a/packages/ui/src/lib/linearSessionStatus.ts b/packages/ui/src/lib/linearSessionStatus.ts new file mode 100644 index 00000000..c7e0cee1 --- /dev/null +++ b/packages/ui/src/lib/linearSessionStatus.ts @@ -0,0 +1,45 @@ +import type { LinearAPI } from '@/lib/api/types'; +import { isElectronShell } from '@/lib/desktop'; +import { getLocalDesktopOrigin } from '@/lib/desktopCurrentHost'; + +function isHttpOrigin(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} + +/** + * Origin Linear comments should open. Packaged desktop UI lives on + * `openchamber-ui://`, which is not a URL a browser can load from Linear, so + * report the http origin the local server actually listens on instead. The + * server decides whether that origin is reachable by anyone else; a comment is + * only posted when it is. + */ +export function resolveLinearSessionOrigin(): string | undefined { + if (typeof window === 'undefined') return undefined; + if (isElectronShell()) { + const localOrigin = getLocalDesktopOrigin().trim(); + if (localOrigin && isHttpOrigin(localOrigin)) { + return new URL(localOrigin).origin; + } + return undefined; + } + const origin = window.location.origin.trim(); + return origin || undefined; +} + +export function postLinearSessionStarted( + linear: LinearAPI | undefined, + args: { sessionId: string; issueIdentifier: string }, +): void { + if (!linear?.sessionStatusPost) return; + void linear.sessionStatusPost({ + kind: 'started', + sessionId: args.sessionId, + issueIdentifier: args.issueIdentifier, + sessionOrigin: resolveLinearSessionOrigin(), + }).catch(() => undefined); +} diff --git a/packages/ui/src/lib/linearStartSession.test.ts b/packages/ui/src/lib/linearStartSession.test.ts new file mode 100644 index 00000000..d19ebf39 --- /dev/null +++ b/packages/ui/src/lib/linearStartSession.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from 'bun:test'; +import { buildIssueContextText } from './linearStartSession'; +import type { LinearIssue } from '@/lib/api/types'; + +const issue: LinearIssue = { + id: 'issue-1', + identifier: 'ENG-12', + title: 'Broken login', + url: 'https://linear.app/openchamber/issue/ENG-12', + description: 'Users cannot sign in.', + comments: [], +}; + +describe('buildIssueContextText', () => { + test('serializes the issue and comments as JSON context', () => { + const text = buildIssueContextText({ + issue, + comments: [{ + id: 'comment-1', + body: 'Still broken', + createdAt: '2026-08-24T10:00:00.000Z', + user: { name: 'Ada', displayName: 'Ada Lovelace' }, + }], + }); + expect(text.startsWith('Linear issue context (JSON)\n')).toBe(true); + expect(text).toContain('"identifier": "ENG-12"'); + expect(text).toContain('Still broken'); + }); +}); diff --git a/packages/ui/src/lib/linearStartSession.ts b/packages/ui/src/lib/linearStartSession.ts new file mode 100644 index 00000000..532db084 --- /dev/null +++ b/packages/ui/src/lib/linearStartSession.ts @@ -0,0 +1,235 @@ +import { toast } from '@/components/ui'; +import type { LinearAPI, LinearIssue, LinearIssueComment, LinearMappingResult } from '@/lib/api/types'; +import type { I18nKey, I18nParams } from '@/lib/i18n'; +import { parseModelIdentifier } from '@/lib/modelIdentifier'; +import { modelVariantNames } from '@/lib/modelVariants'; +import { renderMagicPrompt } from '@/lib/magicPrompts'; +import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator'; +import { generateBranchSlug } from '@/lib/git/branchNameGenerator'; +import { buildLinkedLinearIssue } from '@/lib/linkedIssues'; +import { resolveLinearMappedProjectPath } from '@/lib/linearProjectMapping'; +import { postLinearSessionStarted } from '@/lib/linearSessionStatus'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSelectionStore } from '@/sync/selection-store'; +import * as sessionActions from '@/sync/session-actions'; + +type TranslateFn = (key: I18nKey, params?: I18nParams) => string; + +export function buildIssueContextText(args: { + issue: LinearIssue; + comments: LinearIssueComment[]; +}): string { + const payload = { + issue: args.issue, + comments: args.comments, + }; + return `Linear issue context (JSON)\n${JSON.stringify(payload, null, 2)}`; +} + +function resolveDefaultAgentName(): string | undefined { + const configState = useConfigStore.getState(); + const settingsDefaultAgent = configState.settingsDefaultAgent; + if (settingsDefaultAgent) { + return settingsDefaultAgent; + } + const visibleAgents = configState.agents.filter((agent) => !agent.hidden); + return ( + configState.currentAgentName + || visibleAgents.find((agent) => agent.mode === 'primary' || !agent.mode)?.name + || visibleAgents[0]?.name + ); +} + +function resolveDefaultModelSelection(): { providerID: string; modelID: string } | null { + const configState = useConfigStore.getState(); + const settingsDefaultModel = configState.settingsDefaultModel; + if (!settingsDefaultModel) { + return null; + } + + const parsed = parseModelIdentifier(settingsDefaultModel); + if (!parsed) { + return null; + } + const { providerId: providerID, modelId: modelID } = parsed; + + const modelMetadata = configState.getModelMetadata(providerID, modelID); + if (!modelMetadata) { + return null; + } + + return { providerID, modelID }; +} + +function resolveDefaultVariant(providerID: string, modelID: string): string | undefined { + const configState = useConfigStore.getState(); + const settingsDefaultVariant = configState.settingsDefaultVariant; + const currentVariant = configState.currentProviderId === providerID && configState.currentModelId === modelID + ? configState.currentVariant + : undefined; + + const provider = configState.providers.find((entry) => entry.id === providerID); + const model = provider?.models.find((entry) => entry.id === modelID); + const variantNames = modelVariantNames(model); + if (variantNames.length === 0) { + return settingsDefaultVariant || currentVariant || undefined; + } + if (settingsDefaultVariant && variantNames.includes(settingsDefaultVariant)) { + return settingsDefaultVariant; + } + if (currentVariant && variantNames.includes(currentVariant)) { + return currentVariant; + } + return undefined; +} + +export async function startLinearIssueSession(args: { + linear: LinearAPI | undefined; + issueKey: string; + createInWorktree: boolean; + mapping?: LinearMappingResult | null; + onMappingLoaded?: (mapping: LinearMappingResult) => void; + onSessionCreated?: () => void; + t: TranslateFn; +}): Promise { + const { linear, issueKey, createInWorktree, t } = args; + if (!linear?.issueGet || !linear.mappingGet) { + toast.error(t('session.linearIssuePicker.error.runtimeUnavailable')); + return false; + } + + try { + let mappingView = args.mapping; + if (!mappingView) { + mappingView = await linear.mappingGet(); + args.onMappingLoaded?.(mappingView); + } + if (mappingView.connected === false) { + toast.error(t('session.linearIssuePicker.error.notConnected')); + return false; + } + + const issueRes = await linear.issueGet(issueKey); + if (issueRes.connected === false) { + toast.error(t('session.linearIssuePicker.error.notConnected')); + return false; + } + const issue = issueRes.issue; + if (!issue) { + toast.error(t('session.linearIssuePicker.error.issueNotFound')); + return false; + } + + const projectDirectory = resolveLinearMappedProjectPath(mappingView, issue.team); + if (!projectDirectory) { + toast.error(t('session.linearIssuePicker.error.noMappedProject')); + return false; + } + + const comments = issue.comments ?? []; + const sessionTitle = `${issue.identifier} ${issue.title}`.trim(); + const login = issue.assignee?.displayName || issue.assignee?.name; + + const { sessionId, sessionDirectory } = await (async () => { + if (createInWorktree) { + const preferred = `issue-${issue.identifier}-${generateBranchSlug()}`; + const created = await createWorktreeSessionForNewBranch( + projectDirectory, + preferred, + undefined, + { returnAfterDirectoryCreated: true }, + ); + if (!created?.id) { + throw new Error('Failed to create worktree session'); + } + return { sessionId: created.id, sessionDirectory: created.path }; + } + + const session = await sessionActions.createSession(sessionTitle, projectDirectory, null); + if (!session?.id) { + throw new Error('Failed to create session'); + } + return { sessionId: session.id, sessionDirectory: session.directory ?? projectDirectory }; + })(); + + void sessionActions.updateSessionTitle(sessionId, sessionTitle).catch(() => undefined); + + try { + useSessionUIStore.getState().initializeNewOpenChamberSession(sessionId, useConfigStore.getState().agents); + } catch { + // ignore + } + + args.onSessionCreated?.(); + useUIStore.getState().closeMainSurfaces(); + useUIStore.getState().setSessionSwitcherOpen(false); + + postLinearSessionStarted(linear, { + sessionId, + issueIdentifier: issue.identifier, + }); + + const configState = useConfigStore.getState(); + const lastUsedProvider = useSelectionStore.getState().lastUsedProvider; + const defaultModel = resolveDefaultModelSelection(); + const providerID = defaultModel?.providerID || configState.currentProviderId || lastUsedProvider?.providerID; + const modelID = defaultModel?.modelID || configState.currentModelId || lastUsedProvider?.modelID; + const agentName = resolveDefaultAgentName() || configState.currentAgentName || undefined; + if (!providerID || !modelID) { + toast.error(t('session.linearIssuePicker.error.noModelSelected')); + return true; + } + + const variant = resolveDefaultVariant(providerID, modelID); + const visiblePromptText = await renderMagicPrompt('linear.issue.review.visible', { + identifier: issue.identifier, + }); + const instructionsText = await renderMagicPrompt('linear.issue.review.instructions'); + const contextText = buildIssueContextText({ issue, comments }); + + void sessionActions.setLinkedIssue( + sessionId, + sessionDirectory, + buildLinkedLinearIssue({ + identifier: issue.identifier, + title: issue.title, + url: issue.url, + author: login + ? { login, avatarUrl: issue.assignee?.avatarUrl || undefined } + : undefined, + linkedAt: Date.now(), + }), + true, + ).catch(() => undefined); + + void useSessionUIStore.getState().sendMessage( + visiblePromptText, + providerID, + modelID, + agentName, + undefined, + undefined, + [ + { text: instructionsText, synthetic: true }, + { text: contextText, synthetic: true }, + ], + variant, + undefined, + { sessionId, directory: sessionDirectory }, + ).catch((error) => { + const message = error instanceof Error ? error.message : String(error); + toast.error(t('session.linearIssuePicker.toast.sendContextFailed'), { + description: message, + }); + }); + + toast.success(t('session.linearIssuePicker.toast.sessionCreated')); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + toast.error(t('session.linearIssuePicker.toast.startSessionFailed'), { description: message }); + return false; + } +} diff --git a/packages/ui/src/lib/linkedIssues.test.ts b/packages/ui/src/lib/linkedIssues.test.ts index 34281475..7669649c 100644 --- a/packages/ui/src/lib/linkedIssues.test.ts +++ b/packages/ui/src/lib/linkedIssues.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from 'bun:test'; import type { Session } from '@opencode-ai/sdk/v2'; -import { buildLinkedIssue, buildLinkedIssueId, getLinkedIssues, withLinkedIssue, type LinkedIssue } from './linkedIssues'; +import { buildLinkedIssue, buildLinkedIssueId, buildLinkedLinearIssue, canOpenLinearIssueInContextPanel, getLinkedIssues, withLinkedIssue, type LinkedIssue } from './linkedIssues'; -const issue = (overrides: Partial = {}): LinkedIssue => ({ +type LinkedGitHubIssue = Exclude; + +const issue = (overrides: Partial = {}): LinkedGitHubIssue => ({ id: 'owner/repo#12', number: 12, title: 'Rail badge count', @@ -76,6 +78,28 @@ describe('buildLinkedIssue', () => { }); }); +describe('buildLinkedLinearIssue', () => { + test('stores the Linear identifier without inventing a GitHub number', () => { + const built = buildLinkedLinearIssue({ + identifier: 'ENG-12', + title: 'Broken login', + url: 'https://linear.app/openchamber/issue/ENG-12', + author: { login: 'Ada', avatarUrl: 'https://avatars/1' }, + linkedAt: 5, + }); + expect(built).toEqual({ + id: 'linear:ENG-12', + identifier: 'ENG-12', + title: 'Broken login', + url: 'https://linear.app/openchamber/issue/ENG-12', + kind: 'linear', + author: 'Ada', + authorAvatarUrl: 'https://avatars/1', + linkedAt: 5, + }); + }); +}); + describe('getLinkedIssues', () => { test('returns an empty list for a session with no metadata', () => { expect(getLinkedIssues(undefined)).toEqual([]); @@ -95,6 +119,17 @@ describe('getLinkedIssues', () => { expect(getLinkedIssues(session)).toEqual([good]); }); + test('keeps Linear entries next to GitHub ones', () => { + const github = issue(); + const linear = buildLinkedLinearIssue({ + identifier: 'ENG-12', + title: 'Broken login', + url: 'https://linear.app/openchamber/issue/ENG-12', + linkedAt: 2, + }); + expect(getLinkedIssues(sessionWith([github, linear]))).toEqual([github, linear]); + }); + test('survives a non-array payload', () => { expect(getLinkedIssues(sessionWith({ nope: true }))).toEqual([]); }); @@ -143,3 +178,41 @@ describe('withLinkedIssue', () => { expect((next.openchamber as { linked_issues: LinkedIssue[] }).linked_issues).toEqual([issue()]); }); }); + +describe('canOpenLinearIssueInContextPanel', () => { + test('opens the rail when Linear is connected, the shell has a context panel, and a directory is known', () => { + expect(canOpenLinearIssueInContextPanel({ + linearAvailable: true, + linearConnected: true, + inDedicatedMobileShell: false, + directory: '/repo', + })).toBe(true); + }); + + test('falls back when Linear is missing, disconnected, the mobile shell is open, or the directory is blank', () => { + expect(canOpenLinearIssueInContextPanel({ + linearAvailable: false, + linearConnected: true, + inDedicatedMobileShell: false, + directory: '/repo', + })).toBe(false); + expect(canOpenLinearIssueInContextPanel({ + linearAvailable: true, + linearConnected: false, + inDedicatedMobileShell: false, + directory: '/repo', + })).toBe(false); + expect(canOpenLinearIssueInContextPanel({ + linearAvailable: true, + linearConnected: true, + inDedicatedMobileShell: true, + directory: '/repo', + })).toBe(false); + expect(canOpenLinearIssueInContextPanel({ + linearAvailable: true, + linearConnected: true, + inDedicatedMobileShell: false, + directory: ' ', + })).toBe(false); + }); +}); diff --git a/packages/ui/src/lib/linkedIssues.ts b/packages/ui/src/lib/linkedIssues.ts index da61ba31..5ed1d010 100644 --- a/packages/ui/src/lib/linkedIssues.ts +++ b/packages/ui/src/lib/linkedIssues.ts @@ -2,20 +2,17 @@ import type { Session } from '@opencode-ai/sdk/v2'; import { getSessionMetadata, type SessionMetadataRecord } from './sessionReviewMetadata'; /** - * GitHub issues and pull requests a user has linked to a session. + * Issues and pull requests a user has linked to a session. * - * Stored as a **snapshot**, not a reference: number, title, author and avatar - * only. Enough to render a row and open the thing, and nothing more — the body, - * comments and state of an issue belong to GitHub, and mirroring them here - * would mean owning their staleness. The stored title can drift from the real - * one; that is the accepted cost of a storage that never needs refreshing. + * Stored as a **snapshot**, not a reference: identifier or number, title, author + * and avatar only. Enough to render a row and open the thing, and nothing more. * * Rides the same session-metadata channel as pinned messages * (`contextObligatoryMessages`), so it inherits their persistence and sync for * free. */ -export type LinkedIssue = { +export type LinkedGitHubIssue = { /** `owner/repo#number`, unique per session and stable across renames. */ id: string; number: number; @@ -27,10 +24,24 @@ export type LinkedIssue = { linkedAt: number; }; +export type LinkedLinearIssue = { + /** `linear:{identifier}`, unique per session. */ + id: string; + identifier: string; + title: string; + url: string; + kind: 'linear'; + author?: string; + authorAvatarUrl?: string; + linkedAt: number; +}; + +export type LinkedIssue = LinkedGitHubIssue | LinkedLinearIssue; + const isRecord = (value: unknown): value is Record => Boolean(value && typeof value === 'object' && !Array.isArray(value)); -const isLinkedIssue = (value: unknown): value is LinkedIssue => ( +const isLinkedGitHubIssue = (value: unknown): value is LinkedGitHubIssue => ( isRecord(value) && typeof value.id === 'string' && value.id.length > 0 @@ -43,9 +54,29 @@ const isLinkedIssue = (value: unknown): value is LinkedIssue => ( && Number.isFinite(value.linkedAt) ); +const isLinkedLinearIssue = (value: unknown): value is LinkedLinearIssue => ( + isRecord(value) + && typeof value.id === 'string' + && value.id.length > 0 + && typeof value.identifier === 'string' + && value.identifier.length > 0 + && typeof value.title === 'string' + && typeof value.url === 'string' + && value.kind === 'linear' + && typeof value.linkedAt === 'number' + && Number.isFinite(value.linkedAt) +); + +const isLinkedIssue = (value: unknown): value is LinkedIssue => ( + isLinkedGitHubIssue(value) || isLinkedLinearIssue(value) +); + export const buildLinkedIssueId = (owner: string, repo: string, number: number): string => `${owner}/${repo}#${number}`; +const buildLinkedLinearIssueId = (identifier: string): string => + `linear:${identifier}`; + /** * Builds the stored snapshot from what an attach flow already has. * @@ -61,7 +92,7 @@ export const buildLinkedIssue = (input: { kind: 'issue' | 'pull'; author?: { login?: string; avatarUrl?: string } | null; linkedAt: number; -}): LinkedIssue => { +}): LinkedGitHubIssue => { const match = /github\.com\/([^/]+)\/([^/]+)\//.exec(input.url); const id = match ? buildLinkedIssueId(match[1], match[2], input.number) @@ -79,6 +110,35 @@ export const buildLinkedIssue = (input: { }; }; +export const buildLinkedLinearIssue = (input: { + identifier: string; + title: string; + url: string; + author?: { login?: string; avatarUrl?: string } | null; + linkedAt: number; +}): LinkedLinearIssue => ({ + id: buildLinkedLinearIssueId(input.identifier), + identifier: input.identifier, + title: input.title, + url: input.url, + kind: 'linear', + author: input.author?.login ?? undefined, + authorAvatarUrl: input.author?.avatarUrl ?? undefined, + linkedAt: input.linkedAt, +}); + +export const canOpenLinearIssueInContextPanel = (options: { + linearAvailable: boolean; + linearConnected: boolean; + inDedicatedMobileShell: boolean; + directory: string | null | undefined; +}): boolean => ( + options.linearAvailable + && options.linearConnected + && !options.inDedicatedMobileShell + && Boolean(options.directory?.trim()) +); + export const getLinkedIssues = (session: Session | null | undefined): LinkedIssue[] => { const openchamber = getSessionMetadata(session).openchamber; if (!isRecord(openchamber) || !Array.isArray(openchamber.linked_issues)) return []; diff --git a/packages/ui/src/lib/magicPrompts.ts b/packages/ui/src/lib/magicPrompts.ts index c4201f6c..b0599716 100644 --- a/packages/ui/src/lib/magicPrompts.ts +++ b/packages/ui/src/lib/magicPrompts.ts @@ -13,6 +13,8 @@ export type MagicPromptId = | 'github.pr.review.instructions' | 'github.issue.review.visible' | 'github.issue.review.instructions' + | 'linear.issue.review.visible' + | 'linear.issue.review.instructions' | 'github.pr.checks.review.visible' | 'github.pr.checks.review.instructions' | 'github.pr.comments.review.visible' @@ -56,7 +58,7 @@ export interface MagicPromptDefinition { id: MagicPromptId; title: string; description: string; - group: 'Git' | 'GitHub' | 'Planning' | 'Session'; + group: 'Git' | 'GitHub' | 'Linear' | 'Planning' | 'Session'; template: string; placeholders?: Array<{ key: string; description: string }>; } @@ -261,6 +263,61 @@ Question/Support: - Answer/guidance (max 6 lines) - Missing info (max 4) +Do not implement changes until I confirm; end with: "Next actions: <1 sentence>".`, + }, + { + id: 'linear.issue.review.visible', + title: 'Linear Issue Review Visible Prompt', + group: 'Linear', + description: 'Visible user message when creating a session from a Linear issue.', + placeholders: [ + { key: 'identifier', description: 'Linear issue identifier, such as ENG-12.' }, + ], + template: 'Review this Linear issue {{identifier}} using the provided issue context', + }, + { + id: 'linear.issue.review.instructions', + title: 'Linear Issue Review Instructions', + group: 'Linear', + description: 'Hidden instructions attached when generating a Linear issue review response.', + template: `Review this Linear issue using the provided issue context. + +Process: +- First classify the issue type (bug / feature request / question/support / refactor / ops) and state it as: Type: . +- Gather any needed repository context (code, config, docs) to validate assumptions. +- After gathering, if anything is still unclear or cannot be verified, do not speculate — state what's missing and ask targeted questions. + +Mode selection by type: +- Bug / Question/Support / Ops: deliver the response directly using the matching template below. Do not bombard me with questions for straightforward diagnosis; use "Missing info" / "Repro/diagnostics needed" fields instead. +- Feature request / Refactor with substantive unknowns: this is effectively a planning session. Do not emit the Feature template on the first turn. Instead, ask me focused clarifying questions in batches of at most 3, one topic at a time (scope, constraints, tradeoffs, UX, etc.), wait for answers, drop questions that became irrelevant, and repeat until you have no more substantive questions. Only then emit the Feature template. + +Output rules: +- Compact output; pick ONE template below and omit the others. +- No emojis. No code snippets. No fenced blocks. +- Short inline code identifiers allowed. +- Reference evidence with file paths and line ranges when applicable; if exact lines are not available, cite the file and say "approx" + why. +- Keep the entire response under ~300 words (applies to the final template output, not to clarifying-question turns). + +Templates (choose one): +Bug: +- Summary (1-2 sentences) +- Likely cause (max 2) +- Repro/diagnostics needed (max 3) +- Fix approach (max 4 steps) +- Verification (max 3) + +Feature: +- Summary (1-2 sentences) +- Requirements (max 4) +- Unknowns/questions (max 4) +- Proposed plan (max 5 steps) +- Verification (max 3) + +Question/Support: +- Summary (1-2 sentences) +- Answer/guidance (max 6 lines) +- Missing info (max 4) + Do not implement changes until I confirm; end with: "Next actions: <1 sentence>".`, }, { diff --git a/packages/ui/src/lib/messages/contextParts.test.ts b/packages/ui/src/lib/messages/contextParts.test.ts index 52caca5f..a04b8658 100644 --- a/packages/ui/src/lib/messages/contextParts.test.ts +++ b/packages/ui/src/lib/messages/contextParts.test.ts @@ -114,6 +114,13 @@ describe('round-trip through part metadata', () => { expect(readContextPart(part)).toEqual(payload); }); + test('linear references carry picker-built text and the identifier', () => { + const payload: ContextPartPayload = { kind: 'linear-issue', identifier: 'ENG-12', title: 'Login', url: 'https://linear.app/x/issue/ENG-12' }; + const part = asPart(payload, 'Linear issue context (JSON)\n{}'); + expect(part.text).toBe('Linear issue context (JSON)\n{}'); + expect(readContextPart(part)).toEqual(payload); + }); + test('non-text parts, missing metadata, and malformed payloads read as null', () => { expect(readContextPart({ type: 'file', metadata: {} })).toBeNull(); expect(readContextPart({ type: 'text' })).toBeNull(); diff --git a/packages/ui/src/lib/messages/contextParts.ts b/packages/ui/src/lib/messages/contextParts.ts index f875f8a7..8ec46a2c 100644 --- a/packages/ui/src/lib/messages/contextParts.ts +++ b/packages/ui/src/lib/messages/contextParts.ts @@ -96,6 +96,13 @@ type GitHubPrContext = { url: string; }; +type LinearIssueContext = { + kind: 'linear-issue'; + identifier: string; + title: string; + url: string; +}; + export type ContextPartPayload = | CodeCommentContext | TerminalContextPayload @@ -105,7 +112,8 @@ export type ContextPartPayload = | FileQuoteContext | ChatQuoteContext | GitHubIssueContext - | GitHubPrContext; + | GitHubPrContext + | LinearIssueContext; export type ContextPartMetadata = { [K in typeof CONTEXT_METADATA_KEY]: ContextPartPayload }; @@ -154,6 +162,7 @@ export function formatContextText(payload: ContextPartPayload): string { return `Attached failed GitHub PR check (${payload.label}):\n\`\`\`\n${payload.output}\n\`\`\`${payload.text ? `\n\n${payload.text}` : ''}`; case 'github-issue': case 'github-pr': + case 'linear-issue': // Linked issues/PRs carry server-fetched context text built by // their pickers; there is no default text to derive here. return ''; @@ -162,8 +171,9 @@ export function formatContextText(payload: ContextPartPayload): string { /** * Build the synthetic part for one context payload. `text` overrides the - * derived text; github-issue/github-pr payloads require it because their - * model-facing context is fetched by the picker, not derived from metadata. + * derived text; github-issue/github-pr/linear-issue payloads require it + * because their model-facing context is fetched by the picker, not derived + * from metadata. */ export function createContextPart(payload: ContextPartPayload, text?: string): ContextPart { const resolvedText = text ?? formatContextText(payload); @@ -297,6 +307,12 @@ const contextPayloadSchema = z.discriminatedUnion('kind', [ title: z.string(), url: z.string(), }), + z.object({ + kind: z.literal('linear-issue'), + identifier: z.string().min(1), + title: z.string(), + url: z.string(), + }), ]); /** The subset of a message part that context read-back inspects. */ diff --git a/packages/ui/src/lib/router/openSessionFromRoute.test.ts b/packages/ui/src/lib/router/openSessionFromRoute.test.ts new file mode 100644 index 00000000..d6db8198 --- /dev/null +++ b/packages/ui/src/lib/router/openSessionFromRoute.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; +import type { Session } from '@opencode-ai/sdk/v2'; + +import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; + +import { openSessionFromRoute } from './openSessionFromRoute'; + +const SESSION_ID = 'ses_linear_open'; +const PROJECT_DIR = '/projects/linear-from-url'; +const OTHER_DIR = '/projects/linear-from-url-other'; + +const buildSession = (id: string, directory: string): Session => ({ + id, + title: id, + directory, + time: { created: 1, updated: 2 }, +} as Session); + +describe('openSessionFromRoute', () => { + beforeEach(() => { + useSessionUIStore.getState().setCurrentSession(null); + useGlobalSessionsStore.setState({ + activeSessions: [], + archivedSessions: [], + sessionsByDirectory: new Map(), + hasLoaded: true, + status: 'ready', + }); + }); + + test('selects the routed session once the global list knows its directory', async () => { + useGlobalSessionsStore.setState({ + activeSessions: [buildSession(SESSION_ID, PROJECT_DIR)], + archivedSessions: [], + hasLoaded: true, + status: 'ready', + }); + + await openSessionFromRoute(SESSION_ID); + + expect(useSessionUIStore.getState().currentSessionId).toBe(SESSION_ID); + expect(useSessionUIStore.getState().currentSessionDirectory).toBe(PROJECT_DIR); + }); + + test('replaces a guessed directory once the global list knows the owner', async () => { + const id = 'ses_linear_guessed'; + useSessionUIStore.getState().setCurrentSession(id); + const guessed = useSessionUIStore.getState().currentSessionDirectory; + + useGlobalSessionsStore.setState({ + activeSessions: [buildSession(id, OTHER_DIR)], + archivedSessions: [], + hasLoaded: true, + status: 'ready', + }); + + await openSessionFromRoute(id); + + expect(useSessionUIStore.getState().currentSessionId).toBe(id); + expect(useSessionUIStore.getState().currentSessionDirectory).toBe(OTHER_DIR); + expect(guessed).not.toBe(OTHER_DIR); + }); +}); diff --git a/packages/ui/src/lib/router/openSessionFromRoute.ts b/packages/ui/src/lib/router/openSessionFromRoute.ts new file mode 100644 index 00000000..c59aa9b4 --- /dev/null +++ b/packages/ui/src/lib/router/openSessionFromRoute.ts @@ -0,0 +1,33 @@ +import { ensureGlobalSessionsLoaded, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; + +/** + * Select a session named by `/?session=`. Cold loads often do not know the + * owning directory yet, so a first selection may guess the active project. + * After the global session list is available, re-select with that directory + * unless the user already moved to a different session. + */ +export async function openSessionFromRoute(sessionId: string): Promise { + const id = sessionId.trim(); + if (!id) return; + + const initial = useSessionUIStore.getState(); + if (initial.currentSessionId !== id) { + initial.setCurrentSession(id, initial.getDirectoryForSession(id)); + } + + const snapshot = await ensureGlobalSessionsLoaded().catch(() => null); + if (!snapshot) return; + + const latest = useSessionUIStore.getState(); + if (latest.currentSessionId !== id) return; + + const session = [...snapshot.activeSessions, ...snapshot.archivedSessions] + .find((entry) => entry.id === id); + if (!session) return; + + const directory = resolveGlobalSessionDirectory(session); + if (!directory || directory === latest.currentSessionDirectory) return; + + latest.setCurrentSession(id, directory); +} diff --git a/packages/ui/src/lib/router/parseRoute.test.ts b/packages/ui/src/lib/router/parseRoute.test.ts new file mode 100644 index 00000000..610fb68e --- /dev/null +++ b/packages/ui/src/lib/router/parseRoute.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from 'bun:test'; + +import { parseRoute } from './parseRoute'; + +describe('parseRoute session', () => { + test('reads a session id including OpenCode underscores', () => { + const route = parseRoute(new URLSearchParams('session=ses_abc123')); + expect(route.sessionId).toBe('ses_abc123'); + }); + + test('decodes a percent-encoded session id', () => { + const route = parseRoute(new URLSearchParams('session=ses%5Fabc123')); + expect(route.sessionId).toBe('ses_abc123'); + }); + + test('ignores a blank session param', () => { + const route = parseRoute(new URLSearchParams('session=')); + expect(route.sessionId).toBeNull(); + }); +}); diff --git a/packages/ui/src/lib/settings/metadata.ts b/packages/ui/src/lib/settings/metadata.ts index eea1ba80..4fc3c8d6 100644 --- a/packages/ui/src/lib/settings/metadata.ts +++ b/packages/ui/src/lib/settings/metadata.ts @@ -202,7 +202,7 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [ { slug: 'voice', title: 'Voice', group: 'general', kind: 'single', keywords: ['tts', 'speech', 'voice'], isAvailable: (ctx) => !ctx.isVSCode }, { slug: 'tunnel', title: 'External Tunnel', group: 'projects', kind: 'single', keywords: ['tunnel', 'external', 'cloudflare', 'qr', 'remote', 'mobile', 'share'], isAvailable: (ctx) => !ctx.isVSCode }, { slug: 'about', title: 'About', group: 'general', kind: 'single', keywords: ['about', 'version', 'updates', 'release', 'changelog'], isAvailable: (ctx) => ctx.isMobile && !ctx.isVSCode }, - { slug: 'integrations', title: 'Integrations', group: 'general', kind: 'single', keywords: ['integration', 'plugin', 'provider', 'oauth', 'claude', 'cursor', 'command code', 'connect', 'discord', 'telegram', 'messenger'] }, + { slug: 'integrations', title: 'Integrations', group: 'general', kind: 'single', keywords: ['integration', 'plugin', 'provider', 'oauth', 'claude', 'cursor', 'command code', 'connect', 'discord', 'telegram', 'messenger', 'linear'] }, ] as const; const LEGACY_SIDEBAR_SECTION_TO_SETTINGS_SLUG: Record = { diff --git a/packages/ui/src/lib/settings/search.test.ts b/packages/ui/src/lib/settings/search.test.ts index 96274aa4..e3cf0fa8 100644 --- a/packages/ui/src/lib/settings/search.test.ts +++ b/packages/ui/src/lib/settings/search.test.ts @@ -38,4 +38,30 @@ describe('settings search', () => { expect(results.some((result) => result.id === 'integrations.third-party.opencode-cursor-oauth')).toBe(true); }); + + test('finds Linear connect on the integrations page', () => { + const results = buildSettingsSearchResults({ + query: 'linear', + runtimeCtx, + t, + getPageTitle: (page) => page, + }); + + expect(results.some((result) => result.id === 'integrations.linear')).toBe(true); + expect(results.some((result) => result.id === 'integrations.linear.add-workspace')).toBe(true); + expect(results.some((result) => result.id === 'integrations.linear.mapping')).toBe(true); + }); + + test('hides Linear connect in VS Code', () => { + const results = buildSettingsSearchResults({ + query: 'linear', + runtimeCtx: { ...runtimeCtx, isVSCode: true }, + t, + getPageTitle: (page) => page, + }); + + expect(results.some((result) => result.id === 'integrations.linear')).toBe(false); + expect(results.some((result) => result.id === 'integrations.linear.add-workspace')).toBe(false); + expect(results.some((result) => result.id === 'integrations.linear.mapping')).toBe(false); + }); }); diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index cf8fe7b9..d428ec62 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -984,6 +984,38 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ isAvailable: (ctx) => ctx.isWeb && !ctx.isDesktop && !ctx.isVSCode, }, + { + id: 'integrations.first-party', + page: 'integrations', + titleKey: 'settings.integrations.firstParty.title', + descriptionKey: 'settings.integrations.firstParty.info', + keywords: ['built-in', 'first-party', 'native', 'linear'], + isAvailable: (ctx) => !ctx.isVSCode, + }, + { + id: 'integrations.linear', + page: 'integrations', + titleKey: 'settings.integrations.linear.title', + descriptionKey: 'settings.integrations.linear.description', + keywords: ['linear', 'issues', 'oauth', 'connect', 'workspace'], + isAvailable: (ctx) => !ctx.isVSCode, + }, + { + id: 'integrations.linear.add-workspace', + page: 'integrations', + titleKey: 'settings.integrations.linear.actions.addWorkspace', + descriptionKey: 'settings.integrations.linear.description', + keywords: ['linear', 'workspace', 'add', 'connect', 'oauth'], + isAvailable: (ctx) => !ctx.isVSCode, + }, + { + id: 'integrations.linear.mapping', + page: 'integrations', + titleKey: 'settings.integrations.linear.mapping.defaultProject', + descriptionKey: 'settings.integrations.linear.mapping.defaultProject.info', + keywords: ['linear', 'project', 'team', 'map', 'workspace', 'directory'], + isAvailable: (ctx) => !ctx.isVSCode, + }, { id: 'integrations.third-party', page: 'integrations', diff --git a/packages/ui/src/lib/surfaces/DOCUMENTATION.md b/packages/ui/src/lib/surfaces/DOCUMENTATION.md index fd750557..dbdf65f3 100644 --- a/packages/ui/src/lib/surfaces/DOCUMENTATION.md +++ b/packages/ui/src/lib/surfaces/DOCUMENTATION.md @@ -26,10 +26,10 @@ edge (`components/layout/ContextPanelRail.tsx`) and rendered by (`useUIStore.contextRailHiddenSurfaces`, edited from the rail's trailing configure button — `ContextRailSurfacesDialog`), drops the plan surface unless plan mode is enabled, - drops the walkthrough on VS Code and below `WALKTHROUGH_MIN_WIDTH`, and hides - `has-content` surfaces until a tab of their mode exists. Both consumers use - it so the digit shown on a rail badge always maps to the same surface the - shortcut opens. + drops the walkthrough on VS Code and below `WALKTHROUGH_MIN_WIDTH`, hides + Linear unless a workspace is connected, and hides `has-content` surfaces + until a tab of their mode exists. Both consumers use it so the digit shown + on a rail badge always maps to the same surface the shortcut opens. ## Adding a surface @@ -53,7 +53,22 @@ the `openContext*` actions in `useUIStore`. positions). Chat tab records stay open, but only the active chat iframe is mounted while the panel is open. A selected chat restores its state from the session stores. A closed panel mounts no chat iframe. - Singleton surfaces (git, pr, notes, plan, context) remount on switch. These + Singleton surfaces (git, pr, linear, notes, plan, context) remount on switch. These surfaces must restore their state from stores or snapshots. - Runtime scope: desktop/web `MainLayout` only. VS Code and the dedicated mobile shell have their own layouts and do not consume this registry. + Linear is a desktop/web singleton on this rail. VS Code and mobile omit it + (no this registry, and VS Code has no `RuntimeAPIs.linear`). The Linear + rail icon is hidden until a Linear workspace is connected. A persisted Linear + tab stays open across reload until auth has resolved; only a confirmed + disconnect closes the panel. The surface lists + issues with status (All, Backlog, To Do, In Progress, In Review, Done, Canceled, Duplicate), assignee, team, and priority filters, can switch + the current workspace, and keeps Start session in a footer on the issue card. + Those filters restore from `useUIStore` when the surface remounts. Non-default + status, assignee, team, priority, and search tint the filter icon `text-primary`, + same as the context rail; one control clears them. Workspace switch is not a + filter. Work-status Context sources + can open a specific issue here through `linearIssueFocus`. Below 520px + search and the filters other than status drop to icons; status keeps its label. The card + shows priority and labels. Changing filters keeps the previous list + until the next page arrives. diff --git a/packages/ui/src/lib/surfaces/registry.test.ts b/packages/ui/src/lib/surfaces/registry.test.ts index 73f93acf..e60a1af3 100644 --- a/packages/ui/src/lib/surfaces/registry.test.ts +++ b/packages/ui/src/lib/surfaces/registry.test.ts @@ -12,6 +12,7 @@ const baseOptions = { isVSCode: false, screenWidth: 1200, tabs: [], + linearConnected: true, } as const; describe('getVisibleContextRailSurfaces', () => { @@ -63,4 +64,17 @@ describe('getVisibleContextRailSurfaces', () => { const surfaces = getVisibleContextRailSurfaces({ ...baseOptions, railOrder: ['git', 'context'] }); expect(surfaces.slice(0, 2).map((surface) => surface.id)).toEqual(['git', 'context']); }); + + test('places Linear after Pull Request in the default order', () => { + const ids = getVisibleContextRailSurfaces(baseOptions).map((surface) => surface.id); + const pr = ids.indexOf('pr'); + const linear = ids.indexOf('linear'); + expect(pr).toBeGreaterThanOrEqual(0); + expect(linear).toBe(pr + 1); + }); + + test('hides Linear until a workspace is connected', () => { + expect(getVisibleContextRailSurfaces({ ...baseOptions, linearConnected: false }).some((s) => s.id === 'linear')).toBe(false); + expect(getVisibleContextRailSurfaces({ ...baseOptions, linearConnected: true }).some((s) => s.id === 'linear')).toBe(true); + }); }); diff --git a/packages/ui/src/lib/surfaces/registry.ts b/packages/ui/src/lib/surfaces/registry.ts index 21d2992f..9a0a8e10 100644 --- a/packages/ui/src/lib/surfaces/registry.ts +++ b/packages/ui/src/lib/surfaces/registry.ts @@ -6,6 +6,7 @@ export type ContextSurfaceId = | 'editor' | 'git' | 'pr' + | 'linear' | 'diff' | 'walkthrough' | 'terminal' @@ -65,6 +66,15 @@ export const CONTEXT_SURFACES: readonly ContextSurfaceDescriptor[] = [ labelKey: 'contextPanel.mode.pr', availability: 'always', }, + { + id: 'linear', + descriptionKey: 'contextRail.surface.linear.description', + defaultWidthFraction: 0.45, + mode: 'linear', + icon: 'linear', + labelKey: 'contextPanel.mode.linear', + availability: 'always', + }, { id: 'diff', descriptionKey: 'contextRail.surface.diff.description', @@ -194,6 +204,8 @@ type VisibleRailSurfacesOptions = { isVSCode: boolean; screenWidth: number; tabs: readonly { mode: ContextPanelMode }[]; + /** Linear's rail icon stays off until a workspace is connected. */ + linearConnected: boolean; }; /** @@ -225,6 +237,9 @@ export const getVisibleContextRailSurfaces = (options: VisibleRailSurfacesOption if (surface.id === 'browser' && options.isVSCode) { return false; } + if (surface.id === 'linear' && !options.linearConnected) { + return false; + } if (surface.availability === 'has-content') { return options.tabs.some((tab) => tab.mode === surface.mode); } diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index 1817654a..5f1d7d44 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -38,7 +38,7 @@ Examples: - `useFeatureFlagsStore.ts` - `useUpdateStore.ts` -These stores coordinate visible app state, navigation, selected context-panel tabs, dialogs, and lightweight feature flags. `useUIStore.activeSurface` selects the primary mobile view and the few desktop views that are promoted out of the context panel. It is not a desktop tab selection. +These stores coordinate visible app state, navigation, selected context-panel tabs, dialogs, and lightweight feature flags. `useUIStore.activeSurface` selects the primary mobile view and the few desktop views that are promoted out of the context panel. It is not a desktop tab selection. Linear panel list filters (status, assignee, team, priority) live here too: the Linear rail surface remounts on switch, so those filters restore from this store rather than component state. `resetLinearIssueListFilters` restores those four defaults together; search stays local to the rail. `linearIssueFocus` is a one-shot identifier so work-status can open a specific issue in that panel; it is not persisted. Context-panel session chats mount only the active chat iframe. After installing its message listener, the iframe requests its authoritative visibility from the diff --git a/packages/ui/src/stores/useLinearAuthStore.ts b/packages/ui/src/stores/useLinearAuthStore.ts new file mode 100644 index 00000000..95560a2f --- /dev/null +++ b/packages/ui/src/stores/useLinearAuthStore.ts @@ -0,0 +1,67 @@ +import { create } from 'zustand'; +import type { LinearAuthStatus, RuntimeAPIs } from '@/lib/api/types'; + +type LinearAuthStatusWithError = LinearAuthStatus & { error?: string }; + +type LinearAuthStore = { + status: LinearAuthStatusWithError | null; + isLoading: boolean; + hasChecked: boolean; + setStatus: (status: LinearAuthStatusWithError | null) => void; + refreshStatus: ( + runtimeLinear?: RuntimeAPIs['linear'], + options?: { force?: boolean } + ) => Promise; +}; + +const fetchStatus = async ( + runtimeLinear?: RuntimeAPIs['linear'] +): Promise => { + if (!runtimeLinear) { + return { connected: false }; + } + return runtimeLinear.authStatus(); +}; + +let inFlightAuthRefresh: Promise | null = null; + +export const useLinearAuthStore = create((set, get) => ({ + status: null, + isLoading: false, + hasChecked: false, + setStatus: (status) => set({ status, hasChecked: true }), + refreshStatus: async (runtimeLinear, options) => { + if (!runtimeLinear) { + return get().status; + } + const { hasChecked, status } = get(); + if (hasChecked && !options?.force) { + return status; + } + + if (inFlightAuthRefresh) return inFlightAuthRefresh; + + set({ isLoading: true }); + inFlightAuthRefresh = (async () => { + try { + const payload = await fetchStatus(runtimeLinear); + set({ status: payload, isLoading: false, hasChecked: true }); + return payload; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + // A failed request is not an authoritative disconnect. Keep the last + // known status and leave `hasChecked` false so the next caller retries + // instead of hiding Linear for the rest of the session. + set((state) => ({ + status: state.status + ? { ...state.status, error: message } + : { connected: false, error: message }, + isLoading: false, + })); + return null; + } + })().finally(() => { inFlightAuthRefresh = null; }); + + return inFlightAuthRefresh; + }, +})); diff --git a/packages/ui/src/stores/useUIStore.linearFilters.test.ts b/packages/ui/src/stores/useUIStore.linearFilters.test.ts new file mode 100644 index 00000000..44789aa4 --- /dev/null +++ b/packages/ui/src/stores/useUIStore.linearFilters.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; +import { LINEAR_ISSUE_LIST_ALL_TEAMS, useUIStore } from './useUIStore'; + +describe('linear issue list filters', () => { + beforeEach(() => { + useUIStore.setState({ + linearIssueListStatus: 'all', + linearIssueListAssignee: 'any', + linearIssueListTeamId: LINEAR_ISSUE_LIST_ALL_TEAMS, + linearIssueListPriority: 'all', + linearIssueFocus: null, + }); + }); + + test('stores status, assignee, team, and priority across setter calls', () => { + useUIStore.getState().setLinearIssueListStatus('todo'); + expect(useUIStore.getState().linearIssueListStatus).toBe('todo'); + useUIStore.getState().setLinearIssueListStatus('started'); + expect(useUIStore.getState().linearIssueListStatus).toBe('started'); + useUIStore.getState().setLinearIssueListStatus('inReview'); + expect(useUIStore.getState().linearIssueListStatus).toBe('inReview'); + useUIStore.getState().setLinearIssueListStatus('completed'); + expect(useUIStore.getState().linearIssueListStatus).toBe('completed'); + useUIStore.getState().setLinearIssueListStatus('canceled'); + expect(useUIStore.getState().linearIssueListStatus).toBe('canceled'); + useUIStore.getState().setLinearIssueListStatus('duplicate'); + expect(useUIStore.getState().linearIssueListStatus).toBe('duplicate'); + useUIStore.getState().setLinearIssueListStatus('backlog'); + expect(useUIStore.getState().linearIssueListStatus).toBe('backlog'); + useUIStore.getState().setLinearIssueListStatus('all'); + useUIStore.getState().setLinearIssueListAssignee('me'); + useUIStore.getState().setLinearIssueListTeamId('team-eng'); + useUIStore.getState().setLinearIssueListPriority('urgent'); + + expect(useUIStore.getState().linearIssueListStatus).toBe('all'); + expect(useUIStore.getState().linearIssueListAssignee).toBe('me'); + expect(useUIStore.getState().linearIssueListTeamId).toBe('team-eng'); + expect(useUIStore.getState().linearIssueListPriority).toBe('urgent'); + }); + + test('resets status, assignee, team, and priority together', () => { + useUIStore.getState().setLinearIssueListStatus('todo'); + useUIStore.getState().setLinearIssueListAssignee('me'); + useUIStore.getState().setLinearIssueListTeamId('team-eng'); + useUIStore.getState().setLinearIssueListPriority('urgent'); + + useUIStore.getState().resetLinearIssueListFilters(); + + expect(useUIStore.getState().linearIssueListStatus).toBe('all'); + expect(useUIStore.getState().linearIssueListAssignee).toBe('any'); + expect(useUIStore.getState().linearIssueListTeamId).toBe(LINEAR_ISSUE_LIST_ALL_TEAMS); + expect(useUIStore.getState().linearIssueListPriority).toBe('all'); + }); + + test('treats a blank team id as all teams', () => { + useUIStore.getState().setLinearIssueListTeamId('team-eng'); + useUIStore.getState().setLinearIssueListTeamId(' '); + expect(useUIStore.getState().linearIssueListTeamId).toBe(LINEAR_ISSUE_LIST_ALL_TEAMS); + }); + + test('stores a one-shot Linear issue identifier for the rail panel', () => { + useUIStore.getState().setLinearIssueFocus(' ENG-12 '); + expect(useUIStore.getState().linearIssueFocus).toBe('ENG-12'); + useUIStore.getState().setLinearIssueFocus(' '); + expect(useUIStore.getState().linearIssueFocus).toBeNull(); + useUIStore.getState().setLinearIssueFocus('ENG-12'); + useUIStore.getState().setLinearIssueFocus(null); + expect(useUIStore.getState().linearIssueFocus).toBeNull(); + }); +}); diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 3049ccd8..0066881c 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -7,14 +7,14 @@ import type { ShortcutCombo } from '@/lib/shortcuts'; import type { DraftStarterRef } from '@/lib/draftStarters'; import { DEFAULT_MONO_FONT, DEFAULT_UI_FONT, type MonoFontOption, type UiFontOption } from '@/lib/fontOptions'; import { getStoredMobileKeyboardMode, type MobileKeyboardMode } from '@/lib/mobileKeyboardMode'; -import type { TerminalShell } from '@/lib/api/types'; +import type { LinearIssueListAssignee, LinearIssueListPriority, LinearIssueListStatus, TerminalShell } from '@/lib/api/types'; import type { ProjectRef } from '@/lib/projectContextApi'; import { useFilesViewTabsStore } from './useFilesViewTabsStore'; import { isWindowsArm64 } from '@/lib/platform'; import { isVSCodeRuntime } from '@/lib/desktop'; export type PendingDiffScope = 'working' | 'staged' | 'turn' | 'branch'; -export type ContextPanelMode = 'diff' | 'walkthrough' | 'file' | 'context' | 'plan' | 'chat' | 'browser' | 'git' | 'pr' | 'notes' | 'terminal'; +export type ContextPanelMode = 'diff' | 'walkthrough' | 'file' | 'context' | 'plan' | 'chat' | 'browser' | 'git' | 'pr' | 'linear' | 'notes' | 'terminal'; export type MermaidRenderingMode = 'svg' | 'ascii'; export type UserMessageRenderingMode = 'markdown' | 'plain'; export type ChatRenderMode = 'sorted' | 'live'; @@ -40,6 +40,37 @@ function normalizeFileEditorKeymap(value: unknown): FileEditorKeymap { return value === 'vim' ? 'vim' : 'default'; } +export const LINEAR_ISSUE_LIST_ALL_TEAMS = 'all'; + +function sanitizeLinearIssueListStatus(value: unknown): LinearIssueListStatus { + return value === 'all' + || value === 'backlog' + || value === 'todo' + || value === 'started' + || value === 'inReview' + || value === 'completed' + || value === 'canceled' + || value === 'duplicate' + ? value + : 'all'; +} + +function sanitizeLinearIssueListAssignee(value: unknown): LinearIssueListAssignee { + return value === 'me' || value === 'any' ? value : 'any'; +} + +function sanitizeLinearIssueListTeamId(value: unknown): string { + if (typeof value !== 'string') return LINEAR_ISSUE_LIST_ALL_TEAMS; + const teamId = value.trim(); + return teamId || LINEAR_ISSUE_LIST_ALL_TEAMS; +} + +function sanitizeLinearIssueListPriority(value: unknown): LinearIssueListPriority { + return value === 'none' || value === 'urgent' || value === 'high' || value === 'medium' || value === 'low' || value === 'all' + ? value + : 'all'; +} + type ContextPanelTab = { id: string; mode: ContextPanelMode; @@ -342,7 +373,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => { // Legacy 'preview' tabs are converted to 'browser' by the v14 migration; // anything still carrying an unknown mode here is discarded rather than // resurrected into a tab the panel cannot render. - if (candidate.mode !== 'diff' && candidate.mode !== 'walkthrough' && candidate.mode !== 'file' && candidate.mode !== 'context' && candidate.mode !== 'plan' && candidate.mode !== 'chat' && candidate.mode !== 'browser' && candidate.mode !== 'git' && candidate.mode !== 'pr' && candidate.mode !== 'notes' && candidate.mode !== 'terminal') { + if (candidate.mode !== 'diff' && candidate.mode !== 'walkthrough' && candidate.mode !== 'file' && candidate.mode !== 'context' && candidate.mode !== 'plan' && candidate.mode !== 'chat' && candidate.mode !== 'browser' && candidate.mode !== 'git' && candidate.mode !== 'pr' && candidate.mode !== 'linear' && candidate.mode !== 'notes' && candidate.mode !== 'terminal') { continue; } @@ -618,7 +649,7 @@ const sanitizeContextPanelByDirectory = ( if (candidate.widthByMode && typeof candidate.widthByMode === 'object') { for (const [mode, value] of Object.entries(candidate.widthByMode as Record)) { if ( - (mode === 'diff' || mode === 'file' || mode === 'context' || mode === 'plan' || mode === 'chat' || mode === 'browser' || mode === 'git' || mode === 'pr' || mode === 'notes' || mode === 'terminal') + (mode === 'diff' || mode === 'file' || mode === 'context' || mode === 'plan' || mode === 'chat' || mode === 'browser' || mode === 'git' || mode === 'pr' || mode === 'linear' || mode === 'notes' || mode === 'terminal') && typeof value === 'number' && Number.isFinite(value) ) { @@ -787,6 +818,12 @@ interface UIStore { /** Width of the walkthrough table of contents, in pixels. */ walkthroughTocWidth: number; gitChangesViewMode: 'flat' | 'tree'; + linearIssueListStatus: LinearIssueListStatus; + linearIssueListAssignee: LinearIssueListAssignee; + linearIssueListTeamId: string; + linearIssueListPriority: LinearIssueListPriority; + /** One-shot identifier for opening a Linear issue in the rail panel. Not persisted. */ + linearIssueFocus: string | null; isTimelineDialogOpen: boolean; isPromptNavigatorPanelOpen: boolean; isImagePreviewOpen: boolean; @@ -983,6 +1020,12 @@ interface UIStore { setDiffWrapLines: (wrap: boolean) => void; setWalkthroughTocWidth: (width: number) => void; setGitChangesViewMode: (mode: 'flat' | 'tree') => void; + setLinearIssueListStatus: (status: LinearIssueListStatus) => void; + setLinearIssueListAssignee: (assignee: LinearIssueListAssignee) => void; + setLinearIssueListTeamId: (teamId: string) => void; + setLinearIssueListPriority: (priority: LinearIssueListPriority) => void; + resetLinearIssueListFilters: () => void; + setLinearIssueFocus: (identifier: string | null) => void; setMultiRunLauncherOpen: (open: boolean) => void; setTimelineDialogOpen: (open: boolean) => void; setPromptNavigatorPanelOpen: (open: boolean) => void; @@ -1140,6 +1183,11 @@ export const useUIStore = create()( diffWrapLines: false, walkthroughTocWidth: 224, gitChangesViewMode: 'flat', + linearIssueListStatus: 'all', + linearIssueListAssignee: 'any', + linearIssueListTeamId: LINEAR_ISSUE_LIST_ALL_TEAMS, + linearIssueListPriority: 'all', + linearIssueFocus: null, isTimelineDialogOpen: false, isPromptNavigatorPanelOpen: false, isImagePreviewOpen: false, @@ -2055,7 +2103,37 @@ export const useUIStore = create()( setGitChangesViewMode: (mode) => { set({ gitChangesViewMode: mode }); }, - + + setLinearIssueListStatus: (status) => { + set({ linearIssueListStatus: sanitizeLinearIssueListStatus(status) }); + }, + + setLinearIssueListAssignee: (assignee) => { + set({ linearIssueListAssignee: sanitizeLinearIssueListAssignee(assignee) }); + }, + + setLinearIssueListTeamId: (teamId) => { + set({ linearIssueListTeamId: sanitizeLinearIssueListTeamId(teamId) }); + }, + + setLinearIssueListPriority: (priority) => { + set({ linearIssueListPriority: sanitizeLinearIssueListPriority(priority) }); + }, + + resetLinearIssueListFilters: () => { + set({ + linearIssueListStatus: 'all', + linearIssueListAssignee: 'any', + linearIssueListTeamId: LINEAR_ISSUE_LIST_ALL_TEAMS, + linearIssueListPriority: 'all', + }); + }, + + setLinearIssueFocus: (identifier) => { + const trimmed = identifier?.trim() ?? ''; + set({ linearIssueFocus: trimmed || null }); + }, + setInputBarOffset: (offset) => { set({ inputBarOffset: offset }); }, @@ -2712,6 +2790,11 @@ export const useUIStore = create()( } } + state.linearIssueListStatus = sanitizeLinearIssueListStatus(state.linearIssueListStatus); + state.linearIssueListAssignee = sanitizeLinearIssueListAssignee(state.linearIssueListAssignee); + state.linearIssueListTeamId = sanitizeLinearIssueListTeamId(state.linearIssueListTeamId); + state.linearIssueListPriority = sanitizeLinearIssueListPriority(state.linearIssueListPriority); + state.fileEditorKeymap = normalizeFileEditorKeymap(state.fileEditorKeymap); state.largeTextPasteBehavior = normalizeLargeTextPasteBehavior(state.largeTextPasteBehavior); @@ -2789,6 +2872,10 @@ export const useUIStore = create()( diffWrapLines: state.diffWrapLines, walkthroughTocWidth: state.walkthroughTocWidth, gitChangesViewMode: state.gitChangesViewMode, + linearIssueListStatus: state.linearIssueListStatus, + linearIssueListAssignee: state.linearIssueListAssignee, + linearIssueListTeamId: state.linearIssueListTeamId, + linearIssueListPriority: state.linearIssueListPriority, nativeNotificationsEnabled: state.nativeNotificationsEnabled, notificationMode: state.notificationMode, showTerminalQuickKeysOnDesktop: state.showTerminalQuickKeysOnDesktop, diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 7c174659..911b1844 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -77,6 +77,7 @@ import { createOpenCodeWatcherRuntime } from './lib/opencode/watcher.js'; import { createSessionAssistRuntime } from './lib/session-assist/runtime.js'; import { createSessionGoalRuntime } from './lib/session-goal/runtime.js'; import { createContextObligatoryRuntime } from './lib/context-obligatory/runtime.js'; +import { createLinearSessionStatusRuntime } from './lib/linear/status-runtime.js'; import { createSessionKnowledgeRuntime } from './lib/session-knowledge/runtime.js'; import { createScheduledTasksRuntime } from './lib/scheduled-tasks/runtime.js'; import { createServerStartupRuntime } from './lib/opencode/server-startup-runtime.js'; @@ -856,6 +857,8 @@ const contextObligatoryRuntime = createContextObligatoryRuntime({ sessionKnowledgeRuntime, }); +const linearSessionStatusRuntime = createLinearSessionStatusRuntime(); + const globalMessageStreamHub = createGlobalMessageStreamHub({ buildOpenCodeUrl, getOpenCodeAuthHeaders, @@ -901,6 +904,7 @@ globalMessageStreamHub.subscribeEvent((event) => { sessionAssistRuntime.processPayload(payload, directory); sessionGoalRuntime.processPayload(payload, directory); contextObligatoryRuntime.processPayload(payload, directory); + linearSessionStatusRuntime.processPayload(payload); }); const processForwardedEventPayload = (payload, emitSyntheticEvent) => { diff --git a/packages/web/server/lib/linear/DOCUMENTATION.md b/packages/web/server/lib/linear/DOCUMENTATION.md new file mode 100644 index 00000000..3b158738 --- /dev/null +++ b/packages/web/server/lib/linear/DOCUMENTATION.md @@ -0,0 +1,97 @@ +# Linear Module Documentation + +## Purpose + +This module owns Linear OAuth, issue lookup, Linear-team-to-project mapping, issue status updates, and session status comments on Linear issues. Credentials live on the OpenChamber server, so web, desktop, and a phone paired to that host share them. You can store more than one Linear workspace; exactly one is current. Issue list, mapping, and new OAuth default to the current workspace. Session status comments use the workspace that started the session. The right-hand context panel lists issues for the current workspace, can switch workspace, filters the list, shows a read-only card, changes status or closes the issue, and starts a session or worktree. Start session stays visible in a footer while the issue card scrolls. The chat picker lists issues and attaches them to a message. New Worktree can also start from a Linear issue in the currently active project. A session started from a Linear issue can post started/completed/failure comments, each with an OpenChamber session link. Those comments are opt-in and only appear when this server has a publicly reachable address. + +VS Code omits Linear (`RuntimeAPIs.linear` is optional). Hide Linear UI when the API is missing. + +## Entrypoints and structure + +- `packages/web/server/lib/linear/index.js`: public server entrypoint. `routes.js` loads it lazily with `await import('./index.js')`. +- `packages/web/server/lib/linear/routes.js`: Express registration for the public callback, `/api/linear/auth/*`, `/api/linear/issues/*`, `/api/linear/mapping`, and `/api/linear/session-status`. +- `packages/web/server/lib/linear/auth.js`: auth file, client id, scopes, redirect URI. +- `packages/web/server/lib/linear/oauth.js`: authorization-code + PKCE S256, public callback broker handoff, refresh, revoke. +- `packages/web/server/lib/linear/client.js`: GraphQL helper, viewer/organization lookup, and access-token refresh. GraphQL errors prefer `extensions.userPresentableMessage` / validation constraints over the generic `Argument Validation Error` label. User-facing Linear errors set `LinearApiError.userError`. Requests send `public-file-urls-expire-in: 3600` so file URLs in issue descriptions and comments are temporarily readable in the panel. +- `packages/web/server/lib/linear/issues.js`: list/search/get issues, team workflow states, `issueUpdate`, and `commentCreate`. Parses identifiers and Linear URLs. `issueUpdate` resolves identifiers to UUIDs first because Linear's mutation does not accept `ENG-12`. List/get include `state.id`, `priority` (0–4), and labels (`id`, `name`, sanitized hex `color`) so the panel can show them and update status. +- `packages/web/server/lib/linear/teams.js`: list Linear teams for mapping UI. +- `packages/web/server/lib/linear/mapping.js`: persist default and per-team OpenChamber project paths. Separate from the auth file so disconnect does not wipe maps. +- `packages/web/server/lib/linear/status.js`: persist per-session started/completed/failure flags and post the matching Linear comment with an open-session URL. Posts nothing unless the user opted in and the session origin is public; `isPublicSessionOrigin` rejects loopback, private LAN, carrier-grade NAT, link-local and single-label hosts. The dedupe file keeps the newest 500 sessions. +- `packages/web/server/lib/linear/status-runtime.js`: on the OpenCode event hub, first `session.status` idle after started posts completed once; `session.error` (except abort) posts failure once. +- `packages/web/src/api/linear.ts`: web client wrapper. Electron and hosted/Capacitor mobile reuse it. VS Code omits `linear`. + +## Public routes + +- `GET /linear/oauth/callback`: public fallback for an explicitly configured direct redirect URI. The built-in flow uses the stable callback broker instead, because desktop and self-hosted instances may have private or dynamic addresses. +- `GET /api/linear/auth/status`: connected flag, current user/organization/scope, and `workspaces` (id, name, current, user, authorizedAt). Never returns tokens. A 401 on the current workspace drops that workspace only; if another remains, status returns that one instead of disconnected. Identity refresh does not bump `authorizedAt`. +- `POST /api/linear/auth/start`: returns `{ authorizationUrl, expiresIn, scope }`. Body may include `origin: "desktop"` so the callback page can raise the desktop window. The authorize URL uses `prompt=consent` so Add workspace can pick a different Linear org. Completing OAuth stores or replaces that org and makes it current. +- `POST /api/linear/auth/activate`: body `{ organizationId }`. Makes that stored workspace current. 400 if the id is missing, 404 if it is not stored. +- `DELETE /api/linear/auth`: revokes the current workspace refresh token when present, then drops that workspace only. Other stored workspaces stay. Mapping is kept. +- `GET /api/linear/issues/list?query=&cursor=&status=&assignee=&teamId=&priority=`: issues from the current workspace. Omitted `status` is incomplete states (same as the chat picker). The panel sends `all`, `backlog`, `todo` (Linear `unstarted`), `started` (In Progress, excluding the In Review name), `inReview` (state name In Review), `completed` (Done), `canceled` (excluding the Duplicate name), or `duplicate` (state type or name Duplicate). `assignee` is `any` (default) or `me`. `teamId` limits the list to that Linear team. `priority` is `all` (default), `none`, `urgent`, `high`, `medium`, or `low`. An identifier or Linear URL returns that issue even if it is completed and ignores the other filters. Each issue includes `state.id` when Linear sends it, plus `priority` (0 none through 4 low) and `labels`. Never returns tokens. +- `GET /api/linear/issues/get?id=`: one issue by UUID or identifier, including description, comments, team, `state.id`, priority, and labels. +- `GET /api/linear/issues/states?teamId=`: workflow states for that Linear team (`id`, `name`, `type`, `position`), ordered like Linear's workflow: type (backlog, unstarted, started, completed, canceled) then position. Missing `teamId` is 400. Linear not-found or validation errors are 400 with Linear's presentable message. Disconnected is `{ connected: false }` with HTTP 200. +- `POST /api/linear/issues/update`: body `{ id, stateId }`. `id` may be a UUID, identifier, or Linear URL; identifiers are resolved before `issueUpdate` because Linear's mutation requires a UUID. Returns the updated issue. Closing an issue is this same call with the team's first `type: completed` state. Missing `id` or `stateId` is 400. Linear validation (for example a non-UUID `stateId`) is 400 with Linear's presentable message. A GraphQL 401 clears that workspace only. Disconnected is `{ connected: false }` with HTTP 200. +- `GET /api/linear/mapping`: stored default project plus live Linear teams with their mapped paths. Missing file is empty mapping. Malformed file is 500, not empty success. Disconnected is `{ connected: false }` with HTTP 200. +- `PUT /api/linear/mapping`: replace default project and per-team paths. Body `{ defaultProjectPath, teamProjectPaths }`. Failed write does not touch tokens. Disconnected is `{ connected: false }` and does not save. +- `GET /api/linear/preferences`: `{ sessionComments }`. `PUT /api/linear/preferences` with body `{ sessionComments: boolean }` replaces it and returns the stored value. A non-boolean body is 400. The preference is server-side because the event hub posts completed/failure without going through the interface. +- `POST /api/linear/session-status`: post a started/completed/failure comment on the linked Linear issue. Body `{ kind, sessionId, issueIdentifier?, sessionOrigin? }`. `started` requires `issueIdentifier`. `completed` and `failure` reuse the stored issue and open URL from `started`. Each kind posts at most once per session. Answers in this order: disconnected is `{ connected: false }` with HTTP 200; comments turned off is `skipped: 'disabled'`; a `sessionOrigin` nobody else can reach is `skipped: 'origin-not-public'`. `sessionOrigin` must be `http` or `https` with no path, and must resolve to a public host — loopback, private LAN and desktop deep links post no comment at all rather than a link only its author can open. Comment bodies are one markdown link: `[OpenChamber session started](url)` so Linear keeps the `?session=` query. The comment carries no issue or session title: it already sits on the issue, and titles routinely contain brackets that would break the link. Invalid body is 400. + +`POST /api/linear/auth/start`, `PUT /api/linear/mapping`, `POST /api/linear/issues/update`, and `POST /api/linear/session-status` parse JSON on the route (`16kb`). They are not on the `/api` 50mb allowlist. + +Disconnected list/get/states/update/mapping/session-status return `{ connected: false }` with HTTP 200 so the picker and panel can show an empty state. Missing `id` on get is 400. Missing `teamId` on states is 400. + +## Auth storage and config + +- Auth storage: `~/.config/openchamber/linear-auth.json` (or `$OPENCHAMBER_DATA_DIR/linear-auth.json`). Shape is `{ workspaces: [ { accessToken, refreshToken, user, organization, workspaceId, current, authorizedAt, ... } ] }`. `workspaceId` is the Linear organization id, or `user:` when there is no org, or `legacy` for a migrated token with neither. A legacy single-object file is rewritten to this list on read. Reconnecting the same org replaces that slot. +- Mapping storage: `~/.config/openchamber/linear-mapping.json` (same data dir). Shape is `{ workspaces: { [workspaceId]: { defaultProjectPath, teamProjectPaths } } }`. Reads and writes use the current workspace slice. A legacy flat file is wrapped under the current workspace id on read. Disconnect does not wipe maps. Writes are atomic and file mode is `0o600`. +- Session status storage: `~/.config/openchamber/linear-session-status.json` (same data dir). Writes are atomic and file mode is `0o600`. Dedupes started/completed/failure per OpenChamber session id. +- Writes are atomic and file mode is `0o600`. +- Client ID: `OPENCHAMBER_LINEAR_CLIENT_ID` -> `settings.json` `linearClientId` -> baked-in public default. +- Client secret: `OPENCHAMBER_LINEAR_CLIENT_SECRET` -> `settings.json` `linearClientSecret`. Optional with PKCE. Do not commit a secret. +- Scopes: `OPENCHAMBER_LINEAR_SCOPES` -> `settings.json` `linearScopes` -> `read,write,comments:create`. +- Session comments: `settings.json` `linearSessionComments`, boolean, absent means off. Written only through `PUT /api/linear/preferences`. +- Broker URL: `OPENCHAMBER_LINEAR_BROKER_URL` -> `settings.json` `linearBrokerUrl` -> `https://api.openchamber.dev/v1/oauth/linear`. +- Redirect URI: `OPENCHAMBER_LINEAR_REDIRECT_URI` -> `settings.json` `linearRedirectUri` -> `/callback`. Setting an explicit redirect URI bypasses the broker for custom/self-hosted OAuth applications. + +Linear requires an exact callback match. The built-in application registers `https://api.openchamber.dev/v1/oauth/linear/callback`; the broker holds only the short-lived authorization code. The local OpenChamber server keeps the claim secret and PKCE verifier, exchanges the code for tokens locally, then acknowledges the handoff. Custom brokers must expose `/start`, `/callback`, `/poll`, and `/complete` with the same contract. + +## OAuth contract + +- Authorization code + PKCE S256. Linear has no device flow. +- The broker stores hashes of OAuth state and a separate claim secret for ten minutes. It never receives the PKCE verifier or Linear tokens. The local status polling path claims a completed broker result and persists tokens on the OpenChamber server. +- Access tokens expire in 24 hours. Refresh tokens rotate; persist the new refresh token from every successful refresh. Concurrent refreshes share one in-flight promise per workspace. +- `invalid_grant` / 401 on refresh clears that workspace only so a dead token cannot loop. If it was the last workspace, status becomes disconnected. +- A GraphQL 401 after a valid-looking token also clears that workspace. A network failure while a token is stored does not: status stays connected with the last known user. + +## Project mapping + +OpenChamber has projects (directories), not accounts or organizations. Mapping is how create-session (picker and the right-hand panel) picks a directory: + +1. If the issue's Linear team has a project path, use that. +2. Otherwise use the default project path. +3. If neither is set, the UI tells the user to map the team in Settings → Integrations. It does not fall back to the currently active project. + +A worktree started from the panel or picker is created in that mapped project. New Worktree from Git is different: it stays in the currently active project. + +## Shared UI + +- `RuntimeAPIs.linear` is optional. Hide Linear settings, the chat picker, and the panel when it is missing (VS Code). +- Store: `packages/ui/src/stores/useLinearAuthStore.ts`. App start refreshes it from `App.tsx` and `MobileApp.tsx`, not `VSCodeApp`. +- Settings: first-party section on the Integrations page. Connect opens the authorization URL and polls status until the workspace list or current `authorizedAt` changes, so Add workspace is not treated as done just because a workspace was already connected. When connected, map a default project and optional per-team projects for the current workspace. Other stored workspaces appear in a list with Switch to. Disconnect removes the current workspace only. The panel can also switch the current workspace when more than one is stored. +- Context panel: desktop/web right-hand rail surface `linear` (`packages/ui/src/components/views/LinearIssuesView.tsx`). Singleton like git/pr. The rail icon is hidden until a Linear workspace is connected; disconnecting while the panel is open closes it. List/search defaults to all issues; the status filter is All, Backlog, To Do, In Progress, In Review, Done, Canceled, and Duplicate, matching the card status order. Identifier/URL still finds completed. Status, assignee, team, and priority filters persist in `useUIStore` so they survive rail switches. Non-default list filters and search tint the filter icon `text-primary`, same as the context rail; one control clears them, not the workspace switch. Changing those filters keeps the previous list until the next page arrives and does not disable the filter row. On a narrow panel search and the filters other than status drop to icons; status keeps its label. The card shows priority and labels. Comments render as an avatar timeline matching the pull request panel, so both context surfaces read alike; comment authors carry `avatarUrl`. The card is read-only except status (`issueUpdate`) and Close (first completed workflow state). Start session stays in a footer while the description and comments scroll. Start session / worktree share `startLinearIssueSession` with the picker. No create-issue, no writing comments, no polling. VS Code and the mobile workspace drawer omit this rail. +- Chat: composer attach menu "Link Linear Issue" attaches body and comments as `linear-issue` context on the next send. Exclusive with a linked GitHub issue or PR. The attached issue is stored on session metadata (`kind: 'linear'`) so work status can show it. Clicking that work-status row opens the Linear rail when Linear is connected on desktop/web; otherwise the Linear URL. Managed Chats do not offer start-from-issue; those sessions have no project directory. +- Worktree: New Worktree can start from a Linear issue. It uses the currently active project and does not consult team-to-project mapping. GitHub issue/PR and Linear issue are exclusive on that form. +- Status comments: off until the user turns them on in Settings -> Integrations -> Linear (`LinearSessionComments.tsx`). When on, create-session and worktree-from-Linear post `started` after the session exists. The event hub posts `completed` on the first idle after that, and `failure` on `session.error` except `MessageAbortedError`. Failed comments must not fail session create. Comment bodies are English (they live on Linear) and are one markdown link named `OpenChamber session started` (or completed/failed). Web uses `/?session=` on the current origin; desktop reports the loopback origin its own server listens on, not `openchamber-ui://`. A Linear comment is read by the whole team, so the server posts nothing when that origin is not publicly reachable rather than publishing a link only its author could open. Opening `/?session=` selects that session after the global session list can resolve its directory. +- Magic prompts: `linear.issue.review.visible` / `.instructions`. Do not reuse the GitHub issue-review templates for Linear. + +## Notes for contributors + +The implementation and deployment hand-off for the stable callback broker is +in [`OAUTH-BROKER-HANDOFF.md`](./OAUTH-BROKER-HANDOFF.md). It records the exact +Linear redirect URI that must be registered and why the original loopback +callback could not support packaged desktop or arbitrary self-hosted servers. + +- Do not log tokens, codes, verifiers, or the client secret. +- Do not add Linear under Git or as a third-party plugin row. +- Actor is `user`. Do not enable Linear client-credentials tokens for this flow. +- One OAuth grant is still one Linear organization. The server stores many grants and keeps one current. Webhooks and inbound Linear issue actions are out of scope until a later change. diff --git a/packages/web/server/lib/linear/OAUTH-BROKER-HANDOFF.md b/packages/web/server/lib/linear/OAUTH-BROKER-HANDOFF.md new file mode 100644 index 00000000..12d99711 --- /dev/null +++ b/packages/web/server/lib/linear/OAUTH-BROKER-HANDOFF.md @@ -0,0 +1,91 @@ +# Linear OAuth broker hand-off + +## Required Linear application change + +Register this exact redirect URI in the Linear OAuth application used by the +baked-in client ID: + +`https://api.openchamber.dev/v1/oauth/linear/callback` + +Linear compares the full redirect URI, including scheme, host, path, and port. +Deploy the API broker and apply its D1 migration before testing this branch. + +## Why the original callback failed + +The first implementation redirected Linear back to the OpenChamber server: + +`http://127.0.0.1:/linear/oauth/callback` + +That address is not stable across OpenChamber runtimes: + +- packaged desktop prefers its stored local port and can select another free + port when needed; +- local development and the CLI use different ports; +- self-hosted servers may sit behind a reverse proxy or have no public inbound + address at all. + +Linear requires an exact pre-registered callback. Registering every possible +desktop or self-hosted address is impossible, and forcing desktop onto one port +would make startup fail whenever another process owns that port. + +## New flow + +The built-in Linear client now uses the stable callback broker in +`openchamber-website/apps/api`: + +1. The OpenChamber server generates OAuth state, a PKCE verifier, and a separate + claim secret. +2. The broker stores only hashes of state and the claim secret for ten minutes. +3. Linear sends its authorization code to the stable public callback. +4. The OpenChamber server polls the broker with state and the claim secret. +5. The OpenChamber server exchanges the code using the PKCE verifier and stores + the Linear tokens locally. +6. After persistence succeeds, OpenChamber acknowledges the hand-off and the + broker marks it consumed. + +The broker never receives the PKCE verifier, access token, or refresh token. +Private Relay is not involved; the local server only needs outbound HTTPS. + +## Compatibility and configuration + +- `OPENCHAMBER_LINEAR_BROKER_URL` or `settings.json` `linearBrokerUrl` selects a + self-hosted broker. The default is + `https://api.openchamber.dev/v1/oauth/linear`. +- `OPENCHAMBER_LINEAR_REDIRECT_URI` or `settings.json` `linearRedirectUri` + bypasses the broker and preserves the direct callback flow for a custom + Linear OAuth application. + +## Owning files + +OpenChamber: + +- `auth.js`: broker and redirect configuration. +- `oauth.js`: PKCE, broker registration/poll/acknowledgement, token exchange. +- `routes.js`: starts authorization and completes broker results during status + polling. + +Hosted API, in the `openchamber-website` repository: + +- `apps/api/src/routes/linear-oauth.ts` +- `apps/api/migrations/0010_linear_oauth_transactions.sql` +- `apps/api/LINEAR-OAUTH.md` + +## Validation + +OpenChamber focused tests: + +```sh +bunx vitest run \ + packages/web/server/lib/linear/oauth.test.js \ + packages/web/server/lib/linear/auth.test.js \ + packages/web/server/lib/linear/routes.test.js +``` + +Hosted API checks: + +```sh +cd apps/api +bun test src/routes/linear-oauth.test.ts +bun run check +bun run build +``` diff --git a/packages/web/server/lib/linear/auth.js b/packages/web/server/lib/linear/auth.js new file mode 100644 index 00000000..67ef53c8 --- /dev/null +++ b/packages/web/server/lib/linear/auth.js @@ -0,0 +1,436 @@ +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { isPlainObject, readEnv, readFiniteNumber, readTrimmedString } from './parse.js'; + +const DEFAULT_LINEAR_CLIENT_ID = '91bbe26a69a2c8568d3683f1e01e776c'; +const DEFAULT_LINEAR_SCOPES = 'read,write,comments:create'; +const DEFAULT_LINEAR_BROKER_URL = 'https://api.openchamber.dev/v1/oauth/linear'; +const ACCESS_TOKEN_REFRESH_SKEW_MS = 2 * 60_000; +const LEGACY_WORKSPACE_ID = 'legacy'; +const SESSION_COMMENTS_SETTING_KEY = 'linearSessionComments'; + +function resolveDataDir() { + const fromEnv = readEnv('OPENCHAMBER_DATA_DIR'); + if (fromEnv) { + return path.resolve(fromEnv); + } + return path.join(os.homedir(), '.config', 'openchamber'); +} + +function storageFile() { + return path.join(resolveDataDir(), 'linear-auth.json'); +} + +function settingsFile() { + return path.join(resolveDataDir(), 'settings.json'); +} + +function ensureStorageDir() { + const dir = resolveDataDir(); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +function readJsonFile(filePath) { + if (!fs.existsSync(filePath)) { + return null; + } + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + const parsed = JSON.parse(trimmed); + if (!isPlainObject(parsed)) { + return null; + } + return parsed; + } catch (error) { + console.error('Failed to read Linear auth file:', error); + return null; + } +} + +function writeJsonFile(filePath, payload) { + ensureStorageDir(); + const tmpFile = `${filePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tmpFile, JSON.stringify(payload, null, 2), 'utf8'); + try { + fs.chmodSync(tmpFile, 0o600); + } catch { + // best-effort + } + fs.renameSync(tmpFile, filePath); + try { + fs.chmodSync(filePath, 0o600); + } catch { + // best-effort + } +} + +function normalizeUser(user) { + if (!isPlainObject(user)) { + return null; + } + const id = readTrimmedString(user.id); + if (!id) { + return null; + } + return { + id, + name: readTrimmedString(user.name) || null, + displayName: readTrimmedString(user.displayName) || null, + email: readTrimmedString(user.email) || null, + avatarUrl: readTrimmedString(user.avatarUrl) || null, + }; +} + +function normalizeOrganization(organization) { + if (!isPlainObject(organization)) { + return null; + } + const id = readTrimmedString(organization.id); + const name = readTrimmedString(organization.name); + if (!id || !name) { + return null; + } + return { + id, + name, + urlKey: readTrimmedString(organization.urlKey) || null, + }; +} + +function resolveLinearWorkspaceId({ organization, user, workspaceId } = {}) { + const explicit = readTrimmedString(workspaceId); + if (explicit) return explicit; + const organizationId = organization ? readTrimmedString(organization.id) : ''; + if (organizationId) return organizationId; + const userId = user ? readTrimmedString(user.id) : ''; + if (userId) return `user:${userId}`; + return LEGACY_WORKSPACE_ID; +} + +function normalizeAuthEntry(raw) { + if (!isPlainObject(raw)) { + return null; + } + const accessToken = readTrimmedString(raw.accessToken); + if (!accessToken) { + return null; + } + const user = normalizeUser(raw.user); + const organization = normalizeOrganization(raw.organization); + return { + accessToken, + refreshToken: readTrimmedString(raw.refreshToken) || null, + tokenType: readTrimmedString(raw.tokenType) || 'bearer', + expiresAt: readFiniteNumber(raw.expiresAt), + scope: readTrimmedString(raw.scope), + createdAt: readFiniteNumber(raw.createdAt), + authorizedAt: readFiniteNumber(raw.authorizedAt) || readFiniteNumber(raw.createdAt), + user, + organization, + current: Boolean(raw.current), + workspaceId: resolveLinearWorkspaceId({ + organization, + user, + workspaceId: raw.workspaceId, + }), + }; +} + +function normalizeAuthList(raw) { + const source = Array.isArray(raw?.workspaces) + ? raw.workspaces + : (raw?.accessToken ? [raw] : []); + const list = source.map((entry) => normalizeAuthEntry(entry)).filter(Boolean); + + if (!list.length) { + return { list: [], changed: Boolean(raw && (raw.accessToken || Array.isArray(raw.workspaces))) }; + } + + let changed = Array.isArray(raw?.workspaces) === false && Boolean(raw?.accessToken); + const seen = new Set(); + const deduped = []; + for (const entry of list) { + if (seen.has(entry.workspaceId)) { + changed = true; + continue; + } + seen.add(entry.workspaceId); + deduped.push(entry); + } + + let currentFound = false; + deduped.forEach((entry) => { + if (entry.current && !currentFound) { + currentFound = true; + } else if (entry.current && currentFound) { + entry.current = false; + changed = true; + } + }); + + if (!currentFound && deduped[0]) { + deduped[0].current = true; + changed = true; + } + + return { list: deduped, changed }; +} + +function readAuthList() { + const data = readJsonFile(storageFile()); + if (!data) { + return []; + } + const { list, changed } = normalizeAuthList(data); + if (changed) { + writeAuthList(list); + } + return list; +} + +function writeAuthList(list) { + if (!list.length) { + const filePath = storageFile(); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + return; + } + writeJsonFile(storageFile(), { workspaces: list }); +} + +function readSettings() { + return readJsonFile(settingsFile()) || {}; +} + +function writeSettings(settings) { + writeJsonFile(settingsFile(), settings); +} + +function readSettingString(key) { + const stored = readSettings()[key]; + return readTrimmedString(stored); +} + +export function getLinearAuth() { + const list = readAuthList(); + if (!list.length) { + return null; + } + return list.find((entry) => entry.current) || list[0]; +} + +export function getLinearAuthByWorkspaceId(workspaceId) { + const id = readTrimmedString(workspaceId); + if (!id) { + return getLinearAuth(); + } + return readAuthList().find((entry) => entry.workspaceId === id) || null; +} + +export function getLinearAuthWorkspaces() { + return readAuthList().map((entry) => ({ + id: entry.workspaceId, + name: entry.organization?.name || null, + urlKey: entry.organization?.urlKey || null, + current: Boolean(entry.current), + user: entry.user || null, + authorizedAt: entry.authorizedAt || entry.createdAt || null, + })); +} + +export function setLinearAuth(input, options = {}) { + const accessToken = readTrimmedString(input?.accessToken); + if (!accessToken) { + throw new Error('accessToken is required'); + } + const activate = options.activate !== false; + const list = readAuthList(); + const current = list.find((entry) => entry.current) || list[0] || null; + + const nextUser = Object.prototype.hasOwnProperty.call(input, 'user') + ? normalizeUser(input.user) + : current?.user || null; + const nextOrganization = Object.prototype.hasOwnProperty.call(input, 'organization') + ? normalizeOrganization(input.organization) + : current?.organization || null; + const workspaceId = resolveLinearWorkspaceId({ + organization: nextOrganization, + user: nextUser, + workspaceId: input?.workspaceId || (nextOrganization || nextUser ? '' : current?.workspaceId), + }); + + const existingIndex = list.findIndex((entry) => entry.workspaceId === workspaceId); + const previous = existingIndex >= 0 ? list[existingIndex] : ( + nextOrganization || nextUser ? null : current + ); + const targetIndex = existingIndex >= 0 + ? existingIndex + : (previous && !nextOrganization && !nextUser ? list.indexOf(previous) : -1); + const wasCurrent = previous?.current === true; + + const next = { + accessToken, + refreshToken: Object.prototype.hasOwnProperty.call(input, 'refreshToken') + ? (readTrimmedString(input.refreshToken) || null) + : previous?.refreshToken || null, + tokenType: readTrimmedString(input?.tokenType) || previous?.tokenType || 'bearer', + expiresAt: readFiniteNumber(input?.expiresAt) ?? previous?.expiresAt ?? null, + scope: readTrimmedString(input?.scope) || previous?.scope || '', + createdAt: previous?.createdAt || Date.now(), + authorizedAt: Object.prototype.hasOwnProperty.call(input, 'authorizedAt') + ? (readFiniteNumber(input.authorizedAt) || Date.now()) + : (activate ? Date.now() : (previous?.authorizedAt || previous?.createdAt || Date.now())), + user: nextUser, + organization: nextOrganization, + current: false, + workspaceId, + }; + + if (targetIndex >= 0) { + list[targetIndex] = next; + } else { + list.push(next); + } + + const writtenIndex = targetIndex >= 0 ? targetIndex : list.length - 1; + if (activate || !list.some((entry) => entry.current)) { + list.forEach((entry, index) => { + entry.current = index === writtenIndex; + }); + } else { + list[writtenIndex].current = wasCurrent; + } + + writeAuthList(list); + return list[writtenIndex]; +} + +export function activateLinearAuth(workspaceId) { + const id = readTrimmedString(workspaceId); + if (!id) { + return false; + } + const list = readAuthList(); + const index = list.findIndex((entry) => entry.workspaceId === id); + if (index === -1) { + return false; + } + list.forEach((entry, idx) => { + entry.current = idx === index; + }); + writeAuthList(list); + return true; +} + +export function clearLinearAuth(workspaceId) { + try { + const list = readAuthList(); + if (!list.length) { + return true; + } + const id = readTrimmedString(workspaceId); + const remaining = id + ? list.filter((entry) => entry.workspaceId !== id) + : list.filter((entry) => !entry.current); + if (!remaining.length) { + writeAuthList([]); + return true; + } + if (!remaining.some((entry) => entry.current)) { + remaining[0].current = true; + } + writeAuthList(remaining); + return true; + } catch (error) { + console.error('Failed to clear Linear auth file:', error); + return false; + } +} + +export function isLinearAccessTokenStale(expiresAt, now = Date.now()) { + const expiry = readFiniteNumber(expiresAt); + if (expiry == null) { + return true; + } + return expiry - ACCESS_TOKEN_REFRESH_SKEW_MS <= now; +} + +export function toLinearPublicStatus(auth, workspaces = getLinearAuthWorkspaces()) { + if (!auth?.accessToken) { + return { connected: false }; + } + return { + connected: true, + user: auth.user || null, + organization: auth.organization || null, + scope: auth.scope || undefined, + workspaces, + }; +} + +export function getLinearClientId() { + const fromEnv = readEnv('OPENCHAMBER_LINEAR_CLIENT_ID'); + if (fromEnv) return fromEnv; + const stored = readSettingString('linearClientId'); + if (stored) return stored; + return DEFAULT_LINEAR_CLIENT_ID; +} + +export function getLinearClientSecret() { + const fromEnv = readEnv('OPENCHAMBER_LINEAR_CLIENT_SECRET'); + if (fromEnv) return fromEnv; + return readSettingString('linearClientSecret'); +} + +export function getLinearScopes() { + const fromEnv = readEnv('OPENCHAMBER_LINEAR_SCOPES'); + if (fromEnv) return fromEnv; + const stored = readSettingString('linearScopes'); + if (stored) return stored; + return DEFAULT_LINEAR_SCOPES; +} + +export function getLinearBrokerUrl() { + const fromEnv = readEnv('OPENCHAMBER_LINEAR_BROKER_URL'); + if (fromEnv) return fromEnv.replace(/\/+$/, ''); + const stored = readSettingString('linearBrokerUrl'); + if (stored) return stored.replace(/\/+$/, ''); + return DEFAULT_LINEAR_BROKER_URL; +} + +export function getLinearRedirectUri() { + const fromEnv = readEnv('OPENCHAMBER_LINEAR_REDIRECT_URI'); + if (fromEnv) return fromEnv; + const stored = readSettingString('linearRedirectUri'); + if (stored) return stored; + return `${getLinearBrokerUrl()}/callback`; +} + +/** + * Status comments are opt-in: they are written into a Linear workspace other + * people read, so nothing is posted until the user turns them on. + */ +export function getLinearSessionCommentsEnabled() { + return readSettings()[SESSION_COMMENTS_SETTING_KEY] === true; +} + +export function setLinearSessionCommentsEnabled(enabled) { + const next = enabled === true; + const settings = readSettings(); + settings[SESSION_COMMENTS_SETTING_KEY] = next; + writeSettings(settings); + return next; +} + +export function getLinearAuthFilePath() { + return storageFile(); +} +export const DEFAULT_LINEAR_CLIENT_ID_VALUE = DEFAULT_LINEAR_CLIENT_ID; diff --git a/packages/web/server/lib/linear/auth.test.js b/packages/web/server/lib/linear/auth.test.js new file mode 100644 index 00000000..a45c7149 --- /dev/null +++ b/packages/web/server/lib/linear/auth.test.js @@ -0,0 +1,242 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { + getLinearAuth, + getLinearAuthWorkspaces, + setLinearAuth, + activateLinearAuth, + clearLinearAuth, + toLinearPublicStatus, + getLinearClientId, + getLinearRedirectUri, + isLinearAccessTokenStale, + getLinearAuthFilePath, + DEFAULT_LINEAR_CLIENT_ID_VALUE, +} from './auth.js'; + +const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-auth-')); + +describe('Linear auth storage', () => { + let dataDir; + let previousDataDir; + let previousPort; + let previousClientId; + let previousRedirect; + + beforeEach(() => { + previousDataDir = process.env.OPENCHAMBER_DATA_DIR; + previousPort = process.env.OPENCHAMBER_PORT; + previousClientId = process.env.OPENCHAMBER_LINEAR_CLIENT_ID; + previousRedirect = process.env.OPENCHAMBER_LINEAR_REDIRECT_URI; + dataDir = makeTempDir(); + process.env.OPENCHAMBER_DATA_DIR = dataDir; + delete process.env.OPENCHAMBER_LINEAR_CLIENT_ID; + delete process.env.OPENCHAMBER_LINEAR_SCOPES; + delete process.env.OPENCHAMBER_LINEAR_REDIRECT_URI; + delete process.env.OPENCHAMBER_PORT; + }); + + afterEach(() => { + restoreEnv('OPENCHAMBER_DATA_DIR', previousDataDir); + restoreEnv('OPENCHAMBER_PORT', previousPort); + restoreEnv('OPENCHAMBER_LINEAR_CLIENT_ID', previousClientId); + restoreEnv('OPENCHAMBER_LINEAR_REDIRECT_URI', previousRedirect); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('returns disconnected when no auth file exists', () => { + expect(getLinearAuth()).toBeNull(); + expect(toLinearPublicStatus(null)).toEqual({ connected: false }); + }); + + it('persists tokens without exposing them on the public status', () => { + setLinearAuth({ + accessToken: 'lin_oauth_access', + refreshToken: 'lin_oauth_refresh', + expiresAt: Date.now() + 60_000, + scope: 'read,write', + user: { id: 'user-1', name: 'Ada', displayName: 'Ada Lovelace', email: 'ada@example.com', avatarUrl: 'https://example.com/a.png' }, + organization: { id: 'org-1', name: 'OpenChamber', urlKey: 'openchamber' }, + }); + + const stored = getLinearAuth(); + expect(stored.accessToken).toBe('lin_oauth_access'); + expect(stored.refreshToken).toBe('lin_oauth_refresh'); + expect(stored.workspaceId).toBe('org-1'); + const publicStatus = toLinearPublicStatus(stored); + expect(publicStatus).toEqual({ + connected: true, + user: { + id: 'user-1', + name: 'Ada', + displayName: 'Ada Lovelace', + email: 'ada@example.com', + avatarUrl: 'https://example.com/a.png', + }, + organization: { id: 'org-1', name: 'OpenChamber', urlKey: 'openchamber' }, + scope: 'read,write', + workspaces: [{ + id: 'org-1', + name: 'OpenChamber', + urlKey: 'openchamber', + current: true, + user: { + id: 'user-1', + name: 'Ada', + displayName: 'Ada Lovelace', + email: 'ada@example.com', + avatarUrl: 'https://example.com/a.png', + }, + authorizedAt: stored.authorizedAt, + }], + }); + expect(JSON.stringify(publicStatus)).not.toContain('lin_oauth'); + const file = JSON.parse(fs.readFileSync(getLinearAuthFilePath(), 'utf8')); + expect(file.accessToken).toBeUndefined(); + expect(file.workspaces).toHaveLength(1); + expect(file.workspaces[0].accessToken).toBe('lin_oauth_access'); + }); + + it('keeps the previous refresh token when a later write omits it', () => { + setLinearAuth({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + expiresAt: 1, + }); + setLinearAuth({ + accessToken: 'access-2', + expiresAt: 2, + }); + expect(getLinearAuth().refreshToken).toBe('refresh-1'); + expect(getLinearAuth().accessToken).toBe('access-2'); + }); + + it('rotates the refresh token when a new one is provided', () => { + setLinearAuth({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + }); + setLinearAuth({ + accessToken: 'access-2', + refreshToken: 'refresh-2', + }); + expect(getLinearAuth().refreshToken).toBe('refresh-2'); + }); + + it('rejects a write without an access token', () => { + expect(() => setLinearAuth({ refreshToken: 'refresh-1' })).toThrow('accessToken is required'); + }); + + it('treats a missing or past expiry as stale', () => { + expect(isLinearAccessTokenStale(null)).toBe(true); + expect(isLinearAccessTokenStale(Date.now() - 1)).toBe(true); + expect(isLinearAccessTokenStale(Date.now() + 10 * 60_000)).toBe(false); + }); + + it('uses the baked-in client id unless env or settings override it', () => { + expect(getLinearClientId()).toBe(DEFAULT_LINEAR_CLIENT_ID_VALUE); + process.env.OPENCHAMBER_LINEAR_CLIENT_ID = 'env-client'; + expect(getLinearClientId()).toBe('env-client'); + }); + + it('uses the stable public broker callback by default', () => { + process.env.OPENCHAMBER_PORT = '3001'; + expect(getLinearRedirectUri()).toBe('https://api.openchamber.dev/v1/oauth/linear/callback'); + process.env.OPENCHAMBER_LINEAR_REDIRECT_URI = 'http://localhost:3000/linear/oauth/callback'; + expect(getLinearRedirectUri()).toBe('http://localhost:3000/linear/oauth/callback'); + }); + + it('deletes the auth file on clear', () => { + setLinearAuth({ accessToken: 'access-1', refreshToken: 'refresh-1' }); + expect(fs.existsSync(getLinearAuthFilePath())).toBe(true); + expect(clearLinearAuth()).toBe(true); + expect(fs.existsSync(getLinearAuthFilePath())).toBe(false); + expect(getLinearAuth()).toBeNull(); + }); + + it('migrates a legacy single-workspace file', () => { + fs.writeFileSync(getLinearAuthFilePath(), JSON.stringify({ + accessToken: 'legacy-access', + refreshToken: 'legacy-refresh', + user: { id: 'user-1', name: 'Ada' }, + organization: { id: 'org-1', name: 'OpenChamber', urlKey: 'openchamber' }, + }), 'utf8'); + + const stored = getLinearAuth(); + expect(stored.accessToken).toBe('legacy-access'); + expect(stored.workspaceId).toBe('org-1'); + expect(stored.current).toBe(true); + const file = JSON.parse(fs.readFileSync(getLinearAuthFilePath(), 'utf8')); + expect(file.workspaces).toHaveLength(1); + expect(file.accessToken).toBeUndefined(); + }); + + it('stores a second workspace and activates it without dropping the first', () => { + setLinearAuth({ + accessToken: 'access-a', + refreshToken: 'refresh-a', + user: { id: 'user-a', name: 'Ada' }, + organization: { id: 'org-a', name: 'Alpha', urlKey: 'alpha' }, + }); + setLinearAuth({ + accessToken: 'access-b', + refreshToken: 'refresh-b', + user: { id: 'user-b', name: 'Ben' }, + organization: { id: 'org-b', name: 'Beta', urlKey: 'beta' }, + }); + + expect(getLinearAuth().workspaceId).toBe('org-b'); + expect(getLinearAuthWorkspaces().map((entry) => entry.id).sort()).toEqual(['org-a', 'org-b']); + expect(activateLinearAuth('org-a')).toBe(true); + expect(getLinearAuth().workspaceId).toBe('org-a'); + expect(getLinearAuth().accessToken).toBe('access-a'); + expect(getLinearAuthWorkspaces().find((entry) => entry.id === 'org-b').current).toBe(false); + }); + + it('drops only the current workspace on unscoped clear', () => { + setLinearAuth({ + accessToken: 'access-a', + organization: { id: 'org-a', name: 'Alpha', urlKey: 'alpha' }, + user: { id: 'user-a', name: 'Ada' }, + }); + setLinearAuth({ + accessToken: 'access-b', + organization: { id: 'org-b', name: 'Beta', urlKey: 'beta' }, + user: { id: 'user-b', name: 'Ben' }, + }); + expect(clearLinearAuth()).toBe(true); + expect(getLinearAuth().workspaceId).toBe('org-a'); + expect(getLinearAuth().accessToken).toBe('access-a'); + expect(getLinearAuthWorkspaces()).toHaveLength(1); + }); + + it('does not bump authorizedAt when a later write opts out of activate', () => { + setLinearAuth({ + accessToken: 'access-1', + user: { id: 'user-1', name: 'Ada' }, + organization: { id: 'org-1', name: 'OpenChamber', urlKey: 'openchamber' }, + }); + const file = JSON.parse(fs.readFileSync(getLinearAuthFilePath(), 'utf8')); + file.workspaces[0].authorizedAt = 111; + fs.writeFileSync(getLinearAuthFilePath(), JSON.stringify(file, null, 2), 'utf8'); + + setLinearAuth({ + accessToken: 'access-1', + user: { id: 'user-1', name: 'Ada' }, + organization: { id: 'org-1', name: 'OpenChamber', urlKey: 'openchamber' }, + workspaceId: 'org-1', + }, { activate: false }); + expect(getLinearAuth().authorizedAt).toBe(111); + expect(getLinearAuth().current).toBe(true); + }); +}); + +function restoreEnv(name, previous) { + if (previous === undefined) { + delete process.env[name]; + return; + } + process.env[name] = previous; +} diff --git a/packages/web/server/lib/linear/client.js b/packages/web/server/lib/linear/client.js new file mode 100644 index 00000000..7eee2d60 --- /dev/null +++ b/packages/web/server/lib/linear/client.js @@ -0,0 +1,191 @@ +import { + getLinearAuth, + getLinearAuthByWorkspaceId, + setLinearAuth, + clearLinearAuth, + isLinearAccessTokenStale, +} from './auth.js'; +import { refreshAccessToken } from './oauth.js'; +import { isPlainObject, readTrimmedString } from './parse.js'; + +const LINEAR_GRAPHQL_URL = 'https://api.linear.app/graphql'; +const VIEWER_QUERY = '{ viewer { id name displayName email avatarUrl } organization { id name urlKey } }'; +// Linear file URLs in GraphQL need this header or the browser cannot load +// uploads.linear.app images (comment screenshots, description images). +const LINEAR_PUBLIC_FILE_URL_TTL_SECONDS = '3600'; + +export class LinearApiError extends Error { + constructor(message, status, options = {}) { + super(message); + this.name = 'LinearApiError'; + this.status = status; + this.userError = options.userError === true; + } +} + +function readGraphqlError(payload) { + const errors = Array.isArray(payload.errors) ? payload.errors : []; + const first = errors.length > 0 && isPlainObject(errors[0]) ? errors[0] : null; + if (!first) { + return { message: '', userError: false, status: 502 }; + } + const extensions = isPlainObject(first.extensions) ? first.extensions : null; + const presentable = extensions ? readTrimmedString(extensions.userPresentableMessage) : ''; + let constraint = ''; + const validationErrors = extensions && Array.isArray(extensions.validationErrors) + ? extensions.validationErrors + : []; + for (const entry of validationErrors) { + if (!isPlainObject(entry) || !isPlainObject(entry.constraints)) continue; + for (const value of Object.values(entry.constraints)) { + const text = readTrimmedString(value); + if (text) { + constraint = text; + break; + } + } + if (constraint) break; + } + const message = presentable || constraint || readTrimmedString(first.message); + const code = extensions ? readTrimmedString(extensions.code) : ''; + const userError = extensions?.userError === true + || code === 'INVALID_INPUT' + || code === 'INPUT_ERROR' + || /^entity not found/i.test(message) + || /^argument validation/i.test(message); + return { + message, + userError, + status: userError ? 400 : 502, + }; +} + +function readIdentity(payload) { + const data = isPlainObject(payload) ? payload.data : null; + const viewer = isPlainObject(data) ? data.viewer : null; + if (!isPlainObject(viewer) || !readTrimmedString(viewer.id)) { + return null; + } + const organization = isPlainObject(data) ? data.organization : null; + const organizationId = isPlainObject(organization) ? readTrimmedString(organization.id) : ''; + const organizationName = isPlainObject(organization) ? readTrimmedString(organization.name) : ''; + return { + user: { + id: viewer.id.trim(), + name: readTrimmedString(viewer.name) || null, + displayName: readTrimmedString(viewer.displayName) || null, + email: readTrimmedString(viewer.email) || null, + avatarUrl: readTrimmedString(viewer.avatarUrl) || null, + }, + organization: organizationId && organizationName + ? { + id: organizationId, + name: organizationName, + urlKey: readTrimmedString(organization.urlKey) || null, + } + : null, + }; +} + +export async function fetchLinearGraphql(accessToken, query, variables) { + const token = readTrimmedString(accessToken); + if (!token) { + throw new LinearApiError('Linear is not connected', 401); + } + + const body = { query }; + if (isPlainObject(variables)) { + body.variables = variables; + } + + const response = await fetch(LINEAR_GRAPHQL_URL, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + 'public-file-urls-expire-in': LINEAR_PUBLIC_FILE_URL_TTL_SECONDS, + }, + body: JSON.stringify(body), + }); + const payload = await response.json().catch(() => null); + if (response.status === 401) { + throw new LinearApiError('Linear token expired or revoked', 401); + } + if (!response.ok) { + throw new LinearApiError(`Linear GraphQL request failed (${response.status})`, response.status); + } + if (!isPlainObject(payload)) { + throw new LinearApiError('Linear GraphQL response was not JSON', 502); + } + const data = isPlainObject(payload.data) ? payload.data : null; + if (!data) { + const graphqlError = readGraphqlError(payload); + throw new LinearApiError( + graphqlError.message || 'Linear GraphQL response did not include data', + graphqlError.status, + { userError: graphqlError.userError }, + ); + } + return data; +} + +export async function fetchLinearIdentity(accessToken) { + const data = await fetchLinearGraphql(accessToken, VIEWER_QUERY); + const identity = readIdentity({ data }); + if (!identity) { + throw new LinearApiError('Linear GraphQL response did not include a viewer', 502); + } + return identity; +} + +const inFlightRefreshByWorkspace = new Map(); + +async function refreshWorkspaceAuth(auth) { + const tokens = await refreshAccessToken(auth.refreshToken); + const next = setLinearAuth({ + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken || auth.refreshToken, + tokenType: tokens.tokenType, + expiresAt: tokens.expiresAt, + scope: tokens.scope || auth.scope, + user: auth.user, + organization: auth.organization, + workspaceId: auth.workspaceId, + }, { activate: false }); + return next.accessToken; +} + +export async function getValidLinearAccessToken(workspaceId) { + const auth = workspaceId + ? getLinearAuthByWorkspaceId(workspaceId) + : getLinearAuth(); + if (!auth?.accessToken) { + return null; + } + if (!isLinearAccessTokenStale(auth.expiresAt)) { + return auth.accessToken; + } + if (!auth.refreshToken) { + clearLinearAuth(auth.workspaceId); + return null; + } + const key = auth.workspaceId; + const pending = inFlightRefreshByWorkspace.get(key); + if (pending) { + return pending; + } + const promise = refreshWorkspaceAuth(auth) + .catch((error) => { + if (error?.code === 'INVALID_GRANT' || error?.status === 400 || error?.status === 401) { + clearLinearAuth(auth.workspaceId); + return null; + } + throw error; + }) + .finally(() => { + inFlightRefreshByWorkspace.delete(key); + }); + inFlightRefreshByWorkspace.set(key, promise); + return promise; +} diff --git a/packages/web/server/lib/linear/index.js b/packages/web/server/lib/linear/index.js new file mode 100644 index 00000000..24300d32 --- /dev/null +++ b/packages/web/server/lib/linear/index.js @@ -0,0 +1,61 @@ +export { + getLinearAuth, + getLinearAuthByWorkspaceId, + getLinearAuthWorkspaces, + setLinearAuth, + activateLinearAuth, + clearLinearAuth, + toLinearPublicStatus, + getLinearClientId, + getLinearClientSecret, + getLinearScopes, + getLinearBrokerUrl, + getLinearRedirectUri, + isLinearAccessTokenStale, + getLinearAuthFilePath, + getLinearSessionCommentsEnabled, + setLinearSessionCommentsEnabled, + DEFAULT_LINEAR_CLIENT_ID_VALUE, +} from './auth.js'; + +export { + startAuthorization, + consumeAuthorizationCallback, + pollAuthorizationBroker, + completeAuthorizationBroker, + refreshAccessToken, + revokeToken, + LinearOAuthError, +} from './oauth.js'; + +export { + fetchLinearIdentity, + getValidLinearAccessToken, + LinearApiError, +} from './client.js'; + +export { + listLinearIssues, + getLinearIssue, + listLinearIssueStates, + updateLinearIssue, +} from './issues.js'; + +export { + listLinearTeams, +} from './teams.js'; + +export { + LinearMappingError, + getLinearMappingFilePath, + mergeLinearMappingView, + readStoredLinearMapping, + resolveMappedProjectPath, + setStoredLinearMapping, +} from './mapping.js'; + +export { + LinearSessionStatusError, + isPublicSessionOrigin, + postLinearSessionStatus, +} from './status.js'; diff --git a/packages/web/server/lib/linear/issues.js b/packages/web/server/lib/linear/issues.js new file mode 100644 index 00000000..01b17f74 --- /dev/null +++ b/packages/web/server/lib/linear/issues.js @@ -0,0 +1,499 @@ +import { clearLinearAuth, getLinearAuth, getLinearAuthByWorkspaceId } from './auth.js'; +import { fetchLinearGraphql, getValidLinearAccessToken } from './client.js'; +import { isPlainObject, isString, readFiniteNumber, readTrimmedString } from './parse.js'; + +const PAGE_SIZE = 50; + +const LIST_STATUS_STATE = { + open: { type: { nin: ['completed', 'canceled', 'duplicate'] } }, + backlog: { type: { eq: 'backlog' } }, + todo: { type: { eq: 'unstarted' } }, + started: { type: { eq: 'started' }, name: { neqIgnoreCase: 'In Review' } }, + inReview: { name: { eqIgnoreCase: 'In Review' } }, + completed: { type: { eq: 'completed' } }, + canceled: { type: { eq: 'canceled' }, name: { neqIgnoreCase: 'Duplicate' } }, + duplicate: { or: [{ type: { eq: 'duplicate' } }, { name: { eqIgnoreCase: 'Duplicate' } }] }, +}; + +function readListStatus(value) { + const status = readTrimmedString(value); + if (status === 'all' || Object.hasOwn(LIST_STATUS_STATE, status)) { + return status; + } + return 'open'; +} + +function readListAssignee(value) { + const assignee = readTrimmedString(value); + if (assignee === 'me' || assignee === 'any') { + return assignee; + } + return 'any'; +} + +const LIST_PRIORITY_EQ = { + none: 0, + urgent: 1, + high: 2, + medium: 3, + low: 4, +}; + +function readListPriority(value) { + const priority = readTrimmedString(value); + if (priority === 'none' || priority === 'urgent' || priority === 'high' || priority === 'medium' || priority === 'low') { + return priority; + } + return 'all'; +} + +function buildIssueListFilter({ status, assignee, teamId, priority } = {}) { + const filter = {}; + const resolvedStatus = readListStatus(status); + const resolvedAssignee = readListAssignee(assignee); + const resolvedPriority = readListPriority(priority); + const team = readTrimmedString(teamId); + if (resolvedStatus !== 'all') { + filter.state = LIST_STATUS_STATE[resolvedStatus]; + } + if (resolvedAssignee === 'me') { + filter.assignee = { isMe: { eq: true } }; + } + if (team) { + filter.team = { id: { eq: team } }; + } + if (resolvedPriority !== 'all') { + filter.priority = { eq: LIST_PRIORITY_EQ[resolvedPriority] }; + } + return Object.keys(filter).length > 0 ? filter : undefined; +} + +const ISSUE_SUMMARY_FIELDS = ` + id + identifier + title + url + priority + state { id name type } + assignee { name displayName avatarUrl } + team { id key name } + labels { nodes { id name color } } +`; +const LIST_QUERY = ` + query ListLinearIssues($first: Int!, $after: String, $filter: IssueFilter) { + issues(first: $first, after: $after, filter: $filter, orderBy: updatedAt) { + nodes { ${ISSUE_SUMMARY_FIELDS} } + pageInfo { hasNextPage endCursor } + } + } +`; +const SEARCH_QUERY = ` + query SearchLinearIssues($term: String!, $first: Int!, $after: String, $filter: IssueFilter) { + searchIssues(term: $term, first: $first, after: $after, filter: $filter) { + nodes { ${ISSUE_SUMMARY_FIELDS} } + pageInfo { hasNextPage endCursor } + } + } +`; +const GET_QUERY = ` + query GetLinearIssue($id: String!) { + issue(id: $id) { + ${ISSUE_SUMMARY_FIELDS} + description + comments(first: 50) { + nodes { + id + body + createdAt + user { name displayName avatarUrl } + } + } + } + } +`; +const COMMENT_CREATE = ` + mutation CommentCreate($input: CommentCreateInput!) { + commentCreate(input: $input) { + success + comment { id } + } + } +`; +const STATES_QUERY = ` + query TeamWorkflowStates($id: String!) { + team(id: $id) { + states(first: 50) { + nodes { id name type position } + } + } + } +`; +const ISSUE_UPDATE = ` + mutation IssueUpdate($id: String!, $input: IssueUpdateInput!) { + issueUpdate(id: $id, input: $input) { + success + issue { + ${ISSUE_SUMMARY_FIELDS} + description + comments(first: 50) { + nodes { + id + body + createdAt + user { name displayName avatarUrl } + } + } + } + } + } +`; +const IDENTIFIER_RE = /^[A-Za-z][A-Za-z0-9]*-\d+$/; +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const URL_IDENTIFIER_RE = /linear\.app\/(?:[^/]+\/)?issue\/([A-Za-z][A-Za-z0-9]*-\d+)/i; + +export function parseLinearIssueRef(value) { + const trimmed = readTrimmedString(value); + if (!trimmed) return null; + const urlMatch = trimmed.match(URL_IDENTIFIER_RE); + if (urlMatch) { + return { kind: 'identifier', value: urlMatch[1].toUpperCase() }; + } + if (IDENTIFIER_RE.test(trimmed)) { + return { kind: 'identifier', value: trimmed.toUpperCase() }; + } + if (UUID_RE.test(trimmed)) { + return { kind: 'id', value: trimmed.toLowerCase() }; + } + return null; +} + +function readState(value) { + if (!isPlainObject(value)) return null; + const id = readTrimmedString(value.id) || null; + const name = readTrimmedString(value.name) || null; + const type = readTrimmedString(value.type) || null; + if (!id && !name && !type) return null; + return { id, name, type }; +} + +const WORKFLOW_TYPE_ORDER = { + triage: 0, + backlog: 1, + unstarted: 2, + started: 3, + completed: 4, + canceled: 5, +}; + +function workflowTypeRank(type) { + if (type === 'triage' || type === 'backlog' || type === 'unstarted' || type === 'started' || type === 'completed' || type === 'canceled') { + return WORKFLOW_TYPE_ORDER[type]; + } + return 99; +} + +function compareWorkflowStates(left, right) { + const typeDelta = workflowTypeRank(left.type) - workflowTypeRank(right.type); + if (typeDelta !== 0) return typeDelta; + if (left.position !== right.position) return left.position - right.position; + return left.name.localeCompare(right.name); +} + +function readWorkflowState(value) { + if (!isPlainObject(value)) return null; + const id = readTrimmedString(value.id); + const name = readTrimmedString(value.name); + if (!id || !name) return null; + const position = readFiniteNumber(value.position); + return { + id, + name, + type: readTrimmedString(value.type) || null, + position: position ?? 0, + }; +} + +function readAssignee(value) { + if (!isPlainObject(value)) return null; + const name = readTrimmedString(value.name) || null; + const displayName = readTrimmedString(value.displayName) || null; + const avatarUrl = readTrimmedString(value.avatarUrl) || null; + if (!name && !displayName && !avatarUrl) return null; + return { name, displayName, avatarUrl }; +} + +function readTeam(value) { + if (!isPlainObject(value)) return null; + const id = readTrimmedString(value.id); + const key = readTrimmedString(value.key); + const name = readTrimmedString(value.name); + if (!id || !key || !name) return null; + return { id, key, name }; +} + +function readPriority(value) { + if (!Number.isInteger(value) || value < 0 || value > 4) return null; + return value; +} + +function readLabelColor(value) { + const raw = readTrimmedString(value); + if (!raw) return null; + const hex = raw.startsWith('#') ? raw.slice(1) : raw; + if (!/^[0-9A-Fa-f]{6}$/.test(hex)) return null; + return `#${hex.toLowerCase()}`; +} + +function readLabel(value) { + if (!isPlainObject(value)) return null; + const id = readTrimmedString(value.id); + const name = readTrimmedString(value.name); + if (!id || !name) return null; + return { + id, + name, + color: readLabelColor(value.color), + }; +} + +function readLabels(value) { + const nodes = isPlainObject(value) && Array.isArray(value.nodes) + ? value.nodes + : Array.isArray(value) + ? value + : []; + return nodes.map(readLabel).filter(Boolean); +} + +function readIssueSummary(node) { + if (!isPlainObject(node)) return null; + const id = readTrimmedString(node.id); + const identifier = readTrimmedString(node.identifier); + const title = readTrimmedString(node.title); + const url = readTrimmedString(node.url); + if (!id || !identifier || !title || !url) return null; + return { + id, + identifier, + title, + url, + state: readState(node.state), + assignee: readAssignee(node.assignee), + team: readTeam(node.team), + priority: readPriority(node.priority), + labels: readLabels(node.labels), + }; +} + +function readComment(node) { + if (!isPlainObject(node)) return null; + const id = readTrimmedString(node.id); + if (!id) return null; + const body = isString(node.body) ? node.body : ''; + const user = isPlainObject(node.user) + ? { + name: readTrimmedString(node.user.name) || null, + displayName: readTrimmedString(node.user.displayName) || null, + avatarUrl: readTrimmedString(node.user.avatarUrl) || null, + } + : null; + return { + id, + body, + createdAt: readTrimmedString(node.createdAt) || null, + user: user && (user.name || user.displayName) ? user : null, + }; +} + +function readIssue(node) { + const summary = readIssueSummary(node); + if (!summary) return null; + const commentsPayload = isPlainObject(node.comments) ? node.comments.nodes : null; + const comments = Array.isArray(commentsPayload) + ? commentsPayload.map(readComment).filter(Boolean) + : []; + return { + ...summary, + description: isString(node.description) ? node.description : null, + comments, + }; +} + +function readPageInfo(connection) { + const pageInfo = isPlainObject(connection) ? connection.pageInfo : null; + if (!isPlainObject(pageInfo)) { + return { hasMore: false, cursor: null }; + } + return { + hasMore: pageInfo.hasNextPage === true, + cursor: readTrimmedString(pageInfo.endCursor) || null, + }; +} + +function readIssueNodes(connection) { + const nodes = isPlainObject(connection) ? connection.nodes : null; + if (!Array.isArray(nodes)) return []; + return nodes.map(readIssueSummary).filter(Boolean); +} + +async function withLinearToken(run, workspaceId) { + try { + const token = await getValidLinearAccessToken(workspaceId); + if (!token) { + return { connected: false }; + } + return await run(token); + } catch (error) { + if (error?.status === 401) { + const failed = workspaceId + ? getLinearAuthByWorkspaceId(workspaceId) + : getLinearAuth(); + clearLinearAuth(failed?.workspaceId || workspaceId); + return { connected: false }; + } + throw error; + } +} + +async function fetchIssueByRef(token, ref) { + const data = await fetchLinearGraphql(token, GET_QUERY, { id: ref.value }); + return readIssue(data.issue); +} + +export async function listLinearIssues({ query, cursor, status, assignee, teamId, priority } = {}) { + return withLinearToken(async (token) => { + const ref = parseLinearIssueRef(query); + if (ref) { + const issue = await fetchIssueByRef(token, ref); + return { + connected: true, + issues: issue ? [issue] : [], + cursor: null, + hasMore: false, + }; + } + + const after = readTrimmedString(cursor) || null; + const term = readTrimmedString(query); + const filter = buildIssueListFilter({ status, assignee, teamId, priority }); + const variables = { + first: PAGE_SIZE, + }; + if (filter) { + variables.filter = filter; + } + if (after) { + variables.after = after; + } + + if (term) { + variables.term = term; + const data = await fetchLinearGraphql(token, SEARCH_QUERY, variables); + const connection = isPlainObject(data.searchIssues) ? data.searchIssues : null; + const page = readPageInfo(connection); + return { + connected: true, + issues: readIssueNodes(connection), + cursor: page.cursor, + hasMore: page.hasMore, + }; + } + + const data = await fetchLinearGraphql(token, LIST_QUERY, variables); + const connection = isPlainObject(data.issues) ? data.issues : null; + const page = readPageInfo(connection); + return { + connected: true, + issues: readIssueNodes(connection), + cursor: page.cursor, + hasMore: page.hasMore, + }; + }); +} + +export async function getLinearIssue(id) { + const ref = parseLinearIssueRef(id) || (readTrimmedString(id) ? { kind: 'id', value: readTrimmedString(id) } : null); + if (!ref) { + return { connected: true, issue: null }; + } + return withLinearToken(async (token) => { + const issue = await fetchIssueByRef(token, ref); + return { connected: true, issue }; + }); +} + +export async function listLinearIssueStates(teamId) { + const id = readTrimmedString(teamId); + if (!id) { + const error = new Error('teamId is required'); + error.code = 'INVALID'; + throw error; + } + return withLinearToken(async (token) => { + const data = await fetchLinearGraphql(token, STATES_QUERY, { id }); + const team = isPlainObject(data.team) ? data.team : null; + const connection = isPlainObject(team) ? team.states : null; + const nodes = isPlainObject(connection) && Array.isArray(connection.nodes) + ? connection.nodes + : []; + const states = nodes + .map(readWorkflowState) + .filter(Boolean) + .sort(compareWorkflowStates); + return { connected: true, states }; + }); +} + +export async function updateLinearIssue({ id, stateId } = {}) { + const issueId = readTrimmedString(id); + const nextStateId = readTrimmedString(stateId); + if (!issueId || !nextStateId) { + const error = new Error('id and stateId are required'); + error.code = 'INVALID'; + throw error; + } + const ref = parseLinearIssueRef(issueId) || { kind: 'id', value: issueId }; + return withLinearToken(async (token) => { + const resolved = ref.kind === 'identifier' + ? await fetchIssueByRef(token, ref) + : null; + const resolvedId = resolved?.id || (ref.kind === 'id' ? ref.value : ''); + if (!resolvedId) { + return { connected: true, issue: null }; + } + const data = await fetchLinearGraphql(token, ISSUE_UPDATE, { + id: resolvedId, + input: { stateId: nextStateId }, + }); + const payload = isPlainObject(data.issueUpdate) ? data.issueUpdate : null; + return { + connected: true, + issue: payload ? readIssue(payload.issue) : null, + }; + }); +} + +export async function createLinearIssueComment({ issueId, body, organizationId } = {}) { + const text = isString(body) ? body : ''; + const ref = parseLinearIssueRef(issueId) + || (readTrimmedString(issueId) ? { kind: 'id', value: readTrimmedString(issueId) } : null); + if (!ref || !text.trim()) { + return { connected: true, comment: null }; + } + return withLinearToken(async (token) => { + const issue = await fetchIssueByRef(token, ref); + if (!issue) { + return { connected: true, comment: null }; + } + const data = await fetchLinearGraphql(token, COMMENT_CREATE, { + input: { issueId: issue.id, body: text }, + }); + const payload = isPlainObject(data.commentCreate) ? data.commentCreate : null; + const comment = isPlainObject(payload?.comment) ? payload.comment : null; + const id = comment ? readTrimmedString(comment.id) : ''; + return { + connected: true, + comment: id ? { id } : null, + }; + }, organizationId); +} diff --git a/packages/web/server/lib/linear/issues.test.js b/packages/web/server/lib/linear/issues.test.js new file mode 100644 index 00000000..bc0a0685 --- /dev/null +++ b/packages/web/server/lib/linear/issues.test.js @@ -0,0 +1,512 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { setLinearAuth, clearLinearAuth } from './auth.js'; +import { getLinearIssue, listLinearIssues, listLinearIssueStates, parseLinearIssueRef, createLinearIssueComment, updateLinearIssue } from './issues.js'; + +const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-issues-')); + +const jsonResponse = (payload, status = 200) => new Response(JSON.stringify(payload), { + status, + headers: { 'Content-Type': 'application/json' }, +}); + +const issueNode = { + id: 'issue-uuid-1', + identifier: 'ENG-12', + title: 'Broken login', + url: 'https://linear.app/openchamber/issue/ENG-12', + priority: 1, + state: { id: 'state-started', name: 'In Progress', type: 'started' }, + assignee: { name: 'Ada', displayName: 'Ada Lovelace', avatarUrl: 'https://example.com/a.png' }, + team: { id: 'team-eng', key: 'ENG', name: 'Engineering' }, + labels: { nodes: [{ id: 'label-bug', name: 'Bug', color: 'EB5757' }] }, +}; + +describe('parseLinearIssueRef', () => { + it('reads identifiers, URLs, and UUIDs', () => { + expect(parseLinearIssueRef('eng-12')).toEqual({ kind: 'identifier', value: 'ENG-12' }); + expect(parseLinearIssueRef('https://linear.app/openchamber/issue/ENG-12/broken-login')) + .toEqual({ kind: 'identifier', value: 'ENG-12' }); + expect(parseLinearIssueRef('11111111-2222-3333-4444-555555555555')) + .toEqual({ kind: 'id', value: '11111111-2222-3333-4444-555555555555' }); + expect(parseLinearIssueRef('login redirect')).toBeNull(); + }); +}); + +describe('Linear issue list/get', () => { + let dataDir; + let previousDataDir; + + beforeEach(() => { + previousDataDir = process.env.OPENCHAMBER_DATA_DIR; + dataDir = makeTempDir(); + process.env.OPENCHAMBER_DATA_DIR = dataDir; + setLinearAuth({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + tokenType: 'Bearer', + expiresAt: Date.now() + 86_400_000, + scope: 'read,write,comments:create', + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + clearLinearAuth(); + if (previousDataDir === undefined) { + delete process.env.OPENCHAMBER_DATA_DIR; + } else { + process.env.OPENCHAMBER_DATA_DIR = previousDataDir; + } + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('returns disconnected without calling Linear when there is no auth', async () => { + clearLinearAuth(); + const graphql = vi.fn(); + vi.stubGlobal('fetch', graphql); + await expect(listLinearIssues()).resolves.toEqual({ connected: false }); + expect(graphql).not.toHaveBeenCalled(); + }); + + it('lists incomplete issues and never returns the token', async () => { + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + expect(body.query).toContain('query ListLinearIssues'); + expect(body.variables.filter.state.type.nin).toEqual(['completed', 'canceled', 'duplicate']); + expect(options.headers.Authorization).toBe('Bearer access-1'); + expect(options.headers['public-file-urls-expire-in']).toBe('3600'); + return jsonResponse({ + data: { + issues: { + nodes: [issueNode], + pageInfo: { hasNextPage: true, endCursor: 'cursor-2' }, + }, + }, + }); + })); + + const result = await listLinearIssues(); + expect(result).toEqual({ + connected: true, + issues: [{ + id: 'issue-uuid-1', + identifier: 'ENG-12', + title: 'Broken login', + url: 'https://linear.app/openchamber/issue/ENG-12', + state: { id: 'state-started', name: 'In Progress', type: 'started' }, + assignee: { name: 'Ada', displayName: 'Ada Lovelace', avatarUrl: 'https://example.com/a.png' }, + team: { id: 'team-eng', key: 'ENG', name: 'Engineering' }, + priority: 1, + labels: [{ id: 'label-bug', name: 'Bug', color: '#eb5757' }], + }], + cursor: 'cursor-2', + hasMore: true, + }); + expect(JSON.stringify(result)).not.toContain('access-1'); + }); + + it('includes priority and labels and drops invalid values', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ + data: { + issues: { + nodes: [{ + ...issueNode, + priority: 9, + labels: { + nodes: [ + { id: 'label-ok', name: 'Bug', color: '#EB5757' }, + { id: 'label-bad-color', name: 'Nope', color: 'red' }, + { id: '', name: 'Missing id' }, + ], + }, + }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }))); + + const result = await listLinearIssues(); + expect(result.issues?.[0]?.priority).toBeNull(); + expect(result.issues?.[0]?.labels).toEqual([ + { id: 'label-ok', name: 'Bug', color: '#eb5757' }, + { id: 'label-bad-color', name: 'Nope', color: null }, + ]); + }); + + it('searches by text and looks up an identifier directly', async () => { + const graphql = vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + if (body.query.includes('SearchLinearIssues')) { + expect(body.variables.term).toBe('login'); + return jsonResponse({ + data: { + searchIssues: { + nodes: [issueNode], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + } + expect(body.variables.id).toBe('ENG-12'); + return jsonResponse({ + data: { + issue: { + ...issueNode, + description: 'Users cannot sign in.', + comments: { + nodes: [{ + id: 'comment-1', + body: 'Still broken', + createdAt: '2026-08-24T10:00:00.000Z', + user: { name: 'Ada', displayName: 'Ada Lovelace' }, + }], + }, + }, + }, + }); + }); + vi.stubGlobal('fetch', graphql); + + const search = await listLinearIssues({ query: 'login' }); + expect(search.issues).toHaveLength(1); + expect(search.hasMore).toBe(false); + + const byId = await listLinearIssues({ query: 'https://linear.app/openchamber/issue/ENG-12' }); + expect(byId.issues?.[0]?.identifier).toBe('ENG-12'); + expect(byId.hasMore).toBe(false); + }); + + it('applies status, assignee, team, and priority list filters', async () => { + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + expect(body.variables.filter).toEqual({ + state: { type: { eq: 'started' }, name: { neqIgnoreCase: 'In Review' } }, + assignee: { isMe: { eq: true } }, + team: { id: { eq: 'team-eng' } }, + priority: { eq: 1 }, + }); + return jsonResponse({ + data: { + issues: { + nodes: [issueNode], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + })); + + const result = await listLinearIssues({ + status: 'started', + assignee: 'me', + teamId: 'team-eng', + priority: 'urgent', + }); + expect(result.issues).toHaveLength(1); + }); + + it('filters each panel status to a Linear state type or name', async () => { + const filters = []; + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + filters.push(JSON.parse(options.body).variables.filter); + return jsonResponse({ + data: { + issues: { + nodes: [issueNode], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + })); + + await listLinearIssues({ status: 'todo' }); + await listLinearIssues({ status: 'backlog' }); + await listLinearIssues({ status: 'started' }); + await listLinearIssues({ status: 'inReview' }); + await listLinearIssues({ status: 'completed' }); + await listLinearIssues({ status: 'canceled' }); + await listLinearIssues({ status: 'duplicate' }); + expect(filters).toEqual([ + { state: { type: { eq: 'unstarted' } } }, + { state: { type: { eq: 'backlog' } } }, + { state: { type: { eq: 'started' }, name: { neqIgnoreCase: 'In Review' } } }, + { state: { name: { eqIgnoreCase: 'In Review' } } }, + { state: { type: { eq: 'completed' } } }, + { state: { type: { eq: 'canceled' }, name: { neqIgnoreCase: 'Duplicate' } } }, + { state: { or: [{ type: { eq: 'duplicate' } }, { name: { eqIgnoreCase: 'Duplicate' } }] } }, + ]); + }); + + it('omits the state filter when listing all issues', async () => { + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + expect(body.variables.filter).toBeUndefined(); + return jsonResponse({ + data: { + issues: { + nodes: [issueNode], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + })); + + await listLinearIssues({ status: 'all' }); + }); + + it('filters no-priority issues as Linear priority 0', async () => { + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + expect(body.variables.filter).toEqual({ + priority: { eq: 0 }, + }); + return jsonResponse({ + data: { + issues: { + nodes: [issueNode], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + })); + + await listLinearIssues({ status: 'all', priority: 'none' }); + }); + + it('looks up an identifier without applying list filters', async () => { + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + expect(body.query).toContain('GetLinearIssue'); + expect(body.variables.id).toBe('ENG-12'); + expect(body.variables.filter).toBeUndefined(); + return jsonResponse({ data: { issue: issueNode } }); + })); + + const result = await listLinearIssues({ + query: 'ENG-12', + status: 'completed', + assignee: 'me', + teamId: 'team-eng', + priority: 'urgent', + }); + expect(result.issues?.[0]?.identifier).toBe('ENG-12'); + }); + + it('loads one issue with comments', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ + data: { + issue: { + ...issueNode, + description: 'Users cannot sign in.', + comments: { nodes: [{ id: 'comment-1', body: 'Still broken', createdAt: '2026-08-24T10:00:00.000Z', user: { name: 'Ada', displayName: null, avatarUrl: 'https://linear.app/avatar/ada.png' } }] }, + }, + }, + }))); + + const result = await getLinearIssue('ENG-12'); + expect(result.connected).toBe(true); + expect(result.issue?.description).toBe('Users cannot sign in.'); + expect(result.issue?.priority).toBe(1); + expect(result.issue?.labels).toEqual([{ id: 'label-bug', name: 'Bug', color: '#eb5757' }]); + expect(result.issue?.comments).toEqual([{ + id: 'comment-1', + body: 'Still broken', + createdAt: '2026-08-24T10:00:00.000Z', + user: { name: 'Ada', displayName: null, avatarUrl: 'https://linear.app/avatar/ada.png' }, + }]); + }); + + it('creates a comment on the resolved issue UUID', async () => { + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + if (body.query.includes('query GetLinearIssue')) { + expect(body.variables.id).toBe('ENG-12'); + return jsonResponse({ + data: { + issue: { + ...issueNode, + description: null, + comments: { nodes: [] }, + }, + }, + }); + } + expect(body.query).toContain('mutation CommentCreate'); + expect(body.variables.input).toEqual({ + issueId: 'issue-uuid-1', + body: 'OpenChamber session started.', + }); + expect(options.headers.Authorization).toBe('Bearer access-1'); + return jsonResponse({ + data: { + commentCreate: { + success: true, + comment: { id: 'comment-9' }, + }, + }, + }); + })); + + const result = await createLinearIssueComment({ + issueId: 'ENG-12', + body: 'OpenChamber session started.', + }); + expect(result).toEqual({ connected: true, comment: { id: 'comment-9' } }); + expect(JSON.stringify(result)).not.toContain('access-1'); + }); + + it('clears auth and reports disconnected after a GraphQL 401', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ errors: [{ message: 'Unauthorized' }] }, 401))); + await expect(listLinearIssues()).resolves.toEqual({ connected: false }); + await expect(listLinearIssues()).resolves.toEqual({ connected: false }); + }); + + it('lists team workflow states in Linear workflow order', async () => { + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + expect(body.query).toContain('query TeamWorkflowStates'); + expect(body.variables.id).toBe('team-eng'); + expect(options.headers.Authorization).toBe('Bearer access-1'); + return jsonResponse({ + data: { + team: { + states: { + nodes: [ + { id: 'state-done', name: 'Done', type: 'completed', position: 0 }, + { id: 'state-review', name: 'In Review', type: 'started', position: 1 }, + { id: 'state-todo', name: 'Todo', type: 'unstarted', position: 0 }, + { id: 'state-dup', name: 'Duplicate', type: 'canceled', position: 1 }, + { id: 'state-progress', name: 'In Progress', type: 'started', position: 0 }, + { id: 'state-backlog', name: 'Backlog', type: 'backlog', position: 0 }, + { id: 'state-canceled', name: 'Canceled', type: 'canceled', position: 0 }, + ], + }, + }, + }, + }); + })); + + const result = await listLinearIssueStates('team-eng'); + expect(result.states?.map((state) => state.name)).toEqual([ + 'Backlog', + 'Todo', + 'In Progress', + 'In Review', + 'Done', + 'Canceled', + 'Duplicate', + ]); + }); + + it('rejects workflow states without a team id', async () => { + await expect(listLinearIssueStates('')).rejects.toMatchObject({ + message: 'teamId is required', + code: 'INVALID', + }); + }); + + it('updates an issue state and returns the issue', async () => { + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + expect(body.query).toContain('mutation IssueUpdate'); + expect(body.variables).toEqual({ + id: 'issue-uuid-1', + input: { stateId: 'state-done' }, + }); + expect(options.headers.Authorization).toBe('Bearer access-1'); + return jsonResponse({ + data: { + issueUpdate: { + success: true, + issue: { + ...issueNode, + state: { id: 'state-done', name: 'Done', type: 'completed' }, + description: null, + comments: { nodes: [] }, + }, + }, + }, + }); + })); + + const result = await updateLinearIssue({ id: 'issue-uuid-1', stateId: 'state-done' }); + expect(result.connected).toBe(true); + expect(result.issue?.state).toEqual({ id: 'state-done', name: 'Done', type: 'completed' }); + expect(JSON.stringify(result)).not.toContain('access-1'); + }); + + it('rejects an issue update without id or stateId', async () => { + await expect(updateLinearIssue({ id: 'issue-uuid-1' })).rejects.toMatchObject({ + message: 'id and stateId are required', + code: 'INVALID', + }); + }); + + it('resolves an issue identifier before issueUpdate', async () => { + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + if (body.query.includes('query GetLinearIssue')) { + expect(body.variables.id).toBe('ENG-12'); + return jsonResponse({ + data: { + issue: { + ...issueNode, + description: null, + comments: { nodes: [] }, + }, + }, + }); + } + expect(body.query).toContain('mutation IssueUpdate'); + expect(body.variables).toEqual({ + id: 'issue-uuid-1', + input: { stateId: 'state-done' }, + }); + return jsonResponse({ + data: { + issueUpdate: { + success: true, + issue: { + ...issueNode, + state: { id: 'state-done', name: 'Done', type: 'completed' }, + description: null, + comments: { nodes: [] }, + }, + }, + }, + }); + })); + + const result = await updateLinearIssue({ id: 'ENG-12', stateId: 'state-done' }); + expect(result.connected).toBe(true); + expect(result.issue?.id).toBe('issue-uuid-1'); + expect(result.issue?.state).toEqual({ id: 'state-done', name: 'Done', type: 'completed' }); + }); + + it('surfaces Linear validation constraints from GraphQL errors', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ + data: null, + errors: [{ + message: 'Argument Validation Error', + extensions: { + code: 'INVALID_INPUT', + userError: true, + userPresentableMessage: 'stateId must be a UUID.', + validationErrors: [{ + property: 'stateId', + constraints: { isUuid: 'stateId must be a UUID.' }, + }], + }, + }], + }))); + + await expect(updateLinearIssue({ id: 'issue-uuid-1', stateId: 'not-a-uuid' })).rejects.toMatchObject({ + name: 'LinearApiError', + message: 'stateId must be a UUID.', + status: 400, + userError: true, + }); + }); +}); diff --git a/packages/web/server/lib/linear/mapping.js b/packages/web/server/lib/linear/mapping.js new file mode 100644 index 00000000..0182cc15 --- /dev/null +++ b/packages/web/server/lib/linear/mapping.js @@ -0,0 +1,188 @@ +import fs from 'fs'; +import path from 'path'; +import { getLinearAuth, getLinearAuthFilePath } from './auth.js'; +import { isPlainObject, readTrimmedString } from './parse.js'; + +export class LinearMappingError extends Error { + constructor(message, code) { + super(message); + this.name = 'LinearMappingError'; + this.code = code; + } +} + +function mappingFile() { + return path.join(path.dirname(getLinearAuthFilePath()), 'linear-mapping.json'); +} + +const UNSCOPED_MAPPING_KEY = '__unscoped__'; + +function mappingOrgKey() { + const auth = getLinearAuth(); + return readTrimmedString(auth?.workspaceId) || UNSCOPED_MAPPING_KEY; +} + +function emptyMapping() { + return { + defaultProjectPath: null, + teamProjectPaths: {}, + }; +} + +function readTeamProjectPaths(value) { + if (!isPlainObject(value)) { + return {}; + } + const next = {}; + for (const key of Object.keys(value)) { + const teamId = readTrimmedString(key); + const projectPath = readTrimmedString(value[key]); + if (teamId && projectPath) { + next[teamId] = projectPath; + } + } + return next; +} + +function normalizeMappingSlice(raw) { + if (!isPlainObject(raw)) { + return emptyMapping(); + } + return { + defaultProjectPath: readTrimmedString(raw.defaultProjectPath) || null, + teamProjectPaths: readTeamProjectPaths(raw.teamProjectPaths), + }; +} + +function readMappingDocument(raw) { + if (!isPlainObject(raw)) { + return { workspaces: {} }; + } + if (isPlainObject(raw.workspaces)) { + const workspaces = {}; + for (const key of Object.keys(raw.workspaces)) { + const orgKey = readTrimmedString(key); + if (!orgKey) continue; + workspaces[orgKey] = normalizeMappingSlice(raw.workspaces[key]); + } + return { workspaces }; + } + return { + workspaces: { + [mappingOrgKey()]: normalizeMappingSlice(raw), + }, + }; +} + +function writeJsonFile(filePath, payload) { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + const tmpFile = `${filePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tmpFile, JSON.stringify(payload, null, 2), 'utf8'); + try { + fs.chmodSync(tmpFile, 0o600); + } catch { + // best-effort + } + fs.renameSync(tmpFile, filePath); + try { + fs.chmodSync(filePath, 0o600); + } catch { + // best-effort + } +} + +export function getLinearMappingFilePath() { + return mappingFile(); +} + +export function readStoredLinearMapping() { + const filePath = mappingFile(); + if (!fs.existsSync(filePath)) { + return emptyMapping(); + } + let parsed; + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const trimmed = raw.trim(); + if (!trimmed) { + return emptyMapping(); + } + parsed = JSON.parse(trimmed); + } catch { + throw new LinearMappingError('Linear mapping file is malformed', 'MALFORMED'); + } + if (!isPlainObject(parsed)) { + throw new LinearMappingError('Linear mapping file is malformed', 'MALFORMED'); + } + const document = readMappingDocument(parsed); + return document.workspaces[mappingOrgKey()] || emptyMapping(); +} + +export function setStoredLinearMapping(input) { + if (!isPlainObject(input)) { + throw new LinearMappingError('Mapping body must be an object', 'INVALID'); + } + const filePath = mappingFile(); + let document = { workspaces: {} }; + if (fs.existsSync(filePath)) { + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const trimmed = raw.trim(); + if (trimmed) { + const parsed = JSON.parse(trimmed); + if (!isPlainObject(parsed)) { + throw new LinearMappingError('Linear mapping file is malformed', 'MALFORMED'); + } + document = readMappingDocument(parsed); + } + } catch (error) { + if (error instanceof LinearMappingError) { + throw error; + } + throw new LinearMappingError('Linear mapping file is malformed', 'MALFORMED'); + } + } + const next = { + defaultProjectPath: readTrimmedString(input.defaultProjectPath) || null, + teamProjectPaths: readTeamProjectPaths(input.teamProjectPaths), + }; + document.workspaces[mappingOrgKey()] = next; + writeJsonFile(filePath, document); + return next; +} + +export function mergeLinearMappingView(stored, teams) { + const mapping = stored || emptyMapping(); + const nodes = Array.isArray(teams) ? teams : []; + return { + defaultProjectPath: mapping.defaultProjectPath, + teams: nodes.map((team) => ({ + id: team.id, + key: team.key, + name: team.name, + projectPath: mapping.teamProjectPaths[team.id] || null, + })), + }; +} + +export function resolveMappedProjectPath(view, team) { + const teams = Array.isArray(view?.teams) ? view.teams : []; + const teamId = team ? readTrimmedString(team.id) : ''; + if (teamId) { + const row = teams.find((entry) => entry.id === teamId); + if (row?.projectPath) { + return row.projectPath; + } + } + const teamKey = team ? readTrimmedString(team.key) : ''; + if (teamKey) { + const row = teams.find((entry) => entry.key === teamKey); + if (row?.projectPath) { + return row.projectPath; + } + } + return view?.defaultProjectPath || null; +} diff --git a/packages/web/server/lib/linear/mapping.test.js b/packages/web/server/lib/linear/mapping.test.js new file mode 100644 index 00000000..b0b56a4a --- /dev/null +++ b/packages/web/server/lib/linear/mapping.test.js @@ -0,0 +1,147 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { activateLinearAuth, getLinearAuth, setLinearAuth } from './auth.js'; +import { + getLinearMappingFilePath, + mergeLinearMappingView, + readStoredLinearMapping, + resolveMappedProjectPath, + setStoredLinearMapping, +} from './mapping.js'; + +const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-mapping-')); + +describe('Linear project mapping storage', () => { + let dataDir; + let previousDataDir; + + beforeEach(() => { + previousDataDir = process.env.OPENCHAMBER_DATA_DIR; + dataDir = makeTempDir(); + process.env.OPENCHAMBER_DATA_DIR = dataDir; + }); + + afterEach(() => { + if (previousDataDir === undefined) { + delete process.env.OPENCHAMBER_DATA_DIR; + } else { + process.env.OPENCHAMBER_DATA_DIR = previousDataDir; + } + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('treats a missing file as empty mapping, not a failure', () => { + expect(fs.existsSync(getLinearMappingFilePath())).toBe(false); + expect(readStoredLinearMapping()).toEqual({ + defaultProjectPath: null, + teamProjectPaths: {}, + }); + }); + + it('round-trips a default project and per-team paths', () => { + const written = setStoredLinearMapping({ + defaultProjectPath: '/Users/ada/openchamber', + teamProjectPaths: { + 'team-eng': '/Users/ada/eng', + 'team-empty': ' ', + }, + }); + expect(written).toEqual({ + defaultProjectPath: '/Users/ada/openchamber', + teamProjectPaths: { 'team-eng': '/Users/ada/eng' }, + }); + expect(readStoredLinearMapping()).toEqual(written); + expect(fs.statSync(getLinearMappingFilePath()).mode & 0o777).toBe(0o600); + }); + + it('replaces the previous mapping on write', () => { + setStoredLinearMapping({ + defaultProjectPath: '/old', + teamProjectPaths: { 'team-eng': '/eng' }, + }); + const next = setStoredLinearMapping({ + defaultProjectPath: null, + teamProjectPaths: {}, + }); + expect(next).toEqual({ defaultProjectPath: null, teamProjectPaths: {} }); + expect(readStoredLinearMapping()).toEqual(next); + }); + + it('keeps tokens when a mapping write is rejected', () => { + setLinearAuth({ + accessToken: 'access-keep', + refreshToken: 'refresh-keep', + expiresAt: Date.now() + 60_000, + }); + setStoredLinearMapping({ + defaultProjectPath: '/keep', + teamProjectPaths: { 'team-eng': '/eng' }, + }); + expect(() => setStoredLinearMapping(null)).toThrow(/object/); + expect(readStoredLinearMapping()).toEqual({ + defaultProjectPath: '/keep', + teamProjectPaths: { 'team-eng': '/eng' }, + }); + expect(getLinearAuth().accessToken).toBe('access-keep'); + }); + + it('rejects a malformed mapping file instead of treating it as empty', () => { + fs.writeFileSync(getLinearMappingFilePath(), '{not-json', 'utf8'); + expect(() => readStoredLinearMapping()).toThrow(/malformed/); + }); + + it('merges live teams onto stored paths and resolves team then default', () => { + const stored = { + defaultProjectPath: '/default', + teamProjectPaths: { 'team-eng': '/eng' }, + }; + const view = mergeLinearMappingView(stored, [ + { id: 'team-eng', key: 'ENG', name: 'Engineering' }, + { id: 'team-des', key: 'DES', name: 'Design' }, + ]); + expect(view).toEqual({ + defaultProjectPath: '/default', + teams: [ + { id: 'team-eng', key: 'ENG', name: 'Engineering', projectPath: '/eng' }, + { id: 'team-des', key: 'DES', name: 'Design', projectPath: null }, + ], + }); + expect(resolveMappedProjectPath(view, { id: 'team-eng', key: 'ENG' })).toBe('/eng'); + expect(resolveMappedProjectPath(view, { id: 'team-des', key: 'DES' })).toBe('/default'); + expect(resolveMappedProjectPath(view, null)).toBe('/default'); + }); + + it('keeps mapping slices isolated per workspace', () => { + setLinearAuth({ + accessToken: 'access-a', + user: { id: 'user-a', name: 'Ada' }, + organization: { id: 'org-a', name: 'Alpha', urlKey: 'alpha' }, + }); + setStoredLinearMapping({ + defaultProjectPath: '/alpha', + teamProjectPaths: { 'team-a': '/alpha-eng' }, + }); + + setLinearAuth({ + accessToken: 'access-b', + user: { id: 'user-b', name: 'Ben' }, + organization: { id: 'org-b', name: 'Beta', urlKey: 'beta' }, + }); + setStoredLinearMapping({ + defaultProjectPath: '/beta', + teamProjectPaths: {}, + }); + expect(readStoredLinearMapping()).toEqual({ + defaultProjectPath: '/beta', + teamProjectPaths: {}, + }); + + expect(activateLinearAuth('org-a')).toBe(true); + expect(readStoredLinearMapping()).toEqual({ + defaultProjectPath: '/alpha', + teamProjectPaths: { 'team-a': '/alpha-eng' }, + }); + }); +}); diff --git a/packages/web/server/lib/linear/oauth.js b/packages/web/server/lib/linear/oauth.js new file mode 100644 index 00000000..c25a21de --- /dev/null +++ b/packages/web/server/lib/linear/oauth.js @@ -0,0 +1,345 @@ +import crypto from 'crypto'; +import { + getLinearClientId, + getLinearClientSecret, + getLinearBrokerUrl, + getLinearRedirectUri, + getLinearScopes, +} from './auth.js'; +import { isPlainObject, isString, readFiniteNumber, readTrimmedString } from './parse.js'; + +export const LINEAR_AUTHORIZE_URL = 'https://linear.app/oauth/authorize'; +export const LINEAR_TOKEN_URL = 'https://api.linear.app/oauth/token'; +export const LINEAR_REVOKE_URL = 'https://api.linear.app/oauth/revoke'; +export const PENDING_AUTHORIZATION_TTL_MS = 10 * 60_000; + +const pendingByState = new Map(); +const brokerPollsByState = new Map(); + +export class LinearOAuthError extends Error { + constructor(message, code = 'LINEAR_OAUTH_FAILED') { + super(message); + this.name = 'LinearOAuthError'; + this.code = code; + } +} + +export function createPkcePair() { + const verifier = crypto.randomBytes(32).toString('base64url'); + const challenge = crypto.createHash('sha256').update(verifier).digest('base64url'); + return { verifier, challenge }; +} + +function pruneExpiredPending(now = Date.now()) { + for (const [state, entry] of pendingByState.entries()) { + if (!entry || entry.expiresAt <= now) { + pendingByState.delete(state); + } + } +} + +function normalizeScope(scope) { + if (isString(scope)) { + return scope.trim(); + } + if (Array.isArray(scope)) { + return scope.filter((item) => isString(item) && item.trim()).join(','); + } + return ''; +} + +function readExpiresAt(expiresIn, now = Date.now()) { + const seconds = readFiniteNumber(expiresIn); + if (seconds == null || seconds <= 0) { + return now + 24 * 60 * 60 * 1000; + } + return now + Math.floor(seconds) * 1000; +} + +function parseTokenPayload(payload) { + if (!isPlainObject(payload)) { + throw new LinearOAuthError('Linear token response was empty'); + } + if (readTrimmedString(payload.error)) { + throw new LinearOAuthError( + readTrimmedString(payload.error_description) || readTrimmedString(payload.error), + readTrimmedString(payload.error).toUpperCase(), + ); + } + const accessToken = readTrimmedString(payload.access_token); + if (!accessToken) { + throw new LinearOAuthError('Linear token response was missing access_token'); + } + return { + accessToken, + refreshToken: readTrimmedString(payload.refresh_token) || null, + tokenType: readTrimmedString(payload.token_type) || 'bearer', + expiresAt: readExpiresAt(payload.expires_in), + scope: normalizeScope(payload.scope), + }; +} + +async function postForm(url, body) { + const response = await fetch(url, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams(body).toString(), + }); + const payload = await response.json().catch(() => null); + if (!response.ok) { + const description = isPlainObject(payload) + ? (readTrimmedString(payload.error_description) || readTrimmedString(payload.error)) + : ''; + const error = new LinearOAuthError( + description || `Linear token request failed (${response.status})`, + readTrimmedString(payload?.error).toUpperCase() || 'LINEAR_OAUTH_FAILED', + ); + error.status = response.status; + throw error; + } + return parseTokenPayload(payload); +} + +async function readJsonResponse(response, fallbackMessage) { + const payload = await response.json().catch(() => null); + if (!response.ok) { + const message = isPlainObject(payload) && readTrimmedString(payload.error) + ? readTrimmedString(payload.error) + : `${fallbackMessage} (${response.status})`; + const error = new LinearOAuthError(message, 'LINEAR_BROKER_FAILED'); + error.status = response.status; + throw error; + } + if (!isPlainObject(payload)) { + throw new LinearOAuthError(`${fallbackMessage}: invalid response`, 'LINEAR_BROKER_FAILED'); + } + return payload; +} + +function brokerCallbackUrl(brokerUrl) { + return `${brokerUrl.replace(/\/+$/, '')}/callback`; +} + +async function registerBrokerTransaction({ brokerUrl, state, claimSecret }) { + const response = await fetch(`${brokerUrl}/start`, { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ state, claimSecret }), + }); + const payload = await readJsonResponse(response, 'Could not start Linear authorization broker'); + const redirectUri = readTrimmedString(payload.redirectUri); + if (!redirectUri || redirectUri !== brokerCallbackUrl(brokerUrl)) { + throw new LinearOAuthError('Linear authorization broker returned an unexpected callback URL', 'LINEAR_BROKER_FAILED'); + } + return redirectUri; +} + +export async function startAuthorization({ origin } = {}) { + const clientId = getLinearClientId(); + if (!clientId) { + throw new LinearOAuthError( + 'Linear OAuth client not configured. Set OPENCHAMBER_LINEAR_CLIENT_ID.', + 'LINEAR_CLIENT_ID_MISSING', + ); + } + + pruneExpiredPending(); + const { verifier, challenge } = createPkcePair(); + const state = crypto.randomBytes(32).toString('base64url'); + const brokerUrl = getLinearBrokerUrl(); + const configuredRedirectUri = getLinearRedirectUri(); + const usesBroker = configuredRedirectUri === brokerCallbackUrl(brokerUrl); + const claimSecret = usesBroker ? crypto.randomBytes(32).toString('base64url') : null; + const redirectUri = usesBroker + ? await registerBrokerTransaction({ brokerUrl, state, claimSecret }) + : configuredRedirectUri; + const scope = getLinearScopes(); + pendingByState.set(state, { + codeVerifier: verifier, + redirectUri, + origin: origin === 'desktop' ? 'desktop' : 'web', + broker: usesBroker ? { url: brokerUrl, claimSecret } : null, + expiresAt: Date.now() + PENDING_AUTHORIZATION_TTL_MS, + }); + + const url = new URL(LINEAR_AUTHORIZE_URL); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('client_id', clientId); + url.searchParams.set('redirect_uri', redirectUri); + url.searchParams.set('scope', scope); + url.searchParams.set('state', state); + url.searchParams.set('code_challenge', challenge); + url.searchParams.set('code_challenge_method', 'S256'); + url.searchParams.set('actor', 'user'); + url.searchParams.set('prompt', 'consent'); + + return { + authorizationUrl: url.toString(), + expiresIn: Math.floor(PENDING_AUTHORIZATION_TTL_MS / 1000), + scope, + }; +} + +async function pollBrokerState(state, pending) { + const response = await fetch(`${pending.broker.url}/poll`, { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ state, claimSecret: pending.broker.claimSecret }), + }); + if (response.status === 202) { + return null; + } + const payload = await readJsonResponse(response, 'Could not read Linear authorization result'); + const status = readTrimmedString(payload.status); + if (status === 'complete') { + const result = await consumeAuthorizationCallback({ code: payload.code, state }); + return { + ...result, + brokerReceipt: { state, ...pending.broker }, + }; + } + if (status === 'failed') { + return consumeAuthorizationCallback({ + state, + error: payload.error, + errorDescription: payload.errorDescription, + }); + } + throw new LinearOAuthError('Linear authorization broker returned an unexpected result', 'LINEAR_BROKER_FAILED'); +} + +export async function completeAuthorizationBroker(receipt) { + if (!receipt?.url || !receipt?.state || !receipt?.claimSecret) return false; + const response = await fetch(`${receipt.url}/complete`, { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ state: receipt.state, claimSecret: receipt.claimSecret }), + }); + if (!response.ok) { + throw new LinearOAuthError(`Could not acknowledge Linear authorization result (${response.status})`, 'LINEAR_BROKER_FAILED'); + } + return true; +} + +export async function pollAuthorizationBroker() { + pruneExpiredPending(); + for (const [state, pending] of pendingByState.entries()) { + if (!pending?.broker) continue; + let poll = brokerPollsByState.get(state); + if (!poll) { + poll = pollBrokerState(state, pending).finally(() => brokerPollsByState.delete(state)); + brokerPollsByState.set(state, poll); + } + const result = await poll; + if (result) return result; + } + return null; +} + +function failAuthorization(message, code, origin) { + const error = new LinearOAuthError(message, code); + if (origin) { + error.origin = origin; + } + return error; +} + +export async function consumeAuthorizationCallback({ code, state, error, errorDescription }) { + pruneExpiredPending(); + const pending = readTrimmedString(state) ? pendingByState.get(state) : null; + + if (readTrimmedString(error)) { + if (readTrimmedString(state)) pendingByState.delete(state); + throw failAuthorization( + readTrimmedString(errorDescription) || readTrimmedString(error), + readTrimmedString(error).toUpperCase(), + pending?.origin, + ); + } + if (!readTrimmedString(code)) { + if (readTrimmedString(state)) pendingByState.delete(state); + throw failAuthorization( + 'Linear did not return an authorization code.', + 'MISSING_CODE', + pending?.origin, + ); + } + if (!pending?.codeVerifier) { + throw failAuthorization( + 'This authorization session has expired or is unknown to the running app. Return to OpenChamber and click Connect again.', + 'UNKNOWN_STATE', + ); + } + + const body = { + grant_type: 'authorization_code', + code: code.trim(), + redirect_uri: pending.redirectUri, + client_id: getLinearClientId(), + code_verifier: pending.codeVerifier, + }; + const clientSecret = getLinearClientSecret(); + if (clientSecret) { + body.client_secret = clientSecret; + } + + try { + const tokens = await postForm(LINEAR_TOKEN_URL, body); + pendingByState.delete(state); + return { + ...tokens, + origin: pending.origin, + }; + } catch (caught) { + if (caught instanceof Error) { + caught.origin = pending.origin; + } + throw caught; + } +} + +export async function refreshAccessToken(refreshToken) { + const token = readTrimmedString(refreshToken); + if (!token) { + throw new LinearOAuthError('refresh_token is required', 'MISSING_REFRESH_TOKEN'); + } + const body = { + grant_type: 'refresh_token', + refresh_token: token, + client_id: getLinearClientId(), + }; + const clientSecret = getLinearClientSecret(); + if (clientSecret) { + body.client_secret = clientSecret; + } + return postForm(LINEAR_TOKEN_URL, body); +} + +export async function revokeToken(token, tokenTypeHint) { + const value = readTrimmedString(token); + if (!value) { + return false; + } + const body = { token: value }; + if (tokenTypeHint === 'access_token' || tokenTypeHint === 'refresh_token') { + body.token_type_hint = tokenTypeHint; + } + try { + const response = await fetch(LINEAR_REVOKE_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(body).toString(), + }); + return response.status === 200; + } catch { + return false; + } +} + +export function clearPendingAuthorizationsForTests() { + pendingByState.clear(); + brokerPollsByState.clear(); +} diff --git a/packages/web/server/lib/linear/oauth.test.js b/packages/web/server/lib/linear/oauth.test.js new file mode 100644 index 00000000..3d3e18ef --- /dev/null +++ b/packages/web/server/lib/linear/oauth.test.js @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { + startAuthorization, + consumeAuthorizationCallback, + pollAuthorizationBroker, + completeAuthorizationBroker, + refreshAccessToken, + clearPendingAuthorizationsForTests, +} from './oauth.js'; + +const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-oauth-')); + +describe('Linear OAuth PKCE', () => { + let dataDir; + let previousDataDir; + let previousPort; + let previousRedirect; + + beforeEach(() => { + previousDataDir = process.env.OPENCHAMBER_DATA_DIR; + previousPort = process.env.OPENCHAMBER_PORT; + previousRedirect = process.env.OPENCHAMBER_LINEAR_REDIRECT_URI; + dataDir = makeTempDir(); + process.env.OPENCHAMBER_DATA_DIR = dataDir; + process.env.OPENCHAMBER_PORT = '3001'; + delete process.env.OPENCHAMBER_LINEAR_CLIENT_ID; + process.env.OPENCHAMBER_LINEAR_REDIRECT_URI = 'http://127.0.0.1:3001/linear/oauth/callback'; + clearPendingAuthorizationsForTests(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + clearPendingAuthorizationsForTests(); + restoreEnv('OPENCHAMBER_DATA_DIR', previousDataDir); + restoreEnv('OPENCHAMBER_PORT', previousPort); + restoreEnv('OPENCHAMBER_LINEAR_REDIRECT_URI', previousRedirect); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('creates an S256 authorize URL and stores a pending verifier', async () => { + const started = await startAuthorization({ origin: 'desktop' }); + const url = new URL(started.authorizationUrl); + expect(url.origin + url.pathname).toBe('https://linear.app/oauth/authorize'); + expect(url.searchParams.get('client_id')).toBe('91bbe26a69a2c8568d3683f1e01e776c'); + expect(url.searchParams.get('redirect_uri')).toBe('http://127.0.0.1:3001/linear/oauth/callback'); + expect(url.searchParams.get('code_challenge_method')).toBe('S256'); + expect(url.searchParams.get('code_challenge')).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(url.searchParams.get('actor')).toBe('user'); + expect(url.searchParams.get('prompt')).toBe('consent'); + expect(started.scope).toBe('read,write,comments:create'); + expect(started.expiresIn).toBe(600); + }); + + it('refuses a callback whose state was never started', async () => { + const tokenFetch = vi.fn(); + vi.stubGlobal('fetch', tokenFetch); + await expect(consumeAuthorizationCallback({ + code: 'attacker-code', + state: 'forged', + })).rejects.toMatchObject({ code: 'UNKNOWN_STATE' }); + expect(tokenFetch).not.toHaveBeenCalled(); + }); + + it('exchanges a matching code with the original PKCE verifier', async () => { + const started = await startAuthorization({ origin: 'web' }); + const state = new URL(started.authorizationUrl).searchParams.get('state'); + const tokenFetch = vi.fn(async () => new Response(JSON.stringify({ + access_token: 'access-1', + refresh_token: 'refresh-1', + token_type: 'Bearer', + expires_in: 86399, + scope: 'read,write,comments:create', + }), { status: 200 })); + vi.stubGlobal('fetch', tokenFetch); + + const result = await consumeAuthorizationCallback({ code: 'auth-code', state }); + expect(result.accessToken).toBe('access-1'); + expect(result.refreshToken).toBe('refresh-1'); + expect(result.origin).toBe('web'); + + expect(tokenFetch).toHaveBeenCalledTimes(1); + const [url, init] = tokenFetch.mock.calls[0]; + expect(String(url)).toBe('https://api.linear.app/oauth/token'); + expect(init.headers['Content-Type']).toBe('application/x-www-form-urlencoded'); + const body = new URLSearchParams(init.body); + expect(body.get('grant_type')).toBe('authorization_code'); + expect(body.get('code')).toBe('auth-code'); + expect(body.get('code_verifier')).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(body.get('client_secret')).toBeNull(); + + await expect(consumeAuthorizationCallback({ code: 'auth-code', state })).rejects.toMatchObject({ + code: 'UNKNOWN_STATE', + }); + }); + + it('persists a rotated refresh token from Linear', async () => { + const tokenFetch = vi.fn(async () => new Response(JSON.stringify({ + access_token: 'access-2', + refresh_token: 'refresh-2', + token_type: 'Bearer', + expires_in: 86399, + }), { status: 200 })); + vi.stubGlobal('fetch', tokenFetch); + const tokens = await refreshAccessToken('refresh-1'); + expect(tokens.accessToken).toBe('access-2'); + expect(tokens.refreshToken).toBe('refresh-2'); + const body = new URLSearchParams(tokenFetch.mock.calls[0][1].body); + expect(body.get('grant_type')).toBe('refresh_token'); + expect(body.get('refresh_token')).toBe('refresh-1'); + }); + + it('claims a broker callback and exchanges it locally with PKCE', async () => { + delete process.env.OPENCHAMBER_LINEAR_REDIRECT_URI; + const brokerAndTokenFetch = vi.fn(async (url, init) => { + const target = String(url); + if (target.endsWith('/start')) { + const body = JSON.parse(init.body); + expect(body.state).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(body.claimSecret).toMatch(/^[A-Za-z0-9_-]{43}$/); + return new Response(JSON.stringify({ + redirectUri: 'https://api.openchamber.dev/v1/oauth/linear/callback', + expiresIn: 600, + }), { status: 200 }); + } + if (target.endsWith('/poll')) { + return new Response(JSON.stringify({ status: 'complete', code: 'broker-code' }), { status: 200 }); + } + if (target.endsWith('/complete')) { + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + if (target === 'https://api.linear.app/oauth/token') { + const body = new URLSearchParams(init.body); + expect(body.get('code')).toBe('broker-code'); + expect(body.get('redirect_uri')).toBe('https://api.openchamber.dev/v1/oauth/linear/callback'); + expect(body.get('code_verifier')).toMatch(/^[A-Za-z0-9_-]{43}$/); + return new Response(JSON.stringify({ + access_token: 'broker-access', + refresh_token: 'broker-refresh', + expires_in: 86399, + }), { status: 200 }); + } + throw new Error(`unexpected fetch: ${target}`); + }); + vi.stubGlobal('fetch', brokerAndTokenFetch); + + const started = await startAuthorization({ origin: 'desktop' }); + const authorizationUrl = new URL(started.authorizationUrl); + expect(authorizationUrl.searchParams.get('redirect_uri')).toBe('https://api.openchamber.dev/v1/oauth/linear/callback'); + + const result = await pollAuthorizationBroker(); + expect(result).toMatchObject({ accessToken: 'broker-access', origin: 'desktop' }); + await expect(completeAuthorizationBroker(result.brokerReceipt)).resolves.toBe(true); + expect(brokerAndTokenFetch).toHaveBeenCalledTimes(4); + }); +}); + +function restoreEnv(name, previous) { + if (previous === undefined) { + delete process.env[name]; + return; + } + process.env[name] = previous; +} diff --git a/packages/web/server/lib/linear/parse.js b/packages/web/server/lib/linear/parse.js new file mode 100644 index 00000000..4182b2ef --- /dev/null +++ b/packages/web/server/lib/linear/parse.js @@ -0,0 +1,23 @@ +export function isString(value) { + return Object.prototype.toString.call(value) === '[object String]'; +} + +export function isPlainObject(value) { + if (value == null || Array.isArray(value)) { + return false; + } + return Object.getPrototypeOf(value) === Object.prototype; +} + +export function readTrimmedString(value) { + return isString(value) && value.trim() ? value.trim() : ''; +} + +export function readFiniteNumber(value) { + return Number.isFinite(value) ? value : null; +} + +export function readEnv(name) { + const raw = process.env[name]; + return raw ? raw.trim() : ''; +} diff --git a/packages/web/server/lib/linear/routes.js b/packages/web/server/lib/linear/routes.js new file mode 100644 index 00000000..ada59486 --- /dev/null +++ b/packages/web/server/lib/linear/routes.js @@ -0,0 +1,432 @@ +import express from 'express'; +import { readTrimmedString } from './parse.js'; + +const PENDING_JSON_LIMIT = '16kb'; +const parseJsonBody = express.json({ limit: PENDING_JSON_LIMIT }); + +function queryValue(req, key) { + const raw = req.query?.[key]; + const value = Array.isArray(raw) ? raw[0] : raw; + return readTrimmedString(value); +} + +function isLinearUserError(error) { + return error?.code === 'INVALID' || error?.userError === true; +} + +function escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function renderLinearOAuthCallbackPage({ title, message, desktopReturn }) { + return ` + + + + +${escapeHtml(title)} — OpenChamber + + + +
+

${escapeHtml(title)}

+

${escapeHtml(message)}

+${desktopReturn ? `
Return to OpenChamber +` : ''} +
+ +`; +} + +async function storeAuthorizationResult(libraries, result) { + const { setLinearAuth, fetchLinearIdentity } = libraries; + let user = null; + let organization = null; + try { + const identity = await fetchLinearIdentity(result.accessToken); + user = identity.user; + organization = identity.organization; + } catch (error) { + console.error('Failed to load Linear identity after OAuth:', error); + } + return setLinearAuth({ + accessToken: result.accessToken, + refreshToken: result.refreshToken, + tokenType: result.tokenType, + expiresAt: result.expiresAt, + scope: result.scope, + user, + organization, + }); +} + +export function registerLinearRoutes(app) { + let linearLibraries = null; + const getLinearLibraries = async () => { + if (!linearLibraries) { + linearLibraries = await import('./index.js'); + } + return linearLibraries; + }; + + app.get('/linear/oauth/callback', async (req, res) => { + const finish = (status, { title, message, desktopReturn = false }) => { + res.status(status).type('html').send(renderLinearOAuthCallbackPage({ title, message, desktopReturn })); + }; + + try { + const libraries = await getLinearLibraries(); + const { consumeAuthorizationCallback } = libraries; + const result = await consumeAuthorizationCallback({ + code: queryValue(req, 'code'), + state: queryValue(req, 'state'), + error: queryValue(req, 'error'), + errorDescription: queryValue(req, 'error_description'), + }); + + await storeAuthorizationResult(libraries, result); + + return finish(200, { + title: 'Authorization Complete', + message: 'You can close this tab and return to OpenChamber.', + desktopReturn: result.origin === 'desktop', + }); + } catch (error) { + const code = error instanceof Error ? error.code : ''; + const status = code === 'UNKNOWN_STATE' || code === 'MISSING_CODE' || code === 'ACCESS_DENIED' + ? 400 + : 502; + return finish(status, { + title: 'Authorization Failed', + message: error instanceof Error ? error.message : 'Linear authorization failed. Return to OpenChamber and click Connect again.', + desktopReturn: error?.origin === 'desktop', + }); + } + }); + + app.get('/api/linear/auth/status', async (_req, res) => { + try { + const libraries = await getLinearLibraries(); + const { + getLinearAuth, + getLinearAuthWorkspaces, + getValidLinearAccessToken, + fetchLinearIdentity, + setLinearAuth, + clearLinearAuth, + toLinearPublicStatus, + pollAuthorizationBroker, + completeAuthorizationBroker, + } = libraries; + + try { + const result = await pollAuthorizationBroker(); + if (result) { + await storeAuthorizationResult(libraries, result); + await completeAuthorizationBroker(result.brokerReceipt).catch((error) => { + console.warn('Failed to acknowledge Linear authorization broker result:', error); + }); + } + } catch (error) { + console.error('Failed to complete Linear authorization through broker:', error); + } + + const accessToken = await getValidLinearAccessToken(); + if (!accessToken) { + return res.json({ connected: false }); + } + + const auth = getLinearAuth(); + try { + const identity = await fetchLinearIdentity(accessToken); + const next = setLinearAuth({ + accessToken, + refreshToken: auth?.refreshToken, + tokenType: auth?.tokenType, + expiresAt: auth?.expiresAt, + scope: auth?.scope, + user: identity.user, + organization: identity.organization, + workspaceId: auth?.workspaceId, + }, { activate: false }); + return res.json(toLinearPublicStatus(next, getLinearAuthWorkspaces())); + } catch (error) { + if (error?.status === 401) { + clearLinearAuth(auth?.workspaceId); + const remaining = getLinearAuth(); + if (!remaining) { + return res.json({ connected: false }); + } + return res.json(toLinearPublicStatus(remaining, getLinearAuthWorkspaces())); + } + if (auth) { + return res.json(toLinearPublicStatus(auth, getLinearAuthWorkspaces())); + } + throw error; + } + } catch (error) { + console.error('Failed to get Linear auth status:', error); + return res.status(500).json({ error: error.message || 'Failed to get Linear auth status' }); + } + }); + + app.post('/api/linear/auth/start', parseJsonBody, async (req, res) => { + try { + const { startAuthorization } = await getLinearLibraries(); + const origin = req.body?.origin === 'desktop' ? 'desktop' : 'web'; + const payload = await startAuthorization({ origin }); + return res.json(payload); + } catch (error) { + const status = error?.code === 'LINEAR_CLIENT_ID_MISSING' ? 400 : 500; + console.error('Failed to start Linear authorization:', error); + return res.status(status).json({ error: error.message || 'Failed to start Linear authorization' }); + } + }); + + app.get('/api/linear/issues/list', async (req, res) => { + try { + const { listLinearIssues } = await getLinearLibraries(); + const result = await listLinearIssues({ + query: queryValue(req, 'query'), + cursor: queryValue(req, 'cursor'), + status: queryValue(req, 'status'), + assignee: queryValue(req, 'assignee'), + teamId: queryValue(req, 'teamId'), + priority: queryValue(req, 'priority'), + }); + return res.json(result); + } catch (error) { + console.error('Failed to list Linear issues:', error); + return res.status(500).json({ error: error.message || 'Failed to list Linear issues' }); + } + }); + + app.get('/api/linear/issues/get', async (req, res) => { + try { + const id = queryValue(req, 'id'); + if (!id) { + return res.status(400).json({ error: 'id is required' }); + } + const { getLinearIssue } = await getLinearLibraries(); + const result = await getLinearIssue(id); + return res.json(result); + } catch (error) { + console.error('Failed to load Linear issue:', error); + return res.status(500).json({ error: error.message || 'Failed to load Linear issue' }); + } + }); + + app.get('/api/linear/issues/states', async (req, res) => { + try { + const teamId = queryValue(req, 'teamId'); + if (!teamId) { + return res.status(400).json({ error: 'teamId is required' }); + } + const { listLinearIssueStates } = await getLinearLibraries(); + const result = await listLinearIssueStates(teamId); + return res.json(result); + } catch (error) { + if (isLinearUserError(error)) { + return res.status(400).json({ error: error.message }); + } + console.error('Failed to load Linear workflow states:', error); + return res.status(500).json({ error: error.message || 'Failed to load Linear workflow states' }); + } + }); + + app.post('/api/linear/issues/update', parseJsonBody, async (req, res) => { + try { + const { updateLinearIssue } = await getLinearLibraries(); + const result = await updateLinearIssue({ + id: req.body?.id, + stateId: req.body?.stateId, + }); + return res.json(result); + } catch (error) { + if (isLinearUserError(error)) { + return res.status(400).json({ error: error.message }); + } + console.error('Failed to update Linear issue:', error); + return res.status(500).json({ error: error.message || 'Failed to update Linear issue' }); + } + }); + + app.get('/api/linear/mapping', async (_req, res) => { + try { + const { + listLinearTeams, + readStoredLinearMapping, + mergeLinearMappingView, + LinearMappingError, + } = await getLinearLibraries(); + const teamsResult = await listLinearTeams(); + if (teamsResult.connected === false) { + return res.json({ connected: false }); + } + let stored; + try { + stored = readStoredLinearMapping(); + } catch (error) { + if (error instanceof LinearMappingError && error.code === 'MALFORMED') { + return res.status(500).json({ error: error.message }); + } + throw error; + } + return res.json({ + connected: true, + ...mergeLinearMappingView(stored, teamsResult.teams), + }); + } catch (error) { + console.error('Failed to load Linear mapping:', error); + return res.status(500).json({ error: error.message || 'Failed to load Linear mapping' }); + } + }); + + app.put('/api/linear/mapping', parseJsonBody, async (req, res) => { + try { + const { + getValidLinearAccessToken, + listLinearTeams, + setStoredLinearMapping, + mergeLinearMappingView, + LinearMappingError, + } = await getLinearLibraries(); + const accessToken = await getValidLinearAccessToken(); + if (!accessToken) { + return res.json({ connected: false }); + } + let stored; + try { + stored = setStoredLinearMapping(req.body); + } catch (error) { + if (error instanceof LinearMappingError && error.code === 'INVALID') { + return res.status(400).json({ error: error.message }); + } + throw error; + } + const teamsResult = await listLinearTeams(); + if (teamsResult.connected === false) { + return res.json({ + connected: true, + ...mergeLinearMappingView(stored, []), + }); + } + return res.json({ + connected: true, + ...mergeLinearMappingView(stored, teamsResult.teams), + }); + } catch (error) { + console.error('Failed to save Linear mapping:', error); + return res.status(500).json({ error: error.message || 'Failed to save Linear mapping' }); + } + }); + + app.post('/api/linear/session-status', parseJsonBody, async (req, res) => { + try { + const { postLinearSessionStatus, LinearSessionStatusError } = await getLinearLibraries(); + try { + const result = await postLinearSessionStatus({ + kind: req.body?.kind, + sessionId: req.body?.sessionId, + issueIdentifier: req.body?.issueIdentifier, + sessionOrigin: req.body?.sessionOrigin, + }); + return res.json(result); + } catch (error) { + if (error instanceof LinearSessionStatusError && error.code === 'INVALID') { + return res.status(400).json({ error: error.message }); + } + if (error instanceof LinearSessionStatusError && error.code === 'MALFORMED') { + return res.status(500).json({ error: error.message }); + } + throw error; + } + } catch (error) { + console.error('Failed to post Linear session status:', error); + return res.status(500).json({ error: error.message || 'Failed to post Linear session status' }); + } + }); + + app.get('/api/linear/preferences', async (_req, res) => { + try { + const { getLinearSessionCommentsEnabled } = await getLinearLibraries(); + return res.json({ sessionComments: getLinearSessionCommentsEnabled() }); + } catch (error) { + console.error('Failed to load Linear preferences:', error); + return res.status(500).json({ error: error.message || 'Failed to load Linear preferences' }); + } + }); + + app.put('/api/linear/preferences', parseJsonBody, async (req, res) => { + try { + const sessionComments = req.body?.sessionComments; + if (sessionComments !== true && sessionComments !== false) { + return res.status(400).json({ error: 'sessionComments must be a boolean' }); + } + const { setLinearSessionCommentsEnabled } = await getLinearLibraries(); + return res.json({ sessionComments: setLinearSessionCommentsEnabled(sessionComments) }); + } catch (error) { + console.error('Failed to save Linear preferences:', error); + return res.status(500).json({ error: error.message || 'Failed to save Linear preferences' }); + } + }); + + app.post('/api/linear/auth/activate', parseJsonBody, async (req, res) => { + try { + const { + activateLinearAuth, + getLinearAuth, + getLinearAuthWorkspaces, + toLinearPublicStatus, + } = await getLinearLibraries(); + const organizationId = readTrimmedString(req.body?.organizationId); + if (!organizationId) { + return res.status(400).json({ error: 'organizationId is required' }); + } + const activated = activateLinearAuth(organizationId); + if (!activated) { + return res.status(404).json({ error: 'Linear workspace not found' }); + } + const auth = getLinearAuth(); + if (!auth) { + return res.json({ connected: false }); + } + return res.json(toLinearPublicStatus(auth, getLinearAuthWorkspaces())); + } catch (error) { + console.error('Failed to switch Linear workspace:', error); + return res.status(500).json({ error: error.message || 'Failed to switch Linear workspace' }); + } + }); + + app.delete('/api/linear/auth', async (_req, res) => { + try { + const { getLinearAuth, clearLinearAuth, revokeToken } = await getLinearLibraries(); + const auth = getLinearAuth(); + if (auth?.refreshToken) { + await revokeToken(auth.refreshToken, 'refresh_token'); + } else if (auth?.accessToken) { + await revokeToken(auth.accessToken, 'access_token'); + } + const removed = clearLinearAuth(auth?.workspaceId); + return res.json({ success: true, removed }); + } catch (error) { + console.error('Failed to disconnect Linear:', error); + return res.status(500).json({ error: error.message || 'Failed to disconnect Linear' }); + } + }); +} diff --git a/packages/web/server/lib/linear/routes.test.js b/packages/web/server/lib/linear/routes.test.js new file mode 100644 index 00000000..a6d87112 --- /dev/null +++ b/packages/web/server/lib/linear/routes.test.js @@ -0,0 +1,661 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { registerLinearRoutes } from './routes.js'; +import { setLinearAuth, setLinearSessionCommentsEnabled } from './auth.js'; + +const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-routes-')); + +const createApp = () => { + const app = express(); + registerLinearRoutes(app); + return app; +}; + +const jsonResponse = (payload, status = 200) => new Response(JSON.stringify(payload), { + status, + headers: { 'Content-Type': 'application/json' }, +}); + +describe('Linear auth routes', () => { + let dataDir; + let previousDataDir; + + beforeEach(() => { + previousDataDir = process.env.OPENCHAMBER_DATA_DIR; + dataDir = makeTempDir(); + process.env.OPENCHAMBER_DATA_DIR = dataDir; + process.env.OPENCHAMBER_PORT = '3001'; + process.env.OPENCHAMBER_LINEAR_REDIRECT_URI = 'http://127.0.0.1:3001/linear/oauth/callback'; + delete process.env.OPENCHAMBER_LINEAR_CLIENT_ID; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + if (previousDataDir === undefined) { + delete process.env.OPENCHAMBER_DATA_DIR; + } else { + process.env.OPENCHAMBER_DATA_DIR = previousDataDir; + } + delete process.env.OPENCHAMBER_PORT; + delete process.env.OPENCHAMBER_LINEAR_REDIRECT_URI; + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('starts authorization and completes it from the public callback', async () => { + const app = createApp(); + const start = await request(app) + .post('/api/linear/auth/start') + .send({ origin: 'desktop' }) + .expect(200); + + expect(start.body.authorizationUrl).toContain('https://linear.app/oauth/authorize'); + const state = new URL(start.body.authorizationUrl).searchParams.get('state'); + + vi.stubGlobal('fetch', vi.fn(async (url) => { + const target = String(url); + if (target === 'https://api.linear.app/oauth/token') { + return jsonResponse({ + access_token: 'access-1', + refresh_token: 'refresh-1', + token_type: 'Bearer', + expires_in: 86399, + scope: 'read,write,comments:create', + }); + } + if (target === 'https://api.linear.app/graphql') { + return jsonResponse({ + data: { + viewer: { + id: 'user-1', + name: 'Ada', + displayName: 'Ada Lovelace', + email: 'ada@example.com', + avatarUrl: 'https://example.com/a.png', + }, + organization: { id: 'org-1', name: 'OpenChamber', urlKey: 'openchamber' }, + }, + }); + } + throw new Error(`unexpected fetch: ${target}`); + })); + + const callback = await request(app) + .get('/linear/oauth/callback') + .query({ state, code: 'auth-code' }) + .expect(200); + + expect(callback.text).toContain('Authorization Complete'); + expect(callback.text).toContain('openchamber://focus/linear-auth'); + + const status = await request(app).get('/api/linear/auth/status').expect(200); + expect(status.body.connected).toBe(true); + expect(status.body.user).toEqual({ + id: 'user-1', + name: 'Ada', + displayName: 'Ada Lovelace', + email: 'ada@example.com', + avatarUrl: 'https://example.com/a.png', + }); + expect(status.body.organization).toEqual({ id: 'org-1', name: 'OpenChamber', urlKey: 'openchamber' }); + expect(status.body.scope).toBe('read,write,comments:create'); + expect(status.body.workspaces).toEqual([{ + id: 'org-1', + name: 'OpenChamber', + urlKey: 'openchamber', + current: true, + user: { + id: 'user-1', + name: 'Ada', + displayName: 'Ada Lovelace', + email: 'ada@example.com', + avatarUrl: 'https://example.com/a.png', + }, + authorizedAt: expect.any(Number), + }]); + expect(JSON.stringify(status.body)).not.toContain('access-1'); + expect(JSON.stringify(status.body)).not.toContain('refresh-1'); + + const again = await request(app).get('/api/linear/auth/status').expect(200); + expect(again.body.workspaces[0].authorizedAt).toBe(status.body.workspaces[0].authorizedAt); + }); + + it('never exchanges a code whose state is unknown', async () => { + const tokenFetch = vi.fn(); + vi.stubGlobal('fetch', tokenFetch); + const app = createApp(); + const response = await request(app) + .get('/linear/oauth/callback') + .query({ state: 'forged', code: 'attacker-code' }) + .expect(400); + expect(tokenFetch).not.toHaveBeenCalled(); + expect(response.text).toContain('Authorization Failed'); + expect(response.text).not.toContain('openchamber://'); + }); + + it('omits the desktop deep link for flows started outside the desktop shell', async () => { + const app = createApp(); + const start = await request(app) + .post('/api/linear/auth/start') + .send({ origin: 'web' }) + .expect(200); + const state = new URL(start.body.authorizationUrl).searchParams.get('state'); + + vi.stubGlobal('fetch', vi.fn(async (url) => { + const target = String(url); + if (target.includes('/oauth/token')) { + return jsonResponse({ + access_token: 'access-1', + refresh_token: 'refresh-1', + expires_in: 86399, + }); + } + return jsonResponse({ + data: { viewer: { id: 'user-1', name: 'Ada' }, organization: null }, + }); + })); + + const response = await request(app) + .get('/linear/oauth/callback') + .query({ state, code: 'auth-code' }) + .expect(200); + expect(response.text).not.toContain('openchamber://'); + }); + + it('disconnects and revokes the refresh token', async () => { + const app = createApp(); + const start = await request(app).post('/api/linear/auth/start').send({}).expect(200); + const state = new URL(start.body.authorizationUrl).searchParams.get('state'); + const fetchMock = vi.fn(async (url) => { + const target = String(url); + if (target.includes('/oauth/token')) { + return jsonResponse({ + access_token: 'access-1', + refresh_token: 'refresh-1', + expires_in: 86399, + }); + } + if (target.includes('/graphql')) { + return jsonResponse({ data: { viewer: { id: 'user-1', name: 'Ada' } } }); + } + if (target.includes('/oauth/revoke')) { + return new Response('', { status: 200 }); + } + throw new Error(`unexpected fetch: ${target}`); + }); + vi.stubGlobal('fetch', fetchMock); + + await request(app).get('/linear/oauth/callback').query({ state, code: 'auth-code' }).expect(200); + await request(app).delete('/api/linear/auth').expect(200); + + const revokeCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/oauth/revoke')); + expect(revokeCall).toBeTruthy(); + const body = new URLSearchParams(revokeCall[1].body); + expect(body.get('token')).toBe('refresh-1'); + expect(body.get('token_type_hint')).toBe('refresh_token'); + + const status = await request(app).get('/api/linear/auth/status').expect(200); + expect(status.body).toEqual({ connected: false }); + }); + + it('stores a second workspace, switches current, and disconnects only that one', async () => { + const app = createApp(); + + const startA = await request(app).post('/api/linear/auth/start').send({}).expect(200); + const stateA = new URL(startA.body.authorizationUrl).searchParams.get('state'); + vi.stubGlobal('fetch', vi.fn(async (url) => { + const target = String(url); + if (target.includes('/oauth/token')) { + return jsonResponse({ + access_token: 'access-a', + refresh_token: 'refresh-a', + expires_in: 86399, + scope: 'read,write,comments:create', + }); + } + if (target.includes('/graphql')) { + return jsonResponse({ + data: { + viewer: { id: 'user-a', name: 'Ada' }, + organization: { id: 'org-a', name: 'Alpha', urlKey: 'alpha' }, + }, + }); + } + throw new Error(`unexpected fetch: ${target}`); + })); + await request(app).get('/linear/oauth/callback').query({ state: stateA, code: 'code-a' }).expect(200); + + const startB = await request(app).post('/api/linear/auth/start').send({}).expect(200); + const stateB = new URL(startB.body.authorizationUrl).searchParams.get('state'); + vi.stubGlobal('fetch', vi.fn(async (url) => { + const target = String(url); + if (target.includes('/oauth/token')) { + return jsonResponse({ + access_token: 'access-b', + refresh_token: 'refresh-b', + expires_in: 86399, + scope: 'read,write,comments:create', + }); + } + if (target.includes('/graphql')) { + return jsonResponse({ + data: { + viewer: { id: 'user-b', name: 'Ben' }, + organization: { id: 'org-b', name: 'Beta', urlKey: 'beta' }, + }, + }); + } + if (target.includes('/oauth/revoke')) { + return new Response('', { status: 200 }); + } + throw new Error(`unexpected fetch: ${target}`); + })); + await request(app).get('/linear/oauth/callback').query({ state: stateB, code: 'code-b' }).expect(200); + + const both = await request(app).get('/api/linear/auth/status').expect(200); + expect(both.body.organization.id).toBe('org-b'); + expect(both.body.workspaces).toHaveLength(2); + + await request(app).post('/api/linear/auth/activate').send({}).expect(400); + await request(app).post('/api/linear/auth/activate').send({ organizationId: 'missing' }).expect(404); + + const activated = await request(app) + .post('/api/linear/auth/activate') + .send({ organizationId: 'org-a' }) + .expect(200); + expect(activated.body.organization.id).toBe('org-a'); + expect(activated.body.workspaces.find((entry) => entry.id === 'org-a').current).toBe(true); + expect(activated.body.workspaces.find((entry) => entry.id === 'org-b').current).toBe(false); + + await request(app).delete('/api/linear/auth').expect(200); + const remaining = await request(app).get('/api/linear/auth/status').expect(200); + expect(remaining.body.connected).toBe(true); + expect(remaining.body.organization.id).toBe('org-b'); + expect(remaining.body.workspaces).toHaveLength(1); + expect(remaining.body.workspaces[0].id).toBe('org-b'); + }); + + it('lists and gets issues through authenticated routes without leaking tokens', async () => { + setLinearAuth({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + tokenType: 'Bearer', + expiresAt: Date.now() + 86_400_000, + scope: 'read', + }); + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + if (body.query.includes('GetLinearIssue')) { + return jsonResponse({ + data: { + issue: { + id: 'issue-1', + identifier: 'ENG-12', + title: 'Broken login', + url: 'https://linear.app/openchamber/issue/ENG-12', + state: { name: 'Todo', type: 'unstarted' }, + assignee: null, + description: 'Users cannot sign in.', + comments: { nodes: [] }, + }, + }, + }); + } + return jsonResponse({ + data: { + issues: { + nodes: [{ + id: 'issue-1', + identifier: 'ENG-12', + title: 'Broken login', + url: 'https://linear.app/openchamber/issue/ENG-12', + state: { name: 'Todo', type: 'unstarted' }, + assignee: null, + }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + })); + + const app = createApp(); + const list = await request(app).get('/api/linear/issues/list').expect(200); + expect(list.body.connected).toBe(true); + expect(list.body.issues).toHaveLength(1); + expect(JSON.stringify(list.body)).not.toContain('access-1'); + + const missing = await request(app).get('/api/linear/issues/get').expect(400); + expect(missing.body.error).toBe('id is required'); + + const got = await request(app).get('/api/linear/issues/get').query({ id: 'ENG-12' }).expect(200); + expect(got.body.issue.identifier).toBe('ENG-12'); + expect(got.body.issue.description).toBe('Users cannot sign in.'); + expect(got.body.issue.state).toEqual({ id: null, name: 'Todo', type: 'unstarted' }); + }); + + it('passes list filters from query params to Linear', async () => { + setLinearAuth({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + tokenType: 'Bearer', + expiresAt: Date.now() + 86_400_000, + scope: 'read', + }); + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + expect(body.variables.filter).toEqual({ + state: { type: { eq: 'completed' } }, + assignee: { isMe: { eq: true } }, + team: { id: { eq: 'team-eng' } }, + priority: { eq: 1 }, + }); + return jsonResponse({ + data: { + issues: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + })); + + const app = createApp(); + const list = await request(app).get('/api/linear/issues/list').query({ + status: 'completed', + assignee: 'me', + teamId: 'team-eng', + priority: 'urgent', + }).expect(200); + expect(list.body.connected).toBe(true); + expect(list.body.issues).toEqual([]); + }); + + it('lists workflow states and updates issue status without leaking tokens', async () => { + setLinearAuth({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + tokenType: 'Bearer', + expiresAt: Date.now() + 86_400_000, + scope: 'write', + }); + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + if (body.query.includes('TeamWorkflowStates')) { + expect(body.variables.id).toBe('team-eng'); + expect(options.headers.Authorization).toBe('Bearer access-1'); + return jsonResponse({ + data: { + team: { + states: { + nodes: [ + { id: 'state-todo', name: 'Todo', type: 'unstarted', position: 1 }, + { id: 'state-done', name: 'Done', type: 'completed', position: 2 }, + ], + }, + }, + }, + }); + } + expect(body.query).toContain('mutation IssueUpdate'); + expect(body.variables).toEqual({ + id: 'issue-uuid-1', + input: { stateId: 'state-done' }, + }); + return jsonResponse({ + data: { + issueUpdate: { + success: true, + issue: { + id: 'issue-uuid-1', + identifier: 'ENG-12', + title: 'Broken login', + url: 'https://linear.app/openchamber/issue/ENG-12', + state: { id: 'state-done', name: 'Done', type: 'completed' }, + assignee: null, + description: null, + comments: { nodes: [] }, + }, + }, + }, + }); + })); + + const app = createApp(); + const missingTeam = await request(app).get('/api/linear/issues/states').expect(400); + expect(missingTeam.body.error).toBe('teamId is required'); + + const states = await request(app).get('/api/linear/issues/states').query({ teamId: 'team-eng' }).expect(200); + expect(states.body.connected).toBe(true); + expect(states.body.states).toEqual([ + { id: 'state-todo', name: 'Todo', type: 'unstarted', position: 1 }, + { id: 'state-done', name: 'Done', type: 'completed', position: 2 }, + ]); + expect(JSON.stringify(states.body)).not.toContain('access-1'); + + const missingBody = await request(app).post('/api/linear/issues/update').send({}).expect(400); + expect(missingBody.body.error).toBe('id and stateId are required'); + + const updated = await request(app).post('/api/linear/issues/update').send({ + id: 'issue-uuid-1', + stateId: 'state-done', + }).expect(200); + expect(updated.body.connected).toBe(true); + expect(updated.body.issue.identifier).toBe('ENG-12'); + expect(updated.body.issue.state).toEqual({ id: 'state-done', name: 'Done', type: 'completed' }); + expect(JSON.stringify(updated.body)).not.toContain('access-1'); + }); + + it('returns 400 for Linear validation and not-found GraphQL errors', async () => { + setLinearAuth({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + tokenType: 'Bearer', + expiresAt: Date.now() + 86_400_000, + scope: 'write', + }); + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + if (body.query.includes('TeamWorkflowStates')) { + return jsonResponse({ + data: null, + errors: [{ + message: 'Entity not found: Team', + extensions: { + code: 'INPUT_ERROR', + userError: true, + userPresentableMessage: 'Could not find referenced Team.', + }, + }], + }); + } + return jsonResponse({ + data: null, + errors: [{ + message: 'Argument Validation Error', + extensions: { + code: 'INVALID_INPUT', + userError: true, + userPresentableMessage: 'stateId must be a UUID.', + }, + }], + }); + })); + + const app = createApp(); + const states = await request(app).get('/api/linear/issues/states').query({ teamId: 'missing-team' }).expect(400); + expect(states.body.error).toBe('Could not find referenced Team.'); + + const updated = await request(app).post('/api/linear/issues/update').send({ + id: 'issue-uuid-1', + stateId: 'not-a-uuid', + }).expect(400); + expect(updated.body.error).toBe('stateId must be a UUID.'); + }); + + it('returns disconnected for issue routes when Linear is not connected', async () => { + const app = createApp(); + const list = await request(app).get('/api/linear/issues/list').expect(200); + expect(list.body).toEqual({ connected: false }); + const got = await request(app).get('/api/linear/issues/get').query({ id: 'ENG-12' }).expect(200); + expect(got.body).toEqual({ connected: false }); + const states = await request(app).get('/api/linear/issues/states').query({ teamId: 'team-eng' }).expect(200); + expect(states.body).toEqual({ connected: false }); + const updated = await request(app).post('/api/linear/issues/update').send({ + id: 'issue-1', + stateId: 'state-done', + }).expect(200); + expect(updated.body).toEqual({ connected: false }); + }); + + it('returns disconnected mapping when Linear is not connected', async () => { + const app = createApp(); + const mapping = await request(app).get('/api/linear/mapping').expect(200); + expect(mapping.body).toEqual({ connected: false }); + const saved = await request(app).put('/api/linear/mapping').send({ + defaultProjectPath: '/tmp/project', + teamProjectPaths: {}, + }).expect(200); + expect(saved.body).toEqual({ connected: false }); + }); + + it('saves and reads Linear team-to-project mapping without leaking tokens', async () => { + setLinearAuth({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + tokenType: 'Bearer', + expiresAt: Date.now() + 86_400_000, + scope: 'read', + }); + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + expect(body.query).toContain('query ListLinearTeams'); + expect(options.headers.Authorization).toBe('Bearer access-1'); + return jsonResponse({ + data: { + teams: { + nodes: [ + { id: 'team-eng', key: 'ENG', name: 'Engineering' }, + { id: 'team-des', key: 'DES', name: 'Design' }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + })); + + const app = createApp(); + const empty = await request(app).get('/api/linear/mapping').expect(200); + expect(empty.body).toEqual({ + connected: true, + defaultProjectPath: null, + teams: [ + { id: 'team-eng', key: 'ENG', name: 'Engineering', projectPath: null }, + { id: 'team-des', key: 'DES', name: 'Design', projectPath: null }, + ], + }); + expect(JSON.stringify(empty.body)).not.toContain('access-1'); + + const saved = await request(app).put('/api/linear/mapping').send({ + defaultProjectPath: '/Users/ada/openchamber', + teamProjectPaths: { 'team-eng': '/Users/ada/eng' }, + }).expect(200); + expect(saved.body).toEqual({ + connected: true, + defaultProjectPath: '/Users/ada/openchamber', + teams: [ + { id: 'team-eng', key: 'ENG', name: 'Engineering', projectPath: '/Users/ada/eng' }, + { id: 'team-des', key: 'DES', name: 'Design', projectPath: null }, + ], + }); + expect(JSON.stringify(saved.body)).not.toContain('access-1'); + + const reread = await request(app).get('/api/linear/mapping').expect(200); + expect(reread.body.defaultProjectPath).toBe('/Users/ada/openchamber'); + expect(reread.body.teams[0].projectPath).toBe('/Users/ada/eng'); + }); + + it('posts a session status comment and never leaks the token', async () => { + setLinearSessionCommentsEnabled(true); + setLinearAuth({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + tokenType: 'Bearer', + expiresAt: Date.now() + 86_400_000, + scope: 'read,write,comments:create', + }); + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + if (body.query.includes('query GetLinearIssue')) { + return jsonResponse({ + data: { + issue: { + id: 'issue-1', + identifier: 'ENG-12', + title: 'Broken login', + url: 'https://linear.app/openchamber/issue/ENG-12', + state: { name: 'Todo', type: 'unstarted' }, + assignee: null, + description: null, + comments: { nodes: [] }, + }, + }, + }); + } + expect(body.query).toContain('mutation CommentCreate'); + return jsonResponse({ + data: { + commentCreate: { + success: true, + comment: { id: 'comment-1' }, + }, + }, + }); + })); + + const app = createApp(); + const missing = await request(app).post('/api/linear/session-status').send({ + kind: 'started', + }).expect(400); + expect(missing.body.error).toBe('kind and sessionId are required'); + + const posted = await request(app).post('/api/linear/session-status').send({ + kind: 'started', + sessionId: 'ses_1', + issueIdentifier: 'ENG-12', + sessionOrigin: 'https://app.example.com', + }).expect(200); + expect(posted.body).toEqual({ + connected: true, + posted: true, + commentId: 'comment-1', + }); + expect(JSON.stringify(posted.body)).not.toContain('access-1'); + }); + + it('reads and writes the session-comment preference', async () => { + const app = createApp(); + const initial = await request(app).get('/api/linear/preferences').expect(200); + expect(initial.body).toEqual({ sessionComments: false }); + + const invalid = await request(app).put('/api/linear/preferences').send({ sessionComments: 'yes' }).expect(400); + expect(invalid.body.error).toBe('sessionComments must be a boolean'); + + const enabled = await request(app).put('/api/linear/preferences').send({ sessionComments: true }).expect(200); + expect(enabled.body).toEqual({ sessionComments: true }); + const reread = await request(app).get('/api/linear/preferences').expect(200); + expect(reread.body).toEqual({ sessionComments: true }); + }); + + it('returns disconnected session-status when Linear is not connected', async () => { + const app = createApp(); + const response = await request(app).post('/api/linear/session-status').send({ + kind: 'started', + sessionId: 'ses_1', + issueIdentifier: 'ENG-12', + }).expect(200); + expect(response.body).toEqual({ connected: false }); + }); +}); diff --git a/packages/web/server/lib/linear/status-runtime.js b/packages/web/server/lib/linear/status-runtime.js new file mode 100644 index 00000000..6b2a1e67 --- /dev/null +++ b/packages/web/server/lib/linear/status-runtime.js @@ -0,0 +1,64 @@ +import { isPlainObject, readTrimmedString } from './parse.js'; +import { postLinearSessionStatus } from './status.js'; + +function readProperties(payload) { + if (!isPlainObject(payload)) return {}; + return isPlainObject(payload.properties) ? payload.properties : {}; +} + +function readNested(properties, key) { + return isPlainObject(properties[key]) ? properties[key] : {}; +} + +function extractSessionId(payload) { + const properties = readProperties(payload); + const info = readNested(properties, 'info'); + return readTrimmedString(info.sessionID) + || readTrimmedString(info.sessionId) + || readTrimmedString(properties.sessionID) + || readTrimmedString(properties.sessionId) + || readTrimmedString(properties.session); +} + +function extractStatusType(payload) { + if (!isPlainObject(payload) || payload.type !== 'session.status') return ''; + const properties = readProperties(payload); + const status = readNested(properties, 'status'); + const info = readNested(properties, 'info'); + return readTrimmedString(status.type) || readTrimmedString(info.type); +} + +function extractErrorName(payload) { + if (!isPlainObject(payload) || payload.type !== 'session.error') return ''; + const properties = readProperties(payload); + return readTrimmedString(readNested(properties, 'error').name); +} + +export function createLinearSessionStatusRuntime() { + let stopped = false; + + const processPayload = (payload) => { + if (stopped) return; + const sessionId = extractSessionId(payload); + if (!sessionId) return; + + if (isPlainObject(payload) && payload.type === 'session.error') { + if (extractErrorName(payload) === 'MessageAbortedError') return; + void postLinearSessionStatus({ kind: 'failure', sessionId }).catch((error) => { + console.warn('[linear] failed to post session failure comment:', error?.message || error); + }); + return; + } + + if (extractStatusType(payload) !== 'idle') return; + void postLinearSessionStatus({ kind: 'completed', sessionId }).catch((error) => { + console.warn('[linear] failed to post session completed comment:', error?.message || error); + }); + }; + + const stop = () => { + stopped = true; + }; + + return { processPayload, stop }; +} diff --git a/packages/web/server/lib/linear/status-runtime.test.js b/packages/web/server/lib/linear/status-runtime.test.js new file mode 100644 index 00000000..36bc6856 --- /dev/null +++ b/packages/web/server/lib/linear/status-runtime.test.js @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { setLinearAuth, clearLinearAuth, setLinearSessionCommentsEnabled } from './auth.js'; +import { createLinearSessionStatusRuntime } from './status-runtime.js'; + +const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-status-runtime-')); + +const jsonResponse = (payload, status = 200) => new Response(JSON.stringify(payload), { + status, + headers: { 'Content-Type': 'application/json' }, +}); + +const issueNode = { + id: 'issue-uuid-1', + identifier: 'ENG-12', + title: 'Broken login', + url: 'https://linear.app/openchamber/issue/ENG-12', + state: { name: 'In Progress', type: 'started' }, + assignee: null, + team: { id: 'team-eng', key: 'ENG', name: 'Engineering' }, + description: null, + comments: { nodes: [] }, +}; + +function stubLinearGraphql({ commentId = 'comment-1' } = {}) { + return vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + if (body.query.includes('query GetLinearIssue')) { + return jsonResponse({ data: { issue: issueNode } }); + } + if (body.query.includes('mutation CommentCreate')) { + return jsonResponse({ + data: { + commentCreate: { + success: true, + comment: { id: commentId }, + }, + }, + }); + } + throw new Error(`unexpected query: ${body.query}`); + }); +} + +describe('Linear session status runtime', () => { + let dataDir; + let previousDataDir; + let previousPort; + + beforeEach(() => { + previousDataDir = process.env.OPENCHAMBER_DATA_DIR; + previousPort = process.env.OPENCHAMBER_PORT; + dataDir = makeTempDir(); + process.env.OPENCHAMBER_DATA_DIR = dataDir; + process.env.OPENCHAMBER_PORT = '3001'; + setLinearAuth({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + tokenType: 'Bearer', + expiresAt: Date.now() + 86_400_000, + scope: 'read,write,comments:create', + }); + setLinearSessionCommentsEnabled(true); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + clearLinearAuth(); + if (previousDataDir === undefined) { + delete process.env.OPENCHAMBER_DATA_DIR; + } else { + process.env.OPENCHAMBER_DATA_DIR = previousDataDir; + } + if (previousPort === undefined) { + delete process.env.OPENCHAMBER_PORT; + } else { + process.env.OPENCHAMBER_PORT = previousPort; + } + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('posts completed on the first idle after started, then ignores later idles', async () => { + const { postLinearSessionStatus } = await import('./status.js'); + vi.stubGlobal('fetch', stubLinearGraphql({ commentId: 'started' })); + await postLinearSessionStatus({ + kind: 'started', + sessionId: 'ses_1', + issueIdentifier: 'ENG-12', + sessionOrigin: 'https://app.example.com', + }); + + const graphql = stubLinearGraphql({ commentId: 'done' }); + vi.stubGlobal('fetch', graphql); + const runtime = createLinearSessionStatusRuntime(); + runtime.processPayload({ + type: 'session.status', + properties: { sessionID: 'ses_1', status: { type: 'idle' } }, + }); + runtime.processPayload({ + type: 'session.status', + properties: { sessionID: 'ses_1', status: { type: 'idle' } }, + }); + await vi.waitFor(() => { + const commentCalls = graphql.mock.calls.filter(([, options]) => { + return JSON.parse(options.body).query.includes('mutation CommentCreate'); + }); + expect(commentCalls).toHaveLength(1); + }); + runtime.stop(); + }); + + it('posts failure on session.error and skips user abort', async () => { + const { postLinearSessionStatus } = await import('./status.js'); + vi.stubGlobal('fetch', stubLinearGraphql({ commentId: 'started' })); + await postLinearSessionStatus({ + kind: 'started', + sessionId: 'ses_1', + issueIdentifier: 'ENG-12', + sessionOrigin: 'https://app.example.com', + }); + + const graphql = stubLinearGraphql({ commentId: 'fail' }); + vi.stubGlobal('fetch', graphql); + const runtime = createLinearSessionStatusRuntime(); + runtime.processPayload({ + type: 'session.error', + properties: { + sessionID: 'ses_1', + error: { name: 'MessageAbortedError', message: 'stopped' }, + }, + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(graphql).not.toHaveBeenCalled(); + + runtime.processPayload({ + type: 'session.error', + properties: { + sessionID: 'ses_1', + error: { name: 'ProviderError', message: 'boom' }, + }, + }); + await vi.waitFor(() => { + const commentCalls = graphql.mock.calls.filter(([, options]) => { + return JSON.parse(options.body).query.includes('mutation CommentCreate'); + }); + expect(commentCalls).toHaveLength(1); + const body = JSON.parse(commentCalls[0][1].body).variables.input.body; + expect(body).toContain('OpenChamber session failed'); + }); + runtime.stop(); + }); + + it('does not treat busy as completed', async () => { + const graphql = stubLinearGraphql(); + vi.stubGlobal('fetch', graphql); + const runtime = createLinearSessionStatusRuntime(); + runtime.processPayload({ + type: 'session.status', + properties: { sessionID: 'ses_1', status: { type: 'busy' } }, + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(graphql).not.toHaveBeenCalled(); + runtime.stop(); + }); +}); diff --git a/packages/web/server/lib/linear/status.js b/packages/web/server/lib/linear/status.js new file mode 100644 index 00000000..9ba19457 --- /dev/null +++ b/packages/web/server/lib/linear/status.js @@ -0,0 +1,280 @@ +import fs from 'fs'; +import path from 'path'; +import { getLinearAuth, getLinearAuthFilePath, getLinearSessionCommentsEnabled } from './auth.js'; +import { createLinearIssueComment } from './issues.js'; +import { isPlainObject, readTrimmedString } from './parse.js'; + +const LINEAR_SESSION_STATUS_KINDS = ['started', 'completed', 'failure']; +const MAX_SESSION_STATUS_RECORDS = 500; + +export class LinearSessionStatusError extends Error { + constructor(message, code) { + super(message); + this.name = 'LinearSessionStatusError'; + this.code = code; + } +} + +const inflight = new Map(); + +function statusFile() { + return path.join(path.dirname(getLinearAuthFilePath()), 'linear-session-status.json'); +} + +function writeJsonFile(filePath, payload) { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + const tmpFile = `${filePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tmpFile, JSON.stringify(payload, null, 2), 'utf8'); + try { + fs.chmodSync(tmpFile, 0o600); + } catch { + // best-effort + } + fs.renameSync(tmpFile, filePath); + try { + fs.chmodSync(filePath, 0o600); + } catch { + // best-effort + } +} + +const PRIVATE_HOST_SUFFIXES = ['.local', '.localhost', '.internal', '.lan', '.home.arpa']; + +function isPrivateIpv4(hostname) { + const parts = hostname.split('.'); + if (parts.length !== 4) return false; + const octets = parts.map((part) => (/^\d{1,3}$/.test(part) ? Number(part) : -1)); + if (octets.some((octet) => octet < 0 || octet > 255)) return false; + const [a, b] = octets; + if (a === 0 || a === 10 || a === 127) return true; + if (a === 169 && b === 254) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + // 100.64.0.0/10 is carrier-grade NAT, which Tailscale and similar overlays use. + if (a === 100 && b >= 64 && b <= 127) return true; + return false; +} + +function isPrivateIpv6(hostname) { + const address = hostname.replace(/^\[/, '').replace(/\]$/, '').toLowerCase(); + if (address === '::1' || address === '::') return true; + // fc00::/7 (unique local) and fe80::/10 (link local). + return /^f[cd]/.test(address) || /^fe[89ab]/.test(address); +} + +/** + * A session link is only worth writing into Linear when somebody other than the + * person who started the session can open it. Loopback, private LAN and + * overlay-network addresses reach nobody else, so they do not qualify. + */ +export function isPublicSessionOrigin(value) { + const origin = readSessionOrigin(value); + if (!origin) return false; + let hostname; + try { + hostname = new URL(origin).hostname.toLowerCase(); + } catch { + return false; + } + if (!hostname || hostname === 'localhost') return false; + if (PRIVATE_HOST_SUFFIXES.some((suffix) => hostname.endsWith(suffix))) return false; + if (hostname.includes(':') || hostname.startsWith('[')) return !isPrivateIpv6(hostname); + if (/^[\d.]+$/.test(hostname)) return !isPrivateIpv4(hostname); + // A bare single-label host is a LAN machine name, not a routable address. + return hostname.includes('.'); +} + +export function readSessionOrigin(value) { + const trimmed = readTrimmedString(value); + if (!trimmed) return ''; + try { + const url = new URL(trimmed); + if (url.protocol !== 'http:' && url.protocol !== 'https:') return ''; + if (url.username || url.password) return ''; + if (url.search || url.hash) return ''; + if (url.pathname && url.pathname !== '/') return ''; + return url.origin; + } catch { + return ''; + } +} + +export function buildLinearSessionOpenUrl(sessionId, sessionOrigin) { + const id = readTrimmedString(sessionId); + const origin = readSessionOrigin(sessionOrigin); + if (!origin) return ''; + return `${origin}/?session=${encodeURIComponent(id)}`; +} + +function statusWord(kind) { + if (kind === 'started') return 'started'; + if (kind === 'completed') return 'completed'; + return 'failed'; +} + +export function buildLinearSessionStatusComment({ kind, sessionUrl }) { + const url = readTrimmedString(sessionUrl); + const label = `OpenChamber session ${statusWord(kind)}`; + if (!url) return label; + // The comment already lives on the issue, so it says only what happened and + // links to the session. Issue titles routinely contain brackets ("[Bug] …"), + // which would break this markdown link if they were repeated in the label. + return `[${label}](${url})`; +} + +function readBooleanFlag(value) { + return value === true; +} + +function readRecord(value) { + if (!isPlainObject(value)) return null; + const issueIdentifier = readTrimmedString(value.issueIdentifier); + if (!issueIdentifier) return null; + return { + issueIdentifier, + sessionOrigin: readSessionOrigin(value.sessionOrigin) || null, + organizationId: readTrimmedString(value.organizationId) || null, + started: readBooleanFlag(value.started), + completed: readBooleanFlag(value.completed), + failure: readBooleanFlag(value.failure), + }; +} + +function readRecords() { + const filePath = statusFile(); + if (!fs.existsSync(filePath)) { + return {}; + } + let parsed; + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const trimmed = raw.trim(); + if (!trimmed) { + return {}; + } + parsed = JSON.parse(trimmed); + } catch { + throw new LinearSessionStatusError('Linear session status file is malformed', 'MALFORMED'); + } + if (!isPlainObject(parsed)) { + throw new LinearSessionStatusError('Linear session status file is malformed', 'MALFORMED'); + } + const next = {}; + for (const key of Object.keys(parsed)) { + const sessionId = readTrimmedString(key); + const record = readRecord(parsed[key]); + if (sessionId && record) { + next[sessionId] = record; + } + } + return next; +} + +/** + * The file only exists to dedupe comments, so it does not need to remember + * every session ever started. Keep the newest entries and drop the tail. + */ +export function pruneSessionStatusRecords(records, limit = MAX_SESSION_STATUS_RECORDS) { + const keys = Object.keys(records); + if (keys.length <= limit) { + return records; + } + const kept = {}; + for (const key of keys.slice(keys.length - limit)) { + kept[key] = records[key]; + } + return kept; +} + +function writeRecords(records) { + writeJsonFile(statusFile(), pruneSessionStatusRecords(records)); +} + +async function postOnce(input) { + const kind = readTrimmedString(input?.kind); + const sessionId = readTrimmedString(input?.sessionId); + if (!LINEAR_SESSION_STATUS_KINDS.includes(kind) || !sessionId) { + throw new LinearSessionStatusError('kind and sessionId are required', 'INVALID'); + } + + // Disconnected answers first so the picker and panel keep showing their + // "connect Linear" state whatever the comment preference says. + if (!getLinearAuth()) { + return { connected: false }; + } + if (!getLinearSessionCommentsEnabled()) { + return { connected: true, posted: false, skipped: 'disabled' }; + } + + const records = readRecords(); + const existing = records[sessionId] || null; + if (existing?.[kind] === true) { + return { connected: true, posted: false, skipped: 'already-posted' }; + } + if (kind !== 'started' && existing?.started !== true) { + return { connected: true, posted: false, skipped: 'not-started' }; + } + + const issueIdentifier = readTrimmedString(input?.issueIdentifier) + || readTrimmedString(existing?.issueIdentifier); + if (!issueIdentifier) { + throw new LinearSessionStatusError('issueIdentifier is required', 'INVALID'); + } + + const sessionOrigin = readSessionOrigin(input?.sessionOrigin) + || readTrimmedString(existing?.sessionOrigin); + // Without an origin other people can reach, the comment would carry a link + // only its author could open. Say nothing rather than publish a dead link. + if (!isPublicSessionOrigin(sessionOrigin)) { + return { connected: true, posted: false, skipped: 'origin-not-public' }; + } + const sessionUrl = buildLinearSessionOpenUrl(sessionId, sessionOrigin); + const organizationId = readTrimmedString(input?.organizationId) + || readTrimmedString(existing?.organizationId) + || readTrimmedString(getLinearAuth()?.workspaceId); + const body = buildLinearSessionStatusComment({ kind, sessionUrl }); + const commentResult = await createLinearIssueComment({ + issueId: issueIdentifier, + body, + organizationId, + }); + if (commentResult.connected === false) { + return { connected: false }; + } + if (!commentResult.comment) { + return { connected: true, posted: false, skipped: 'issue-not-found' }; + } + + records[sessionId] = { + issueIdentifier, + sessionOrigin: sessionOrigin || null, + organizationId: organizationId || null, + started: existing?.started === true || kind === 'started', + completed: existing?.completed === true || kind === 'completed', + failure: existing?.failure === true || kind === 'failure', + }; + writeRecords(records); + return { + connected: true, + posted: true, + commentId: commentResult.comment.id, + }; +} + +export async function postLinearSessionStatus(input) { + const kind = readTrimmedString(input?.kind); + const sessionId = readTrimmedString(input?.sessionId); + const key = `${sessionId}:${kind}`; + const pending = inflight.get(key); + if (pending) { + return pending; + } + const promise = postOnce(input).finally(() => { + inflight.delete(key); + }); + inflight.set(key, promise); + return promise; +} diff --git a/packages/web/server/lib/linear/status.test.js b/packages/web/server/lib/linear/status.test.js new file mode 100644 index 00000000..d2c9fb8a --- /dev/null +++ b/packages/web/server/lib/linear/status.test.js @@ -0,0 +1,271 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { setLinearAuth, clearLinearAuth, setLinearSessionCommentsEnabled } from './auth.js'; +import { + buildLinearSessionOpenUrl, + buildLinearSessionStatusComment, + isPublicSessionOrigin, + postLinearSessionStatus, + pruneSessionStatusRecords, + readSessionOrigin, +} from './status.js'; + +const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-status-')); + +const jsonResponse = (payload, status = 200) => new Response(JSON.stringify(payload), { + status, + headers: { 'Content-Type': 'application/json' }, +}); + +const issueNode = { + id: 'issue-uuid-1', + identifier: 'ENG-12', + title: 'Broken login', + url: 'https://linear.app/openchamber/issue/ENG-12', + state: { name: 'In Progress', type: 'started' }, + assignee: null, + team: { id: 'team-eng', key: 'ENG', name: 'Engineering' }, + description: null, + comments: { nodes: [] }, +}; + +function stubLinearGraphql({ commentId = 'comment-1' } = {}) { + return vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + if (body.query.includes('query GetLinearIssue')) { + return jsonResponse({ data: { issue: issueNode } }); + } + if (body.query.includes('mutation CommentCreate')) { + expect(body.variables.input.issueId).toBe('issue-uuid-1'); + expect(body.variables.input.body).toContain('/?session=ses_1'); + return jsonResponse({ + data: { + commentCreate: { + success: true, + comment: { id: commentId }, + }, + }, + }); + } + throw new Error(`unexpected query: ${body.query}`); + }); +} + +describe('Linear session status comments', () => { + let dataDir; + let previousDataDir; + let previousPort; + + beforeEach(() => { + previousDataDir = process.env.OPENCHAMBER_DATA_DIR; + previousPort = process.env.OPENCHAMBER_PORT; + dataDir = makeTempDir(); + process.env.OPENCHAMBER_DATA_DIR = dataDir; + process.env.OPENCHAMBER_PORT = '3001'; + setLinearAuth({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + tokenType: 'Bearer', + expiresAt: Date.now() + 86_400_000, + scope: 'read,write,comments:create', + }); + setLinearSessionCommentsEnabled(true); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + clearLinearAuth(); + if (previousDataDir === undefined) { + delete process.env.OPENCHAMBER_DATA_DIR; + } else { + process.env.OPENCHAMBER_DATA_DIR = previousDataDir; + } + if (previousPort === undefined) { + delete process.env.OPENCHAMBER_PORT; + } else { + process.env.OPENCHAMBER_PORT = previousPort; + } + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('reads http(s) origins and rejects other URLs', () => { + expect(readSessionOrigin('https://app.example.com')).toBe('https://app.example.com'); + expect(readSessionOrigin('http://127.0.0.1:3001/')).toBe('http://127.0.0.1:3001'); + expect(readSessionOrigin('javascript:alert(1)')).toBe(''); + expect(readSessionOrigin('https://app.example.com/secret')).toBe(''); + expect(readSessionOrigin('openchamber:')).toBe(''); + expect(buildLinearSessionOpenUrl('ses_1', 'https://app.example.com')) + .toBe('https://app.example.com/?session=ses_1'); + expect(buildLinearSessionOpenUrl('ses_1', '')).toBe(''); + }); + + it('treats only externally reachable origins as public', () => { + expect(isPublicSessionOrigin('https://chamber.example.com')).toBe(true); + expect(isPublicSessionOrigin('http://chamber.example.com:8080')).toBe(true); + expect(isPublicSessionOrigin('https://203.0.113.10')).toBe(true); + + expect(isPublicSessionOrigin('http://localhost:3001')).toBe(false); + expect(isPublicSessionOrigin('http://127.0.0.1:3001')).toBe(false); + expect(isPublicSessionOrigin('http://[::1]:3001')).toBe(false); + expect(isPublicSessionOrigin('http://192.168.1.20:3001')).toBe(false); + expect(isPublicSessionOrigin('http://10.0.0.5:3001')).toBe(false); + expect(isPublicSessionOrigin('http://172.20.1.4:3001')).toBe(false); + expect(isPublicSessionOrigin('http://169.254.10.1:3001')).toBe(false); + expect(isPublicSessionOrigin('http://100.101.102.103:3001')).toBe(false); + expect(isPublicSessionOrigin('http://macbook.local:3001')).toBe(false); + expect(isPublicSessionOrigin('http://macbook:3001')).toBe(false); + expect(isPublicSessionOrigin('http://[fd00::1]:3001')).toBe(false); + expect(isPublicSessionOrigin('openchamber:')).toBe(false); + expect(isPublicSessionOrigin('')).toBe(false); + }); + + it('posts nothing while session comments are turned off', async () => { + setLinearSessionCommentsEnabled(false); + const graphql = vi.fn(); + vi.stubGlobal('fetch', graphql); + await expect(postLinearSessionStatus({ + kind: 'started', + sessionId: 'ses_1', + issueIdentifier: 'ENG-12', + sessionOrigin: 'https://app.example.com', + })).resolves.toEqual({ connected: true, posted: false, skipped: 'disabled' }); + expect(graphql).not.toHaveBeenCalled(); + }); + + it('posts nothing when the session origin only the author can reach', async () => { + const graphql = vi.fn(); + vi.stubGlobal('fetch', graphql); + await expect(postLinearSessionStatus({ + kind: 'started', + sessionId: 'ses_1', + issueIdentifier: 'ENG-12', + sessionOrigin: 'http://127.0.0.1:3001', + })).resolves.toEqual({ connected: true, posted: false, skipped: 'origin-not-public' }); + await expect(postLinearSessionStatus({ + kind: 'started', + sessionId: 'ses_2', + issueIdentifier: 'ENG-12', + })).resolves.toEqual({ connected: true, posted: false, skipped: 'origin-not-public' }); + expect(graphql).not.toHaveBeenCalled(); + }); + + it('keeps the newest dedupe records and drops the oldest', () => { + const records = {}; + for (let index = 0; index < 5; index += 1) { + records[`ses_${index}`] = { issueIdentifier: 'ENG-12', started: true }; + } + expect(Object.keys(pruneSessionStatusRecords(records, 3))).toEqual(['ses_2', 'ses_3', 'ses_4']); + expect(Object.keys(pruneSessionStatusRecords(records, 10))).toHaveLength(5); + }); + + it('makes the whole status line one link and carries no title', () => { + expect(buildLinearSessionStatusComment({ + kind: 'started', + sessionUrl: 'https://app.example.com/?session=ses_1', + })).toBe('[OpenChamber session started](https://app.example.com/?session=ses_1)'); + expect(buildLinearSessionStatusComment({ + kind: 'completed', + sessionUrl: 'https://app.example.com/?session=ses_1', + })).toBe('[OpenChamber session completed](https://app.example.com/?session=ses_1)'); + expect(buildLinearSessionStatusComment({ + kind: 'failure', + sessionUrl: 'https://app.example.com/?session=ses_1', + })).toBe('[OpenChamber session failed](https://app.example.com/?session=ses_1)'); + }); + + it('cannot be broken by brackets in the issue title', async () => { + const graphql = stubLinearGraphql(); + vi.stubGlobal('fetch', graphql); + await postLinearSessionStatus({ + kind: 'started', + sessionId: 'ses_1', + issueIdentifier: 'ENG-12', + sessionOrigin: 'https://app.example.com', + }); + const commentCalls = graphql.mock.calls.filter(([, options]) => { + return JSON.parse(options.body).query.includes('mutation CommentCreate'); + }); + const body = JSON.parse(commentCalls[0][1].body).variables.input.body; + // One balanced pair of brackets, so a title like "[Bug] …" can never leak in + // and split the link across the renderer. + expect(body.match(/\[/g)).toHaveLength(1); + expect(body.match(/\]/g)).toHaveLength(1); + }); + + it('returns disconnected without calling Linear when there is no auth', async () => { + clearLinearAuth(); + const graphql = vi.fn(); + vi.stubGlobal('fetch', graphql); + await expect(postLinearSessionStatus({ + kind: 'started', + sessionId: 'ses_1', + issueIdentifier: 'ENG-12', + })).resolves.toEqual({ connected: false }); + expect(graphql).not.toHaveBeenCalled(); + }); + + it('posts a started comment once and skips repeats', async () => { + const graphql = stubLinearGraphql(); + vi.stubGlobal('fetch', graphql); + + const first = await postLinearSessionStatus({ + kind: 'started', + sessionId: 'ses_1', + issueIdentifier: 'ENG-12', + sessionOrigin: 'https://app.example.com', + }); + expect(first).toEqual({ connected: true, posted: true, commentId: 'comment-1' }); + + const second = await postLinearSessionStatus({ + kind: 'started', + sessionId: 'ses_1', + issueIdentifier: 'ENG-12', + sessionOrigin: 'https://app.example.com', + }); + expect(second).toEqual({ connected: true, posted: false, skipped: 'already-posted' }); + + const commentCalls = graphql.mock.calls.filter(([, options]) => { + return JSON.parse(options.body).query.includes('mutation CommentCreate'); + }); + expect(commentCalls).toHaveLength(1); + const body = JSON.parse(commentCalls[0][1].body).variables.input.body; + expect(body).toBe('[OpenChamber session started](https://app.example.com/?session=ses_1)'); + expect(JSON.stringify(first)).not.toContain('access-1'); + }); + + it('skips completed until started has been posted', async () => { + const graphql = stubLinearGraphql(); + vi.stubGlobal('fetch', graphql); + await expect(postLinearSessionStatus({ + kind: 'completed', + sessionId: 'ses_1', + })).resolves.toEqual({ connected: true, posted: false, skipped: 'not-started' }); + expect(graphql).not.toHaveBeenCalled(); + }); + + it('posts completed once after started, reusing the stored open URL', async () => { + vi.stubGlobal('fetch', stubLinearGraphql({ commentId: 'comment-started' })); + await postLinearSessionStatus({ + kind: 'started', + sessionId: 'ses_1', + issueIdentifier: 'ENG-12', + sessionOrigin: 'https://app.example.com', + }); + + const graphql = stubLinearGraphql({ commentId: 'comment-done' }); + vi.stubGlobal('fetch', graphql); + const first = await postLinearSessionStatus({ kind: 'completed', sessionId: 'ses_1' }); + expect(first).toEqual({ connected: true, posted: true, commentId: 'comment-done' }); + const second = await postLinearSessionStatus({ kind: 'completed', sessionId: 'ses_1' }); + expect(second).toEqual({ connected: true, posted: false, skipped: 'already-posted' }); + + const commentCalls = graphql.mock.calls.filter(([, options]) => { + return JSON.parse(options.body).query.includes('mutation CommentCreate'); + }); + expect(commentCalls).toHaveLength(1); + const body = JSON.parse(commentCalls[0][1].body).variables.input.body; + expect(body).toBe('[OpenChamber session completed](https://app.example.com/?session=ses_1)'); + }); +}); diff --git a/packages/web/server/lib/linear/teams.js b/packages/web/server/lib/linear/teams.js new file mode 100644 index 00000000..2cdcf93a --- /dev/null +++ b/packages/web/server/lib/linear/teams.js @@ -0,0 +1,72 @@ +import { clearLinearAuth, getLinearAuth } from './auth.js'; +import { fetchLinearGraphql, getValidLinearAccessToken } from './client.js'; +import { isPlainObject, readTrimmedString } from './parse.js'; + +const TEAMS_QUERY = ` + query ListLinearTeams($first: Int!, $after: String) { + teams(first: $first, after: $after) { + nodes { id key name } + pageInfo { hasNextPage endCursor } + } + } +`; +const PAGE_SIZE = 50; +const MAX_PAGES = 20; + +function readTeam(node) { + if (!isPlainObject(node)) { + return null; + } + const id = readTrimmedString(node.id); + const key = readTrimmedString(node.key); + const name = readTrimmedString(node.name); + if (!id || !key || !name) { + return null; + } + return { id, key, name }; +} + +export async function listLinearTeams() { + try { + const token = await getValidLinearAccessToken(); + if (!token) { + return { connected: false }; + } + + const teams = []; + let after = null; + for (let page = 0; page < MAX_PAGES; page += 1) { + const variables = { first: PAGE_SIZE }; + if (after) { + variables.after = after; + } + const data = await fetchLinearGraphql(token, TEAMS_QUERY, variables); + const connection = isPlainObject(data.teams) ? data.teams : null; + const nodes = isPlainObject(connection) && Array.isArray(connection.nodes) + ? connection.nodes + : []; + for (const node of nodes) { + const team = readTeam(node); + if (team) { + teams.push(team); + } + } + const pageInfo = isPlainObject(connection) ? connection.pageInfo : null; + if (!isPlainObject(pageInfo) || pageInfo.hasNextPage !== true) { + break; + } + after = readTrimmedString(pageInfo.endCursor); + if (!after) { + break; + } + } + + return { connected: true, teams }; + } catch (error) { + if (error?.status === 401) { + clearLinearAuth(getLinearAuth()?.workspaceId); + return { connected: false }; + } + throw error; + } +} diff --git a/packages/web/server/lib/linear/teams.test.js b/packages/web/server/lib/linear/teams.test.js new file mode 100644 index 00000000..806b259e --- /dev/null +++ b/packages/web/server/lib/linear/teams.test.js @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { clearLinearAuth, setLinearAuth } from './auth.js'; +import { listLinearTeams } from './teams.js'; + +const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-teams-')); + +const jsonResponse = (payload, status = 200) => new Response(JSON.stringify(payload), { + status, + headers: { 'Content-Type': 'application/json' }, +}); + +describe('Linear teams list', () => { + let dataDir; + let previousDataDir; + + beforeEach(() => { + previousDataDir = process.env.OPENCHAMBER_DATA_DIR; + dataDir = makeTempDir(); + process.env.OPENCHAMBER_DATA_DIR = dataDir; + setLinearAuth({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + tokenType: 'Bearer', + expiresAt: Date.now() + 86_400_000, + scope: 'read,write,comments:create', + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + clearLinearAuth(); + if (previousDataDir === undefined) { + delete process.env.OPENCHAMBER_DATA_DIR; + } else { + process.env.OPENCHAMBER_DATA_DIR = previousDataDir; + } + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('returns disconnected without calling Linear when there is no auth', async () => { + clearLinearAuth(); + const graphql = vi.fn(); + vi.stubGlobal('fetch', graphql); + await expect(listLinearTeams()).resolves.toEqual({ connected: false }); + expect(graphql).not.toHaveBeenCalled(); + }); + + it('lists teams across pages and never returns the token', async () => { + const graphql = vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + expect(body.query).toContain('query ListLinearTeams'); + expect(options.headers.Authorization).toBe('Bearer access-1'); + if (!body.variables.after) { + return jsonResponse({ + data: { + teams: { + nodes: [{ id: 'team-eng', key: 'ENG', name: 'Engineering' }], + pageInfo: { hasNextPage: true, endCursor: 'cursor-2' }, + }, + }, + }); + } + expect(body.variables.after).toBe('cursor-2'); + return jsonResponse({ + data: { + teams: { + nodes: [{ id: 'team-des', key: 'DES', name: 'Design' }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + }); + vi.stubGlobal('fetch', graphql); + + const result = await listLinearTeams(); + expect(result).toEqual({ + connected: true, + teams: [ + { id: 'team-eng', key: 'ENG', name: 'Engineering' }, + { id: 'team-des', key: 'DES', name: 'Design' }, + ], + }); + expect(JSON.stringify(result)).not.toContain('access-1'); + expect(graphql).toHaveBeenCalledTimes(2); + }); + + it('clears auth and reports disconnected after a GraphQL 401', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ errors: [{ message: 'Unauthorized' }] }, 401))); + await expect(listLinearTeams()).resolves.toEqual({ connected: false }); + }); +}); diff --git a/packages/web/server/lib/opencode/feature-routes-runtime.js b/packages/web/server/lib/opencode/feature-routes-runtime.js index f36f0c45..4a39043f 100644 --- a/packages/web/server/lib/opencode/feature-routes-runtime.js +++ b/packages/web/server/lib/opencode/feature-routes-runtime.js @@ -4,6 +4,7 @@ import { registerSmallModelRoutes } from '../small-model/routes.js'; import { registerWalkthroughRoutes } from '../walkthrough/routes.js'; import { registerSessionGoalRoutes } from '../session-goal/routes.js'; import { registerGitHubRoutes } from '../github/routes.js'; +import { registerLinearRoutes } from '../linear/routes.js'; import { registerGitRoutes } from '../git/routes.js'; import { registerDevServerRoutes } from '../dev-servers/routes.js'; import { registerMagicPromptRoutes } from '../magic-prompts/routes.js'; @@ -300,6 +301,7 @@ export const createFeatureRoutesRuntime = (dependencies) => { registerWalkthroughRoutes(app, { getWalkthroughService }); registerSessionGoalRoutes(app); registerGitHubRoutes(app); + registerLinearRoutes(app); registerGitRoutes(app); registerDevServerRoutes(app, { scanner: devServerScanner, getOwnPorts }); registerMagicPromptRoutes(app, { diff --git a/packages/web/server/lib/opencode/static-routes-runtime.js b/packages/web/server/lib/opencode/static-routes-runtime.js index de935be5..2feaa4ee 100644 --- a/packages/web/server/lib/opencode/static-routes-runtime.js +++ b/packages/web/server/lib/opencode/static-routes-runtime.js @@ -47,20 +47,20 @@ export const createStaticRoutesRuntime = (dependencies) => { normalizePwaOrientation, }); - app.get(/^(?!\/api|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (_req, res) => { + app.get(/^(?!\/api|\/linear|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (_req, res) => { res.sendFile(path.join(distPath, 'index.html')); }); return; } console.warn(`Warning: ${distPath} not found, static files will not be served`); - app.get(/^(?!\/api|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (_req, res) => { + app.get(/^(?!\/api|\/linear|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (_req, res) => { res.status(404).send('Static files not found. Please build the application first.'); }); }; const registerApiOnlyFallbackRoutes = (app) => { - app.get(/^(?!\/api|\/auth|\/health|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (req, res) => { + app.get(/^(?!\/api|\/auth|\/health|\/linear|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (req, res) => { const command = 'openchamber connect-url --help'; res.status(200).format({ html: () => { diff --git a/packages/web/src/api/index.ts b/packages/web/src/api/index.ts index 12831517..b286108c 100644 --- a/packages/web/src/api/index.ts +++ b/packages/web/src/api/index.ts @@ -15,6 +15,7 @@ import { createWebNotificationsAPI } from './notifications'; import { createWebToolsAPI } from './tools'; import { createWebPushAPI } from './push'; import { createWebGitHubAPI } from './github'; +import { createWebLinearAPI } from './linear'; import { createWebClientAuthAPI } from './clientAuth'; export interface WebAPIsOptions { @@ -45,6 +46,7 @@ export const createWebAPIs = (options: WebAPIsOptions = {}): RuntimeAPIs => { permissions: createWebPermissionsAPI(), notifications: createWebNotificationsAPI(), github: createWebGitHubAPI({ urls: activeUrls }), + linear: createWebLinearAPI(), push: createWebPushAPI(), clientAuth: createWebClientAuthAPI(), tools: createWebToolsAPI(), diff --git a/packages/web/src/api/linear.ts b/packages/web/src/api/linear.ts new file mode 100644 index 00000000..216c72f8 --- /dev/null +++ b/packages/web/src/api/linear.ts @@ -0,0 +1,609 @@ +import type { + LinearAPI, + LinearAuthOrigin, + LinearAuthStart, + LinearAuthStatus, + LinearIssue, + LinearIssueAssignee, + LinearIssueComment, + LinearIssueLabel, + LinearIssuePriority, + LinearIssueGetResult, + LinearIssueState, + LinearIssueStatesResult, + LinearIssueUpdateInput, + LinearIssueUpdateResult, + LinearIssueSummary, + LinearIssueTeam, + LinearIssuesListOptions, + LinearIssuesListResult, + LinearMappingResult, + LinearMappingWrite, + LinearOrganizationSummary, + LinearPreferences, + LinearSessionStatusPostInput, + LinearSessionStatusPostResult, + LinearTeamMapping, + LinearWorkflowState, + LinearUserSummary, + LinearWorkspaceSummary, +} from '@openchamber/ui/lib/api/types'; +import { runtimeFetch } from '@openchamber/ui/lib/runtime-fetch'; + +type LinearJson = { + connected?: boolean; + user?: LinearUserSummary | null; + organization?: LinearOrganizationSummary | null; + scope?: string; + workspaces?: LinearWorkspaceSummary[]; + authorizationUrl?: string; + expiresIn?: number; + removed?: boolean; + error?: string; + issues?: LinearIssueSummary[]; + cursor?: string | null; + hasMore?: boolean; + issue?: LinearIssue | null; + states?: LinearWorkflowState[]; + defaultProjectPath?: string | null; + teams?: LinearTeamMapping[]; + posted?: boolean; + skipped?: string; + commentId?: string | null; + sessionComments?: boolean; +}; + +async function readLinearJson(response: Response): Promise { + try { + return await response.json(); + } catch { + return null; + } +} + +function readErrorMessage(payload: LinearJson | null, fallback: string): string { + const error = payload?.error?.trim(); + return error || fallback; +} + +function readFiniteNumber(value: number | null | undefined): number | null { + return Number.isFinite(value) ? (value ?? null) : null; +} + +function readRawString(value: string | null | undefined): string | null { + return Object.prototype.toString.call(value) === '[object String]' ? `${value}` : null; +} + +function parseUser(payload: LinearUserSummary | null | undefined): LinearUserSummary | null { + const id = payload?.id?.trim(); + if (!id) return null; + return { + id, + name: payload?.name?.trim() || null, + displayName: payload?.displayName?.trim() || null, + email: payload?.email?.trim() || null, + avatarUrl: payload?.avatarUrl?.trim() || null, + }; +} + +function parseOrganization(payload: LinearOrganizationSummary | null | undefined): LinearOrganizationSummary | null { + const id = payload?.id?.trim(); + const name = payload?.name?.trim(); + if (!id || !name) return null; + return { + id, + name, + urlKey: payload?.urlKey?.trim() || null, + }; +} + +function parseWorkspace(payload: LinearWorkspaceSummary | null | undefined): LinearWorkspaceSummary | null { + const id = payload?.id?.trim(); + if (!id) return null; + const authorizedAt = payload?.authorizedAt; + return { + id, + name: payload?.name?.trim() || null, + urlKey: payload?.urlKey?.trim() || null, + current: payload?.current === true, + user: parseUser(payload?.user), + authorizedAt: readFiniteNumber(authorizedAt), + }; +} + +function toAuthStatus(payload: LinearJson | null): LinearAuthStatus | null { + if (payload?.connected !== true && payload?.connected !== false) { + return null; + } + const workspaces = Array.isArray(payload.workspaces) + ? payload.workspaces.map(parseWorkspace).filter((entry): entry is LinearWorkspaceSummary => entry != null) + : []; + return { + connected: payload.connected, + user: parseUser(payload.user), + organization: parseOrganization(payload.organization), + scope: payload.scope?.trim() || undefined, + workspaces: payload.connected ? workspaces : undefined, + }; +} + +function toAuthStart(payload: LinearJson | null): LinearAuthStart | null { + const authorizationUrl = payload?.authorizationUrl?.trim(); + const expiresIn = payload?.expiresIn; + const scope = payload?.scope?.trim(); + if (!authorizationUrl || !Number.isFinite(expiresIn) || expiresIn == null || !scope) { + return null; + } + return { authorizationUrl, expiresIn, scope }; +} + +function parseState(payload: LinearIssueState | null | undefined): LinearIssueState | null { + const id = payload?.id?.trim() || null; + const name = payload?.name?.trim() || null; + const type = payload?.type?.trim() || null; + if (!id && !name && !type) return null; + return { id, name, type }; +} + +function parseWorkflowState(payload: LinearWorkflowState | null | undefined): LinearWorkflowState | null { + const id = payload?.id?.trim(); + const name = payload?.name?.trim(); + if (!id || !name) return null; + const position = payload?.position; + return { + id, + name, + type: payload?.type?.trim() || null, + position: readFiniteNumber(position) ?? 0, + }; +} + +function parseAssignee(payload: LinearIssueAssignee | null | undefined): LinearIssueAssignee | null { + const name = payload?.name?.trim() || null; + const displayName = payload?.displayName?.trim() || null; + const avatarUrl = payload?.avatarUrl?.trim() || null; + if (!name && !displayName && !avatarUrl) return null; + return { name, displayName, avatarUrl }; +} + +function parseTeam(payload: LinearIssueTeam | null | undefined): LinearIssueTeam | null { + const id = payload?.id?.trim(); + const key = payload?.key?.trim(); + const name = payload?.name?.trim(); + if (!id || !key || !name) return null; + return { id, key, name }; +} + +function parsePriority(value: LinearIssueSummary['priority']): LinearIssuePriority | null { + if (value !== 0 && value !== 1 && value !== 2 && value !== 3 && value !== 4) { + return null; + } + return value; +} + +function parseLabelColor(value: string | null | undefined): string | null { + const raw = value?.trim(); + if (!raw) return null; + const hex = raw.startsWith('#') ? raw.slice(1) : raw; + if (!/^[0-9A-Fa-f]{6}$/.test(hex)) return null; + return `#${hex.toLowerCase()}`; +} + +function parseLabel(payload: LinearIssueLabel | null | undefined): LinearIssueLabel | null { + if (!payload) return null; + const id = payload?.id?.trim(); + const name = payload?.name?.trim(); + if (!id || !name) return null; + return { + id, + name, + color: parseLabelColor(payload.color), + }; +} + +function parseLabels(payload: LinearIssueSummary['labels']): LinearIssueLabel[] { + if (!Array.isArray(payload)) return []; + return payload.map(parseLabel).filter((label): label is LinearIssueLabel => label != null); +} + +function parseIssueSummary(payload: LinearIssueSummary | null | undefined): LinearIssueSummary | null { + if (!payload) return null; + const id = payload?.id?.trim(); + const identifier = payload?.identifier?.trim(); + const title = payload?.title?.trim(); + const url = payload?.url?.trim(); + if (!id || !identifier || !title || !url) return null; + return { + id, + identifier, + title, + url, + state: parseState(payload.state), + assignee: parseAssignee(payload.assignee), + team: parseTeam(payload.team), + priority: parsePriority(payload.priority), + labels: parseLabels(payload.labels), + }; +} + +function parseComment(payload: LinearIssueComment | null | undefined): LinearIssueComment | null { + const id = payload?.id?.trim(); + if (!id) return null; + const body = payload?.body; + return { + id, + body: readRawString(body) ?? '', + createdAt: payload?.createdAt?.trim() || null, + user: payload?.user + ? { + name: payload.user.name?.trim() || null, + displayName: payload.user.displayName?.trim() || null, + avatarUrl: payload.user.avatarUrl?.trim() || null, + } + : null, + }; +} + +function parseIssue(payload: LinearIssue | null | undefined): LinearIssue | null { + const summary = parseIssueSummary(payload); + if (!summary) return null; + const comments = Array.isArray(payload?.comments) + ? payload.comments.map(parseComment).filter((comment): comment is LinearIssueComment => comment != null) + : []; + const description = payload?.description; + return { + ...summary, + description: readRawString(description), + comments, + }; +} + +function toIssuesList(payload: LinearJson | null): LinearIssuesListResult | null { + if (payload?.connected !== true && payload?.connected !== false) { + return null; + } + if (payload.connected === false) { + return { connected: false }; + } + const issues = Array.isArray(payload.issues) + ? payload.issues.map(parseIssueSummary).filter((issue): issue is LinearIssueSummary => issue != null) + : []; + return { + connected: true, + issues, + cursor: payload.cursor?.trim() || null, + hasMore: payload.hasMore === true, + }; +} + +function toIssueGet(payload: LinearJson | null): LinearIssueGetResult | null { + if (payload?.connected !== true && payload?.connected !== false) { + return null; + } + if (payload.connected === false) { + return { connected: false }; + } + return { + connected: true, + issue: parseIssue(payload.issue), + }; +} + +function toIssueStates(payload: LinearJson | null): LinearIssueStatesResult | null { + if (payload?.connected !== true && payload?.connected !== false) { + return null; + } + if (payload.connected === false) { + return { connected: false }; + } + const states = Array.isArray(payload.states) + ? payload.states.map(parseWorkflowState).filter((state): state is LinearWorkflowState => state != null) + : []; + return { connected: true, states }; +} + +function toIssueUpdate(payload: LinearJson | null): LinearIssueUpdateResult | null { + if (payload?.connected !== true && payload?.connected !== false) { + return null; + } + if (payload.connected === false) { + return { connected: false }; + } + return { + connected: true, + issue: parseIssue(payload.issue), + }; +} + +function parseTeamMapping(payload: LinearTeamMapping | null | undefined): LinearTeamMapping | null { + const id = payload?.id?.trim(); + const key = payload?.key?.trim(); + const name = payload?.name?.trim(); + if (!id || !key || !name) return null; + const projectPath = payload?.projectPath?.trim() || null; + return { id, key, name, projectPath }; +} + +function toMapping(payload: LinearJson | null): LinearMappingResult | null { + if (payload?.connected !== true && payload?.connected !== false) { + return null; + } + if (payload.connected === false) { + return { connected: false }; + } + const teams = Array.isArray(payload.teams) + ? payload.teams.map(parseTeamMapping).filter((team): team is LinearTeamMapping => team != null) + : []; + return { + connected: true, + defaultProjectPath: payload.defaultProjectPath?.trim() || null, + teams, + }; +} + +type LinearSessionStatusSkipped = Extract< + LinearSessionStatusPostResult, + { posted: false } +>['skipped']; + +const SESSION_STATUS_SKIPPED: readonly LinearSessionStatusSkipped[] = [ + 'already-posted', + 'issue-not-found', + 'not-started', + 'disabled', + 'origin-not-public', +]; + +function parseSkipped(value: string | undefined): LinearSessionStatusSkipped | null { + return SESSION_STATUS_SKIPPED.find((entry) => entry === value) ?? null; +} + +function toPreferences(payload: LinearJson | null): LinearPreferences | null { + if (payload?.sessionComments !== true && payload?.sessionComments !== false) { + return null; + } + return { sessionComments: payload.sessionComments }; +} + +function toSessionStatusPost(payload: LinearJson | null): LinearSessionStatusPostResult | null { + if (payload?.connected !== true && payload?.connected !== false) { + return null; + } + if (payload.connected === false) { + return { connected: false }; + } + if (payload.posted === true) { + return { + connected: true, + posted: true, + commentId: payload.commentId?.trim() || null, + }; + } + const skipped = parseSkipped(payload.skipped); + if (payload.posted === false && skipped) { + return { connected: true, posted: false, skipped }; + } + return null; +} + +export const createWebLinearAPI = (): LinearAPI => ({ + async authStatus(): Promise { + const response = await runtimeFetch('/api/linear/auth/status', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + const payload = await readLinearJson(response); + const status = toAuthStatus(payload); + if (!response.ok || !status) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to load Linear status')); + } + return status; + }, + + async authStart(origin?: LinearAuthOrigin): Promise { + const response = await runtimeFetch('/api/linear/auth/start', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(origin ? { origin } : {}), + }); + const payload = await readLinearJson(response); + const started = toAuthStart(payload); + if (!response.ok || !started) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to start Linear auth')); + } + return started; + }, + + async authDisconnect(): Promise<{ removed: boolean }> { + const response = await runtimeFetch('/api/linear/auth', { + method: 'DELETE', + headers: { Accept: 'application/json' }, + }); + const payload = await readLinearJson(response); + if (!response.ok) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to disconnect Linear')); + } + return { removed: payload?.removed === true }; + }, + + async authActivate(organizationId: string): Promise { + const response = await runtimeFetch('/api/linear/auth/activate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ organizationId }), + }); + const payload = await readLinearJson(response); + const status = toAuthStatus(payload); + if (!response.ok || !status) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to switch Linear workspace')); + } + return status; + }, + + async issuesList(options?: LinearIssuesListOptions): Promise { + const params = new URLSearchParams(); + const query = options?.query?.trim(); + const cursor = options?.cursor?.trim(); + const status = options?.status?.trim(); + const assignee = options?.assignee?.trim(); + const teamId = options?.teamId?.trim(); + const priority = options?.priority?.trim(); + if (query) params.set('query', query); + if (cursor) params.set('cursor', cursor); + if (status) params.set('status', status); + if (assignee) params.set('assignee', assignee); + if (teamId) params.set('teamId', teamId); + if (priority) params.set('priority', priority); + const queryString = params.toString(); + const suffix = queryString ? `?${queryString}` : ''; + const response = await runtimeFetch(`/api/linear/issues/list${suffix}`, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + const payload = await readLinearJson(response); + const result = toIssuesList(payload); + if (!response.ok || !result) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to load Linear issues')); + } + return result; + }, + + async issueGet(id: string): Promise { + const params = new URLSearchParams({ id }); + const response = await runtimeFetch(`/api/linear/issues/get?${params.toString()}`, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + const payload = await readLinearJson(response); + const result = toIssueGet(payload); + if (!response.ok || !result) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to load Linear issue')); + } + return result; + }, + + async issueStates(teamId: string): Promise { + const params = new URLSearchParams({ teamId }); + const response = await runtimeFetch(`/api/linear/issues/states?${params.toString()}`, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + const payload = await readLinearJson(response); + const result = toIssueStates(payload); + if (!response.ok || !result) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to load Linear workflow states')); + } + return result; + }, + + async issueUpdate(input: LinearIssueUpdateInput): Promise { + const response = await runtimeFetch('/api/linear/issues/update', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + id: input.id, + stateId: input.stateId, + }), + }); + const payload = await readLinearJson(response); + const result = toIssueUpdate(payload); + if (!response.ok || !result) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to update Linear issue')); + } + return result; + }, + + async mappingGet(): Promise { + const response = await runtimeFetch('/api/linear/mapping', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + const payload = await readLinearJson(response); + const result = toMapping(payload); + if (!response.ok || !result) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to load Linear mapping')); + } + return result; + }, + + async mappingSet(mapping: LinearMappingWrite): Promise { + const response = await runtimeFetch('/api/linear/mapping', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + defaultProjectPath: mapping.defaultProjectPath, + teamProjectPaths: mapping.teamProjectPaths, + }), + }); + const payload = await readLinearJson(response); + const result = toMapping(payload); + if (!response.ok || !result) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to save Linear mapping')); + } + return result; + }, + + async sessionStatusPost(input: LinearSessionStatusPostInput): Promise { + const response = await runtimeFetch('/api/linear/session-status', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + kind: input.kind, + sessionId: input.sessionId, + issueIdentifier: input.issueIdentifier, + sessionOrigin: input.sessionOrigin, + }), + }); + const payload = await readLinearJson(response); + const result = toSessionStatusPost(payload); + if (!response.ok || !result) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to post Linear session status')); + } + return result; + }, + + async preferencesGet(): Promise { + const response = await runtimeFetch('/api/linear/preferences', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + const payload = await readLinearJson(response); + const result = toPreferences(payload); + if (!response.ok || !result) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to load Linear preferences')); + } + return result; + }, + + async preferencesSet(preferences: LinearPreferences): Promise { + const response = await runtimeFetch('/api/linear/preferences', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ sessionComments: preferences.sessionComments }), + }); + const payload = await readLinearJson(response); + const result = toPreferences(payload); + if (!response.ok || !result) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to save Linear preferences')); + } + return result; + }, +}); diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index 60877c12..5fbae619 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -115,6 +115,10 @@ export default defineConfig({ target: `http://127.0.0.1:${process.env.OPENCHAMBER_PORT || 3001}`, changeOrigin: true, }, + '/linear': { + target: `http://127.0.0.1:${process.env.OPENCHAMBER_PORT || 3001}`, + changeOrigin: true, + }, '/api': { target: `http://127.0.0.1:${process.env.OPENCHAMBER_PORT || 3001}`, changeOrigin: true,