diff --git a/src/main/ipc/linear.ts b/src/main/ipc/linear.ts index d1a4c659393..96de78c74d5 100644 --- a/src/main/ipc/linear.ts +++ b/src/main/ipc/linear.ts @@ -1,5 +1,5 @@ import { ipcMain } from 'electron' -import { connect, disconnect, getStatus, testConnection } from '../linear/client' +import { connect, disconnect, getStatus, selectWorkspace, testConnection } from '../linear/client' import { _resetPreflightCache } from './preflight' import { getIssue, @@ -12,10 +12,19 @@ import { } from '../linear/issues' import { listTeams, getTeamStates, getTeamLabels, getTeamMembers } from '../linear/teams' import type { LinearListFilter } from '../linear/issues' -import type { LinearIssueUpdate } from '../../shared/types' +import type { LinearIssueUpdate, LinearWorkspaceSelection } from '../../shared/types' const VALID_FILTERS = new Set(['assigned', 'created', 'all', 'completed']) +function normalizeWorkspaceId(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} + +function normalizeWorkspaceSelection(value: unknown): LinearWorkspaceSelection | undefined { + const workspaceId = normalizeWorkspaceId(value) + return workspaceId as LinearWorkspaceSelection | undefined +} + export function registerLinearHandlers(): void { ipcMain.handle('linear:connect', async (_event, args: { apiKey: string }) => { if (typeof args?.apiKey !== 'string' || !args.apiKey.trim()) { @@ -28,41 +37,61 @@ export function registerLinearHandlers(): void { return result }) - ipcMain.handle('linear:disconnect', async () => { - disconnect() + ipcMain.handle('linear:disconnect', async (_event, args?: { workspaceId?: string }) => { + disconnect(normalizeWorkspaceId(args?.workspaceId)) _resetPreflightCache() }) + ipcMain.handle('linear:selectWorkspace', async (_event, args: { workspaceId: string }) => { + const workspaceId = normalizeWorkspaceSelection(args?.workspaceId) + if (!workspaceId) { + return getStatus() + } + return selectWorkspace(workspaceId) + }) + ipcMain.handle('linear:status', async () => { return getStatus() }) - ipcMain.handle('linear:testConnection', async () => { - return testConnection() - }) - - ipcMain.handle('linear:searchIssues', async (_event, args: { query: string; limit?: number }) => { - if (typeof args?.query !== 'string') { - return [] - } - const limit = Math.min(Math.max(1, args.limit ?? 20), 50) - return searchIssues(args.query, limit) + ipcMain.handle('linear:testConnection', async (_event, args?: { workspaceId?: string }) => { + return testConnection(normalizeWorkspaceId(args?.workspaceId)) }) + ipcMain.handle( + 'linear:searchIssues', + async ( + _event, + args: { query: string; limit?: number; workspaceId?: LinearWorkspaceSelection } + ) => { + if (typeof args?.query !== 'string') { + return [] + } + const limit = Math.min(Math.max(1, args.limit ?? 20), 50) + return searchIssues(args.query, limit, normalizeWorkspaceSelection(args.workspaceId)) + } + ) + ipcMain.handle( 'linear:listIssues', - async (_event, args?: { filter?: LinearListFilter; limit?: number }) => { + async ( + _event, + args?: { filter?: LinearListFilter; limit?: number; workspaceId?: LinearWorkspaceSelection } + ) => { const filter = VALID_FILTERS.has(args?.filter as LinearListFilter) ? (args!.filter as LinearListFilter) : undefined const limit = Math.min(Math.max(1, args?.limit ?? 20), 50) - return listIssues(filter, limit) + return listIssues(filter, limit, normalizeWorkspaceSelection(args?.workspaceId)) } ) ipcMain.handle( 'linear:createIssue', - async (_event, args: { teamId: string; title: string; description?: string }) => { + async ( + _event, + args: { teamId: string; title: string; description?: string; workspaceId?: string } + ) => { if (typeof args?.teamId !== 'string' || !args.teamId.trim()) { return { ok: false, error: 'Team ID is required' } } @@ -72,21 +101,22 @@ export function registerLinearHandlers(): void { return createIssue( args.teamId.trim(), args.title.trim(), - args.description?.trim() || undefined + args.description?.trim() || undefined, + normalizeWorkspaceId(args.workspaceId) ) } ) - ipcMain.handle('linear:getIssue', async (_event, args: { id: string }) => { + ipcMain.handle('linear:getIssue', async (_event, args: { id: string; workspaceId?: string }) => { if (typeof args?.id !== 'string' || !args.id.trim()) { return null } - return getIssue(args.id.trim()) + return getIssue(args.id.trim(), normalizeWorkspaceId(args.workspaceId)) }) ipcMain.handle( 'linear:updateIssue', - async (_event, args: { id: string; updates: LinearIssueUpdate }) => { + async (_event, args: { id: string; updates: LinearIssueUpdate; workspaceId?: string }) => { if (typeof args?.id !== 'string' || !args.id.trim()) { return { ok: false, error: 'Issue ID is required' } } @@ -112,52 +142,71 @@ export function registerLinearHandlers(): void { ) { return { ok: false, error: 'Label IDs must be an array of strings' } } - return updateIssue(args.id.trim(), args.updates) + return updateIssue(args.id.trim(), args.updates, normalizeWorkspaceId(args.workspaceId)) } ) ipcMain.handle( 'linear:addIssueComment', - async (_event, args: { issueId: string; body: string }) => { + async (_event, args: { issueId: string; body: string; workspaceId?: string }) => { if (typeof args?.issueId !== 'string' || !args.issueId.trim()) { return { ok: false, error: 'Issue ID is required' } } if (!args.body?.trim()) { return { ok: false, error: 'Comment body is required' } } - return addIssueComment(args.issueId.trim(), args.body.trim()) + return addIssueComment( + args.issueId.trim(), + args.body.trim(), + normalizeWorkspaceId(args.workspaceId) + ) } ) - ipcMain.handle('linear:issueComments', async (_event, args: { issueId: string }) => { - if (typeof args?.issueId !== 'string' || !args.issueId.trim()) { - return [] + ipcMain.handle( + 'linear:issueComments', + async (_event, args: { issueId: string; workspaceId?: string }) => { + if (typeof args?.issueId !== 'string' || !args.issueId.trim()) { + return [] + } + return getIssueComments(args.issueId.trim(), normalizeWorkspaceId(args.workspaceId)) } - return getIssueComments(args.issueId.trim()) - }) + ) - ipcMain.handle('linear:listTeams', async () => { - return listTeams() - }) - - ipcMain.handle('linear:teamStates', async (_event, args: { teamId: string }) => { - if (typeof args?.teamId !== 'string' || !args.teamId.trim()) { - return [] + ipcMain.handle( + 'linear:listTeams', + async (_event, args?: { workspaceId?: LinearWorkspaceSelection }) => { + return listTeams(normalizeWorkspaceSelection(args?.workspaceId)) } - return getTeamStates(args.teamId.trim()) - }) + ) - ipcMain.handle('linear:teamLabels', async (_event, args: { teamId: string }) => { - if (typeof args?.teamId !== 'string' || !args.teamId.trim()) { - return [] + ipcMain.handle( + 'linear:teamStates', + async (_event, args: { teamId: string; workspaceId?: string }) => { + if (typeof args?.teamId !== 'string' || !args.teamId.trim()) { + return [] + } + return getTeamStates(args.teamId.trim(), normalizeWorkspaceId(args.workspaceId)) } - return getTeamLabels(args.teamId.trim()) - }) + ) - ipcMain.handle('linear:teamMembers', async (_event, args: { teamId: string }) => { - if (typeof args?.teamId !== 'string' || !args.teamId.trim()) { - return [] + ipcMain.handle( + 'linear:teamLabels', + async (_event, args: { teamId: string; workspaceId?: string }) => { + if (typeof args?.teamId !== 'string' || !args.teamId.trim()) { + return [] + } + return getTeamLabels(args.teamId.trim(), normalizeWorkspaceId(args.workspaceId)) } - return getTeamMembers(args.teamId.trim()) - }) + ) + + ipcMain.handle( + 'linear:teamMembers', + async (_event, args: { teamId: string; workspaceId?: string }) => { + if (typeof args?.teamId !== 'string' || !args.teamId.trim()) { + return [] + } + return getTeamMembers(args.teamId.trim(), normalizeWorkspaceId(args.workspaceId)) + } + ) } diff --git a/src/main/linear/client.test.ts b/src/main/linear/client.test.ts new file mode 100644 index 00000000000..30c049b3c80 --- /dev/null +++ b/src/main/linear/client.test.ts @@ -0,0 +1,170 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import type * as Os from 'os' +import { join } from 'path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type ViewerFixture = { + displayName: string + email: string | null + organizationId: string + organizationName: string + organizationUrlKey: string +} + +let tempHome = '' +let fixtures = new Map() +let linearClientMock: ReturnType + +function writeLegacyLinearFiles(token: string, viewer: Record): void { + const orcaDir = join(tempHome, '.orca') + mkdirSync(orcaDir, { recursive: true }) + writeFileSync(join(orcaDir, 'linear-token.enc'), token, { encoding: 'utf-8' }) + writeFileSync(join(orcaDir, 'linear-viewer.json'), JSON.stringify(viewer), { + encoding: 'utf-8' + }) +} + +async function loadClientModule() { + vi.resetModules() + linearClientMock = vi.fn(function LinearClient( + this: { viewer: Promise }, + { apiKey }: { apiKey: string } + ) { + const fixture = fixtures.get(apiKey) + if (!fixture) { + throw new Error('Invalid API key') + } + this.viewer = Promise.resolve({ + displayName: fixture.displayName, + email: fixture.email, + organization: Promise.resolve({ + id: fixture.organizationId, + name: fixture.organizationName, + urlKey: fixture.organizationUrlKey + }) + }) + }) + vi.doMock('electron', () => ({ + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (value: string) => Buffer.from(value), + decryptString: (value: Buffer) => value.toString('utf-8') + } + })) + vi.doMock('os', async () => { + const actual = await vi.importActual('os') + return { ...actual, homedir: () => tempHome } + }) + vi.doMock('@linear/sdk', () => ({ + AuthenticationLinearError: class AuthenticationLinearError extends Error {}, + LinearClient: linearClientMock + })) + + return import('./client') +} + +beforeEach(() => { + tempHome = mkdtempLike('orca-linear-client-') + fixtures = new Map([ + [ + 'token-alpha', + { + displayName: 'Ada', + email: 'ada@example.com', + organizationId: 'org-alpha', + organizationName: 'Alpha', + organizationUrlKey: 'alpha' + } + ], + [ + 'token-beta', + { + displayName: 'Grace', + email: 'grace@example.com', + organizationId: 'org-beta', + organizationName: 'Beta', + organizationUrlKey: 'beta' + } + ] + ]) + vi.restoreAllMocks() +}) + +function mkdtempLike(prefix: string): string { + return mkdtempSync(join(tmpdir(), prefix)) +} + +describe('Linear client workspace storage', () => { + it('stores multiple workspaces and remembers the selected workspace', async () => { + const linear = await loadClientModule() + + await expect(linear.connect('token-alpha')).resolves.toMatchObject({ + ok: true, + workspace: { id: 'org-alpha', organizationName: 'Alpha' } + }) + await expect(linear.connect('token-beta')).resolves.toMatchObject({ + ok: true, + workspace: { id: 'org-beta', organizationName: 'Beta' } + }) + + expect(linear.getStatus()).toMatchObject({ + connected: true, + selectedWorkspaceId: 'org-beta', + workspaces: [ + { id: 'org-alpha', organizationName: 'Alpha' }, + { id: 'org-beta', organizationName: 'Beta' } + ] + }) + + expect(linear.selectWorkspace('all')).toMatchObject({ selectedWorkspaceId: 'all' }) + + linear.disconnect('org-alpha') + expect(linear.getStatus()).toMatchObject({ + connected: true, + workspaces: [{ id: 'org-beta', organizationName: 'Beta' }] + }) + }) + + it('reports a legacy single-token workspace without constructing a Linear client', async () => { + writeLegacyLinearFiles('token-alpha', { + displayName: 'Ada', + email: 'ada@example.com', + organizationName: 'Alpha' + }) + const linear = await loadClientModule() + + expect(linear.getStatus()).toMatchObject({ + connected: true, + selectedWorkspaceId: 'legacy', + workspaces: [{ id: 'legacy', organizationName: 'Alpha', isLegacy: true }] + }) + expect(linearClientMock).not.toHaveBeenCalled() + }) + + it('migrates legacy token storage to a real workspace id when explicitly tested', async () => { + writeLegacyLinearFiles('token-alpha', { + displayName: 'Ada', + email: 'ada@example.com', + organizationName: 'Alpha' + }) + const linear = await loadClientModule() + + await expect(linear.testConnection('legacy')).resolves.toMatchObject({ + ok: true, + workspace: { id: 'org-alpha', organizationName: 'Alpha' } + }) + + const status = linear.getStatus() + expect(status).toMatchObject({ + connected: true, + selectedWorkspaceId: 'org-alpha', + workspaces: [{ id: 'org-alpha', organizationName: 'Alpha' }] + }) + expect(status.workspaces?.some((workspace) => workspace.id === 'legacy')).toBe(false) + expect(existsSync(join(tempHome, '.orca', 'linear-token.enc'))).toBe(false) + expect(readFileSync(join(tempHome, '.orca', 'linear-workspaces.json'), 'utf-8')).toContain( + 'org-alpha' + ) + }) +}) diff --git a/src/main/linear/client.ts b/src/main/linear/client.ts index 04854e3423f..e5924176e17 100644 --- a/src/main/linear/client.ts +++ b/src/main/linear/client.ts @@ -1,9 +1,17 @@ +/* eslint-disable max-lines -- Why: Linear credential storage and client + selection share one module so keychain-safe status reads and token mutation + stay in one consistency boundary. */ import { safeStorage } from 'electron' import { LinearClient, AuthenticationLinearError } from '@linear/sdk' -import { readFileSync, writeFileSync, unlinkSync, mkdirSync, existsSync } from 'fs' -import { join } from 'path' +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'fs' import { homedir } from 'os' -import type { LinearViewer, LinearConnectionStatus } from '../../shared/types' +import { join } from 'path' +import type { + LinearConnectionStatus, + LinearViewer, + LinearWorkspace, + LinearWorkspaceSelection +} from '../../shared/types' // ── Concurrency limiter — max 4 parallel Linear API calls ──────────── const MAX_CONCURRENT = 4 @@ -31,28 +39,73 @@ export function release(): void { } } -// ── Token + viewer storage ─────────────────────────────────────────── -// Why: the token is encrypted via safeStorage (OS keychain). The viewer -// metadata is kept in a separate *plaintext* file so settings/status -// checks can answer "are you connected, and as whom?" without ever -// decrypting the token. Decrypting triggers a macOS Keychain permission -// dialog after every app signature change (e.g. every update), so we only -// touch the encrypted token when the user actually makes a Linear API -// call or explicitly tests the connection. -function getTokenPath(): string { - return join(homedir(), '.orca', 'linear-token.enc') +// ── Token + workspace storage ──────────────────────────────────────── +// Why: tokens remain encrypted via safeStorage, while workspace metadata stays +// plaintext so status checks can render connected accounts without decrypting +// and triggering OS keychain prompts after app updates. +const LEGACY_WORKSPACE_ID = 'legacy' + +type LinearWorkspaceFile = { + version: 1 + activeWorkspaceId: string | null + selectedWorkspaceId: LinearWorkspaceSelection | null + workspaces: LinearWorkspace[] } -function getViewerPath(): string { - return join(homedir(), '.orca', 'linear-viewer.json') +export type LinearClientForWorkspace = { + workspace: LinearWorkspace + client: LinearClient } -let cachedToken: string | null = null -let cachedViewer: LinearViewer | null = null -let viewerLoadedFromDisk = false +let cachedTokens = new Map() +let cachedLegacyViewer: LinearViewer | null = null +let legacyViewerLoadedFromDisk = false +let cachedWorkspaceFile: LinearWorkspaceFile | null = null +let workspaceFileLoadedFromDisk = false -function readViewerFromDisk(): LinearViewer | null { - const path = getViewerPath() +function getOrcaDir(): string { + return join(homedir(), '.orca') +} + +function getLegacyTokenPath(): string { + return join(getOrcaDir(), 'linear-token.enc') +} + +function getLegacyViewerPath(): string { + return join(getOrcaDir(), 'linear-viewer.json') +} + +function getWorkspaceFilePath(): string { + return join(getOrcaDir(), 'linear-workspaces.json') +} + +function getWorkspaceTokenDir(): string { + return join(getOrcaDir(), 'linear-tokens') +} + +function getWorkspaceTokenPath(workspaceId: string): string { + if (workspaceId === LEGACY_WORKSPACE_ID) { + return getLegacyTokenPath() + } + return join(getWorkspaceTokenDir(), `${Buffer.from(workspaceId).toString('base64url')}.enc`) +} + +function ensureOrcaDir(): void { + const dir = getOrcaDir() + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } +} + +function ensureWorkspaceTokenDir(): void { + const dir = getWorkspaceTokenDir() + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } +} + +function readLegacyViewerFromDisk(): LinearViewer | null { + const path = getLegacyViewerPath() if (!existsSync(path)) { return null } @@ -65,111 +118,404 @@ function readViewerFromDisk(): LinearViewer | null { return { displayName: parsed.displayName, email: typeof parsed.email === 'string' ? parsed.email : null, - organizationName: parsed.organizationName + organizationId: typeof parsed.organizationId === 'string' ? parsed.organizationId : undefined, + organizationName: parsed.organizationName, + organizationUrlKey: + typeof parsed.organizationUrlKey === 'string' ? parsed.organizationUrlKey : undefined } } catch { return null } } -function writeViewerToDisk(viewer: LinearViewer): void { - const dir = join(homedir(), '.orca') - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) +function getLegacyViewer(): LinearViewer | null { + if (!legacyViewerLoadedFromDisk) { + cachedLegacyViewer = readLegacyViewerFromDisk() + legacyViewerLoadedFromDisk = true } - writeFileSync(getViewerPath(), JSON.stringify(viewer), { encoding: 'utf-8', mode: 0o600 }) + return cachedLegacyViewer } -function clearViewerOnDisk(): void { +function normalizeWorkspace(input: unknown): LinearWorkspace | null { + if (!input || typeof input !== 'object') { + return null + } + const record = input as Record + if (typeof record.id !== 'string' || typeof record.organizationName !== 'string') { + return null + } + if (typeof record.displayName !== 'string') { + return null + } + + const organizationId = + typeof record.organizationId === 'string' && record.organizationId + ? record.organizationId + : record.id + + return { + id: record.id, + organizationId, + organizationName: record.organizationName, + organizationUrlKey: + typeof record.organizationUrlKey === 'string' ? record.organizationUrlKey : undefined, + displayName: record.displayName, + email: typeof record.email === 'string' ? record.email : null + } +} + +function emptyWorkspaceFile(): LinearWorkspaceFile { + return { + version: 1, + activeWorkspaceId: null, + selectedWorkspaceId: null, + workspaces: [] + } +} + +function readWorkspaceFileFromDisk(): LinearWorkspaceFile { + const path = getWorkspaceFilePath() + if (!existsSync(path)) { + return emptyWorkspaceFile() + } try { - unlinkSync(getViewerPath()) + const raw = readFileSync(path, { encoding: 'utf-8' }) + const parsed = JSON.parse(raw) as Partial + const workspaces = Array.isArray(parsed.workspaces) + ? parsed.workspaces + .map((workspace) => normalizeWorkspace(workspace)) + .filter((workspace): workspace is LinearWorkspace => workspace !== null) + .filter((workspace) => hasStoredToken(workspace.id)) + : [] + const activeWorkspaceId = + typeof parsed.activeWorkspaceId === 'string' && + workspaces.some((workspace) => workspace.id === parsed.activeWorkspaceId) + ? parsed.activeWorkspaceId + : (workspaces[0]?.id ?? null) + const selectedWorkspaceId = + parsed.selectedWorkspaceId === 'all' || + (typeof parsed.selectedWorkspaceId === 'string' && + workspaces.some((workspace) => workspace.id === parsed.selectedWorkspaceId)) + ? parsed.selectedWorkspaceId + : activeWorkspaceId + + return { + version: 1, + activeWorkspaceId, + selectedWorkspaceId, + workspaces + } + } catch { + return emptyWorkspaceFile() + } +} + +function getWorkspaceFile(): LinearWorkspaceFile { + if (!workspaceFileLoadedFromDisk || !cachedWorkspaceFile) { + cachedWorkspaceFile = readWorkspaceFileFromDisk() + workspaceFileLoadedFromDisk = true + } + return cachedWorkspaceFile +} + +function writeWorkspaceFile(file: LinearWorkspaceFile): void { + ensureOrcaDir() + const persistedWorkspaces = file.workspaces.filter( + (workspace) => workspace.id !== LEGACY_WORKSPACE_ID + ) + const selectableIds = new Set(persistedWorkspaces.map((workspace) => workspace.id)) + if (hasStoredToken(LEGACY_WORKSPACE_ID)) { + selectableIds.add(LEGACY_WORKSPACE_ID) + } + const activeWorkspaceId = + file.activeWorkspaceId && selectableIds.has(file.activeWorkspaceId) + ? file.activeWorkspaceId + : (persistedWorkspaces[0]?.id ?? + (selectableIds.has(LEGACY_WORKSPACE_ID) ? LEGACY_WORKSPACE_ID : null)) + const selectedWorkspaceId = + file.selectedWorkspaceId === 'all' + ? 'all' + : file.selectedWorkspaceId && selectableIds.has(file.selectedWorkspaceId) + ? file.selectedWorkspaceId + : activeWorkspaceId + + cachedWorkspaceFile = { + version: 1, + activeWorkspaceId, + selectedWorkspaceId, + workspaces: persistedWorkspaces + } + workspaceFileLoadedFromDisk = true + writeFileSync(getWorkspaceFilePath(), JSON.stringify(cachedWorkspaceFile, null, 2), { + encoding: 'utf-8', + mode: 0o600 + }) +} + +function getLegacyWorkspace(): LinearWorkspace | null { + if (!hasStoredToken(LEGACY_WORKSPACE_ID)) { + return null + } + const viewer = getLegacyViewer() + return { + id: LEGACY_WORKSPACE_ID, + organizationId: viewer?.organizationId ?? LEGACY_WORKSPACE_ID, + organizationName: viewer?.organizationName ?? 'Saved Linear workspace', + organizationUrlKey: viewer?.organizationUrlKey, + displayName: viewer?.displayName ?? 'Linear API key', + email: viewer?.email ?? null, + isLegacy: true + } +} + +function getWorkspaceState(): LinearWorkspaceFile { + const file = getWorkspaceFile() + const legacyWorkspace = getLegacyWorkspace() + const workspaces = [ + ...(legacyWorkspace ? [legacyWorkspace] : []), + ...file.workspaces.filter((workspace) => hasStoredToken(workspace.id)) + ] + const activeWorkspaceId = + file.activeWorkspaceId && + workspaces.some((workspace) => workspace.id === file.activeWorkspaceId) + ? file.activeWorkspaceId + : (workspaces[0]?.id ?? null) + const selectedWorkspaceId = + file.selectedWorkspaceId === 'all' + ? 'all' + : file.selectedWorkspaceId && + workspaces.some((workspace) => workspace.id === file.selectedWorkspaceId) + ? file.selectedWorkspaceId + : activeWorkspaceId + + return { + version: 1, + activeWorkspaceId, + selectedWorkspaceId, + workspaces + } +} + +function clearLegacyViewerOnDisk(): void { + try { + unlinkSync(getLegacyViewerPath()) } catch { // File may not exist — safe to ignore. } } -export function saveToken(apiKey: string): void { - const dir = join(homedir(), '.orca') - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) - } - const tokenPath = getTokenPath() - // Why: safeStorage uses the OS keychain (macOS Keychain, Windows DPAPI, - // Linux libsecret) to encrypt. If the keychain is unavailable (e.g. headless - // Linux without a keyring), fall back to plaintext with a warning — the user - // explicitly chose to store a personal API key on this machine. +function writeEncryptedToken(path: string, apiKey: string): void { if (safeStorage.isEncryptionAvailable()) { const encrypted = safeStorage.encryptString(apiKey) - writeFileSync(tokenPath, encrypted, { mode: 0o600 }) - } else { - console.warn('[linear] safeStorage encryption unavailable — storing token in plaintext') - writeFileSync(tokenPath, apiKey, { encoding: 'utf-8', mode: 0o600 }) + writeFileSync(path, encrypted, { mode: 0o600 }) + return } - cachedToken = apiKey + + console.warn('[linear] safeStorage encryption unavailable — storing token in plaintext') + writeFileSync(path, apiKey, { encoding: 'utf-8', mode: 0o600 }) } -// Why: force=true is used when the caller wants a token and accepts the -// keychain prompt (explicit "Test connection" or an actual API call). The -// default call path is fine with returning null if we haven't decrypted yet -// this session, so status checks don't trigger Keychain. -export function loadToken(options: { force?: boolean } = {}): string | null { - if (cachedToken !== null) { - return cachedToken +function saveWorkspaceToken(workspaceId: string, apiKey: string): void { + ensureOrcaDir() + if (workspaceId !== LEGACY_WORKSPACE_ID) { + ensureWorkspaceTokenDir() + } + const tokenPath = getWorkspaceTokenPath(workspaceId) + writeEncryptedToken(tokenPath, apiKey) + cachedTokens.set(workspaceId, apiKey) +} + +// Backward-compatible export for the legacy single-workspace storage path. +export function saveToken(apiKey: string): void { + saveWorkspaceToken(LEGACY_WORKSPACE_ID, apiKey) +} + +export function loadToken(options: { force?: boolean; workspaceId?: string } = {}): string | null { + const workspaceId = options.workspaceId ?? resolveWorkspaceId() + if (!workspaceId) { + return null + } + const cached = cachedTokens.get(workspaceId) + if (cached !== undefined) { + return cached } if (!options.force) { return null } - const tokenPath = getTokenPath() + const tokenPath = getWorkspaceTokenPath(workspaceId) if (!existsSync(tokenPath)) { return null } try { const raw = readFileSync(tokenPath) - cachedToken = safeStorage.isEncryptionAvailable() + const token = safeStorage.isEncryptionAvailable() ? safeStorage.decryptString(raw) : raw.toString('utf-8') - return cachedToken + cachedTokens.set(workspaceId, token) + return token } catch { return null } } -export function hasStoredToken(): boolean { - if (cachedToken !== null) { +export function hasStoredToken(workspaceId?: string): boolean { + if (!workspaceId) { + return getWorkspaceState().workspaces.length > 0 + } + if (cachedTokens.has(workspaceId)) { return true } - return existsSync(getTokenPath()) + return existsSync(getWorkspaceTokenPath(workspaceId)) } -export function clearToken(): void { - cachedToken = null - cachedViewer = null - viewerLoadedFromDisk = false - const tokenPath = getTokenPath() +function clearTokenFile(workspaceId: string): void { + cachedTokens.delete(workspaceId) try { - unlinkSync(tokenPath) + unlinkSync(getWorkspaceTokenPath(workspaceId)) } catch { // File may not exist — safe to ignore. } - clearViewerOnDisk() +} + +export function clearToken(workspaceId?: string): void { + if (!workspaceId) { + const state = getWorkspaceState() + for (const workspace of state.workspaces) { + clearTokenFile(workspace.id) + } + cachedTokens = new Map() + cachedLegacyViewer = null + legacyViewerLoadedFromDisk = false + cachedWorkspaceFile = emptyWorkspaceFile() + workspaceFileLoadedFromDisk = true + clearLegacyViewerOnDisk() + writeWorkspaceFile(emptyWorkspaceFile()) + return + } + + clearTokenFile(workspaceId) + if (workspaceId === LEGACY_WORKSPACE_ID) { + cachedLegacyViewer = null + legacyViewerLoadedFromDisk = false + clearLegacyViewerOnDisk() + return + } + + const file = getWorkspaceFile() + const workspaces = file.workspaces.filter((workspace) => workspace.id !== workspaceId) + const activeWorkspaceId = + file.activeWorkspaceId === workspaceId ? (workspaces[0]?.id ?? null) : file.activeWorkspaceId + const selectedWorkspaceId = + file.selectedWorkspaceId === workspaceId ? activeWorkspaceId : file.selectedWorkspaceId + writeWorkspaceFile({ + version: 1, + activeWorkspaceId, + selectedWorkspaceId, + workspaces + }) +} + +function workspaceFromLinearData( + me: { displayName: string; email?: string | null }, + org: { id: string; name: string; urlKey?: string | null } +): LinearWorkspace { + return { + id: org.id, + organizationId: org.id, + organizationName: org.name, + organizationUrlKey: org.urlKey ?? undefined, + displayName: me.displayName, + email: me.email ?? null + } +} + +function upsertWorkspace(workspace: LinearWorkspace, options: { select?: boolean } = {}): void { + const file = getWorkspaceFile() + const withoutCurrent = file.workspaces.filter((entry) => entry.id !== workspace.id) + const workspaces = [...withoutCurrent, workspace].sort((a, b) => + a.organizationName.localeCompare(b.organizationName) + ) + const selectedWorkspaceId = options.select + ? workspace.id + : file.selectedWorkspaceId && file.selectedWorkspaceId !== LEGACY_WORKSPACE_ID + ? file.selectedWorkspaceId + : workspace.id + writeWorkspaceFile({ + version: 1, + activeWorkspaceId: workspace.id, + selectedWorkspaceId, + workspaces + }) +} + +function replaceLegacyWorkspace(workspace: LinearWorkspace, token: string): void { + saveWorkspaceToken(workspace.id, token) + clearTokenFile(LEGACY_WORKSPACE_ID) + clearLegacyViewerOnDisk() + cachedLegacyViewer = null + legacyViewerLoadedFromDisk = true + upsertWorkspace(workspace, { select: true }) +} + +function resolveWorkspaceId(workspaceId?: string | null): string | null { + if (workspaceId && workspaceId !== 'all') { + return workspaceId + } + const state = getWorkspaceState() + if ( + state.selectedWorkspaceId && + state.selectedWorkspaceId !== 'all' && + state.workspaces.some((workspace) => workspace.id === state.selectedWorkspaceId) + ) { + return state.selectedWorkspaceId + } + if ( + state.activeWorkspaceId && + state.workspaces.some((workspace) => workspace.id === state.activeWorkspaceId) + ) { + return state.activeWorkspaceId + } + return state.workspaces[0]?.id ?? null } // ── Client factory ─────────────────────────────────────────────────── -// Why: this is called by the issues/teams modules when the user actually -// performs a Linear action — at that point decrypting the token (and -// surfacing a Keychain prompt if needed) is expected. -export function getClient(): LinearClient | null { - const token = loadToken({ force: true }) +// Why: issues/teams modules call this for real Linear actions — at that point +// decrypting the token and surfacing a keychain prompt is expected. +export function getClient(workspaceId?: string | null): LinearClient | null { + const token = loadToken({ + force: true, + workspaceId: resolveWorkspaceId(workspaceId) ?? undefined + }) if (!token) { return null } return new LinearClient({ apiKey: token }) } +export function getClients( + workspaceId?: LinearWorkspaceSelection | null +): LinearClientForWorkspace[] { + const state = getWorkspaceState() + const selectedWorkspaces = + workspaceId === 'all' + ? state.workspaces + : state.workspaces.filter((workspace) => workspace.id === resolveWorkspaceId(workspaceId)) + + const clients: LinearClientForWorkspace[] = [] + for (const workspace of selectedWorkspaces) { + const token = loadToken({ force: true, workspaceId: workspace.id }) + if (!token) { + continue + } + clients.push({ workspace, client: new LinearClient({ apiKey: token }) }) + } + return clients +} + // ── Auth error detection ───────────────────────────────────────────── // Why: 401 errors must trigger token clearing and a re-auth prompt in the -// renderer (design §Error Propagation). All other errors are swallowed -// with console.warn to match GitHub client's graceful degradation. +// renderer. All other errors are swallowed with console.warn to match GitHub +// client's graceful degradation. export function isAuthError(error: unknown): boolean { return error instanceof AuthenticationLinearError } @@ -177,58 +523,89 @@ export function isAuthError(error: unknown): boolean { // ── Connect / disconnect / status ──────────────────────────────────── export async function connect( apiKey: string -): Promise<{ ok: true; viewer: LinearViewer } | { ok: false; error: string }> { +): Promise< + { ok: true; viewer: LinearViewer; workspace: LinearWorkspace } | { ok: false; error: string } +> { try { const client = new LinearClient({ apiKey }) const me = await client.viewer const org = await me.organization + const workspace = workspaceFromLinearData(me, org) - const viewer: LinearViewer = { - displayName: me.displayName, - email: me.email ?? null, - organizationName: org.name + saveWorkspaceToken(workspace.id, apiKey) + const legacyWorkspace = getLegacyWorkspace() + if ( + legacyWorkspace && + legacyWorkspace.organizationName === workspace.organizationName && + legacyWorkspace.email === workspace.email + ) { + clearTokenFile(LEGACY_WORKSPACE_ID) + clearLegacyViewerOnDisk() + cachedLegacyViewer = null + legacyViewerLoadedFromDisk = true } - - saveToken(apiKey) - writeViewerToDisk(viewer) - cachedViewer = viewer - viewerLoadedFromDisk = true - return { ok: true, viewer } + upsertWorkspace(workspace, { select: true }) + return { ok: true, viewer: workspace, workspace } } catch (error) { const message = error instanceof Error ? error.message : 'Failed to validate API key' return { ok: false, error: message } } } -export function disconnect(): void { - clearToken() +export function disconnect(workspaceId?: string): void { + clearToken(workspaceId) +} + +export function selectWorkspace(workspaceId: LinearWorkspaceSelection): LinearConnectionStatus { + const state = getWorkspaceState() + if ( + workspaceId !== 'all' && + !state.workspaces.some((workspace) => workspace.id === workspaceId) + ) { + return getStatus() + } + + const file = getWorkspaceFile() + writeWorkspaceFile({ + version: 1, + activeWorkspaceId: workspaceId === 'all' ? file.activeWorkspaceId : workspaceId, + selectedWorkspaceId: workspaceId, + workspaces: file.workspaces + }) + return getStatus() } -// Why: getStatus must NEVER decrypt the token. It returns the cached -// viewer (written at connect time) so the settings/landing UIs can show -// "Connected as X" without triggering a Keychain permission dialog after -// every app update. The encrypted token is only touched lazily on real -// Linear API calls or when the user clicks "Test connection". export function getStatus(): LinearConnectionStatus { - if (!hasStoredToken()) { - return { connected: false, viewer: null } - } + const state = getWorkspaceState() + const selectedWorkspace = + state.selectedWorkspaceId && state.selectedWorkspaceId !== 'all' + ? state.workspaces.find((workspace) => workspace.id === state.selectedWorkspaceId) + : null + const activeWorkspace = + selectedWorkspace ?? + state.workspaces.find((workspace) => workspace.id === state.activeWorkspaceId) ?? + state.workspaces[0] ?? + null - if (!cachedViewer && !viewerLoadedFromDisk) { - cachedViewer = readViewerFromDisk() - viewerLoadedFromDisk = true + return { + connected: state.workspaces.length > 0, + viewer: activeWorkspace, + workspaces: state.workspaces, + activeWorkspaceId: state.activeWorkspaceId, + selectedWorkspaceId: state.selectedWorkspaceId } - - return { connected: true, viewer: cachedViewer } } -// Why: explicit user-initiated check. Decrypts the token, pings the -// Linear API to re-validate, and refreshes the cached viewer file. If the -// token is rejected (401) it clears state just like a live API error. -export async function testConnection(): Promise< - { ok: true; viewer: LinearViewer } | { ok: false; error: string } +export async function testConnection( + workspaceId?: string +): Promise< + { ok: true; viewer: LinearViewer; workspace: LinearWorkspace } | { ok: false; error: string } > { - const token = loadToken({ force: true }) + const resolvedWorkspaceId = resolveWorkspaceId(workspaceId) + if (!resolvedWorkspaceId) { + return { ok: false, error: 'No API key stored.' } + } + const token = loadToken({ force: true, workspaceId: resolvedWorkspaceId }) if (!token) { return { ok: false, error: 'No API key stored.' } } @@ -237,31 +614,26 @@ export async function testConnection(): Promise< const client = new LinearClient({ apiKey: token }) const me = await client.viewer const org = await me.organization - const viewer: LinearViewer = { - displayName: me.displayName, - email: me.email ?? null, - organizationName: org.name + const workspace = workspaceFromLinearData(me, org) + if (resolvedWorkspaceId === LEGACY_WORKSPACE_ID) { + replaceLegacyWorkspace(workspace, token) + } else { + saveWorkspaceToken(workspace.id, token) + upsertWorkspace(workspace, { select: true }) } - writeViewerToDisk(viewer) - cachedViewer = viewer - viewerLoadedFromDisk = true - return { ok: true, viewer } + return { ok: true, viewer: workspace, workspace } } catch (error) { if (isAuthError(error)) { - clearToken() + clearToken(resolvedWorkspaceId) } const message = error instanceof Error ? error.message : 'Test failed' return { ok: false, error: message } } } -// Why: called at main-process startup. This used to eagerly decrypt the -// token (which triggered a Keychain prompt on every launch after an app -// update). We now only warm the plaintext viewer cache — the token stays -// encrypted on disk until actually needed. +// Why: called at main-process startup. We warm plaintext metadata only; tokens +// stay encrypted on disk until a user performs an actual Linear action. export function initLinearToken(): void { - if (!viewerLoadedFromDisk) { - cachedViewer = readViewerFromDisk() - viewerLoadedFromDisk = true - } + getWorkspaceFile() + getLegacyViewer() } diff --git a/src/main/linear/issues.ts b/src/main/linear/issues.ts index 69bd579d249..afc59809973 100644 --- a/src/main/linear/issues.ts +++ b/src/main/linear/issues.ts @@ -1,49 +1,106 @@ -import type { LinearIssue, LinearIssueUpdate, LinearComment } from '../../shared/types' -import { acquire, release, getClient, isAuthError, clearToken } from './client' +/* eslint-disable max-lines -- Why: Linear issue reads and mutations share the + same workspace fan-out/error handling, so keeping them together avoids + drifting auth-clearing behavior between operations. */ +import type { + LinearIssue, + LinearIssueUpdate, + LinearComment, + LinearWorkspaceSelection +} from '../../shared/types' +import { + acquire, + release, + getClients, + isAuthError, + clearToken, + type LinearClientForWorkspace +} from './client' import { mapLinearIssue } from './mappers' -export async function getIssue(id: string): Promise { - const client = getClient() - if (!client) { - return null - } - - await acquire() - try { - const issue = await client.issue(id) - return await mapLinearIssue(issue) - } catch (error) { - if (isAuthError(error)) { - clearToken() - throw error - } - console.warn('[linear] getIssue failed:', error) - return null - } finally { - release() +async function mapIssueForWorkspace( + entry: LinearClientForWorkspace, + issue: Parameters[0] +): Promise { + const mapped = await mapLinearIssue(issue) + return { + ...mapped, + workspaceId: entry.workspace.id, + workspaceName: entry.workspace.organizationName } } -export async function searchIssues(query: string, limit = 20): Promise { - const client = getClient() - if (!client) { +function sortAndLimitIssues(issues: LinearIssue[], limit: number): LinearIssue[] { + return issues + .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) + .slice(0, limit) +} + +function shouldThrowAuthError(selection: LinearWorkspaceSelection | null | undefined): boolean { + return selection !== 'all' +} + +export async function getIssue( + id: string, + workspaceId?: LinearWorkspaceSelection | null +): Promise { + const entries = getClients(workspaceId) + if (entries.length === 0) { + return null + } + + for (const entry of entries) { + await acquire() + try { + const issue = await entry.client.issue(id) + return await mapIssueForWorkspace(entry, issue) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.workspace.id) + if (shouldThrowAuthError(workspaceId)) { + throw error + } + } else { + console.warn('[linear] getIssue failed:', error) + } + } finally { + release() + } + } + return null +} + +export async function searchIssues( + query: string, + limit = 20, + workspaceId?: LinearWorkspaceSelection | null +): Promise { + const entries = getClients(workspaceId) + if (entries.length === 0) { return [] } - await acquire() - try { - const result = await client.searchIssues(query, { first: limit }) - return await Promise.all(result.nodes.map(mapLinearIssue)) - } catch (error) { - if (isAuthError(error)) { - clearToken() - throw error - } - console.warn('[linear] searchIssues failed:', error) - return [] - } finally { - release() - } + const results = await Promise.all( + entries.map(async (entry) => { + await acquire() + try { + const result = await entry.client.searchIssues(query, { first: limit }) + return await Promise.all(result.nodes.map((issue) => mapIssueForWorkspace(entry, issue))) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.workspace.id) + if (shouldThrowAuthError(workspaceId)) { + throw error + } + } else { + console.warn('[linear] searchIssues failed:', error) + } + return [] + } finally { + release() + } + }) + ) + return sortAndLimitIssues(results.flat(), limit) } export type LinearListFilter = 'assigned' | 'created' | 'all' | 'completed' @@ -53,81 +110,99 @@ const COMPLETED_STATE_FILTER = { state: { type: { in: ['completed', 'canceled'] export async function listIssues( filter: LinearListFilter = 'assigned', - limit = 20 + limit = 20, + workspaceId?: LinearWorkspaceSelection | null ): Promise { - const client = getClient() - if (!client) { + const entries = getClients(workspaceId) + if (entries.length === 0) { return [] } - await acquire() - try { - const orderBy = 'updatedAt' as never + const results = await Promise.all( + entries.map(async (entry) => { + await acquire() + try { + const orderBy = 'updatedAt' as never - if (filter === 'assigned') { - const viewer = await client.viewer - const connection = await viewer.assignedIssues({ - first: limit, - orderBy, - filter: ACTIVE_STATE_FILTER - }) - return await Promise.all(connection.nodes.map(mapLinearIssue)) - } + if (filter === 'assigned') { + const viewer = await entry.client.viewer + const connection = await viewer.assignedIssues({ + first: limit, + orderBy, + filter: ACTIVE_STATE_FILTER + }) + return await Promise.all( + connection.nodes.map((issue) => mapIssueForWorkspace(entry, issue)) + ) + } - if (filter === 'created') { - const viewer = await client.viewer - const connection = await viewer.createdIssues({ - first: limit, - orderBy, - filter: ACTIVE_STATE_FILTER - }) - return await Promise.all(connection.nodes.map(mapLinearIssue)) - } + if (filter === 'created') { + const viewer = await entry.client.viewer + const connection = await viewer.createdIssues({ + first: limit, + orderBy, + filter: ACTIVE_STATE_FILTER + }) + return await Promise.all( + connection.nodes.map((issue) => mapIssueForWorkspace(entry, issue)) + ) + } - if (filter === 'completed') { - const viewer = await client.viewer - const connection = await viewer.assignedIssues({ - first: limit, - orderBy, - filter: COMPLETED_STATE_FILTER - }) - return await Promise.all(connection.nodes.map(mapLinearIssue)) - } + if (filter === 'completed') { + const viewer = await entry.client.viewer + const connection = await viewer.assignedIssues({ + first: limit, + orderBy, + filter: COMPLETED_STATE_FILTER + }) + return await Promise.all( + connection.nodes.map((issue) => mapIssueForWorkspace(entry, issue)) + ) + } - // 'all' — all active issues across the workspace - const connection = await client.issues({ - first: limit, - orderBy, - filter: ACTIVE_STATE_FILTER + // 'all' — all active issues across the workspace + const connection = await entry.client.issues({ + first: limit, + orderBy, + filter: ACTIVE_STATE_FILTER + }) + return await Promise.all( + connection.nodes.map((issue) => mapIssueForWorkspace(entry, issue)) + ) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.workspace.id) + if (shouldThrowAuthError(workspaceId)) { + throw error + } + } else { + console.warn('[linear] listIssues failed:', error) + } + return [] + } finally { + release() + } }) - return await Promise.all(connection.nodes.map(mapLinearIssue)) - } catch (error) { - if (isAuthError(error)) { - clearToken() - throw error - } - console.warn('[linear] listIssues failed:', error) - return [] - } finally { - release() - } + ) + return sortAndLimitIssues(results.flat(), limit) } export async function createIssue( teamId: string, title: string, - description?: string + description?: string, + workspaceId?: string | null ): Promise< { ok: true; id: string; identifier: string; url: string } | { ok: false; error: string } > { - const client = getClient() - if (!client) { + const entry = getClients(workspaceId)[0] + if (!entry) { return { ok: false, error: 'Not connected to Linear' } } await acquire() try { - const result = await client.createIssue({ + const result = await entry.client.createIssue({ teamId, title, ...(description ? { description } : {}) @@ -142,7 +217,7 @@ export async function createIssue( return { ok: true, id: issue.id, identifier: issue.identifier, url: issue.url } } catch (error) { if (isAuthError(error)) { - clearToken() + clearToken(entry.workspace.id) throw error } const message = error instanceof Error ? error.message : String(error) @@ -154,10 +229,11 @@ export async function createIssue( export async function updateIssue( id: string, - updates: LinearIssueUpdate + updates: LinearIssueUpdate, + workspaceId?: string | null ): Promise<{ ok: true } | { ok: false; error: string }> { - const client = getClient() - if (!client) { + const entry = getClients(workspaceId)[0] + if (!entry) { return { ok: false, error: 'Not connected to Linear' } } @@ -186,14 +262,14 @@ export async function updateIssue( payload.labelIds = resolvedLabelIds } - const result = await client.updateIssue(id, payload) + const result = await entry.client.updateIssue(id, payload) if (!result.success) { return { ok: false, error: 'Linear update failed' } } return { ok: true } } catch (error) { if (isAuthError(error)) { - clearToken() + clearToken(entry.workspace.id) throw error } const message = error instanceof Error ? error.message : String(error) @@ -205,16 +281,17 @@ export async function updateIssue( export async function addIssueComment( issueId: string, - body: string + body: string, + workspaceId?: string | null ): Promise<{ ok: true; id: string } | { ok: false; error: string }> { - const client = getClient() - if (!client) { + const entry = getClients(workspaceId)[0] + if (!entry) { return { ok: false, error: 'Not connected to Linear' } } await acquire() try { - const result = await client.createComment({ issueId, body }) + const result = await entry.client.createComment({ issueId, body }) if (!result.success) { return { ok: false, error: 'Failed to create comment' } } @@ -222,7 +299,7 @@ export async function addIssueComment( return { ok: true, id: comment?.id ?? '' } } catch (error) { if (isAuthError(error)) { - clearToken() + clearToken(entry.workspace.id) throw error } const message = error instanceof Error ? error.message : String(error) @@ -232,15 +309,18 @@ export async function addIssueComment( } } -export async function getIssueComments(issueId: string): Promise { - const client = getClient() - if (!client) { +export async function getIssueComments( + issueId: string, + workspaceId?: string | null +): Promise { + const entry = getClients(workspaceId)[0] + if (!entry) { return [] } await acquire() try { - const issue = await client.issue(issueId) + const issue = await entry.client.issue(issueId) const comments = await issue.comments() const results: LinearComment[] = [] for (const c of comments.nodes) { @@ -257,7 +337,7 @@ export async function getIssueComments(issueId: string): Promise { - const client = getClient() - if (!client) { +export async function listTeams( + workspaceId?: LinearWorkspaceSelection | null +): Promise { + const entries = getClients(workspaceId) + if (entries.length === 0) { return [] } - await acquire() - try { - const teams = await client.teams() - return teams.nodes - .map((t) => ({ id: t.id, name: t.name, key: t.key })) - .sort((a, b) => a.name.localeCompare(b.name)) - } catch (error) { - if (isAuthError(error)) { - clearToken() - throw error - } - console.warn('[linear] listTeams failed:', error) - return [] - } finally { - release() - } + const results = await Promise.all( + entries.map(async (entry) => { + await acquire() + try { + const teams = await entry.client.teams() + return teams.nodes.map((t) => ({ + id: t.id, + workspaceId: entry.workspace.id, + workspaceName: entry.workspace.organizationName, + name: t.name, + key: t.key + })) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.workspace.id) + if (workspaceId !== 'all') { + throw error + } + } else { + console.warn('[linear] listTeams failed:', error) + } + return [] + } finally { + release() + } + }) + ) + return results.flat().sort((a, b) => a.name.localeCompare(b.name)) } -export async function getTeamStates(teamId: string): Promise { - const client = getClient() - if (!client) { +export async function getTeamStates( + teamId: string, + workspaceId?: string | null +): Promise { + const entry = getClients(workspaceId)[0] + if (!entry) { return [] } await acquire() try { - const team = await client.team(teamId) + const team = await entry.client.team(teamId) const states = await team.states() return states.nodes .map((s) => ({ @@ -46,7 +69,7 @@ export async function getTeamStates(teamId: string): Promise a.position - b.position) } catch (error) { if (isAuthError(error)) { - clearToken() + clearToken(entry.workspace.id) throw error } console.warn('[linear] getTeamStates failed:', error) @@ -56,20 +79,23 @@ export async function getTeamStates(teamId: string): Promise { - const client = getClient() - if (!client) { +export async function getTeamLabels( + teamId: string, + workspaceId?: string | null +): Promise { + const entry = getClients(workspaceId)[0] + if (!entry) { return [] } await acquire() try { - const team = await client.team(teamId) + const team = await entry.client.team(teamId) const labels = await team.labels() return labels.nodes.map((l) => ({ id: l.id, name: l.name, color: l.color })) } catch (error) { if (isAuthError(error)) { - clearToken() + clearToken(entry.workspace.id) throw error } console.warn('[linear] getTeamLabels failed:', error) @@ -79,15 +105,18 @@ export async function getTeamLabels(teamId: string): Promise { } } -export async function getTeamMembers(teamId: string): Promise { - const client = getClient() - if (!client) { +export async function getTeamMembers( + teamId: string, + workspaceId?: string | null +): Promise { + const entry = getClients(workspaceId)[0] + if (!entry) { return [] } await acquire() try { - const team = await client.team(teamId) + const team = await entry.client.team(teamId) const members = await team.members() return members.nodes.map((m) => ({ id: m.id, @@ -96,7 +125,7 @@ export async function getTeamMembers(teamId: string): Promise { })) } catch (error) { if (isAuthError(error)) { - clearToken() + clearToken(entry.workspace.id) throw error } console.warn('[linear] getTeamMembers failed:', error) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 9f0277b35db..53a8e2ce6ef 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -27,6 +27,7 @@ import type { WorktreeRemoteBranchConflictEvent, WorktreeStartupLaunch, LinearIssueUpdate, + LinearWorkspaceSelection, TuiAgent } from '../../shared/types' import { splitWorktreeId } from '../../shared/worktree-id' @@ -116,6 +117,7 @@ import { connect as connectLinear, disconnect as disconnectLinear, getStatus as getLinearStatus, + selectWorkspace as selectLinearWorkspace, testConnection as testLinearConnection } from '../linear/client' import { @@ -7758,65 +7760,89 @@ export class OrcaRuntimeService { return connectLinear(apiKey) } - linearDisconnect(): { ok: true } { - disconnectLinear() + linearDisconnect(workspaceId?: string): { ok: true } { + disconnectLinear(workspaceId) return { ok: true } } + linearSelectWorkspace(workspaceId: LinearWorkspaceSelection): ReturnType { + return selectLinearWorkspace(workspaceId) + } + linearStatus(): ReturnType { return getLinearStatus() } - linearTestConnection(): ReturnType { - return testLinearConnection() + linearTestConnection(workspaceId?: string): ReturnType { + return testLinearConnection(workspaceId) } - linearSearchIssues(query: string, limit = 20): ReturnType { - return searchLinearIssues(query, Math.min(Math.max(1, limit), 50)) + linearSearchIssues( + query: string, + limit = 20, + workspaceId?: LinearWorkspaceSelection + ): ReturnType { + return searchLinearIssues(query, Math.min(Math.max(1, limit), 50), workspaceId) } - linearListIssues(filter?: LinearListFilter, limit = 20): ReturnType { - return listLinearIssues(filter, Math.min(Math.max(1, limit), 50)) + linearListIssues( + filter?: LinearListFilter, + limit = 20, + workspaceId?: LinearWorkspaceSelection + ): ReturnType { + return listLinearIssues(filter, Math.min(Math.max(1, limit), 50), workspaceId) } linearCreateIssue( teamId: string, title: string, - description?: string + description?: string, + workspaceId?: string ): ReturnType { - return createLinearIssue(teamId, title, description) + return createLinearIssue(teamId, title, description, workspaceId) } - linearGetIssue(id: string): ReturnType { - return getLinearIssue(id) + linearGetIssue(id: string, workspaceId?: string): ReturnType { + return getLinearIssue(id, workspaceId) } - linearUpdateIssue(id: string, updates: LinearIssueUpdate): ReturnType { - return updateLinearIssue(id, updates) + linearUpdateIssue( + id: string, + updates: LinearIssueUpdate, + workspaceId?: string + ): ReturnType { + return updateLinearIssue(id, updates, workspaceId) } - linearAddIssueComment(issueId: string, body: string): ReturnType { - return addLinearIssueComment(issueId, body) + linearAddIssueComment( + issueId: string, + body: string, + workspaceId?: string + ): ReturnType { + return addLinearIssueComment(issueId, body, workspaceId) } - linearIssueComments(issueId: string): ReturnType { - return getLinearIssueComments(issueId) + linearIssueComments( + issueId: string, + workspaceId?: string + ): ReturnType { + return getLinearIssueComments(issueId, workspaceId) } - linearListTeams(): ReturnType { - return listLinearTeams() + linearListTeams(workspaceId?: LinearWorkspaceSelection): ReturnType { + return listLinearTeams(workspaceId) } - linearTeamStates(teamId: string): ReturnType { - return getLinearTeamStates(teamId) + linearTeamStates(teamId: string, workspaceId?: string): ReturnType { + return getLinearTeamStates(teamId, workspaceId) } - linearTeamLabels(teamId: string): ReturnType { - return getLinearTeamLabels(teamId) + linearTeamLabels(teamId: string, workspaceId?: string): ReturnType { + return getLinearTeamLabels(teamId, workspaceId) } - linearTeamMembers(teamId: string): ReturnType { - return getLinearTeamMembers(teamId) + linearTeamMembers(teamId: string, workspaceId?: string): ReturnType { + return getLinearTeamMembers(teamId, workspaceId) } // ── Browser automation ── diff --git a/src/main/runtime/rpc/methods/linear.test.ts b/src/main/runtime/rpc/methods/linear.test.ts index ec95e5525b2..85ec802fc53 100644 --- a/src/main/runtime/rpc/methods/linear.test.ts +++ b/src/main/runtime/rpc/methods/linear.test.ts @@ -15,6 +15,7 @@ describe('linear RPC methods', () => { linearStatus: vi.fn().mockResolvedValue({ connected: true, viewer: null }), linearTestConnection: vi.fn().mockResolvedValue({ ok: true, viewer: { displayName: 'Ada' } }), linearConnect: vi.fn().mockResolvedValue({ ok: true, viewer: { displayName: 'Ada' } }), + linearSelectWorkspace: vi.fn().mockResolvedValue({ connected: true, viewer: null }), linearDisconnect: vi.fn().mockResolvedValue({ ok: true }) } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: LINEAR_METHODS }) @@ -22,11 +23,13 @@ describe('linear RPC methods', () => { await dispatcher.dispatch(makeRequest('linear.status')) await dispatcher.dispatch(makeRequest('linear.testConnection')) await dispatcher.dispatch(makeRequest('linear.connect', { apiKey: 'lin_api_key' })) + await dispatcher.dispatch(makeRequest('linear.selectWorkspace', { workspaceId: 'workspace-1' })) await dispatcher.dispatch(makeRequest('linear.disconnect')) expect(runtime.linearStatus).toHaveBeenCalled() expect(runtime.linearTestConnection).toHaveBeenCalled() expect(runtime.linearConnect).toHaveBeenCalledWith('lin_api_key') + expect(runtime.linearSelectWorkspace).toHaveBeenCalledWith('workspace-1') expect(runtime.linearDisconnect).toHaveBeenCalled() }) @@ -43,39 +46,70 @@ describe('linear RPC methods', () => { } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: LINEAR_METHODS }) - await dispatcher.dispatch(makeRequest('linear.searchIssues', { query: 'bug', limit: 30 })) - await dispatcher.dispatch(makeRequest('linear.listIssues', { filter: 'assigned', limit: 20 })) - await dispatcher.dispatch(makeRequest('linear.getIssue', { id: 'issue-3' })) + await dispatcher.dispatch( + makeRequest('linear.searchIssues', { query: 'bug', limit: 30, workspaceId: 'all' }) + ) + await dispatcher.dispatch( + makeRequest('linear.listIssues', { + filter: 'assigned', + limit: 20, + workspaceId: 'workspace-1' + }) + ) + await dispatcher.dispatch( + makeRequest('linear.getIssue', { id: 'issue-3', workspaceId: 'workspace-1' }) + ) await dispatcher.dispatch( makeRequest('linear.createIssue', { teamId: 'team-1', title: 'Fix bug', - description: 'Details' + description: 'Details', + workspaceId: 'workspace-1' }) ) await dispatcher.dispatch( makeRequest('linear.updateIssue', { id: 'issue-3', + workspaceId: 'workspace-1', updates: { stateId: 'state-1', assigneeId: null, priority: 2, labelIds: ['label-1'] } }) ) await dispatcher.dispatch( - makeRequest('linear.addIssueComment', { issueId: 'issue-3', body: 'Looks good' }) + makeRequest('linear.addIssueComment', { + issueId: 'issue-3', + body: 'Looks good', + workspaceId: 'workspace-1' + }) + ) + await dispatcher.dispatch( + makeRequest('linear.issueComments', { issueId: 'issue-3', workspaceId: 'workspace-1' }) ) - await dispatcher.dispatch(makeRequest('linear.issueComments', { issueId: 'issue-3' })) - expect(runtime.linearSearchIssues).toHaveBeenCalledWith('bug', 30) - expect(runtime.linearListIssues).toHaveBeenCalledWith('assigned', 20) - expect(runtime.linearGetIssue).toHaveBeenCalledWith('issue-3') - expect(runtime.linearCreateIssue).toHaveBeenCalledWith('team-1', 'Fix bug', 'Details') - expect(runtime.linearUpdateIssue).toHaveBeenCalledWith('issue-3', { - stateId: 'state-1', - assigneeId: null, - priority: 2, - labelIds: ['label-1'] - }) - expect(runtime.linearAddIssueComment).toHaveBeenCalledWith('issue-3', 'Looks good') - expect(runtime.linearIssueComments).toHaveBeenCalledWith('issue-3') + expect(runtime.linearSearchIssues).toHaveBeenCalledWith('bug', 30, 'all') + expect(runtime.linearListIssues).toHaveBeenCalledWith('assigned', 20, 'workspace-1') + expect(runtime.linearGetIssue).toHaveBeenCalledWith('issue-3', 'workspace-1') + expect(runtime.linearCreateIssue).toHaveBeenCalledWith( + 'team-1', + 'Fix bug', + 'Details', + 'workspace-1' + ) + expect(runtime.linearUpdateIssue).toHaveBeenCalledWith( + 'issue-3', + { + stateId: 'state-1', + assigneeId: null, + priority: 2, + labelIds: ['label-1'] + }, + 'workspace-1' + ) + expect(runtime.linearAddIssueComment).toHaveBeenCalledWith( + 'issue-3', + 'Looks good', + 'workspace-1' + ) + expect(runtime.linearIssueComments).toHaveBeenCalledWith('issue-3', 'workspace-1') }) it('routes Linear metadata requests to the runtime server', async () => { @@ -88,14 +122,20 @@ describe('linear RPC methods', () => { } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: LINEAR_METHODS }) - await dispatcher.dispatch(makeRequest('linear.listTeams')) - await dispatcher.dispatch(makeRequest('linear.teamStates', { teamId: 'team-1' })) - await dispatcher.dispatch(makeRequest('linear.teamLabels', { teamId: 'team-1' })) - await dispatcher.dispatch(makeRequest('linear.teamMembers', { teamId: 'team-1' })) + await dispatcher.dispatch(makeRequest('linear.listTeams', { workspaceId: 'all' })) + await dispatcher.dispatch( + makeRequest('linear.teamStates', { teamId: 'team-1', workspaceId: 'workspace-1' }) + ) + await dispatcher.dispatch( + makeRequest('linear.teamLabels', { teamId: 'team-1', workspaceId: 'workspace-1' }) + ) + await dispatcher.dispatch( + makeRequest('linear.teamMembers', { teamId: 'team-1', workspaceId: 'workspace-1' }) + ) - expect(runtime.linearListTeams).toHaveBeenCalled() - expect(runtime.linearTeamStates).toHaveBeenCalledWith('team-1') - expect(runtime.linearTeamLabels).toHaveBeenCalledWith('team-1') - expect(runtime.linearTeamMembers).toHaveBeenCalledWith('team-1') + expect(runtime.linearListTeams).toHaveBeenCalledWith('all') + expect(runtime.linearTeamStates).toHaveBeenCalledWith('team-1', 'workspace-1') + expect(runtime.linearTeamLabels).toHaveBeenCalledWith('team-1', 'workspace-1') + expect(runtime.linearTeamMembers).toHaveBeenCalledWith('team-1', 'workspace-1') }) }) diff --git a/src/main/runtime/rpc/methods/linear.ts b/src/main/runtime/rpc/methods/linear.ts index 29bdcf488f8..71c10ab3d02 100644 --- a/src/main/runtime/rpc/methods/linear.ts +++ b/src/main/runtime/rpc/methods/linear.ts @@ -8,39 +8,56 @@ const Connect = z.object({ apiKey: requiredString('Invalid API key') }) +const WorkspaceSelection = z + .object({ + workspaceId: OptionalString + }) + .optional() + +const SelectWorkspace = z.object({ + workspaceId: requiredString('Workspace ID is required') +}) + const SearchIssues = z.object({ query: requiredString('Missing query'), - limit: OptionalFiniteNumber + limit: OptionalFiniteNumber, + workspaceId: OptionalString }) const ListIssues = z .object({ filter: z.enum(VALID_FILTERS).optional(), - limit: OptionalFiniteNumber + limit: OptionalFiniteNumber, + workspaceId: OptionalString }) .optional() const CreateIssue = z.object({ teamId: requiredString('Team ID is required'), title: requiredString('Title is required'), - description: OptionalString + description: OptionalString, + workspaceId: OptionalString }) const IssueId = z.object({ - id: requiredString('Issue ID is required') + id: requiredString('Issue ID is required'), + workspaceId: OptionalString }) const IssueComment = z.object({ issueId: requiredString('Issue ID is required'), - body: requiredString('Comment body is required') + body: requiredString('Comment body is required'), + workspaceId: OptionalString }) const TeamId = z.object({ - teamId: requiredString('Team ID is required') + teamId: requiredString('Team ID is required'), + workspaceId: OptionalString }) const IssueUpdate = z.object({ id: requiredString('Issue ID is required'), + workspaceId: OptionalString, updates: z.object({ stateId: OptionalString, title: OptionalString, @@ -58,8 +75,13 @@ export const LINEAR_METHODS: RpcMethod[] = [ }), defineMethod({ name: 'linear.disconnect', - params: null, - handler: async (_params, { runtime }) => runtime.linearDisconnect() + params: WorkspaceSelection, + handler: async (params, { runtime }) => runtime.linearDisconnect(params?.workspaceId) + }), + defineMethod({ + name: 'linear.selectWorkspace', + params: SelectWorkspace, + handler: async (params, { runtime }) => runtime.linearSelectWorkspace(params.workspaceId.trim()) }), defineMethod({ name: 'linear.status', @@ -68,18 +90,20 @@ export const LINEAR_METHODS: RpcMethod[] = [ }), defineMethod({ name: 'linear.testConnection', - params: null, - handler: async (_params, { runtime }) => runtime.linearTestConnection() + params: WorkspaceSelection, + handler: async (params, { runtime }) => runtime.linearTestConnection(params?.workspaceId) }), defineMethod({ name: 'linear.searchIssues', params: SearchIssues, - handler: async (params, { runtime }) => runtime.linearSearchIssues(params.query, params.limit) + handler: async (params, { runtime }) => + runtime.linearSearchIssues(params.query, params.limit, params.workspaceId) }), defineMethod({ name: 'linear.listIssues', params: ListIssues, - handler: async (params, { runtime }) => runtime.linearListIssues(params?.filter, params?.limit) + handler: async (params, { runtime }) => + runtime.linearListIssues(params?.filter, params?.limit, params?.workspaceId) }), defineMethod({ name: 'linear.createIssue', @@ -88,49 +112,58 @@ export const LINEAR_METHODS: RpcMethod[] = [ runtime.linearCreateIssue( params.teamId.trim(), params.title.trim(), - params.description?.trim() || undefined + params.description?.trim() || undefined, + params.workspaceId ) }), defineMethod({ name: 'linear.getIssue', params: IssueId, - handler: async (params, { runtime }) => runtime.linearGetIssue(params.id.trim()) + handler: async (params, { runtime }) => + runtime.linearGetIssue(params.id.trim(), params.workspaceId) }), defineMethod({ name: 'linear.updateIssue', params: IssueUpdate, handler: async (params, { runtime }) => - runtime.linearUpdateIssue(params.id.trim(), params.updates) + runtime.linearUpdateIssue(params.id.trim(), params.updates, params.workspaceId) }), defineMethod({ name: 'linear.addIssueComment', params: IssueComment, handler: async (params, { runtime }) => - runtime.linearAddIssueComment(params.issueId.trim(), params.body.trim()) + runtime.linearAddIssueComment(params.issueId.trim(), params.body.trim(), params.workspaceId) }), defineMethod({ name: 'linear.issueComments', - params: z.object({ issueId: requiredString('Issue ID is required') }), - handler: async (params, { runtime }) => runtime.linearIssueComments(params.issueId.trim()) + params: z.object({ + issueId: requiredString('Issue ID is required'), + workspaceId: OptionalString + }), + handler: async (params, { runtime }) => + runtime.linearIssueComments(params.issueId.trim(), params.workspaceId) }), defineMethod({ name: 'linear.listTeams', - params: null, - handler: async (_params, { runtime }) => runtime.linearListTeams() + params: WorkspaceSelection, + handler: async (params, { runtime }) => runtime.linearListTeams(params?.workspaceId) }), defineMethod({ name: 'linear.teamStates', params: TeamId, - handler: async (params, { runtime }) => runtime.linearTeamStates(params.teamId.trim()) + handler: async (params, { runtime }) => + runtime.linearTeamStates(params.teamId.trim(), params.workspaceId) }), defineMethod({ name: 'linear.teamLabels', params: TeamId, - handler: async (params, { runtime }) => runtime.linearTeamLabels(params.teamId.trim()) + handler: async (params, { runtime }) => + runtime.linearTeamLabels(params.teamId.trim(), params.workspaceId) }), defineMethod({ name: 'linear.teamMembers', params: TeamId, - handler: async (params, { runtime }) => runtime.linearTeamMembers(params.teamId.trim()) + handler: async (params, { runtime }) => + runtime.linearTeamMembers(params.teamId.trim(), params.workspaceId) }) ] diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index a874def4fca..5e21c47a89f 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -47,6 +47,7 @@ import type { IssueInfo, LinearViewer, LinearConnectionStatus, + LinearWorkspaceSelection, LinearIssue, LinearIssueUpdate, LinearComment, @@ -867,35 +868,48 @@ export type PreloadApi = { connect: (args: { apiKey: string }) => Promise<{ ok: true; viewer: LinearViewer } | { ok: false; error: string }> - disconnect: () => Promise + disconnect: (args?: { workspaceId?: string }) => Promise + selectWorkspace: (args: { + workspaceId: LinearWorkspaceSelection + }) => Promise status: () => Promise - testConnection: () => Promise<{ ok: true; viewer: LinearViewer } | { ok: false; error: string }> - searchIssues: (args: { query: string; limit?: number }) => Promise + testConnection: (args?: { + workspaceId?: string + }) => Promise<{ ok: true; viewer: LinearViewer } | { ok: false; error: string }> + searchIssues: (args: { + query: string + limit?: number + workspaceId?: LinearWorkspaceSelection + }) => Promise listIssues: (args?: { filter?: 'assigned' | 'created' | 'all' | 'completed' limit?: number + workspaceId?: LinearWorkspaceSelection }) => Promise createIssue: (args: { teamId: string title: string description?: string + workspaceId?: string }) => Promise< { ok: true; id: string; identifier: string; url: string } | { ok: false; error: string } > - getIssue: (args: { id: string }) => Promise + getIssue: (args: { id: string; workspaceId?: string }) => Promise updateIssue: (args: { id: string updates: LinearIssueUpdate + workspaceId?: string }) => Promise<{ ok: true } | { ok: false; error: string }> addIssueComment: (args: { issueId: string body: string + workspaceId?: string }) => Promise<{ ok: true; id: string } | { ok: false; error: string }> - issueComments: (args: { issueId: string }) => Promise - listTeams: () => Promise - teamStates: (args: { teamId: string }) => Promise - teamLabels: (args: { teamId: string }) => Promise - teamMembers: (args: { teamId: string }) => Promise + issueComments: (args: { issueId: string; workspaceId?: string }) => Promise + listTeams: (args?: { workspaceId?: LinearWorkspaceSelection }) => Promise + teamStates: (args: { teamId: string; workspaceId?: string }) => Promise + teamLabels: (args: { teamId: string; workspaceId?: string }) => Promise + teamMembers: (args: { teamId: string; workspaceId?: string }) => Promise } starNag: { onShow: (callback: () => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 014996c28de..764714f28cb 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -917,56 +917,70 @@ const api = { }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => ipcRenderer.invoke('linear:connect', args), - disconnect: (): Promise => ipcRenderer.invoke('linear:disconnect'), + disconnect: (args?: { workspaceId?: string }): Promise => + ipcRenderer.invoke('linear:disconnect', args), + + selectWorkspace: (args: { workspaceId: string }): Promise => + ipcRenderer.invoke('linear:selectWorkspace', args), status: (): Promise => ipcRenderer.invoke('linear:status'), - testConnection: (): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => - ipcRenderer.invoke('linear:testConnection'), + testConnection: (args?: { + workspaceId?: string + }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => + ipcRenderer.invoke('linear:testConnection', args), - searchIssues: (args: { query: string; limit?: number }): Promise => - ipcRenderer.invoke('linear:searchIssues', args), + searchIssues: (args: { + query: string + limit?: number + workspaceId?: string + }): Promise => ipcRenderer.invoke('linear:searchIssues', args), listIssues: (args?: { filter?: 'assigned' | 'created' | 'all' | 'completed' limit?: number + workspaceId?: string }): Promise => ipcRenderer.invoke('linear:listIssues', args), createIssue: (args: { teamId: string title: string description?: string + workspaceId?: string }): Promise< { ok: true; id: string; identifier: string; url: string } | { ok: false; error: string } > => ipcRenderer.invoke('linear:createIssue', args), - getIssue: (args: { id: string }): Promise => + getIssue: (args: { id: string; workspaceId?: string }): Promise => ipcRenderer.invoke('linear:getIssue', args), updateIssue: (args: { id: string updates: unknown + workspaceId?: string }): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('linear:updateIssue', args), addIssueComment: (args: { issueId: string body: string + workspaceId?: string }): Promise<{ ok: true; id: string } | { ok: false; error: string }> => ipcRenderer.invoke('linear:addIssueComment', args), - issueComments: (args: { issueId: string }): Promise => + issueComments: (args: { issueId: string; workspaceId?: string }): Promise => ipcRenderer.invoke('linear:issueComments', args), - listTeams: (): Promise => ipcRenderer.invoke('linear:listTeams'), + listTeams: (args?: { workspaceId?: string }): Promise => + ipcRenderer.invoke('linear:listTeams', args), - teamStates: (args: { teamId: string }): Promise => + teamStates: (args: { teamId: string; workspaceId?: string }): Promise => ipcRenderer.invoke('linear:teamStates', args), - teamLabels: (args: { teamId: string }): Promise => + teamLabels: (args: { teamId: string; workspaceId?: string }): Promise => ipcRenderer.invoke('linear:teamLabels', args), - teamMembers: (args: { teamId: string }): Promise => + teamMembers: (args: { teamId: string; workspaceId?: string }): Promise => ipcRenderer.invoke('linear:teamMembers', args) }, diff --git a/src/renderer/src/components/LinearItemDrawer.tsx b/src/renderer/src/components/LinearItemDrawer.tsx index 38fa892267c..7f55e78f572 100644 --- a/src/renderer/src/components/LinearItemDrawer.tsx +++ b/src/renderer/src/components/LinearItemDrawer.tsx @@ -106,9 +106,9 @@ function EditSection({ issue, editState, onEditStateChange }: EditSectionProps): } = editState const teamId = issue.team?.id || null - const states = useTeamStates(teamId, settings) - const labels = useTeamLabels(teamId, settings) - const members = useTeamMembers(teamId, settings) + const states = useTeamStates(teamId, settings, issue.workspaceId) + const labels = useTeamLabels(teamId, settings, issue.workspaceId) + const members = useTeamMembers(teamId, settings, issue.workspaceId) const handleStateChange = useCallback( (stateId: string) => { @@ -121,7 +121,7 @@ function EditSection({ issue, editState, onEditStateChange }: EditSectionProps): const stateValue = { name: newState.name, type: newState.type, color: newState.color } run('state', { - mutate: () => linearUpdateIssue(settings, issue.id, { stateId }), + mutate: () => linearUpdateIssue(settings, issue.id, { stateId }, issue.workspaceId), onOptimistic: () => { onEditStateChange({ state: stateValue }) patchLinearIssue(issue.id, { state: stateValue }) @@ -133,7 +133,16 @@ function EditSection({ issue, editState, onEditStateChange }: EditSectionProps): onError: (err) => toast.error(err) }) }, - [issue.id, localState, settings, states.data, patchLinearIssue, run, onEditStateChange] + [ + issue.id, + issue.workspaceId, + localState, + settings, + states.data, + patchLinearIssue, + run, + onEditStateChange + ] ) const handlePriorityChange = useCallback( @@ -141,7 +150,7 @@ function EditSection({ issue, editState, onEditStateChange }: EditSectionProps): const priority = parseInt(value, 10) const prevPriority = localPriority run('priority', { - mutate: () => linearUpdateIssue(settings, issue.id, { priority }), + mutate: () => linearUpdateIssue(settings, issue.id, { priority }, issue.workspaceId), onOptimistic: () => { onEditStateChange({ priority }) patchLinearIssue(issue.id, { priority }) @@ -153,7 +162,7 @@ function EditSection({ issue, editState, onEditStateChange }: EditSectionProps): onError: (err) => toast.error(err) }) }, - [issue.id, localPriority, settings, patchLinearIssue, run, onEditStateChange] + [issue.id, issue.workspaceId, localPriority, settings, patchLinearIssue, run, onEditStateChange] ) const handleAssigneeChange = useCallback( @@ -165,7 +174,7 @@ function EditSection({ issue, editState, onEditStateChange }: EditSectionProps): ? { id: member.id, displayName: member.displayName, avatarUrl: member.avatarUrl } : undefined run('assignee', { - mutate: () => linearUpdateIssue(settings, issue.id, { assigneeId }), + mutate: () => linearUpdateIssue(settings, issue.id, { assigneeId }, issue.workspaceId), onOptimistic: () => { onEditStateChange({ assignee: newAssignee }) patchLinearIssue(issue.id, { assignee: newAssignee }) @@ -177,7 +186,16 @@ function EditSection({ issue, editState, onEditStateChange }: EditSectionProps): onError: (err) => toast.error(err) }) }, - [issue.id, localAssignee, settings, members.data, patchLinearIssue, run, onEditStateChange] + [ + issue.id, + issue.workspaceId, + localAssignee, + settings, + members.data, + patchLinearIssue, + run, + onEditStateChange + ] ) const handleLabelToggle = useCallback( @@ -193,7 +211,8 @@ function EditSection({ issue, editState, onEditStateChange }: EditSectionProps): .filter((n): n is string => !!n) run('labels', { - mutate: () => linearUpdateIssue(settings, issue.id, { labelIds: newLabelIds }), + mutate: () => + linearUpdateIssue(settings, issue.id, { labelIds: newLabelIds }, issue.workspaceId), onOptimistic: () => { onEditStateChange({ labelIds: newLabelIds, labels: newLabels }) patchLinearIssue(issue.id, { labelIds: newLabelIds, labels: newLabels }) @@ -207,6 +226,7 @@ function EditSection({ issue, editState, onEditStateChange }: EditSectionProps): }, [ issue.id, + issue.workspaceId, localLabelIds, localLabels, settings, @@ -417,9 +437,11 @@ type LocalComment = { id: string; body: string; createdAt: string } function CommentFooter({ issueId, + workspaceId, onCommentAdded }: { issueId: string + workspaceId?: string | null onCommentAdded: (comment: LocalComment) => void }): React.JSX.Element { const settings = useAppStore((s) => s.settings) @@ -443,7 +465,7 @@ function CommentFooter({ } setSubmitting(true) try { - const result = await linearAddIssueComment(settings, issueId, trimmed) + const result = await linearAddIssueComment(settings, issueId, trimmed, workspaceId) const typed = result as { ok: boolean; id?: string; error?: string } if (typed.ok) { setBody('') @@ -460,7 +482,7 @@ function CommentFooter({ } finally { setSubmitting(false) } - }, [body, issueId, onCommentAdded, settings]) + }, [body, issueId, onCommentAdded, settings, workspaceId]) const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -553,7 +575,7 @@ export default function LinearItemDrawer({ // Why: fetch issue and comments independently so a transient comments // failure doesn't discard the successfully-fetched issue data. - linearGetIssue(settings, issue.id) + linearGetIssue(settings, issue.id, issue.workspaceId) .then((issueResult) => { if (requestId !== requestIdRef.current) { return @@ -570,7 +592,7 @@ export default function LinearItemDrawer({ }) .catch(() => {}) - linearIssueComments(settings, issue.id) + linearIssueComments(settings, issue.id, issue.workspaceId) .then((commentsResult) => { if (requestId !== requestIdRef.current) { return @@ -595,7 +617,7 @@ export default function LinearItemDrawer({ } }) // oxlint-disable-next-line react-hooks/exhaustive-deps - }, [issue?.id, settings]) + }, [issue?.id, issue?.workspaceId, settings]) // Why: same pointer-events fix as GitHubItemDialog — Radix may leave // pointer-events: none on body when overlays transition. @@ -667,6 +689,7 @@ export default function LinearItemDrawer({ {displayed.title}
+ {displayed.workspaceName && {displayed.workspaceName}} {displayed.team?.name && {displayed.team.name}} · {formatRelativeTime(displayed.updatedAt)}
@@ -779,7 +802,11 @@ export default function LinearItemDrawer({ {/* Comment footer + Start workspace */} - +
- {taskSource === 'linear' && availableTeams.length > 0 ? ( -
- { - setLinearTeamSelection(next) - void updateSettings({ defaultLinearTeamSelection: [...next] }).catch( - () => { - toast.error('Failed to save team selection.') - } - ) - }} - onSelectAll={() => { - setLinearTeamSelection(new Set(availableTeams.map((t) => t.id))) - void updateSettings({ defaultLinearTeamSelection: null }).catch(() => { - toast.error('Failed to save team selection.') - }) - }} - triggerClassName="h-8 w-full rounded-md border border-border/50 bg-muted/50 px-2 text-xs font-medium shadow-sm transition hover:bg-muted/50 focus:ring-2 focus:ring-ring/20 focus:outline-none" - /> + {taskSource === 'linear' && linearStatus.connected ? ( +
+ {linearWorkspaces.length > 1 ? ( + + ) : null} + {availableTeams.length > 0 ? ( +
+ { + setLinearTeamSelection(next) + void updateSettings({ defaultLinearTeamSelection: [...next] }).catch( + () => { + toast.error('Failed to save team selection.') + } + ) + }} + onSelectAll={() => { + setLinearTeamSelection(new Set(availableTeams.map((t) => t.id))) + void updateSettings({ defaultLinearTeamSelection: null }).catch( + () => { + toast.error('Failed to save team selection.') + } + ) + }} + triggerClassName="h-8 w-full rounded-md border border-border/50 bg-muted/50 px-2 text-xs font-medium shadow-sm transition hover:bg-muted/50 focus:ring-2 focus:ring-ring/20 focus:outline-none" + /> +
+ ) : null}
) : null}
@@ -3119,6 +3185,9 @@ export default function TaskPage(): React.JSX.Element { {issue.title}
+ {selectedLinearWorkspaceId === 'all' && issue.workspaceName ? ( + {issue.workspaceName} + ) : null} {issue.assignee ? {issue.assignee.displayName} : null} {issue.labels.slice(0, 3).map((label) => ( {availableTeams.length > 1 ? 'Creates a new issue in the selected team.' - : `Creates a new issue in ${newLinearIssueTargetTeam?.name ?? 'your team'}.`} + : `Creates a new issue in ${ + newLinearIssueTargetTeam?.workspaceName + ? `${newLinearIssueTargetTeam.workspaceName} / ` + : '' + }${newLinearIssueTargetTeam?.name ?? 'your team'}.`}
@@ -3402,6 +3475,9 @@ export default function TaskPage(): React.JSX.Element { {availableTeams.map((t) => ( + {selectedLinearWorkspaceId === 'all' && t.workspaceName + ? `${t.workspaceName} · ` + : ''} {t.key} — {t.name} ))} @@ -3529,10 +3605,10 @@ export default function TaskPage(): React.JSX.Element { }} > - Connect Linear + Connect Linear workspace Paste a Personal API key to - browse your assigned issues. + browse issues from that workspace.
diff --git a/src/renderer/src/components/settings/IntegrationsPane.tsx b/src/renderer/src/components/settings/IntegrationsPane.tsx index aa69b840b7b..9847c21163a 100644 --- a/src/renderer/src/components/settings/IntegrationsPane.tsx +++ b/src/renderer/src/components/settings/IntegrationsPane.tsx @@ -94,8 +94,10 @@ export function IntegrationsPane(): React.JSX.Element { const linearStatus = useAppStore((s) => s.linearStatus) const connectLinear = useAppStore((s) => s.connectLinear) const disconnectLinear = useAppStore((s) => s.disconnectLinear) + const disconnectLinearWorkspace = useAppStore((s) => s.disconnectLinearWorkspace) const checkLinearConnection = useAppStore((s) => s.checkLinearConnection) const testLinearConnection = useAppStore((s) => s.testLinearConnection) + const linearWorkspaces = linearStatus.workspaces ?? [] const [ghStatus, setGhStatus] = useState('checking') const [glabStatus, setGlabStatus] = useState('checking') @@ -110,10 +112,10 @@ export function IntegrationsPane(): React.JSX.Element { 'idle' ) const [linearConnectError, setLinearConnectError] = useState(null) - const [linearTestState, setLinearTestState] = useState<'idle' | 'testing' | 'ok' | 'error'>( - 'idle' - ) - const [linearTestError, setLinearTestError] = useState(null) + const [linearTestingWorkspaceId, setLinearTestingWorkspaceId] = useState(null) + const [linearTestResultByWorkspace, setLinearTestResultByWorkspace] = useState< + Record + >({}) useEffect(() => { void checkLinearConnection() @@ -165,6 +167,7 @@ export function IntegrationsPane(): React.JSX.Element { setLinearApiKeyDraft('') setLinearConnectState('idle') setLinearDialogOpen(false) + setLinearTestResultByWorkspace({}) } else { setLinearConnectState('error') setLinearConnectError(result.error) @@ -175,28 +178,37 @@ export function IntegrationsPane(): React.JSX.Element { } } - const handleLinearDisconnect = async (): Promise => { - await disconnectLinear() + const handleLinearDisconnect = async (workspaceId?: string): Promise => { + await (workspaceId ? disconnectLinearWorkspace(workspaceId) : disconnectLinear()) setLinearConnectState('idle') setLinearConnectError(null) - setLinearTestState('idle') - setLinearTestError(null) + setLinearTestResultByWorkspace({}) } // Why: explicit user-triggered verification. This is the *only* path in // settings that decrypts the stored API key, so the macOS Keychain prompt // (if the app signature has changed since the item was stored) only // appears when the user clicks Test — not just for opening Settings. - const handleLinearTest = async (): Promise => { - setLinearTestState('testing') - setLinearTestError(null) - const result = await testLinearConnection() + const handleLinearTest = async (workspaceId: string): Promise => { + setLinearTestingWorkspaceId(workspaceId) + setLinearTestResultByWorkspace((prev) => { + const next = { ...prev } + delete next[workspaceId] + return next + }) + const result = await testLinearConnection(workspaceId) if (result.ok) { - setLinearTestState('ok') + setLinearTestResultByWorkspace((prev) => ({ + ...prev, + [workspaceId]: { state: 'ok' } + })) } else { - setLinearTestState('error') - setLinearTestError(result.error) + setLinearTestResultByWorkspace((prev) => ({ + ...prev, + [workspaceId]: { state: 'error', error: result.error } + })) } + setLinearTestingWorkspaceId(null) } const handleRefreshGlab = (): void => { @@ -582,21 +594,15 @@ export function IntegrationsPane(): React.JSX.Element {

Linear

{linearStatus.connected - ? linearStatus.viewer - ? `${linearStatus.viewer.organizationName} · ${linearStatus.viewer.displayName}${linearStatus.viewer.email ? ` · ${linearStatus.viewer.email}` : ''}` - : 'API key saved. Test to verify.' + ? `${linearWorkspaces.length} workspace${linearWorkspaces.length === 1 ? '' : 's'} connected` : 'Browse and link issues to workspaces.'}

{linearStatus.connected ? (
- + Connected @@ -612,39 +618,64 @@ export function IntegrationsPane(): React.JSX.Element {
{linearStatus.connected && ( -
- - {linearTestState === 'ok' && ( - - - Verified - - )} - {linearTestState === 'error' && linearTestError && ( - - - {linearTestError} - - )} - {linearTestState === 'idle' && ( - - Verifies your API key against Linear. - - )} +
+ {linearWorkspaces.map((workspace) => { + const testResult = linearTestResultByWorkspace[workspace.id] + const testing = linearTestingWorkspaceId === workspace.id + return ( +
+
+

+ {workspace.organizationName} +

+

+ {workspace.displayName} + {workspace.email ? ` · ${workspace.email}` : ''} +

+
+ {testResult?.state === 'ok' ? ( + + + Verified + + ) : null} + {testResult?.state === 'error' ? ( + + + {testResult.error} + + ) : null} + + +
+ ) + })} +

+ Each workspace uses its own locally stored API key. +

)}
@@ -672,10 +703,10 @@ export function IntegrationsPane(): React.JSX.Element { }} > - Connect Linear + Connect Linear workspace Paste a Personal API key to - browse your assigned issues. + add a workspace to Orca.
diff --git a/src/renderer/src/hooks/useIssueMetadata.ts b/src/renderer/src/hooks/useIssueMetadata.ts index deec6cb6a9b..93a1434a5f5 100644 --- a/src/renderer/src/hooks/useIssueMetadata.ts +++ b/src/renderer/src/hooks/useIssueMetadata.ts @@ -182,10 +182,14 @@ const linearMemberStore = createMetadataRequestStore() function linearMetadataCacheKey( teamId: string, - settings: Pick | null | undefined + settings: Pick | null | undefined, + workspaceId?: string | null ): string { const target = getActiveRuntimeTarget(settings) - return target.kind === 'environment' ? `runtime:${target.environmentId}:${teamId}` : teamId + const workspaceKey = workspaceId ?? 'selected' + return target.kind === 'environment' + ? `runtime:${target.environmentId}:${workspaceKey}:${teamId}` + : `${workspaceKey}:${teamId}` } export function clearLinearMetadataCache(): void { @@ -201,7 +205,8 @@ export function clearGitHubMetadataCache(): void { export function useTeamStates( teamId: string | null, - settings?: Pick | null + settings?: Pick | null, + workspaceId?: string | null ): MetadataState { const [state, setState] = useState>({ data: [], @@ -215,7 +220,7 @@ export function useTeamStates( return } - const cacheKey = linearMetadataCacheKey(teamId, settings) + const cacheKey = linearMetadataCacheKey(teamId, settings, workspaceId) const cached = getFreshMetadata(linearStateStore, cacheKey) if (cached) { if (activeKeyRef.current !== cacheKey) { @@ -234,7 +239,9 @@ export function useTeamStates( error: null })) loadMetadata(linearStateStore, cacheKey, () => - linearTeamStates(settings, teamId).then((states) => states as LinearWorkflowState[]) + linearTeamStates(settings, teamId, workspaceId).then( + (states) => states as LinearWorkflowState[] + ) ) .then((data) => { if (activeKeyRef.current !== requestKey) { @@ -253,14 +260,15 @@ export function useTeamStates( error: err instanceof Error ? err.message : 'Failed to load states' })) }) - }, [settings, teamId]) + }, [settings, teamId, workspaceId]) return state } export function useTeamLabels( teamId: string | null, - settings?: Pick | null + settings?: Pick | null, + workspaceId?: string | null ): MetadataState { const [state, setState] = useState>({ data: [], @@ -274,7 +282,7 @@ export function useTeamLabels( return } - const cacheKey = linearMetadataCacheKey(teamId, settings) + const cacheKey = linearMetadataCacheKey(teamId, settings, workspaceId) const cached = getFreshMetadata(linearLabelStore, cacheKey) if (cached) { if (activeKeyRef.current !== cacheKey) { @@ -293,7 +301,7 @@ export function useTeamLabels( error: null })) loadMetadata(linearLabelStore, cacheKey, () => - linearTeamLabels(settings, teamId).then((labels) => labels as LinearLabel[]) + linearTeamLabels(settings, teamId, workspaceId).then((labels) => labels as LinearLabel[]) ) .then((data) => { if (activeKeyRef.current !== requestKey) { @@ -312,14 +320,15 @@ export function useTeamLabels( error: err instanceof Error ? err.message : 'Failed to load labels' })) }) - }, [settings, teamId]) + }, [settings, teamId, workspaceId]) return state } export function useTeamMembers( teamId: string | null, - settings?: Pick | null + settings?: Pick | null, + workspaceId?: string | null ): MetadataState { const [state, setState] = useState>({ data: [], @@ -333,7 +342,7 @@ export function useTeamMembers( return } - const cacheKey = linearMetadataCacheKey(teamId, settings) + const cacheKey = linearMetadataCacheKey(teamId, settings, workspaceId) const cached = getFreshMetadata(linearMemberStore, cacheKey) if (cached) { if (activeKeyRef.current !== cacheKey) { @@ -352,7 +361,7 @@ export function useTeamMembers( error: null })) loadMetadata(linearMemberStore, cacheKey, () => - linearTeamMembers(settings, teamId).then((members) => members as LinearMember[]) + linearTeamMembers(settings, teamId, workspaceId).then((members) => members as LinearMember[]) ) .then((data) => { if (activeKeyRef.current !== requestKey) { @@ -371,7 +380,7 @@ export function useTeamMembers( error: err instanceof Error ? err.message : 'Failed to load members' })) }) - }, [settings, teamId]) + }, [settings, teamId, workspaceId]) return state } diff --git a/src/renderer/src/runtime/runtime-linear-client.test.ts b/src/renderer/src/runtime/runtime-linear-client.test.ts index a552db06e19..8f4755a98e3 100644 --- a/src/renderer/src/runtime/runtime-linear-client.test.ts +++ b/src/renderer/src/runtime/runtime-linear-client.test.ts @@ -3,6 +3,7 @@ import { linearCreateIssue, linearListTeams, linearSearchIssues, + linearSelectWorkspace, linearStatus, linearUpdateIssue } from './runtime-linear-client' @@ -19,6 +20,7 @@ const linearSearchIssuesLocal = vi.fn() const linearCreateIssueLocal = vi.fn() const linearUpdateIssueLocal = vi.fn() const linearListTeamsLocal = vi.fn() +const linearSelectWorkspaceLocal = vi.fn() beforeEach(() => { clearRuntimeCompatibilityCacheForTests() @@ -29,6 +31,7 @@ beforeEach(() => { linearCreateIssueLocal.mockReset() linearUpdateIssueLocal.mockReset() linearListTeamsLocal.mockReset() + linearSelectWorkspaceLocal.mockReset() runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) }) @@ -40,7 +43,8 @@ beforeEach(() => { searchIssues: linearSearchIssuesLocal, createIssue: linearCreateIssueLocal, updateIssue: linearUpdateIssueLocal, - listTeams: linearListTeamsLocal + listTeams: linearListTeamsLocal, + selectWorkspace: linearSelectWorkspaceLocal } } }) @@ -60,7 +64,11 @@ describe('runtime linear client', () => { ).resolves.toEqual([{ id: 'issue-1' }]) expect(linearStatusLocal).toHaveBeenCalled() - expect(linearSearchIssuesLocal).toHaveBeenCalledWith({ query: 'bug', limit: 10 }) + expect(linearSearchIssuesLocal).toHaveBeenCalledWith({ + query: 'bug', + limit: 10, + workspaceId: undefined + }) expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) @@ -80,7 +88,7 @@ describe('runtime linear client', () => { }) await linearStatus({ activeRuntimeEnvironmentId: 'env-1' }) - await linearSearchIssues({ activeRuntimeEnvironmentId: 'env-1' }, 'bug', 10) + await linearSearchIssues({ activeRuntimeEnvironmentId: 'env-1' }, 'bug', 10, 'all') expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { selector: 'env-1', @@ -91,7 +99,7 @@ describe('runtime linear client', () => { expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { selector: 'env-1', method: 'linear.searchIssues', - params: { query: 'bug', limit: 10 }, + params: { query: 'bug', limit: 10, workspaceId: 'all' }, timeoutMs: 30_000 }) expect(linearStatusLocal).not.toHaveBeenCalled() @@ -118,31 +126,49 @@ describe('runtime linear client', () => { result: [{ id: 'team-1' }], _meta: { runtimeId: 'runtime-1' } }) + .mockResolvedValueOnce({ + id: 'rpc-select', + ok: true, + result: { connected: true, viewer: null }, + _meta: { runtimeId: 'runtime-1' } + }) await linearCreateIssue( { activeRuntimeEnvironmentId: 'env-1' }, - { teamId: 'team-1', title: 'Fix bug' } + { teamId: 'team-1', title: 'Fix bug', workspaceId: 'workspace-1' } ) - await linearUpdateIssue({ activeRuntimeEnvironmentId: 'env-1' }, 'issue-1', { priority: 2 }) - await linearListTeams({ activeRuntimeEnvironmentId: 'env-1' }) + await linearUpdateIssue( + { activeRuntimeEnvironmentId: 'env-1' }, + 'issue-1', + { priority: 2 }, + 'workspace-1' + ) + await linearListTeams({ activeRuntimeEnvironmentId: 'env-1' }, 'all') + await linearSelectWorkspace({ activeRuntimeEnvironmentId: 'env-1' }, 'workspace-1') expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { selector: 'env-1', method: 'linear.createIssue', - params: { teamId: 'team-1', title: 'Fix bug' }, + params: { teamId: 'team-1', title: 'Fix bug', workspaceId: 'workspace-1' }, timeoutMs: 30_000 }) expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { selector: 'env-1', method: 'linear.updateIssue', - params: { id: 'issue-1', updates: { priority: 2 } }, + params: { id: 'issue-1', updates: { priority: 2 }, workspaceId: 'workspace-1' }, timeoutMs: 30_000 }) expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(3, { selector: 'env-1', method: 'linear.listTeams', - params: undefined, + params: { workspaceId: 'all' }, timeoutMs: 30_000 }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(4, { + selector: 'env-1', + method: 'linear.selectWorkspace', + params: { workspaceId: 'workspace-1' }, + timeoutMs: 15_000 + }) }) }) diff --git a/src/renderer/src/runtime/runtime-linear-client.ts b/src/renderer/src/runtime/runtime-linear-client.ts index 7821a46d8aa..9d1183f7923 100644 --- a/src/renderer/src/runtime/runtime-linear-client.ts +++ b/src/renderer/src/runtime/runtime-linear-client.ts @@ -8,6 +8,7 @@ import type { LinearMember, LinearTeam, LinearViewer, + LinearWorkspaceSelection, LinearWorkflowState } from '../../../shared/types' import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' @@ -37,14 +38,20 @@ export async function linearStatus( } export async function linearTestConnection( - settings: RuntimeLinearSettings + settings: RuntimeLinearSettings, + workspaceId?: string | null ): Promise { const target = getActiveRuntimeTarget(settings) return target.kind === 'environment' - ? callRuntimeRpc(target, 'linear.testConnection', undefined, { - timeoutMs: 30_000 - }) - : window.api.linear.testConnection() + ? callRuntimeRpc( + target, + 'linear.testConnection', + workspaceId ? { workspaceId } : undefined, + { + timeoutMs: 30_000 + } + ) + : window.api.linear.testConnection(workspaceId ? { workspaceId } : undefined) } export async function linearConnect( @@ -63,51 +70,80 @@ export async function linearConnect( } export async function linearDisconnect(settings: RuntimeLinearSettings): Promise { + return linearDisconnectWorkspace(settings) +} + +export async function linearDisconnectWorkspace( + settings: RuntimeLinearSettings, + workspaceId?: string | null +): Promise { const target = getActiveRuntimeTarget(settings) if (target.kind === 'environment') { - await callRuntimeRpc<{ ok: true }>(target, 'linear.disconnect', undefined, { - timeoutMs: 15_000 - }) + await callRuntimeRpc<{ ok: true }>( + target, + 'linear.disconnect', + workspaceId ? { workspaceId } : undefined, + { + timeoutMs: 15_000 + } + ) return } - await window.api.linear.disconnect() + await window.api.linear.disconnect(workspaceId ? { workspaceId } : undefined) +} + +export async function linearSelectWorkspace( + settings: RuntimeLinearSettings, + workspaceId: LinearWorkspaceSelection +): Promise { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.selectWorkspace', + { workspaceId }, + { timeoutMs: 15_000 } + ) + : window.api.linear.selectWorkspace({ workspaceId }) } export async function linearSearchIssues( settings: RuntimeLinearSettings, query: string, - limit?: number + limit?: number, + workspaceId?: LinearWorkspaceSelection | null ): Promise { const target = getActiveRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc( target, 'linear.searchIssues', - { query, limit }, + { query, limit, workspaceId: workspaceId ?? undefined }, { timeoutMs: 30_000 } ) - : window.api.linear.searchIssues({ query, limit }) + : window.api.linear.searchIssues({ query, limit, workspaceId: workspaceId ?? undefined }) } export async function linearListIssues( settings: RuntimeLinearSettings, filter?: LinearIssueFilter, - limit?: number + limit?: number, + workspaceId?: LinearWorkspaceSelection | null ): Promise { const target = getActiveRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc( target, 'linear.listIssues', - { filter, limit }, + { filter, limit, workspaceId: workspaceId ?? undefined }, { timeoutMs: 30_000 } ) - : window.api.linear.listIssues({ filter, limit }) + : window.api.linear.listIssues({ filter, limit, workspaceId: workspaceId ?? undefined }) } export async function linearCreateIssue( settings: RuntimeLinearSettings, - args: { teamId: string; title: string; description?: string } + args: { teamId: string; title: string; description?: string; workspaceId?: string } ): Promise { const target = getActiveRuntimeTarget(settings) return target.kind === 'environment' @@ -119,104 +155,129 @@ export async function linearCreateIssue( export async function linearGetIssue( settings: RuntimeLinearSettings, - id: string + id: string, + workspaceId?: string | null ): Promise { const target = getActiveRuntimeTarget(settings) return target.kind === 'environment' - ? callRuntimeRpc(target, 'linear.getIssue', { id }, { timeoutMs: 30_000 }) - : window.api.linear.getIssue({ id }) + ? callRuntimeRpc( + target, + 'linear.getIssue', + { id, workspaceId: workspaceId ?? undefined }, + { timeoutMs: 30_000 } + ) + : window.api.linear.getIssue({ id, workspaceId: workspaceId ?? undefined }) } export async function linearUpdateIssue( settings: RuntimeLinearSettings, id: string, - updates: LinearIssueUpdate + updates: LinearIssueUpdate, + workspaceId?: string | null ): Promise { const target = getActiveRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc( target, 'linear.updateIssue', - { id, updates }, + { id, updates, workspaceId: workspaceId ?? undefined }, { timeoutMs: 30_000 } ) - : window.api.linear.updateIssue({ id, updates }) + : window.api.linear.updateIssue({ id, updates, workspaceId: workspaceId ?? undefined }) } export async function linearAddIssueComment( settings: RuntimeLinearSettings, issueId: string, - body: string + body: string, + workspaceId?: string | null ): Promise { const target = getActiveRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc( target, 'linear.addIssueComment', - { issueId, body }, + { issueId, body, workspaceId: workspaceId ?? undefined }, { timeoutMs: 30_000 } ) - : window.api.linear.addIssueComment({ issueId, body }) + : window.api.linear.addIssueComment({ issueId, body, workspaceId: workspaceId ?? undefined }) } export async function linearIssueComments( settings: RuntimeLinearSettings, - issueId: string + issueId: string, + workspaceId?: string | null ): Promise { const target = getActiveRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc( target, 'linear.issueComments', - { issueId }, + { issueId, workspaceId: workspaceId ?? undefined }, { timeoutMs: 30_000 } ) - : window.api.linear.issueComments({ issueId }) + : window.api.linear.issueComments({ issueId, workspaceId: workspaceId ?? undefined }) } -export async function linearListTeams(settings: RuntimeLinearSettings): Promise { +export async function linearListTeams( + settings: RuntimeLinearSettings, + workspaceId?: LinearWorkspaceSelection | null +): Promise { const target = getActiveRuntimeTarget(settings) return target.kind === 'environment' - ? callRuntimeRpc(target, 'linear.listTeams', undefined, { timeoutMs: 30_000 }) - : window.api.linear.listTeams() + ? callRuntimeRpc( + target, + 'linear.listTeams', + workspaceId ? { workspaceId } : undefined, + { timeoutMs: 30_000 } + ) + : window.api.linear.listTeams(workspaceId ? { workspaceId } : undefined) } export async function linearTeamStates( settings: RuntimeLinearSettings, - teamId: string + teamId: string, + workspaceId?: string | null ): Promise { const target = getActiveRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc( target, 'linear.teamStates', - { teamId }, + { teamId, workspaceId: workspaceId ?? undefined }, { timeoutMs: 30_000 } ) - : window.api.linear.teamStates({ teamId }) + : window.api.linear.teamStates({ teamId, workspaceId: workspaceId ?? undefined }) } export async function linearTeamLabels( settings: RuntimeLinearSettings, - teamId: string + teamId: string, + workspaceId?: string | null ): Promise { const target = getActiveRuntimeTarget(settings) return target.kind === 'environment' - ? callRuntimeRpc(target, 'linear.teamLabels', { teamId }, { timeoutMs: 30_000 }) - : window.api.linear.teamLabels({ teamId }) + ? callRuntimeRpc( + target, + 'linear.teamLabels', + { teamId, workspaceId: workspaceId ?? undefined }, + { timeoutMs: 30_000 } + ) + : window.api.linear.teamLabels({ teamId, workspaceId: workspaceId ?? undefined }) } export async function linearTeamMembers( settings: RuntimeLinearSettings, - teamId: string + teamId: string, + workspaceId?: string | null ): Promise { const target = getActiveRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc( target, 'linear.teamMembers', - { teamId }, + { teamId, workspaceId: workspaceId ?? undefined }, { timeoutMs: 30_000 } ) - : window.api.linear.teamMembers({ teamId }) + : window.api.linear.teamMembers({ teamId, workspaceId: workspaceId ?? undefined }) } diff --git a/src/renderer/src/store/slices/linear.ts b/src/renderer/src/store/slices/linear.ts index 084c8e29cf9..618f7fe6520 100644 --- a/src/renderer/src/store/slices/linear.ts +++ b/src/renderer/src/store/slices/linear.ts @@ -1,14 +1,24 @@ +/* eslint-disable max-lines -- Why: the Linear slice owns status, workspace + selection, issue caches, and optimistic patch propagation as one store + boundary so cache invalidation stays coherent. */ import type { StateCreator } from 'zustand' import type { AppState } from '../types' -import type { LinearViewer, LinearConnectionStatus, LinearIssue } from '../../../../shared/types' +import type { + LinearViewer, + LinearConnectionStatus, + LinearIssue, + LinearWorkspaceSelection +} from '../../../../shared/types' import type { CacheEntry } from './github' import { clearLinearMetadataCache } from '../../hooks/useIssueMetadata' import { linearConnect, linearDisconnect, + linearDisconnectWorkspace, linearGetIssue, linearListIssues, linearSearchIssues, + linearSelectWorkspace, linearStatus, linearTestConnection } from '@/runtime/runtime-linear-client' @@ -45,6 +55,10 @@ const inflightIssueRequests = new Map>() const inflightSearchRequests = new Map>() const inflightListRequests = new Map>() +function getSelectedWorkspaceId(status: LinearConnectionStatus): LinearWorkspaceSelection | null { + return status.selectedWorkspaceId ?? status.activeWorkspaceId ?? null +} + export type LinearSlice = { linearStatus: LinearConnectionStatus linearStatusChecked: boolean @@ -55,11 +69,13 @@ export type LinearSlice = { connectLinear: ( apiKey: string ) => Promise<{ ok: true; viewer: LinearViewer } | { ok: false; error: string }> - testLinearConnection: () => Promise< - { ok: true; viewer: LinearViewer } | { ok: false; error: string } - > + testLinearConnection: ( + workspaceId?: string | null + ) => Promise<{ ok: true; viewer: LinearViewer } | { ok: false; error: string }> + selectLinearWorkspace: (workspaceId: LinearWorkspaceSelection) => Promise disconnectLinear: () => Promise - fetchLinearIssue: (id: string) => Promise + disconnectLinearWorkspace: (workspaceId: string) => Promise + fetchLinearIssue: (id: string, workspaceId?: string | null) => Promise searchLinearIssues: (query: string, limit?: number) => Promise listLinearIssues: ( filter?: 'assigned' | 'created' | 'all' | 'completed', @@ -78,7 +94,12 @@ export const createLinearSlice: StateCreator = (s try { const status = (await linearStatus(get().settings)) as LinearConnectionStatus const prev = get().linearStatus - if (prev.connected !== status.connected || prev.viewer?.email !== status.viewer?.email) { + if ( + prev.connected !== status.connected || + prev.viewer?.email !== status.viewer?.email || + getSelectedWorkspaceId(prev) !== getSelectedWorkspaceId(status) || + (prev.workspaces?.length ?? 0) !== (status.workspaces?.length ?? 0) + ) { set({ linearStatus: status, linearStatusChecked: true }) } else if (!get().linearStatusChecked) { set({ linearStatusChecked: true }) @@ -92,24 +113,13 @@ export const createLinearSlice: StateCreator = (s } }, - testLinearConnection: async () => { + testLinearConnection: async (workspaceId) => { try { - const result = (await linearTestConnection(get().settings)) as + const result = (await linearTestConnection(get().settings, workspaceId)) as | { ok: true; viewer: LinearViewer } | { ok: false; error: string } - if (result.ok) { - set({ - linearStatus: { connected: true, viewer: result.viewer }, - linearStatusChecked: true - }) - } else { - // Why: testConnection clears the token on auth errors; reflect that - // locally so the UI drops back to the Connect state. - set({ - linearStatus: { connected: false, viewer: null }, - linearStatusChecked: true - }) - } + const status = await linearStatus(get().settings) + set({ linearStatus: status, linearStatusChecked: true }) return result } catch (error) { const message = error instanceof Error ? error.message : 'Test failed' @@ -127,6 +137,7 @@ export const createLinearSlice: StateCreator = (s viewer: result.viewer as LinearViewer } }) + void get().checkLinearConnection() } return result as { ok: true; viewer: LinearViewer } | { ok: false; error: string } } catch (error) { @@ -135,6 +146,20 @@ export const createLinearSlice: StateCreator = (s } }, + selectLinearWorkspace: async (workspaceId) => { + const status = await linearSelectWorkspace(get().settings, workspaceId) + inflightIssueRequests.clear() + inflightSearchRequests.clear() + inflightListRequests.clear() + clearLinearMetadataCache() + set({ + linearStatus: status, + linearIssueCache: {}, + linearSearchCache: {}, + linearStatusChecked: true + }) + }, + disconnectLinear: async () => { await linearDisconnect(get().settings) inflightIssueRequests.clear() @@ -148,24 +173,40 @@ export const createLinearSlice: StateCreator = (s }) }, - fetchLinearIssue: async (id: string) => { - const cached = get().linearIssueCache[id] + disconnectLinearWorkspace: async (workspaceId) => { + await linearDisconnectWorkspace(get().settings, workspaceId) + inflightIssueRequests.clear() + inflightSearchRequests.clear() + inflightListRequests.clear() + clearLinearMetadataCache() + const status = await linearStatus(get().settings) + set({ + linearStatus: status, + linearIssueCache: {}, + linearSearchCache: {}, + linearStatusChecked: true + }) + }, + + fetchLinearIssue: async (id: string, workspaceId?: string | null) => { + const issueCacheKey = `${workspaceId ?? 'selected'}::${id}` + const cached = get().linearIssueCache[issueCacheKey] ?? get().linearIssueCache[id] if (isFresh(cached)) { return cached.data } - const inflight = inflightIssueRequests.get(id) + const inflight = inflightIssueRequests.get(issueCacheKey) if (inflight) { return inflight } - const promise = linearGetIssue(get().settings, id) + const promise = linearGetIssue(get().settings, id, workspaceId) .then((issue) => { const data = issue as LinearIssue | null set((s) => ({ linearIssueCache: evictStaleEntries({ ...s.linearIssueCache, - [id]: { data, fetchedAt: Date.now() } + [issueCacheKey]: { data, fetchedAt: Date.now() } }) })) return data @@ -178,15 +219,16 @@ export const createLinearSlice: StateCreator = (s return null }) .finally(() => { - inflightIssueRequests.delete(id) + inflightIssueRequests.delete(issueCacheKey) }) - inflightIssueRequests.set(id, promise) + inflightIssueRequests.set(issueCacheKey, promise) return promise }, searchLinearIssues: async (query: string, limit = 20) => { - const cacheKey = `${query}::${limit}` + const workspaceId = getSelectedWorkspaceId(get().linearStatus) + const cacheKey = `${workspaceId ?? 'default'}::${query}::${limit}` const cached = get().linearSearchCache[cacheKey] if (isFresh(cached)) { return cached.data ?? [] @@ -197,7 +239,7 @@ export const createLinearSlice: StateCreator = (s return inflight } - const promise = linearSearchIssues(get().settings, query, limit) + const promise = linearSearchIssues(get().settings, query, limit, workspaceId) .then((issues) => { const data = issues as LinearIssue[] set((s) => ({ @@ -224,7 +266,8 @@ export const createLinearSlice: StateCreator = (s }, listLinearIssues: async (filter = 'assigned', limit = 20) => { - const cacheKey = `list::${filter}::${limit}` + const workspaceId = getSelectedWorkspaceId(get().linearStatus) + const cacheKey = `${workspaceId ?? 'default'}::list::${filter}::${limit}` const cached = get().linearSearchCache[cacheKey] if (isFresh(cached)) { return cached.data ?? [] @@ -235,7 +278,7 @@ export const createLinearSlice: StateCreator = (s return inflight } - const promise = linearListIssues(get().settings, filter, limit) + const promise = linearListIssues(get().settings, filter, limit, workspaceId) .then((issues) => { const data = issues as LinearIssue[] set((s) => ({ diff --git a/src/shared/types.ts b/src/shared/types.ts index 53ec7a17340..448ce8b0091 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -680,16 +680,31 @@ export type GitHubWorkItemDetails = { export type LinearViewer = { displayName: string email: string | null + organizationId?: string organizationName: string + organizationUrlKey?: string } +export type LinearWorkspace = LinearViewer & { + id: string + organizationId: string + isLegacy?: true +} + +export type LinearWorkspaceSelection = string | 'all' + export type LinearConnectionStatus = { connected: boolean viewer: LinearViewer | null + workspaces?: LinearWorkspace[] + activeWorkspaceId?: string | null + selectedWorkspaceId?: LinearWorkspaceSelection | null } export type LinearIssue = { id: string + workspaceId?: string + workspaceName?: string identifier: string title: string description?: string @@ -885,6 +900,8 @@ export type LinearMember = { export type LinearTeam = { id: string + workspaceId?: string + workspaceName?: string name: string key: string }