From 49f0a9e62fc674647a139aeec4e9c61339a983cd Mon Sep 17 00:00:00 2001 From: Alex Kutas Date: Sun, 30 Aug 2026 02:18:40 +0300 Subject: [PATCH] OPE-296: Add linear integration for starting sessions from issues (#3235) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(linear): start sessions from Linear issues Authorize a Linear workspace on this OpenChamber server, map teams to projects, attach an issue from chat, start a session or worktree from an issue, and post started/completed/failed comments that open the session. Hidden in VS Code. * feat(linear): connect more than one Linear workspace Store each OAuth grant on this OpenChamber server and keep one current, so Settings can add and switch workspaces without dropping the others. Project mapping is per workspace. Remove the Linear button next to New Chat; start-from-issue stays on New Worktree. * feat(linear): add a right-hand issues panel Browse and filter issues in the rail, open a card to change status or start a session, and collapse search plus most filters to icons on a narrow panel. * feat(linear): open issues in the rail and filter by Linear status The rail icon only shows after Linear is connected. Clicking a Linear row on work status opens the panel. Status options match the card, including Done, Canceled, and Duplicate. The Integrations experimental warning sits under Third-party integrations. * fix(linear): use stable OAuth callback broker * fix(chat): preview Linear issue attachments The context switch missed linear-issue, so tsc treated the preview helpers as incomplete. * fix(ui): restore Linear i18n parity and the #2903 sync harness Turkish was missing the Linear dictionaries, and the subagent test still wrapped only SyncContext after reads moved to SyncRuntimeContext. * fix(linear): drop changelog hunks and close review races Keep changelogs out of this PR, restore CodeMirror ranges, ignore stale Linear list pages, and leave a persisted Linear tab open until auth has actually resolved. * fix(linear): tint active issue filters and clear them in one click * fix(markdown): read escaped brackets as text, not display math `\[...\]` is display math in LaTeX and an escaped bracket pair in CommonMark. The block tokenizer claimed every `\[`, so prose like `[title \[Bug\] more](url)` was handed to KaTeX: "Bug" rendered as a centered formula and the block token split the paragraph, tearing the link into three pieces. Linear, GitHub and any other source that escapes brackets the way CommonMark requires hit this. Display math now has to own its line — `\[` starts one and `\]` ends one. A formula on its own line still renders; `\[` mid-sentence stays an escape, which is what CommonMark says it is and what prose almost always means. Inline `\(...\)` keeps the same ambiguity, but inline math is legitimately mid-sentence, so there is no position to judge it by. Covered by regression tests, including the verbatim comment body that surfaced this. * feat(linear): make session status comments opt-in and public-only A status comment lands in a Linear workspace the whole team reads, and the link it carried pointed at whatever origin started the session — usually loopback or a LAN address. Everyone but its author got a dead link, and nobody had agreed to the comments in the first place. Comments are now off until the user turns them on in Settings -> Integrations -> Linear, and the check lives on the server: the event hub posts completed and failure without going through the interface, so a client-side gate would not hold. When the resolved origin is not publicly reachable the server posts nothing at all rather than a link only its author can open; `isPublicSessionOrigin` rejects loopback, private LAN, carrier-grade NAT, link-local and single-label hosts. The desktop deep-link origin is gone with it, since no one else can follow one either. The comment body also dropped the session title. It repeated the issue the comment already sits on, and issue titles routinely carry brackets ("[Bug] ...") that broke the markdown link. The body is now one short link, and `sessionTitle` is gone from the route, client and types. Also caps the dedupe file at the newest 500 sessions; it grew forever. * fix(linear): match the pull request panel and clear review findings Comments in the Linear panel now render as the same avatar timeline the pull request panel uses, with the shared time-format preference instead of a raw locale string. Comment authors carry `avatarUrl`, which the GraphQL selection was not requesting. Review findings from the same pass: - `status-runtime.js` hand-rolled `typeof` narrowing and failed the vendored anti-slop lint; it now parses through `parse.js` like every other file in the module. - `useLinearAuthStore` turned any failed request into `connected: false` with `hasChecked: true`. Since the rail icon, the composer entry and the worktree option all gate on `connected === true`, one network blip hid Linear for the rest of the session, and Settings only re-checked when it had never checked. It now keeps the last known status and leaves `hasChecked` false so the next caller retries. - `LinearIssuesView` (1096 lines) was a static import in `ContextPanel`, shipping in the main bundle although its rail icon stays hidden until a workspace is connected. It is lazy now, like `GitView`. - Dropped dead code: the unused port helpers left over from the loopback callback, two re-exported default values nothing read, and a redundant export in `linkedIssues`. - Integrations is no longer badged beta. --- packages/ui/src/App.tsx | 5 +- packages/ui/src/apps/MobileApp.tsx | 8 +- packages/ui/src/components/chat/ChatInput.tsx | 81 +- .../ui/src/components/chat/FileAttachment.tsx | 40 +- .../composer/state/useMobileComposerShell.ts | 7 +- .../__tests__/buildOutgoingMessage.test.ts | 14 + .../composer/submit/buildOutgoingMessage.ts | 6 + .../ui/ComposerAttachmentControls.tsx | 15 + .../chat/composer/ui/ComposerFooter.tsx | 8 + .../chat/composer/ui/MobilePillComposer.tsx | 6 + .../chat/lib/messagePreview.test.ts | 8 + .../src/components/chat/lib/messagePreview.ts | 3 + .../chat/markdown/markdownCore.test.ts | 38 + .../components/chat/markdown/markdownCore.ts | 16 +- .../chat/message/normalizeUserDisplayParts.ts | 40 +- .../chat/message/parts/DOCUMENTATION.md | 9 +- .../chat/message/parts/UserContextPart.tsx | 1 + .../chat/work-status/DOCUMENTATION.md | 6 +- .../work-status/WorkStatusContextSection.tsx | 50 +- packages/ui/src/components/icon/sprite.ts | 1 + .../ui/src/components/layout/ContextPanel.tsx | 10 + .../components/layout/ContextPanelRail.tsx | 14 +- .../linear-panel-review-guards.test.ts | 42 + .../integrations/IntegrationsPage.tsx | 22 +- .../integrations/LinearProjectMapping.tsx | 230 ++++ .../integrations/LinearSessionComments.tsx | 95 ++ .../sections/integrations/LinearSettings.tsx | 350 +++++ .../ThirdPartyIntegrationsSection.tsx | 6 + .../magic-prompts/MagicPromptsPage.tsx | 8 + .../magic-prompts/MagicPromptsSidebar.tsx | 6 + .../session/LinearIssuePickerDialog.tsx | 492 +++++++ .../components/session/NewWorktreeDialog.tsx | 275 +++- .../src/components/views/LinearIssuesView.tsx | 1168 +++++++++++++++++ .../ui/src/components/views/SettingsView.tsx | 2 +- packages/ui/src/hooks/useKeyboardShortcuts.ts | 2 + packages/ui/src/hooks/useRouter.ts | 10 +- packages/ui/src/lib/api/types.ts | 194 +++ .../ui/src/lib/i18n/messages/de.settings.ts | 2 + packages/ui/src/lib/i18n/messages/de.ts | 4 + .../ui/src/lib/i18n/messages/en.settings.ts | 2 + packages/ui/src/lib/i18n/messages/en.ts | 4 + .../ui/src/lib/i18n/messages/es.settings.ts | 2 + packages/ui/src/lib/i18n/messages/es.ts | 4 + .../ui/src/lib/i18n/messages/fr.settings.ts | 2 + packages/ui/src/lib/i18n/messages/fr.ts | 4 + .../ui/src/lib/i18n/messages/ja.settings.ts | 2 + packages/ui/src/lib/i18n/messages/ja.ts | 4 + .../ui/src/lib/i18n/messages/ko.settings.ts | 2 + packages/ui/src/lib/i18n/messages/ko.ts | 4 + .../messages/linear-integration.i18n.test.ts | 71 + .../i18n/messages/linear-integration.i18n.ts | 567 ++++++++ .../messages/linear-issue-picker.i18n.test.ts | 59 + .../i18n/messages/linear-issue-picker.i18n.ts | 471 +++++++ .../i18n/messages/linear-panel.i18n.test.ts | 78 ++ .../lib/i18n/messages/linear-panel.i18n.ts | 627 +++++++++ .../ui/src/lib/i18n/messages/pl.settings.ts | 2 + packages/ui/src/lib/i18n/messages/pl.ts | 4 + .../src/lib/i18n/messages/pt-BR.settings.ts | 2 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 4 + .../ui/src/lib/i18n/messages/tr.settings.ts | 2 + packages/ui/src/lib/i18n/messages/tr.ts | 4 + .../ui/src/lib/i18n/messages/uk.settings.ts | 2 + packages/ui/src/lib/i18n/messages/uk.ts | 4 + .../src/lib/i18n/messages/zh-CN.settings.ts | 2 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 4 + .../src/lib/i18n/messages/zh-TW.settings.ts | 2 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 4 + .../ui/src/lib/linearProjectMapping.test.ts | 39 + packages/ui/src/lib/linearProjectMapping.ts | 24 + .../ui/src/lib/linearSessionStatus.test.ts | 50 + packages/ui/src/lib/linearSessionStatus.ts | 45 + .../ui/src/lib/linearStartSession.test.ts | 29 + packages/ui/src/lib/linearStartSession.ts | 235 ++++ packages/ui/src/lib/linkedIssues.test.ts | 77 +- packages/ui/src/lib/linkedIssues.ts | 78 +- packages/ui/src/lib/magicPrompts.ts | 59 +- .../ui/src/lib/messages/contextParts.test.ts | 7 + packages/ui/src/lib/messages/contextParts.ts | 22 +- .../lib/router/openSessionFromRoute.test.ts | 64 + .../ui/src/lib/router/openSessionFromRoute.ts | 33 + packages/ui/src/lib/router/parseRoute.test.ts | 20 + packages/ui/src/lib/settings/metadata.ts | 2 +- packages/ui/src/lib/settings/search.test.ts | 26 + packages/ui/src/lib/settings/search.ts | 32 + packages/ui/src/lib/surfaces/DOCUMENTATION.md | 25 +- packages/ui/src/lib/surfaces/registry.test.ts | 14 + packages/ui/src/lib/surfaces/registry.ts | 15 + packages/ui/src/stores/DOCUMENTATION.md | 2 +- packages/ui/src/stores/useLinearAuthStore.ts | 67 + .../stores/useUIStore.linearFilters.test.ts | 70 + packages/ui/src/stores/useUIStore.ts | 97 +- packages/web/server/index.js | 4 + .../web/server/lib/linear/DOCUMENTATION.md | 97 ++ .../server/lib/linear/OAUTH-BROKER-HANDOFF.md | 91 ++ packages/web/server/lib/linear/auth.js | 436 ++++++ packages/web/server/lib/linear/auth.test.js | 242 ++++ packages/web/server/lib/linear/client.js | 191 +++ packages/web/server/lib/linear/index.js | 61 + packages/web/server/lib/linear/issues.js | 499 +++++++ packages/web/server/lib/linear/issues.test.js | 512 ++++++++ packages/web/server/lib/linear/mapping.js | 188 +++ .../web/server/lib/linear/mapping.test.js | 147 +++ packages/web/server/lib/linear/oauth.js | 345 +++++ packages/web/server/lib/linear/oauth.test.js | 166 +++ packages/web/server/lib/linear/parse.js | 23 + packages/web/server/lib/linear/routes.js | 432 ++++++ packages/web/server/lib/linear/routes.test.js | 661 ++++++++++ .../web/server/lib/linear/status-runtime.js | 64 + .../server/lib/linear/status-runtime.test.js | 167 +++ packages/web/server/lib/linear/status.js | 280 ++++ packages/web/server/lib/linear/status.test.js | 271 ++++ packages/web/server/lib/linear/teams.js | 72 + packages/web/server/lib/linear/teams.test.js | 94 ++ .../lib/opencode/feature-routes-runtime.js | 2 + .../lib/opencode/static-routes-runtime.js | 6 +- packages/web/src/api/index.ts | 2 + packages/web/src/api/linear.ts | 609 +++++++++ packages/web/vite.config.ts | 4 + 118 files changed, 11888 insertions(+), 143 deletions(-) create mode 100644 packages/ui/src/components/layout/__tests__/linear-panel-review-guards.test.ts create mode 100644 packages/ui/src/components/sections/integrations/LinearProjectMapping.tsx create mode 100644 packages/ui/src/components/sections/integrations/LinearSessionComments.tsx create mode 100644 packages/ui/src/components/sections/integrations/LinearSettings.tsx create mode 100644 packages/ui/src/components/session/LinearIssuePickerDialog.tsx create mode 100644 packages/ui/src/components/views/LinearIssuesView.tsx create mode 100644 packages/ui/src/lib/i18n/messages/linear-integration.i18n.test.ts create mode 100644 packages/ui/src/lib/i18n/messages/linear-integration.i18n.ts create mode 100644 packages/ui/src/lib/i18n/messages/linear-issue-picker.i18n.test.ts create mode 100644 packages/ui/src/lib/i18n/messages/linear-issue-picker.i18n.ts create mode 100644 packages/ui/src/lib/i18n/messages/linear-panel.i18n.test.ts create mode 100644 packages/ui/src/lib/i18n/messages/linear-panel.i18n.ts create mode 100644 packages/ui/src/lib/linearProjectMapping.test.ts create mode 100644 packages/ui/src/lib/linearProjectMapping.ts create mode 100644 packages/ui/src/lib/linearSessionStatus.test.ts create mode 100644 packages/ui/src/lib/linearSessionStatus.ts create mode 100644 packages/ui/src/lib/linearStartSession.test.ts create mode 100644 packages/ui/src/lib/linearStartSession.ts create mode 100644 packages/ui/src/lib/router/openSessionFromRoute.test.ts create mode 100644 packages/ui/src/lib/router/openSessionFromRoute.ts create mode 100644 packages/ui/src/lib/router/parseRoute.test.ts create mode 100644 packages/ui/src/stores/useLinearAuthStore.ts create mode 100644 packages/ui/src/stores/useUIStore.linearFilters.test.ts create mode 100644 packages/web/server/lib/linear/DOCUMENTATION.md create mode 100644 packages/web/server/lib/linear/OAUTH-BROKER-HANDOFF.md create mode 100644 packages/web/server/lib/linear/auth.js create mode 100644 packages/web/server/lib/linear/auth.test.js create mode 100644 packages/web/server/lib/linear/client.js create mode 100644 packages/web/server/lib/linear/index.js create mode 100644 packages/web/server/lib/linear/issues.js create mode 100644 packages/web/server/lib/linear/issues.test.js create mode 100644 packages/web/server/lib/linear/mapping.js create mode 100644 packages/web/server/lib/linear/mapping.test.js create mode 100644 packages/web/server/lib/linear/oauth.js create mode 100644 packages/web/server/lib/linear/oauth.test.js create mode 100644 packages/web/server/lib/linear/parse.js create mode 100644 packages/web/server/lib/linear/routes.js create mode 100644 packages/web/server/lib/linear/routes.test.js create mode 100644 packages/web/server/lib/linear/status-runtime.js create mode 100644 packages/web/server/lib/linear/status-runtime.test.js create mode 100644 packages/web/server/lib/linear/status.js create mode 100644 packages/web/server/lib/linear/status.test.js create mode 100644 packages/web/server/lib/linear/teams.js create mode 100644 packages/web/server/lib/linear/teams.test.js create mode 100644 packages/web/src/api/linear.ts 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,