mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 08:02:32 +00:00
The oxfmt 0.65 -> 0.70 bump landed without a repo-wide reformat, so 36 files already in the tree no longer matched what the new version emits. Anyone running `pnpm format` picked all of them up alongside their own change. Also excludes `resources/licenses/**`: `oxfmt --write .` was rewriting the vendored PCRE2 licence, turning its `*` redistribution bullets into `-`. Third party licence text has to be reproduced verbatim, so formatting must not touch it.
327 lines
9.7 KiB
TypeScript
327 lines
9.7 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { LinearClientForWorkspace } from './client'
|
|
import { credentialDecryptionMessage } from '../../shared/integration-credential-errors'
|
|
|
|
const getClients = vi.fn()
|
|
const clearToken = vi.fn()
|
|
const isAuthError = vi.fn()
|
|
const acquire = vi.fn().mockResolvedValue(undefined)
|
|
const release = vi.fn()
|
|
|
|
vi.mock('./linear-request-concurrency', () => ({
|
|
acquire,
|
|
release
|
|
}))
|
|
|
|
vi.mock('./linear-token-store', () => ({
|
|
clearToken: (...args: unknown[]) => clearToken(...args)
|
|
}))
|
|
|
|
vi.mock('./client', () => ({
|
|
getClients: (...args: unknown[]) => getClients(...args),
|
|
isAuthError: (...args: unknown[]) => isAuthError(...args)
|
|
}))
|
|
|
|
type TeamNode = {
|
|
id: string
|
|
name: string
|
|
key: string
|
|
}
|
|
|
|
type LabelNode = {
|
|
id: string
|
|
name: string
|
|
color: string
|
|
}
|
|
|
|
type MemberNode = {
|
|
id: string
|
|
displayName: string
|
|
name?: string | null
|
|
email?: string | null
|
|
avatarUrl?: string | null
|
|
}
|
|
|
|
type StateNode = {
|
|
id: string
|
|
name: string
|
|
type: string
|
|
color: string
|
|
position: number
|
|
}
|
|
|
|
function team(id: string, name = id, key = id.toUpperCase()): TeamNode {
|
|
return { id, name, key }
|
|
}
|
|
|
|
function makeLabel(id: string, name = id): LabelNode {
|
|
return { id, name, color: '#ff0000' }
|
|
}
|
|
|
|
function makeMember(id: string, displayName = id): MemberNode {
|
|
return { id, displayName, avatarUrl: null }
|
|
}
|
|
|
|
function makeState(id: string, name = id, position = 0): StateNode {
|
|
return { id, name, type: 'started', color: '#00ff00', position }
|
|
}
|
|
|
|
function makeConnection<TNode>(pages: TNode[][]) {
|
|
const nodes = [...(pages[0] ?? [])]
|
|
let pageIndex = 0
|
|
return {
|
|
nodes,
|
|
pageInfo: { hasNextPage: pages.length > 1 },
|
|
fetchNext: vi.fn().mockImplementation(async function fetchNext(this: {
|
|
nodes: TNode[]
|
|
pageInfo: { hasNextPage: boolean }
|
|
}) {
|
|
pageIndex += 1
|
|
this.nodes.push(...(pages[pageIndex] ?? []))
|
|
this.pageInfo.hasNextPage = pageIndex < pages.length - 1
|
|
return this
|
|
})
|
|
}
|
|
}
|
|
|
|
function makeEntry(
|
|
workspaceId: string,
|
|
organizationName: string,
|
|
organizationUrlKey: string,
|
|
pages: TeamNode[][]
|
|
): LinearClientForWorkspace {
|
|
return {
|
|
workspace: {
|
|
id: workspaceId,
|
|
organizationId: workspaceId,
|
|
organizationName,
|
|
organizationUrlKey,
|
|
displayName: 'Ada',
|
|
email: 'ada@example.com'
|
|
},
|
|
client: {
|
|
teams: vi.fn().mockResolvedValue(makeConnection(pages))
|
|
}
|
|
} as unknown as LinearClientForWorkspace
|
|
}
|
|
|
|
function makeTeamLookupEntry(
|
|
workspaceId: string,
|
|
organizationName: string,
|
|
teamNode: unknown
|
|
): LinearClientForWorkspace {
|
|
return {
|
|
workspace: {
|
|
id: workspaceId,
|
|
organizationId: workspaceId,
|
|
organizationName,
|
|
displayName: 'Ada',
|
|
email: 'ada@example.com'
|
|
},
|
|
client: {
|
|
team: vi.fn().mockResolvedValue(teamNode)
|
|
}
|
|
} as unknown as LinearClientForWorkspace
|
|
}
|
|
|
|
function makeFailingEntry(
|
|
workspaceId: string,
|
|
organizationName: string,
|
|
error: Error
|
|
): LinearClientForWorkspace {
|
|
return {
|
|
workspace: {
|
|
id: workspaceId,
|
|
organizationId: workspaceId,
|
|
organizationName,
|
|
displayName: 'Ada',
|
|
email: 'ada@example.com'
|
|
},
|
|
client: {
|
|
teams: vi.fn().mockRejectedValue(error)
|
|
}
|
|
} as unknown as LinearClientForWorkspace
|
|
}
|
|
|
|
describe('Linear teams', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
isAuthError.mockReturnValue(false)
|
|
})
|
|
|
|
it('fetches every page of teams for a workspace', async () => {
|
|
const entry = makeEntry('workspace-1', 'Workspace', 'acme', [
|
|
[team('team-1', 'Backend', 'BE')],
|
|
[team('team-2', 'Frontend', 'FE')],
|
|
[team('team-3', 'Support', 'SUP')]
|
|
])
|
|
getClients.mockReturnValue([entry])
|
|
const { listTeams } = await import('./teams')
|
|
|
|
await expect(listTeams('workspace-1')).resolves.toMatchObject([
|
|
{ id: 'team-1', url: 'https://linear.app/acme/team/BE/all' },
|
|
{ id: 'team-2', url: 'https://linear.app/acme/team/FE/all' },
|
|
{ id: 'team-3', url: 'https://linear.app/acme/team/SUP/all' }
|
|
])
|
|
|
|
expect(entry.client.teams).toHaveBeenCalledWith({ first: 100 })
|
|
})
|
|
|
|
it('aggregates teams across all selected workspaces', async () => {
|
|
getClients.mockReturnValue([
|
|
makeEntry('workspace-1', 'Alpha', 'alpha', [[team('team-a', 'Alpha Team', 'ALP')]]),
|
|
makeEntry('workspace-2', 'Beta', 'beta', [[team('team-b', 'Beta Team', 'BET')]])
|
|
])
|
|
const { listTeams } = await import('./teams')
|
|
|
|
await expect(listTeams('all')).resolves.toMatchObject([
|
|
{ id: 'team-a', workspaceId: 'workspace-1', workspaceName: 'Alpha' },
|
|
{ id: 'team-b', workspaceId: 'workspace-2', workspaceName: 'Beta' }
|
|
])
|
|
})
|
|
|
|
it('keeps partial workspace errors for agent team lists', async () => {
|
|
getClients.mockReturnValue([
|
|
makeEntry('workspace-1', 'Alpha', 'alpha', [[team('team-a', 'Alpha Team', 'ALP')]]),
|
|
makeFailingEntry('workspace-2', 'Beta', new Error('fetch failed'))
|
|
])
|
|
const { listTeamsForAgent } = await import('./teams')
|
|
|
|
await expect(listTeamsForAgent('all')).resolves.toMatchObject({
|
|
teams: [{ id: 'team-a', workspaceId: 'workspace-1', workspaceName: 'Alpha' }],
|
|
errors: [
|
|
{
|
|
workspaceId: 'workspace-2',
|
|
workspaceName: 'Beta',
|
|
type: 'unknown',
|
|
message: 'fetch failed'
|
|
}
|
|
]
|
|
})
|
|
})
|
|
|
|
it('fetches every page of team labels', async () => {
|
|
const labels = vi
|
|
.fn()
|
|
.mockResolvedValue(
|
|
makeConnection([
|
|
[makeLabel('label-1', 'Bug')],
|
|
[makeLabel('label-2', 'Feature')],
|
|
[makeLabel('label-3', 'Docs')]
|
|
])
|
|
)
|
|
const entry = makeTeamLookupEntry('workspace-1', 'Workspace', { labels })
|
|
getClients.mockReturnValue([entry])
|
|
const { getTeamLabelsOrThrow } = await import('./teams')
|
|
|
|
await expect(getTeamLabelsOrThrow('team-1', 'workspace-1')).resolves.toEqual([
|
|
{ id: 'label-1', name: 'Bug', color: '#ff0000' },
|
|
{ id: 'label-2', name: 'Feature', color: '#ff0000' },
|
|
{ id: 'label-3', name: 'Docs', color: '#ff0000' }
|
|
])
|
|
|
|
expect(entry.client.team).toHaveBeenCalledWith('team-1')
|
|
expect(labels).toHaveBeenCalledWith({ first: 100 })
|
|
})
|
|
|
|
it('fetches every page of team states', async () => {
|
|
const states = vi
|
|
.fn()
|
|
.mockResolvedValue(
|
|
makeConnection([
|
|
[makeState('state-2', 'Doing', 2)],
|
|
[makeState('state-1', 'Todo', 1)],
|
|
[makeState('state-3', 'Review', 3)]
|
|
])
|
|
)
|
|
const entry = makeTeamLookupEntry('workspace-1', 'Workspace', { states })
|
|
getClients.mockReturnValue([entry])
|
|
const { getTeamStatesOrThrow } = await import('./teams')
|
|
|
|
await expect(getTeamStatesOrThrow('team-1', 'workspace-1')).resolves.toEqual([
|
|
{ id: 'state-1', name: 'Todo', type: 'started', color: '#00ff00', position: 1 },
|
|
{ id: 'state-2', name: 'Doing', type: 'started', color: '#00ff00', position: 2 },
|
|
{ id: 'state-3', name: 'Review', type: 'started', color: '#00ff00', position: 3 }
|
|
])
|
|
|
|
expect(entry.client.team).toHaveBeenCalledWith('team-1')
|
|
expect(states).toHaveBeenCalledWith({ first: 100 })
|
|
})
|
|
|
|
it('fetches every page of team members', async () => {
|
|
const members = vi
|
|
.fn()
|
|
.mockResolvedValue(
|
|
makeConnection([
|
|
[{ ...makeMember('user-1', 'Ada'), name: 'Ada Lovelace', email: 'ada@example.com' }],
|
|
[makeMember('user-2', 'Grace')],
|
|
[makeMember('user-3', 'Linus')]
|
|
])
|
|
)
|
|
const entry = makeTeamLookupEntry('workspace-1', 'Workspace', { members })
|
|
getClients.mockReturnValue([entry])
|
|
const { getTeamMembersOrThrow } = await import('./teams')
|
|
|
|
await expect(getTeamMembersOrThrow('team-1', 'workspace-1')).resolves.toEqual([
|
|
{
|
|
id: 'user-1',
|
|
displayName: 'Ada',
|
|
name: 'Ada Lovelace',
|
|
email: 'ada@example.com',
|
|
avatarUrl: undefined
|
|
},
|
|
{
|
|
id: 'user-2',
|
|
displayName: 'Grace',
|
|
name: undefined,
|
|
email: undefined,
|
|
avatarUrl: undefined
|
|
},
|
|
{
|
|
id: 'user-3',
|
|
displayName: 'Linus',
|
|
name: undefined,
|
|
email: undefined,
|
|
avatarUrl: undefined
|
|
}
|
|
])
|
|
|
|
expect(entry.client.team).toHaveBeenCalledWith('team-1')
|
|
expect(members).toHaveBeenCalledWith({ first: 100 })
|
|
})
|
|
|
|
it.each([
|
|
['getTeamStates', 'getTeamStatesOrThrow'],
|
|
['getTeamLabels', 'getTeamLabelsOrThrow'],
|
|
['getTeamMembers', 'getTeamMembersOrThrow']
|
|
] as const)('preserves the %s error policy', async (fallbackReader, throwingReader) => {
|
|
const error = new Error('request failed')
|
|
const entry = makeTeamLookupEntry('workspace-1', 'Workspace', null)
|
|
vi.mocked(entry.client.team).mockRejectedValue(error)
|
|
getClients.mockReturnValue([entry])
|
|
const readers = await import('./teams')
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
|
|
|
await expect(readers[fallbackReader]('team-1', 'workspace-1')).resolves.toEqual([])
|
|
await expect(readers[throwingReader]('team-1', 'workspace-1')).rejects.toBe(error)
|
|
expect(warn).toHaveBeenCalledWith(`[linear] ${fallbackReader} failed:`, error)
|
|
expect(release).toHaveBeenCalledTimes(2)
|
|
|
|
isAuthError.mockReturnValue(true)
|
|
await expect(readers[fallbackReader]('team-1', 'workspace-1')).rejects.toBe(error)
|
|
expect(clearToken).toHaveBeenCalledWith('workspace-1')
|
|
expect(release).toHaveBeenCalledTimes(3)
|
|
warn.mockRestore()
|
|
})
|
|
|
|
it('surfaces Linear credential decrypt errors on active team reads', async () => {
|
|
const error = new Error(credentialDecryptionMessage('Linear'))
|
|
getClients.mockImplementation(() => {
|
|
throw error
|
|
})
|
|
const { listTeams } = await import('./teams')
|
|
|
|
await expect(listTeams('workspace-1')).rejects.toThrow(error.message)
|
|
})
|
|
})
|