Support multiple Linear workspaces (#1917)

This commit is contained in:
Neil
2026-05-15 11:32:46 -07:00
committed by GitHub
parent 7c384a36e3
commit 96f0c683eb
18 changed files with 1735 additions and 618 deletions
+97 -48
View File
@@ -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<LinearListFilter>(['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))
}
)
}
+170
View File
@@ -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<string, ViewerFixture>()
let linearClientMock: ReturnType<typeof vi.fn>
function writeLegacyLinearFiles(token: string, viewer: Record<string, unknown>): 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<unknown> },
{ 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<typeof Os>('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'
)
})
})
+491 -119
View File
@@ -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<string, string>()
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<string, unknown>
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<LinearWorkspaceFile>
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()
}
+187 -107
View File
@@ -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<LinearIssue | null> {
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<typeof mapLinearIssue>[0]
): Promise<LinearIssue> {
const mapped = await mapLinearIssue(issue)
return {
...mapped,
workspaceId: entry.workspace.id,
workspaceName: entry.workspace.organizationName
}
}
export async function searchIssues(query: string, limit = 20): Promise<LinearIssue[]> {
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<LinearIssue | null> {
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<LinearIssue[]> {
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<LinearIssue[]> {
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<LinearComment[]> {
const client = getClient()
if (!client) {
export async function getIssueComments(
issueId: string,
workspaceId?: string | null
): Promise<LinearComment[]> {
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<LinearComment[]
return results
} catch (error) {
if (isAuthError(error)) {
clearToken()
clearToken(entry.workspace.id)
throw error
}
console.warn('[linear] getIssueComments failed:', error)
+65 -36
View File
@@ -1,39 +1,62 @@
import type { LinearTeam, LinearWorkflowState, LinearLabel, LinearMember } from '../../shared/types'
import { acquire, release, getClient, isAuthError, clearToken } from './client'
import type {
LinearTeam,
LinearWorkflowState,
LinearLabel,
LinearMember,
LinearWorkspaceSelection
} from '../../shared/types'
import { acquire, release, getClients, isAuthError, clearToken } from './client'
export async function listTeams(): Promise<LinearTeam[]> {
const client = getClient()
if (!client) {
export async function listTeams(
workspaceId?: LinearWorkspaceSelection | null
): Promise<LinearTeam[]> {
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<LinearWorkflowState[]> {
const client = getClient()
if (!client) {
export async function getTeamStates(
teamId: string,
workspaceId?: string | null
): Promise<LinearWorkflowState[]> {
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<LinearWorkflowState
.sort((a, b) => 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<LinearWorkflowState
}
}
export async function getTeamLabels(teamId: string): Promise<LinearLabel[]> {
const client = getClient()
if (!client) {
export async function getTeamLabels(
teamId: string,
workspaceId?: string | null
): Promise<LinearLabel[]> {
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<LinearLabel[]> {
}
}
export async function getTeamMembers(teamId: string): Promise<LinearMember[]> {
const client = getClient()
if (!client) {
export async function getTeamMembers(
teamId: string,
workspaceId?: string | null
): Promise<LinearMember[]> {
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<LinearMember[]> {
}))
} catch (error) {
if (isAuthError(error)) {
clearToken()
clearToken(entry.workspace.id)
throw error
}
console.warn('[linear] getTeamMembers failed:', error)
+52 -26
View File
@@ -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<typeof getLinearStatus> {
return selectLinearWorkspace(workspaceId)
}
linearStatus(): ReturnType<typeof getLinearStatus> {
return getLinearStatus()
}
linearTestConnection(): ReturnType<typeof testLinearConnection> {
return testLinearConnection()
linearTestConnection(workspaceId?: string): ReturnType<typeof testLinearConnection> {
return testLinearConnection(workspaceId)
}
linearSearchIssues(query: string, limit = 20): ReturnType<typeof searchLinearIssues> {
return searchLinearIssues(query, Math.min(Math.max(1, limit), 50))
linearSearchIssues(
query: string,
limit = 20,
workspaceId?: LinearWorkspaceSelection
): ReturnType<typeof searchLinearIssues> {
return searchLinearIssues(query, Math.min(Math.max(1, limit), 50), workspaceId)
}
linearListIssues(filter?: LinearListFilter, limit = 20): ReturnType<typeof listLinearIssues> {
return listLinearIssues(filter, Math.min(Math.max(1, limit), 50))
linearListIssues(
filter?: LinearListFilter,
limit = 20,
workspaceId?: LinearWorkspaceSelection
): ReturnType<typeof listLinearIssues> {
return listLinearIssues(filter, Math.min(Math.max(1, limit), 50), workspaceId)
}
linearCreateIssue(
teamId: string,
title: string,
description?: string
description?: string,
workspaceId?: string
): ReturnType<typeof createLinearIssue> {
return createLinearIssue(teamId, title, description)
return createLinearIssue(teamId, title, description, workspaceId)
}
linearGetIssue(id: string): ReturnType<typeof getLinearIssue> {
return getLinearIssue(id)
linearGetIssue(id: string, workspaceId?: string): ReturnType<typeof getLinearIssue> {
return getLinearIssue(id, workspaceId)
}
linearUpdateIssue(id: string, updates: LinearIssueUpdate): ReturnType<typeof updateLinearIssue> {
return updateLinearIssue(id, updates)
linearUpdateIssue(
id: string,
updates: LinearIssueUpdate,
workspaceId?: string
): ReturnType<typeof updateLinearIssue> {
return updateLinearIssue(id, updates, workspaceId)
}
linearAddIssueComment(issueId: string, body: string): ReturnType<typeof addLinearIssueComment> {
return addLinearIssueComment(issueId, body)
linearAddIssueComment(
issueId: string,
body: string,
workspaceId?: string
): ReturnType<typeof addLinearIssueComment> {
return addLinearIssueComment(issueId, body, workspaceId)
}
linearIssueComments(issueId: string): ReturnType<typeof getLinearIssueComments> {
return getLinearIssueComments(issueId)
linearIssueComments(
issueId: string,
workspaceId?: string
): ReturnType<typeof getLinearIssueComments> {
return getLinearIssueComments(issueId, workspaceId)
}
linearListTeams(): ReturnType<typeof listLinearTeams> {
return listLinearTeams()
linearListTeams(workspaceId?: LinearWorkspaceSelection): ReturnType<typeof listLinearTeams> {
return listLinearTeams(workspaceId)
}
linearTeamStates(teamId: string): ReturnType<typeof getLinearTeamStates> {
return getLinearTeamStates(teamId)
linearTeamStates(teamId: string, workspaceId?: string): ReturnType<typeof getLinearTeamStates> {
return getLinearTeamStates(teamId, workspaceId)
}
linearTeamLabels(teamId: string): ReturnType<typeof getLinearTeamLabels> {
return getLinearTeamLabels(teamId)
linearTeamLabels(teamId: string, workspaceId?: string): ReturnType<typeof getLinearTeamLabels> {
return getLinearTeamLabels(teamId, workspaceId)
}
linearTeamMembers(teamId: string): ReturnType<typeof getLinearTeamMembers> {
return getLinearTeamMembers(teamId)
linearTeamMembers(teamId: string, workspaceId?: string): ReturnType<typeof getLinearTeamMembers> {
return getLinearTeamMembers(teamId, workspaceId)
}
// ── Browser automation ──
+66 -26
View File
@@ -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')
})
})
+56 -23
View File
@@ -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)
})
]
+23 -9
View File
@@ -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<void>
disconnect: (args?: { workspaceId?: string }) => Promise<void>
selectWorkspace: (args: {
workspaceId: LinearWorkspaceSelection
}) => Promise<LinearConnectionStatus>
status: () => Promise<LinearConnectionStatus>
testConnection: () => Promise<{ ok: true; viewer: LinearViewer } | { ok: false; error: string }>
searchIssues: (args: { query: string; limit?: number }) => Promise<LinearIssue[]>
testConnection: (args?: {
workspaceId?: string
}) => Promise<{ ok: true; viewer: LinearViewer } | { ok: false; error: string }>
searchIssues: (args: {
query: string
limit?: number
workspaceId?: LinearWorkspaceSelection
}) => Promise<LinearIssue[]>
listIssues: (args?: {
filter?: 'assigned' | 'created' | 'all' | 'completed'
limit?: number
workspaceId?: LinearWorkspaceSelection
}) => Promise<LinearIssue[]>
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<LinearIssue | null>
getIssue: (args: { id: string; workspaceId?: string }) => Promise<LinearIssue | null>
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<LinearComment[]>
listTeams: () => Promise<LinearTeam[]>
teamStates: (args: { teamId: string }) => Promise<LinearWorkflowState[]>
teamLabels: (args: { teamId: string }) => Promise<LinearLabel[]>
teamMembers: (args: { teamId: string }) => Promise<LinearMember[]>
issueComments: (args: { issueId: string; workspaceId?: string }) => Promise<LinearComment[]>
listTeams: (args?: { workspaceId?: LinearWorkspaceSelection }) => Promise<LinearTeam[]>
teamStates: (args: { teamId: string; workspaceId?: string }) => Promise<LinearWorkflowState[]>
teamLabels: (args: { teamId: string; workspaceId?: string }) => Promise<LinearLabel[]>
teamMembers: (args: { teamId: string; workspaceId?: string }) => Promise<LinearMember[]>
}
starNag: {
onShow: (callback: () => void) => () => void
+25 -11
View File
@@ -917,56 +917,70 @@ const api = {
}): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> =>
ipcRenderer.invoke('linear:connect', args),
disconnect: (): Promise<void> => ipcRenderer.invoke('linear:disconnect'),
disconnect: (args?: { workspaceId?: string }): Promise<void> =>
ipcRenderer.invoke('linear:disconnect', args),
selectWorkspace: (args: { workspaceId: string }): Promise<unknown> =>
ipcRenderer.invoke('linear:selectWorkspace', args),
status: (): Promise<unknown> => 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<unknown[]> =>
ipcRenderer.invoke('linear:searchIssues', args),
searchIssues: (args: {
query: string
limit?: number
workspaceId?: string
}): Promise<unknown[]> => ipcRenderer.invoke('linear:searchIssues', args),
listIssues: (args?: {
filter?: 'assigned' | 'created' | 'all' | 'completed'
limit?: number
workspaceId?: string
}): Promise<unknown[]> => 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<unknown> =>
getIssue: (args: { id: string; workspaceId?: string }): Promise<unknown> =>
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<unknown[]> =>
issueComments: (args: { issueId: string; workspaceId?: string }): Promise<unknown[]> =>
ipcRenderer.invoke('linear:issueComments', args),
listTeams: (): Promise<unknown[]> => ipcRenderer.invoke('linear:listTeams'),
listTeams: (args?: { workspaceId?: string }): Promise<unknown[]> =>
ipcRenderer.invoke('linear:listTeams', args),
teamStates: (args: { teamId: string }): Promise<unknown[]> =>
teamStates: (args: { teamId: string; workspaceId?: string }): Promise<unknown[]> =>
ipcRenderer.invoke('linear:teamStates', args),
teamLabels: (args: { teamId: string }): Promise<unknown[]> =>
teamLabels: (args: { teamId: string; workspaceId?: string }): Promise<unknown[]> =>
ipcRenderer.invoke('linear:teamLabels', args),
teamMembers: (args: { teamId: string }): Promise<unknown[]> =>
teamMembers: (args: { teamId: string; workspaceId?: string }): Promise<unknown[]> =>
ipcRenderer.invoke('linear:teamMembers', args)
},
@@ -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}
</h2>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground">
{displayed.workspaceName && <span>{displayed.workspaceName}</span>}
{displayed.team?.name && <span>{displayed.team.name}</span>}
<span>· {formatRelativeTime(displayed.updatedAt)}</span>
</div>
@@ -779,7 +802,11 @@ export default function LinearItemDrawer({
</div>
{/* Comment footer + Start workspace */}
<CommentFooter issueId={displayed.id} onCommentAdded={handleCommentAdded} />
<CommentFooter
issueId={displayed.id}
workspaceId={displayed.workspaceId}
onCommentAdded={handleCommentAdded}
/>
<div className="flex-none border-t border-border/60 bg-background/40 px-4 py-3">
<Button
onClick={() => onUse(displayed)}
+117 -41
View File
@@ -86,6 +86,7 @@ import type {
GitLabTodo,
GitLabWorkItem,
LinearIssue,
LinearTeam,
Repo,
TaskViewPresetId
} from '../../../shared/types'
@@ -376,7 +377,7 @@ function LinearStatusCell({ issue }: { issue: LinearIssue }): React.JSX.Element
}, [issue.state])
const teamId = issue.team?.id || null
const states = useTeamStates(teamId, settings)
const states = useTeamStates(teamId, settings, issue.workspaceId)
const handleStateChange = useCallback(
(stateId: string) => {
@@ -391,7 +392,7 @@ function LinearStatusCell({ issue }: { issue: LinearIssue }): React.JSX.Element
setLocalState(stateValue)
patchLinearIssue(issue.id, { state: stateValue })
linearUpdateIssue(settings, issue.id, { stateId })
linearUpdateIssue(settings, issue.id, { stateId }, issue.workspaceId)
.then((result) => {
if (reqId !== reqRef.current) {
return
@@ -402,7 +403,7 @@ function LinearStatusCell({ issue }: { issue: LinearIssue }): React.JSX.Element
patchLinearIssue(issue.id, { state: issue.state })
toast.error(typed.error ?? 'Failed to update status')
} else {
fetchLinearIssue(issue.id)
fetchLinearIssue(issue.id, issue.workspaceId)
}
})
.catch(() => {
@@ -414,7 +415,15 @@ function LinearStatusCell({ issue }: { issue: LinearIssue }): React.JSX.Element
toast.error('Failed to update status')
})
},
[issue.id, issue.state, settings, states.data, patchLinearIssue, fetchLinearIssue]
[
issue.id,
issue.state,
issue.workspaceId,
settings,
states.data,
patchLinearIssue,
fetchLinearIssue
]
)
const currentStateId = states.data.find(
@@ -494,7 +503,7 @@ function LinearPriorityCell({ issue }: { issue: LinearIssue }): React.JSX.Elemen
setLocalPriority(priority)
patchLinearIssue(issue.id, { priority })
setPending(true)
linearUpdateIssue(settings, issue.id, { priority })
linearUpdateIssue(settings, issue.id, { priority }, issue.workspaceId)
.then((result) => {
if (reqId !== reqRef.current) {
return
@@ -505,7 +514,7 @@ function LinearPriorityCell({ issue }: { issue: LinearIssue }): React.JSX.Elemen
patchLinearIssue(issue.id, { priority: issue.priority })
toast.error(typed.error ?? 'Failed to update priority')
} else {
fetchLinearIssue(issue.id)
fetchLinearIssue(issue.id, issue.workspaceId)
}
})
.catch(() => {
@@ -523,7 +532,15 @@ function LinearPriorityCell({ issue }: { issue: LinearIssue }): React.JSX.Elemen
setPending(false)
})
},
[issue.id, issue.priority, localPriority, settings, patchLinearIssue, fetchLinearIssue]
[
issue.id,
issue.priority,
issue.workspaceId,
localPriority,
settings,
patchLinearIssue,
fetchLinearIssue
]
)
const [open, setOpen] = useState(false)
@@ -717,6 +734,7 @@ export default function TaskPage(): React.JSX.Element {
const linearStatus = useAppStore((s) => s.linearStatus)
const linearStatusChecked = useAppStore((s) => s.linearStatusChecked)
const connectLinear = useAppStore((s) => s.connectLinear)
const selectLinearWorkspace = useAppStore((s) => s.selectLinearWorkspace)
const searchLinearIssues = useAppStore((s) => s.searchLinearIssues)
const listLinearIssues = useAppStore((s) => s.listLinearIssues)
const checkLinearConnection = useAppStore((s) => s.checkLinearConnection)
@@ -790,6 +808,12 @@ export default function TaskPage(): React.JSX.Element {
// optimistic stub) need *a* repo. First selected is used as the default;
// cross-repo dialogs still let the user override per-action.
const primaryRepo = selectedRepos[0] ?? null
const linearWorkspaces = linearStatus.workspaces ?? []
const selectedLinearWorkspaceId =
linearStatus.selectedWorkspaceId ??
linearStatus.activeWorkspaceId ??
linearWorkspaces[0]?.id ??
null
// Why: seed the preset + query from the user's saved default synchronously
// so the first fetch effect issues exactly one request keyed to the final
@@ -1088,24 +1112,36 @@ export default function TaskPage(): React.JSX.Element {
// Why: fetch the full team list from the Linear API so the selector shows
// all teams the user belongs to, not just teams with issues in the current
// fetch window. Fetched once when the Linear tab is active and connected.
const [availableTeams, setAvailableTeams] = useState<{ id: string; name: string; key: string }[]>(
[]
)
const [availableTeams, setAvailableTeams] = useState<LinearTeam[]>([])
useEffect(() => {
if (!taskResumeApplied) {
return
}
if (taskSource !== 'linear' || !linearStatus.connected) {
setAvailableTeams([])
return
}
void linearListTeams(settings)
.then(setAvailableTeams)
.catch(() => {
console.warn('[TaskPage] Failed to fetch Linear teams')
let cancelled = false
// Why: workspace switches must not leave the prior workspace's teams
// available for new-issue creation while the replacement fetch is pending.
setAvailableTeams([])
void linearListTeams(settings, selectedLinearWorkspaceId)
.then((teams) => {
if (!cancelled) {
setAvailableTeams(teams)
}
})
.catch(() => {
if (!cancelled) {
console.warn('[TaskPage] Failed to fetch Linear teams')
}
})
return () => {
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings, taskSource, linearStatus.connected, taskResumeApplied])
}, [settings, taskSource, linearStatus.connected, selectedLinearWorkspaceId, taskResumeApplied])
// Why: stable key for `selectedRepos` so the GitLab fetch effect below
// doesn't re-run on every parent re-render just because the array
@@ -1741,7 +1777,8 @@ export default function TaskPage(): React.JSX.Element {
const result = await linearCreateIssue(settings, {
teamId: newLinearIssueTargetTeam.id,
title,
description: newLinearIssueBody || undefined
description: newLinearIssueBody || undefined,
workspaceId: newLinearIssueTargetTeam.workspaceId
})
if (!result.ok) {
toast.error(result.error || 'Failed to create issue.')
@@ -1762,7 +1799,7 @@ export default function TaskPage(): React.JSX.Element {
// Why: auto-open the new issue in the side drawer so the user sees
// exactly what was filed, mirroring the GitHub create-issue flow.
void linearGetIssue(settings, result.id)
void linearGetIssue(settings, result.id, newLinearIssueTargetTeam.workspaceId)
.then((full) => {
if (full) {
setDrawerLinearIssue(full)
@@ -1911,6 +1948,7 @@ export default function TaskPage(): React.JSX.Element {
}, [
taskSource,
linearStatus.connected,
selectedLinearWorkspaceId,
appliedLinearSearch,
activeLinearPreset,
linearRefreshNonce,
@@ -2041,27 +2079,55 @@ export default function TaskPage(): React.JSX.Element {
)
})}
</div>
{taskSource === 'linear' && availableTeams.length > 0 ? (
<div className="w-[200px]">
<TeamMultiCombobox
teams={availableTeams}
selected={linearTeamSelection}
onChange={(next) => {
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 ? (
<div className="flex items-center gap-2">
{linearWorkspaces.length > 1 ? (
<Select
value={selectedLinearWorkspaceId ?? undefined}
onValueChange={(value) => {
void selectLinearWorkspace(value).catch(() => {
toast.error('Failed to switch Linear workspace.')
})
}}
>
<SelectTrigger className="h-8 w-[200px] rounded-md border-border/50 bg-muted/50 text-xs font-medium shadow-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All workspaces</SelectItem>
{linearWorkspaces.map((workspace) => (
<SelectItem key={workspace.id} value={workspace.id}>
{workspace.organizationName}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
{availableTeams.length > 0 ? (
<div className="w-[200px]">
<TeamMultiCombobox
teams={availableTeams}
selected={linearTeamSelection}
onChange={(next) => {
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"
/>
</div>
) : null}
</div>
) : null}
</div>
@@ -3119,6 +3185,9 @@ export default function TaskPage(): React.JSX.Element {
{issue.title}
</h3>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground">
{selectedLinearWorkspaceId === 'all' && issue.workspaceName ? (
<span>{issue.workspaceName}</span>
) : null}
{issue.assignee ? <span>{issue.assignee.displayName}</span> : null}
{issue.labels.slice(0, 3).map((label) => (
<span
@@ -3384,7 +3453,11 @@ export default function TaskPage(): React.JSX.Element {
<DialogDescription>
{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'}.`}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
@@ -3402,6 +3475,9 @@ export default function TaskPage(): React.JSX.Element {
<SelectContent>
{availableTeams.map((t) => (
<SelectItem key={t.id} value={t.id}>
{selectedLinearWorkspaceId === 'all' && t.workspaceName
? `${t.workspaceName} · `
: ''}
{t.key} — {t.name}
</SelectItem>
))}
@@ -3529,10 +3605,10 @@ export default function TaskPage(): React.JSX.Element {
}}
>
<DialogHeader className="gap-3">
<DialogTitle className="leading-tight">Connect Linear</DialogTitle>
<DialogTitle className="leading-tight">Connect Linear workspace</DialogTitle>
<DialogDescription>
Paste a <strong className="font-semibold text-foreground">Personal API key</strong> to
browse your assigned issues.
browse issues from that workspace.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
@@ -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<GhStatus>('checking')
const [glabStatus, setGlabStatus] = useState<GlabStatus>('checking')
@@ -110,10 +112,10 @@ export function IntegrationsPane(): React.JSX.Element {
'idle'
)
const [linearConnectError, setLinearConnectError] = useState<string | null>(null)
const [linearTestState, setLinearTestState] = useState<'idle' | 'testing' | 'ok' | 'error'>(
'idle'
)
const [linearTestError, setLinearTestError] = useState<string | null>(null)
const [linearTestingWorkspaceId, setLinearTestingWorkspaceId] = useState<string | null>(null)
const [linearTestResultByWorkspace, setLinearTestResultByWorkspace] = useState<
Record<string, { state: 'ok' | 'error'; error?: string }>
>({})
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<void> => {
await disconnectLinear()
const handleLinearDisconnect = async (workspaceId?: string): Promise<void> => {
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<void> => {
setLinearTestState('testing')
setLinearTestError(null)
const result = await testLinearConnection()
const handleLinearTest = async (workspaceId: string): Promise<void> => {
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 {
<p className="text-sm font-medium">Linear</p>
<p className="text-xs text-muted-foreground">
{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.'}
</p>
</div>
{linearStatus.connected ? (
<div className="flex shrink-0 items-center gap-1.5">
<button
onClick={handleLinearDisconnect}
aria-label="Disconnect Linear"
className="rounded-md p-1 text-muted-foreground/50 transition-colors hover:text-destructive"
>
<Unlink className="size-3.5" />
</button>
<Button variant="outline" size="sm" onClick={() => setLinearDialogOpen(true)}>
Add workspace
</Button>
<span className="rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2.5 py-1 text-[11px] font-medium text-emerald-700 dark:text-emerald-300">
Connected
</span>
@@ -612,39 +618,64 @@ export function IntegrationsPane(): React.JSX.Element {
</div>
{linearStatus.connected && (
<div className="mt-2.5 flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => void handleLinearTest()}
disabled={linearTestState === 'testing'}
>
{linearTestState === 'testing' ? (
<>
<LoaderCircle className="size-3.5 mr-1.5 animate-spin" />
Testing…
</>
) : (
'Test connection'
)}
</Button>
{linearTestState === 'ok' && (
<span className="flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400">
<CheckCircle2 className="size-3.5" />
Verified
</span>
)}
{linearTestState === 'error' && linearTestError && (
<span className="flex items-center gap-1 text-xs text-destructive">
<AlertCircle className="size-3.5" />
{linearTestError}
</span>
)}
{linearTestState === 'idle' && (
<span className="text-[11px] text-muted-foreground/70">
Verifies your API key against Linear.
</span>
)}
<div className="mt-3 space-y-2">
{linearWorkspaces.map((workspace) => {
const testResult = linearTestResultByWorkspace[workspace.id]
const testing = linearTestingWorkspaceId === workspace.id
return (
<div
key={workspace.id}
className="flex items-center gap-3 rounded-md border border-border/50 bg-background/60 px-3 py-2"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{workspace.organizationName}
</p>
<p className="truncate text-xs text-muted-foreground">
{workspace.displayName}
{workspace.email ? ` · ${workspace.email}` : ''}
</p>
</div>
{testResult?.state === 'ok' ? (
<span className="flex shrink-0 items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400">
<CheckCircle2 className="size-3.5" />
Verified
</span>
) : null}
{testResult?.state === 'error' ? (
<span className="flex min-w-0 max-w-[220px] shrink items-center gap-1 truncate text-xs text-destructive">
<AlertCircle className="size-3.5 shrink-0" />
<span className="truncate">{testResult.error}</span>
</span>
) : null}
<Button
variant="outline"
size="sm"
onClick={() => void handleLinearTest(workspace.id)}
disabled={testing}
>
{testing ? (
<>
<LoaderCircle className="size-3.5 mr-1.5 animate-spin" />
Testing…
</>
) : (
'Test'
)}
</Button>
<button
onClick={() => void handleLinearDisconnect(workspace.id)}
aria-label={`Disconnect ${workspace.organizationName}`}
className="rounded-md p-1 text-muted-foreground/50 transition-colors hover:text-destructive"
>
<Unlink className="size-3.5" />
</button>
</div>
)
})}
<p className="text-[11px] text-muted-foreground/70">
Each workspace uses its own locally stored API key.
</p>
</div>
)}
</div>
@@ -672,10 +703,10 @@ export function IntegrationsPane(): React.JSX.Element {
}}
>
<DialogHeader className="gap-3">
<DialogTitle className="leading-tight">Connect Linear</DialogTitle>
<DialogTitle className="leading-tight">Connect Linear workspace</DialogTitle>
<DialogDescription>
Paste a <strong className="font-semibold text-foreground">Personal API key</strong> to
browse your assigned issues.
add a workspace to Orca.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
+23 -14
View File
@@ -182,10 +182,14 @@ const linearMemberStore = createMetadataRequestStore<LinearMember[]>()
function linearMetadataCacheKey(
teamId: string,
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | 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<GlobalSettings, 'activeRuntimeEnvironmentId'> | null
settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null,
workspaceId?: string | null
): MetadataState<LinearWorkflowState[]> {
const [state, setState] = useState<MetadataState<LinearWorkflowState[]>>({
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<GlobalSettings, 'activeRuntimeEnvironmentId'> | null
settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null,
workspaceId?: string | null
): MetadataState<LinearLabel[]> {
const [state, setState] = useState<MetadataState<LinearLabel[]>>({
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<GlobalSettings, 'activeRuntimeEnvironmentId'> | null
settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null,
workspaceId?: string | null
): MetadataState<LinearMember[]> {
const [state, setState] = useState<MetadataState<LinearMember[]>>({
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
}
@@ -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
})
})
})
+101 -40
View File
@@ -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<LinearConnectResult> {
const target = getActiveRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearConnectResult>(target, 'linear.testConnection', undefined, {
timeoutMs: 30_000
})
: window.api.linear.testConnection()
? callRuntimeRpc<LinearConnectResult>(
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<void> {
return linearDisconnectWorkspace(settings)
}
export async function linearDisconnectWorkspace(
settings: RuntimeLinearSettings,
workspaceId?: string | null
): Promise<void> {
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<LinearConnectionStatus> {
const target = getActiveRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearConnectionStatus>(
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<LinearIssue[]> {
const target = getActiveRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearIssue[]>(
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<LinearIssue[]> {
const target = getActiveRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearIssue[]>(
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<LinearCreateIssueResult> {
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<LinearIssue | null> {
const target = getActiveRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearIssue | null>(target, 'linear.getIssue', { id }, { timeoutMs: 30_000 })
: window.api.linear.getIssue({ id })
? callRuntimeRpc<LinearIssue | null>(
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<LinearMutationResult> {
const target = getActiveRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearMutationResult>(
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<LinearCommentResult> {
const target = getActiveRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearCommentResult>(
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<LinearComment[]> {
const target = getActiveRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearComment[]>(
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<LinearTeam[]> {
export async function linearListTeams(
settings: RuntimeLinearSettings,
workspaceId?: LinearWorkspaceSelection | null
): Promise<LinearTeam[]> {
const target = getActiveRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearTeam[]>(target, 'linear.listTeams', undefined, { timeoutMs: 30_000 })
: window.api.linear.listTeams()
? callRuntimeRpc<LinearTeam[]>(
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<LinearWorkflowState[]> {
const target = getActiveRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearWorkflowState[]>(
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<LinearLabel[]> {
const target = getActiveRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearLabel[]>(target, 'linear.teamLabels', { teamId }, { timeoutMs: 30_000 })
: window.api.linear.teamLabels({ teamId })
? callRuntimeRpc<LinearLabel[]>(
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<LinearMember[]> {
const target = getActiveRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearMember[]>(
target,
'linear.teamMembers',
{ teamId },
{ teamId, workspaceId: workspaceId ?? undefined },
{ timeoutMs: 30_000 }
)
: window.api.linear.teamMembers({ teamId })
: window.api.linear.teamMembers({ teamId, workspaceId: workspaceId ?? undefined })
}
+75 -32
View File
@@ -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<string, Promise<LinearIssue | null>>()
const inflightSearchRequests = new Map<string, Promise<LinearIssue[]>>()
const inflightListRequests = new Map<string, Promise<LinearIssue[]>>()
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<void>
disconnectLinear: () => Promise<void>
fetchLinearIssue: (id: string) => Promise<LinearIssue | null>
disconnectLinearWorkspace: (workspaceId: string) => Promise<void>
fetchLinearIssue: (id: string, workspaceId?: string | null) => Promise<LinearIssue | null>
searchLinearIssues: (query: string, limit?: number) => Promise<LinearIssue[]>
listLinearIssues: (
filter?: 'assigned' | 'created' | 'all' | 'completed',
@@ -78,7 +94,12 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (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<AppState, [], [], LinearSlice> = (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<AppState, [], [], LinearSlice> = (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<AppState, [], [], LinearSlice> = (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<AppState, [], [], LinearSlice> = (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<AppState, [], [], LinearSlice> = (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<AppState, [], [], LinearSlice> = (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<AppState, [], [], LinearSlice> = (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<AppState, [], [], LinearSlice> = (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) => ({
+17
View File
@@ -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
}