mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 08:02:38 +00:00
Support multiple Linear workspaces (#1917)
This commit is contained in:
+97
-48
@@ -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))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
]
|
||||
|
||||
@@ -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
@@ -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)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user