mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 16:02:37 +00:00
Add read-only orca linear CLI with trusted launch-prompt pointer (V1) (#5126)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { LinearClientForWorkspace } from './client'
|
||||
|
||||
const getClients = vi.fn()
|
||||
const getStatus = vi.fn()
|
||||
const isAuthError = vi.fn()
|
||||
const clearToken = vi.fn()
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
acquire: vi.fn().mockResolvedValue(undefined),
|
||||
release: vi.fn(),
|
||||
getClients: (...args: unknown[]) => getClients(...args),
|
||||
getStatus: (...args: unknown[]) => getStatus(...args),
|
||||
isAuthError: (...args: unknown[]) => isAuthError(...args),
|
||||
clearToken: (...args: unknown[]) => clearToken(...args)
|
||||
}))
|
||||
|
||||
function makeEntry(options: {
|
||||
workspaceId: string
|
||||
organizationName: string
|
||||
rawRequest: ReturnType<typeof vi.fn>
|
||||
}): LinearClientForWorkspace {
|
||||
return {
|
||||
workspace: {
|
||||
id: options.workspaceId,
|
||||
organizationId: options.workspaceId,
|
||||
organizationName: options.organizationName,
|
||||
displayName: 'Brennan',
|
||||
email: 'brennan@example.com'
|
||||
},
|
||||
client: {
|
||||
client: { rawRequest: options.rawRequest }
|
||||
}
|
||||
} as unknown as LinearClientForWorkspace
|
||||
}
|
||||
|
||||
function rawIssue(identifier: string) {
|
||||
return {
|
||||
id: `${identifier}-id`,
|
||||
identifier,
|
||||
title: `Title ${identifier}`,
|
||||
url: `https://linear.app/acme/issue/${identifier}`,
|
||||
labels: { nodes: [] }
|
||||
}
|
||||
}
|
||||
|
||||
describe('Linear agent issue context client', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
getStatus.mockReturnValue({ workspaces: [] })
|
||||
isAuthError.mockReturnValue(false)
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('keeps implicit multi-workspace issue reads working when an unrelated workspace fails', async () => {
|
||||
const failingRequest = vi.fn().mockRejectedValue(new Error('fetch failed'))
|
||||
const workingRequest = vi.fn().mockResolvedValue({ data: { issue: rawIssue('ENG-123') } })
|
||||
getClients.mockReturnValue([
|
||||
makeEntry({
|
||||
workspaceId: 'workspace-stale',
|
||||
organizationName: 'Stale',
|
||||
rawRequest: failingRequest
|
||||
}),
|
||||
makeEntry({
|
||||
workspaceId: 'workspace-good',
|
||||
organizationName: 'Good',
|
||||
rawRequest: workingRequest
|
||||
})
|
||||
])
|
||||
const { resolveIssue } = await import('./issue-context-client')
|
||||
|
||||
await expect(resolveIssue('ENG-123', {})).resolves.toMatchObject({
|
||||
issue: { identifier: 'ENG-123' },
|
||||
workspace: { id: 'workspace-good' }
|
||||
})
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
'[linear] agent issue read failed:',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps implicit multi-workspace search working when an unrelated workspace fails', async () => {
|
||||
const failingRequest = vi.fn().mockRejectedValue(new Error('fetch failed'))
|
||||
const workingRequest = vi.fn().mockResolvedValue({
|
||||
data: { searchIssues: { nodes: [rawIssue('ENG-123')] } }
|
||||
})
|
||||
getClients.mockReturnValue([
|
||||
makeEntry({
|
||||
workspaceId: 'workspace-stale',
|
||||
organizationName: 'Stale',
|
||||
rawRequest: failingRequest
|
||||
}),
|
||||
makeEntry({
|
||||
workspaceId: 'workspace-good',
|
||||
organizationName: 'Good',
|
||||
rawRequest: workingRequest
|
||||
})
|
||||
])
|
||||
const { searchLinearIssuesForAgents } = await import('./issue-context-client')
|
||||
|
||||
await expect(
|
||||
searchLinearIssuesForAgents({ query: 'auth', workspaceId: 'all' })
|
||||
).resolves.toMatchObject({
|
||||
issues: [{ identifier: 'ENG-123', workspace: { id: 'workspace-good' } }],
|
||||
meta: {
|
||||
returned: 1,
|
||||
partial: true,
|
||||
workspaceErrors: [
|
||||
{
|
||||
workspace: { id: 'workspace-stale', name: 'Stale' },
|
||||
code: 'linear_network_error'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps all-workspace search working when one saved credential cannot load', async () => {
|
||||
const workingRequest = vi.fn().mockResolvedValue({
|
||||
data: { searchIssues: { nodes: [rawIssue('ENG-123')] } }
|
||||
})
|
||||
getStatus.mockReturnValue({
|
||||
workspaces: [
|
||||
{
|
||||
id: 'workspace-stale',
|
||||
organizationId: 'workspace-stale',
|
||||
organizationName: 'Stale',
|
||||
displayName: 'Brennan',
|
||||
email: 'brennan@example.com'
|
||||
},
|
||||
{
|
||||
id: 'workspace-good',
|
||||
organizationId: 'workspace-good',
|
||||
organizationName: 'Good',
|
||||
displayName: 'Brennan',
|
||||
email: 'brennan@example.com'
|
||||
}
|
||||
]
|
||||
})
|
||||
getClients.mockImplementation((workspaceId: string) => {
|
||||
if (workspaceId === 'workspace-stale') {
|
||||
throw new Error('Could not decrypt Linear credential')
|
||||
}
|
||||
return [
|
||||
makeEntry({
|
||||
workspaceId: 'workspace-good',
|
||||
organizationName: 'Good',
|
||||
rawRequest: workingRequest
|
||||
})
|
||||
]
|
||||
})
|
||||
const { searchLinearIssuesForAgents } = await import('./issue-context-client')
|
||||
|
||||
await expect(
|
||||
searchLinearIssuesForAgents({ query: 'auth', workspaceId: 'all' })
|
||||
).resolves.toMatchObject({
|
||||
issues: [{ identifier: 'ENG-123', workspace: { id: 'workspace-good' } }],
|
||||
meta: {
|
||||
returned: 1,
|
||||
partial: true,
|
||||
workspaceErrors: [
|
||||
{
|
||||
workspace: { id: 'workspace-stale', name: 'Stale' },
|
||||
message: 'Could not decrypt Linear credential'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('does not report not-found when every successful workspace missed but another failed', async () => {
|
||||
const failingRequest = vi.fn().mockRejectedValue(new Error('fetch failed'))
|
||||
const missingRequest = vi.fn().mockResolvedValue({ data: { issue: null } })
|
||||
getClients.mockReturnValue([
|
||||
makeEntry({
|
||||
workspaceId: 'workspace-stale',
|
||||
organizationName: 'Stale',
|
||||
rawRequest: failingRequest
|
||||
}),
|
||||
makeEntry({
|
||||
workspaceId: 'workspace-empty',
|
||||
organizationName: 'Empty',
|
||||
rawRequest: missingRequest
|
||||
})
|
||||
])
|
||||
const { resolveIssue } = await import('./issue-context-client')
|
||||
|
||||
await expect(resolveIssue('ENG-123', {})).rejects.toMatchObject({
|
||||
code: 'linear_network_error'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves hard errors for explicitly selected workspaces', async () => {
|
||||
const failingRequest = vi.fn().mockRejectedValue(new Error('fetch failed'))
|
||||
getStatus.mockReturnValue({
|
||||
workspaces: [
|
||||
{
|
||||
id: 'workspace-selected',
|
||||
organizationId: 'workspace-selected',
|
||||
organizationName: 'Selected',
|
||||
displayName: 'Brennan',
|
||||
email: 'brennan@example.com'
|
||||
}
|
||||
]
|
||||
})
|
||||
getClients.mockReturnValue([
|
||||
makeEntry({
|
||||
workspaceId: 'workspace-selected',
|
||||
organizationName: 'Selected',
|
||||
rawRequest: failingRequest
|
||||
})
|
||||
])
|
||||
const { resolveIssue } = await import('./issue-context-client')
|
||||
|
||||
await expect(
|
||||
resolveIssue('ENG-123', { workspaceId: 'workspace-selected' })
|
||||
).rejects.toMatchObject({ code: 'linear_network_error' })
|
||||
})
|
||||
|
||||
it('normalizes explicit issue workspace credential-load failures', async () => {
|
||||
getStatus.mockReturnValue({
|
||||
workspaces: [
|
||||
{
|
||||
id: 'workspace-selected',
|
||||
organizationId: 'workspace-selected',
|
||||
organizationName: 'Selected',
|
||||
displayName: 'Brennan',
|
||||
email: 'brennan@example.com'
|
||||
}
|
||||
]
|
||||
})
|
||||
getClients.mockImplementation((workspaceId: string) => {
|
||||
if (workspaceId === 'workspace-selected') {
|
||||
throw new Error('Could not decrypt Linear credential')
|
||||
}
|
||||
return []
|
||||
})
|
||||
const { resolveIssue } = await import('./issue-context-client')
|
||||
|
||||
await expect(
|
||||
resolveIssue('ENG-123', { workspaceId: 'workspace-selected' })
|
||||
).rejects.toMatchObject({
|
||||
code: 'linear_network_error',
|
||||
message: 'Could not decrypt Linear credential'
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes explicit search workspace credential-load failures', async () => {
|
||||
getStatus.mockReturnValue({
|
||||
workspaces: [
|
||||
{
|
||||
id: 'workspace-selected',
|
||||
organizationId: 'workspace-selected',
|
||||
organizationName: 'Selected',
|
||||
displayName: 'Brennan',
|
||||
email: 'brennan@example.com'
|
||||
}
|
||||
]
|
||||
})
|
||||
getClients.mockImplementation((workspaceId: string) => {
|
||||
if (workspaceId === 'workspace-selected') {
|
||||
throw new Error('Could not decrypt Linear credential')
|
||||
}
|
||||
return []
|
||||
})
|
||||
const { searchLinearIssuesForAgents } = await import('./issue-context-client')
|
||||
|
||||
await expect(
|
||||
searchLinearIssuesForAgents({ query: 'auth', workspaceId: 'workspace-selected' })
|
||||
).rejects.toMatchObject({
|
||||
code: 'linear_network_error',
|
||||
message: 'Could not decrypt Linear credential'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports an invalid workspace for explicit search workspace typos', async () => {
|
||||
getStatus.mockReturnValue({
|
||||
workspaces: [
|
||||
{
|
||||
id: 'workspace-selected',
|
||||
organizationId: 'workspace-selected',
|
||||
organizationName: 'Selected',
|
||||
displayName: 'Brennan',
|
||||
email: 'brennan@example.com'
|
||||
}
|
||||
]
|
||||
})
|
||||
const { searchLinearIssuesForAgents } = await import('./issue-context-client')
|
||||
|
||||
await expect(
|
||||
searchLinearIssuesForAgents({ query: 'auth', workspaceId: 'workspace-typo' })
|
||||
).rejects.toMatchObject({
|
||||
code: 'linear_invalid_workspace'
|
||||
})
|
||||
expect(getClients).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports invalid explicit search workspace typos when clients still exist', async () => {
|
||||
const workingRequest = vi.fn()
|
||||
getStatus.mockReturnValue({ connected: false, workspaces: [] })
|
||||
getClients.mockImplementation((workspaceId: string) => {
|
||||
if (workspaceId === 'all') {
|
||||
return [
|
||||
makeEntry({
|
||||
workspaceId: 'workspace-good',
|
||||
organizationName: 'Good',
|
||||
rawRequest: workingRequest
|
||||
})
|
||||
]
|
||||
}
|
||||
return []
|
||||
})
|
||||
const { searchLinearIssuesForAgents } = await import('./issue-context-client')
|
||||
|
||||
await expect(
|
||||
searchLinearIssuesForAgents({ query: 'auth', workspaceId: 'workspace-typo' })
|
||||
).rejects.toMatchObject({
|
||||
code: 'linear_invalid_workspace'
|
||||
})
|
||||
expect(workingRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not fan out explicit issue workspace typos when clients still exist', async () => {
|
||||
const workingRequest = vi.fn().mockResolvedValue({ data: { issue: rawIssue('ENG-123') } })
|
||||
getStatus.mockReturnValue({ connected: false, workspaces: [] })
|
||||
getClients.mockImplementation((workspaceId: string) => {
|
||||
if (workspaceId === 'all') {
|
||||
return [
|
||||
makeEntry({
|
||||
workspaceId: 'workspace-good',
|
||||
organizationName: 'Good',
|
||||
rawRequest: workingRequest
|
||||
})
|
||||
]
|
||||
}
|
||||
return []
|
||||
})
|
||||
const { resolveIssue } = await import('./issue-context-client')
|
||||
|
||||
await expect(resolveIssue('ENG-123', { workspaceId: 'workspace-typo' })).rejects.toMatchObject({
|
||||
code: 'linear_invalid_workspace'
|
||||
})
|
||||
expect(workingRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,302 @@
|
||||
import type { LinearSearchIssueSummary, LinearSearchResult } from '../../shared/linear-agent-access'
|
||||
import { clampLinearSearchLimit } from '../../shared/linear-agent-access'
|
||||
import type { LinearWorkspace } from '../../shared/types'
|
||||
import {
|
||||
acquire,
|
||||
clearToken,
|
||||
getClients,
|
||||
getStatus,
|
||||
isAuthError,
|
||||
release,
|
||||
type LinearClientForWorkspace
|
||||
} from './client'
|
||||
import {
|
||||
ISSUE_QUERY,
|
||||
SEARCH_QUERY,
|
||||
mapIssue,
|
||||
pickSearchIssue,
|
||||
type RawIssueResponse
|
||||
} from './issue-context-raw'
|
||||
import {
|
||||
LinearAgentAccessError,
|
||||
classifyLinearError,
|
||||
linearError,
|
||||
linearMessage
|
||||
} from './issue-context-errors'
|
||||
import {
|
||||
getFanoutClientEntries,
|
||||
workspaceFailure,
|
||||
type WorkspaceReadFailure
|
||||
} from './issue-context-fanout'
|
||||
import {
|
||||
ambiguousWorkspace,
|
||||
resolveWorkspaceSelector,
|
||||
unknownWorkspace
|
||||
} from './issue-context-workspaces'
|
||||
|
||||
export type ResolvedIssue = {
|
||||
issue: ReturnType<typeof mapIssue>
|
||||
workspace: LinearWorkspace
|
||||
}
|
||||
|
||||
export async function searchLinearIssuesForAgents(args: {
|
||||
query: string
|
||||
limit?: number
|
||||
workspaceId?: string | 'all'
|
||||
}): Promise<LinearSearchResult> {
|
||||
const limit = clampLinearSearchLimit(args.limit)
|
||||
const workspaceId = resolveSearchWorkspaceId(args.workspaceId)
|
||||
const { entries, failures: entryFailures } =
|
||||
workspaceId === 'all' ? getFanoutClientEntries() : getExplicitClientEntries(workspaceId)
|
||||
if (entries.length === 0) {
|
||||
throwIfExplicitWorkspaceHasConnectedAlternatives(workspaceId)
|
||||
if (entryFailures[0]) {
|
||||
throw entryFailures[0].error
|
||||
}
|
||||
throw linearError('linear_not_connected', 'Linear is not connected.', {
|
||||
nextSteps: ['Connect Linear from Orca settings, then retry the search.']
|
||||
})
|
||||
}
|
||||
|
||||
const perWorkspace = await readSearchWorkspaces(
|
||||
entries,
|
||||
args.query,
|
||||
limit + 1,
|
||||
workspaceId,
|
||||
entryFailures
|
||||
)
|
||||
const merged = perWorkspace.results
|
||||
.flat()
|
||||
.sort((left, right) => Date.parse(right.updatedAt ?? '') - Date.parse(left.updatedAt ?? ''))
|
||||
const limited = merged.slice(0, limit)
|
||||
return {
|
||||
issues: limited,
|
||||
meta: {
|
||||
query: args.query,
|
||||
workspaceId,
|
||||
limit,
|
||||
returned: limited.length,
|
||||
limitReached: merged.length > limit,
|
||||
partial: perWorkspace.failures.length > 0,
|
||||
workspaceErrors: perWorkspace.failures.map(({ workspace, code, message }) => ({
|
||||
workspace,
|
||||
code,
|
||||
message
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveIssue(
|
||||
identifier: string,
|
||||
selectors: { workspaceId?: string | null; organizationUrlKey?: string | null }
|
||||
): Promise<ResolvedIssue> {
|
||||
const workspace = resolveWorkspaceSelector(selectors, getConnectedWorkspaces())
|
||||
const selection = workspace?.id ?? selectors.workspaceId ?? 'all'
|
||||
const { entries, failures: entryFailures } =
|
||||
selection === 'all' ? getFanoutClientEntries() : getExplicitClientEntries(selection)
|
||||
if (entries.length === 0) {
|
||||
throwIfExplicitWorkspaceHasConnectedAlternatives(selection)
|
||||
if (entryFailures[0]) {
|
||||
throw entryFailures[0].error
|
||||
}
|
||||
throw linearError('linear_not_connected', 'Linear is not connected.', {
|
||||
nextSteps: ['Connect Linear from Orca settings, then retry the issue read.']
|
||||
})
|
||||
}
|
||||
|
||||
const results = await readIssueWorkspaces(entries, identifier, selection, entryFailures)
|
||||
|
||||
if (results.length === 0) {
|
||||
throw linearError('linear_issue_not_found', `Linear issue ${identifier} was not found.`)
|
||||
}
|
||||
if (results.length > 1) {
|
||||
throw ambiguousWorkspace(
|
||||
results.map((result) => result.workspace),
|
||||
identifier
|
||||
)
|
||||
}
|
||||
return results[0]
|
||||
}
|
||||
|
||||
export const getConnectedWorkspaces = (): LinearWorkspace[] => getStatus().workspaces ?? []
|
||||
|
||||
export function getRequiredEntry(workspaceId: string): LinearClientForWorkspace {
|
||||
const entry = getClients(workspaceId)[0]
|
||||
if (!entry) {
|
||||
throw linearError('linear_not_connected', 'Linear is not connected.')
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
function getExplicitClientEntries(workspaceId?: string): {
|
||||
entries: LinearClientForWorkspace[]
|
||||
failures: WorkspaceReadFailure[]
|
||||
} {
|
||||
try {
|
||||
return { entries: getClients(workspaceId), failures: [] }
|
||||
} catch (error) {
|
||||
if (error instanceof LinearAgentAccessError) {
|
||||
throw error
|
||||
}
|
||||
throw linearError(classifyLinearError(error), linearMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSearchWorkspaceId(workspaceId?: string | 'all'): string | 'all' | undefined {
|
||||
if (!workspaceId || workspaceId === 'all') {
|
||||
return workspaceId
|
||||
}
|
||||
return resolveWorkspaceSelector({ workspaceId }, getConnectedWorkspaces())?.id ?? workspaceId
|
||||
}
|
||||
|
||||
function throwIfExplicitWorkspaceHasConnectedAlternatives(workspaceId?: string | 'all'): void {
|
||||
if (!workspaceId || workspaceId === 'all') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (getClients('all').length > 0) {
|
||||
throw unknownWorkspace(workspaceId)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof LinearAgentAccessError && error.code === 'linear_invalid_workspace') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function withLinearRead<T>(
|
||||
entry: LinearClientForWorkspace,
|
||||
read: () => Promise<T>,
|
||||
selection?: string | 'all'
|
||||
): Promise<T> {
|
||||
void selection
|
||||
await acquire()
|
||||
try {
|
||||
return await read()
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
clearToken(entry.workspace.id)
|
||||
throw linearError('linear_auth_expired', 'Linear authentication expired.', {
|
||||
nextSteps: ['Reconnect Linear from Orca settings.']
|
||||
})
|
||||
}
|
||||
throw linearError(classifyLinearError(error), linearMessage(error))
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
async function readIssueWorkspace(
|
||||
entry: LinearClientForWorkspace,
|
||||
identifier: string
|
||||
): Promise<ResolvedIssue | null> {
|
||||
const response = await withLinearRead(entry, async () => {
|
||||
const raw = await entry.client.client.rawRequest<RawIssueResponse, Record<string, unknown>>(
|
||||
ISSUE_QUERY,
|
||||
{ id: identifier }
|
||||
)
|
||||
return raw.data?.issue ?? null
|
||||
})
|
||||
return response ? { issue: mapIssue(response), workspace: entry.workspace } : null
|
||||
}
|
||||
|
||||
async function readIssueWorkspaces(
|
||||
entries: LinearClientForWorkspace[],
|
||||
identifier: string,
|
||||
selection: string | 'all',
|
||||
initialFailures: WorkspaceReadFailure[] = []
|
||||
): Promise<ResolvedIssue[]> {
|
||||
if (selection !== 'all') {
|
||||
const selected = await readIssueWorkspace(entries[0], identifier)
|
||||
return selected ? [selected] : []
|
||||
}
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
entries.map((entry) => readIssueWorkspace(entry, identifier))
|
||||
)
|
||||
const results: ResolvedIssue[] = []
|
||||
const failures: LinearAgentAccessError[] = initialFailures.map((failure) => failure.error)
|
||||
|
||||
for (const result of settled) {
|
||||
if (result.status === 'fulfilled') {
|
||||
if (result.value) {
|
||||
results.push(result.value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (result.reason instanceof LinearAgentAccessError) {
|
||||
failures.push(result.reason)
|
||||
}
|
||||
console.warn('[linear] agent issue read failed:', result.reason)
|
||||
}
|
||||
|
||||
if (results.length === 0 && failures[0]) {
|
||||
throw failures[0]
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
async function readSearchWorkspace(
|
||||
entry: LinearClientForWorkspace,
|
||||
query: string,
|
||||
limit: number,
|
||||
workspaceId?: string | 'all'
|
||||
): Promise<LinearSearchIssueSummary[]> {
|
||||
const response = await withLinearRead(
|
||||
entry,
|
||||
async () => {
|
||||
const raw = await entry.client.client.rawRequest<RawIssueResponse, Record<string, unknown>>(
|
||||
SEARCH_QUERY,
|
||||
{ term: query, first: limit }
|
||||
)
|
||||
return raw.data?.searchIssues?.nodes ?? []
|
||||
},
|
||||
workspaceId
|
||||
)
|
||||
return response.map((issue) => ({
|
||||
...pickSearchIssue(mapIssue(issue)),
|
||||
workspace: {
|
||||
id: entry.workspace.id,
|
||||
name: entry.workspace.organizationName
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
async function readSearchWorkspaces(
|
||||
entries: LinearClientForWorkspace[],
|
||||
query: string,
|
||||
limit: number,
|
||||
workspaceId?: string | 'all',
|
||||
initialFailures: WorkspaceReadFailure[] = []
|
||||
): Promise<{ results: LinearSearchIssueSummary[][]; failures: WorkspaceReadFailure[] }> {
|
||||
if (workspaceId && workspaceId !== 'all') {
|
||||
return {
|
||||
results: [await readSearchWorkspace(entries[0], query, limit, workspaceId)],
|
||||
failures: []
|
||||
}
|
||||
}
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
entries.map(async (entry) => readSearchWorkspace(entry, query, limit, workspaceId))
|
||||
)
|
||||
const attemptedWorkspaceCount = entries.length + initialFailures.length
|
||||
const results: LinearSearchIssueSummary[][] = []
|
||||
const failures: WorkspaceReadFailure[] = [...initialFailures]
|
||||
for (let index = 0; index < settled.length; index += 1) {
|
||||
const result = settled[index]
|
||||
if (result.status === 'fulfilled') {
|
||||
results.push(result.value)
|
||||
continue
|
||||
}
|
||||
if (result.reason instanceof LinearAgentAccessError) {
|
||||
failures.push(workspaceFailure(entries[index].workspace, result.reason))
|
||||
}
|
||||
console.warn('[linear] agent search failed:', result.reason)
|
||||
}
|
||||
|
||||
if (results.length === 0 && failures.length === attemptedWorkspaceCount && failures[0]) {
|
||||
throw failures[0].error
|
||||
}
|
||||
return { results, failures }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { parseLinearIssueInput } from '../../shared/linear-links'
|
||||
import { getConnectedWorkspaces } from './issue-context-client'
|
||||
import { linearError } from './issue-context-errors'
|
||||
|
||||
export type CurrentIssueLink = {
|
||||
identifier: string
|
||||
workspaceId?: string | null
|
||||
organizationUrlKey?: string | null
|
||||
worktreeId?: string
|
||||
worktreePath?: string
|
||||
backfill?: {
|
||||
workspaceId?: string | null
|
||||
organizationUrlKey?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export function getLinearCurrentIssueFromWorktree(worktree: {
|
||||
id: string
|
||||
path: string
|
||||
linkedLinearIssue?: string | null
|
||||
linkedLinearIssueWorkspaceId?: string | null
|
||||
linkedLinearIssueOrganizationUrlKey?: string | null
|
||||
}): CurrentIssueLink {
|
||||
const linked = worktree.linkedLinearIssue?.trim()
|
||||
if (!linked) {
|
||||
throw linearError('linear_no_linked_issue', 'The current worktree is not linked to Linear.', {
|
||||
nextSteps: ['Open a Linear-linked worktree or pass an explicit issue id.']
|
||||
})
|
||||
}
|
||||
const parsed = parseLinearIssueInput(linked)
|
||||
return {
|
||||
identifier: parsed?.identifier ?? linked.toUpperCase(),
|
||||
workspaceId: worktree.linkedLinearIssueWorkspaceId,
|
||||
organizationUrlKey:
|
||||
worktree.linkedLinearIssueOrganizationUrlKey ?? parsed?.organizationUrlKey ?? null,
|
||||
worktreeId: worktree.id,
|
||||
worktreePath: worktree.path
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveLegacyLinearLinkWorkspace(identifier: string): CurrentIssueLink['backfill'] {
|
||||
const parsed = parseLinearIssueInput(identifier)
|
||||
const organizationUrlKey = parsed?.organizationUrlKey
|
||||
if (!organizationUrlKey) {
|
||||
return undefined
|
||||
}
|
||||
const matches = getConnectedWorkspaces().filter(
|
||||
(workspace) => workspace.organizationUrlKey === organizationUrlKey
|
||||
)
|
||||
return matches.length === 1
|
||||
? { workspaceId: matches[0].id, organizationUrlKey }
|
||||
: { organizationUrlKey }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { LinearErrorCode, LinearIncludeErrorCode } from '../../shared/linear-agent-access'
|
||||
|
||||
export class LinearAgentAccessError extends Error {
|
||||
readonly code: LinearErrorCode
|
||||
readonly data?: unknown
|
||||
|
||||
constructor(code: LinearErrorCode, message: string, data?: unknown) {
|
||||
super(message)
|
||||
this.name = 'LinearAgentAccessError'
|
||||
this.code = code
|
||||
this.data = data
|
||||
}
|
||||
}
|
||||
|
||||
export function linearError(
|
||||
code: LinearErrorCode,
|
||||
message: string,
|
||||
data?: unknown
|
||||
): LinearAgentAccessError {
|
||||
return new LinearAgentAccessError(code, message, data)
|
||||
}
|
||||
|
||||
export function includeErrorCode(error: unknown): LinearIncludeErrorCode {
|
||||
if (error instanceof LinearAgentAccessError) {
|
||||
if (
|
||||
error.code === 'linear_timeout' ||
|
||||
error.code === 'linear_rate_limited' ||
|
||||
error.code === 'linear_permission_denied' ||
|
||||
error.code === 'linear_auth_expired' ||
|
||||
error.code === 'linear_network_error'
|
||||
) {
|
||||
return error.code
|
||||
}
|
||||
}
|
||||
return 'linear_include_failed'
|
||||
}
|
||||
|
||||
export function classifyLinearError(error: unknown): LinearErrorCode {
|
||||
const message = linearMessage(error).toLowerCase()
|
||||
if (message.includes('rate limit') || message.includes('429')) {
|
||||
return 'linear_rate_limited'
|
||||
}
|
||||
if (message.includes('timeout') || message.includes('timed out')) {
|
||||
return 'linear_timeout'
|
||||
}
|
||||
if (message.includes('permission') || message.includes('forbidden') || message.includes('403')) {
|
||||
return 'linear_permission_denied'
|
||||
}
|
||||
if (
|
||||
message.includes('network') ||
|
||||
message.includes('econnreset') ||
|
||||
message.includes('enotfound') ||
|
||||
message.includes('fetch failed')
|
||||
) {
|
||||
return 'linear_network_error'
|
||||
}
|
||||
return 'linear_network_error'
|
||||
}
|
||||
|
||||
export function linearMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { LinearErrorCode, LinearWorkspaceCandidate } from '../../shared/linear-agent-access'
|
||||
import type { LinearWorkspace } from '../../shared/types'
|
||||
import { getClients, getStatus, type LinearClientForWorkspace } from './client'
|
||||
import {
|
||||
LinearAgentAccessError,
|
||||
classifyLinearError,
|
||||
linearError,
|
||||
linearMessage
|
||||
} from './issue-context-errors'
|
||||
|
||||
export type WorkspaceReadFailure = {
|
||||
workspace: LinearWorkspaceCandidate
|
||||
code: LinearErrorCode
|
||||
message: string
|
||||
error: LinearAgentAccessError
|
||||
}
|
||||
|
||||
export function getFanoutClientEntries(): {
|
||||
entries: LinearClientForWorkspace[]
|
||||
failures: WorkspaceReadFailure[]
|
||||
} {
|
||||
const workspaces = getStatus().workspaces ?? []
|
||||
if (workspaces.length === 0) {
|
||||
return { entries: getClients('all'), failures: [] }
|
||||
}
|
||||
|
||||
const entries: LinearClientForWorkspace[] = []
|
||||
const failures: WorkspaceReadFailure[] = []
|
||||
for (const workspace of workspaces) {
|
||||
try {
|
||||
const entry = getClients(workspace.id)[0]
|
||||
if (entry) {
|
||||
entries.push(entry)
|
||||
}
|
||||
} catch (error) {
|
||||
const failure = workspaceFailure(workspace, toLinearAccessError(error))
|
||||
failures.push(failure)
|
||||
console.warn('[linear] agent workspace credential read failed:', error)
|
||||
}
|
||||
}
|
||||
return { entries, failures }
|
||||
}
|
||||
|
||||
export function workspaceFailure(
|
||||
workspace: LinearWorkspace,
|
||||
error: LinearAgentAccessError
|
||||
): WorkspaceReadFailure {
|
||||
return {
|
||||
workspace: {
|
||||
id: workspace.id,
|
||||
name: workspace.organizationName
|
||||
},
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
error
|
||||
}
|
||||
}
|
||||
|
||||
function toLinearAccessError(error: unknown): LinearAgentAccessError {
|
||||
if (error instanceof LinearAgentAccessError) {
|
||||
return error
|
||||
}
|
||||
return linearError(classifyLinearError(error), linearMessage(error))
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { LinearIssueContextResult, LinearIssueRequest } from '../../shared/linear-agent-access'
|
||||
import type { ResolvedIssue } from './issue-context-client'
|
||||
import {
|
||||
ATTACHMENTS_QUERY,
|
||||
CHILDREN_QUERY,
|
||||
COMMENTS_QUERY,
|
||||
RELATIONS_QUERY
|
||||
} from './issue-context-raw'
|
||||
|
||||
const rawRequest = vi.fn()
|
||||
|
||||
vi.mock('./issue-context-client', () => ({
|
||||
getRequiredEntry: () => ({
|
||||
workspace: {
|
||||
id: 'workspace-1',
|
||||
organizationId: 'workspace-1',
|
||||
organizationName: 'Acme',
|
||||
displayName: 'Brennan',
|
||||
email: 'brennan@example.com'
|
||||
},
|
||||
client: { client: { rawRequest } }
|
||||
}),
|
||||
withLinearRead: async (_entry: unknown, read: () => Promise<unknown>) => read()
|
||||
}))
|
||||
|
||||
function rawChild(index: number) {
|
||||
return {
|
||||
id: `child-${index}`,
|
||||
identifier: `ENG-${index}`,
|
||||
title: `Child ${index}`,
|
||||
url: `https://linear.app/acme/issue/ENG-${index}`,
|
||||
labels: { nodes: [] }
|
||||
}
|
||||
}
|
||||
|
||||
function rawComment(index: number) {
|
||||
return {
|
||||
id: `comment-${index}`,
|
||||
body: `Comment ${index}`
|
||||
}
|
||||
}
|
||||
|
||||
function resolvedIssue(): ResolvedIssue {
|
||||
return {
|
||||
issue: {
|
||||
id: 'parent',
|
||||
identifier: 'ENG-1',
|
||||
title: 'Parent',
|
||||
url: 'https://linear.app/acme/issue/ENG-1',
|
||||
labels: []
|
||||
},
|
||||
workspace: {
|
||||
id: 'workspace-1',
|
||||
organizationId: 'workspace-1',
|
||||
organizationName: 'Acme',
|
||||
displayName: 'Brennan',
|
||||
email: 'brennan@example.com'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function request(): LinearIssueRequest {
|
||||
return {
|
||||
include: { comments: false, children: true, attachments: false, relations: false },
|
||||
depth: 2
|
||||
}
|
||||
}
|
||||
|
||||
function requestWithDepth(depth: number): LinearIssueRequest {
|
||||
return {
|
||||
...request(),
|
||||
depth
|
||||
}
|
||||
}
|
||||
|
||||
function requestWithComments(): LinearIssueRequest {
|
||||
return {
|
||||
include: { comments: true, children: false, attachments: false, relations: false },
|
||||
depth: 2
|
||||
}
|
||||
}
|
||||
|
||||
function result(): LinearIssueContextResult {
|
||||
return {
|
||||
issue: resolvedIssue().issue,
|
||||
meta: {
|
||||
requested: {
|
||||
current: false,
|
||||
include: { comments: false, children: true, attachments: false, relations: false },
|
||||
depth: 2
|
||||
},
|
||||
resolved: {
|
||||
id: 'parent',
|
||||
identifier: 'ENG-1',
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceName: 'Acme'
|
||||
},
|
||||
partial: false,
|
||||
includeErrors: [],
|
||||
sections: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('Linear issue context includes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('declares cursor variables on every paged include query', () => {
|
||||
for (const query of [COMMENTS_QUERY, CHILDREN_QUERY, ATTACHMENTS_QUERY, RELATIONS_QUERY]) {
|
||||
expect(query).toContain('$after: String')
|
||||
expect(query).toContain('after: $after')
|
||||
}
|
||||
})
|
||||
|
||||
it('does not probe grandchildren when the first child page exhausts the node cap', async () => {
|
||||
for (let page = 0; page < 4; page += 1) {
|
||||
rawRequest.mockResolvedValueOnce({
|
||||
data: {
|
||||
issue: {
|
||||
children: {
|
||||
nodes: Array.from({ length: 50 }, (_, index) => rawChild(page * 50 + index + 1)),
|
||||
pageInfo: {
|
||||
hasNextPage: page < 3,
|
||||
endCursor: page < 3 ? `cursor-${page}` : null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
const { readOptionalIncludes } = await import('./issue-context-includes')
|
||||
const output = result()
|
||||
|
||||
await readOptionalIncludes(resolvedIssue(), request(), output, [], output.meta.sections)
|
||||
|
||||
expect(output.children).toHaveLength(200)
|
||||
expect(output.meta.sections.children).toMatchObject({
|
||||
returned: 200,
|
||||
cap: 200,
|
||||
capReached: true,
|
||||
mayHaveMore: true
|
||||
})
|
||||
expect(rawRequest).toHaveBeenCalledTimes(4)
|
||||
expect(rawRequest.mock.calls[0]?.[1]).toEqual({ id: 'parent', first: 50 })
|
||||
expect(rawRequest.mock.calls[1]?.[1]).toEqual({
|
||||
id: 'parent',
|
||||
first: 50,
|
||||
after: 'cursor-0'
|
||||
})
|
||||
})
|
||||
|
||||
it('paginates comments up to the advertised include cap', async () => {
|
||||
for (let page = 0; page < 3; page += 1) {
|
||||
rawRequest.mockResolvedValueOnce({
|
||||
data: {
|
||||
issue: {
|
||||
comments: {
|
||||
nodes: Array.from({ length: 50 }, (_, index) => rawComment(page * 50 + index + 1)),
|
||||
pageInfo: {
|
||||
hasNextPage: page < 2,
|
||||
endCursor: page < 2 ? `comment-cursor-${page}` : null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
const { readOptionalIncludes } = await import('./issue-context-includes')
|
||||
const output = result()
|
||||
|
||||
await readOptionalIncludes(
|
||||
resolvedIssue(),
|
||||
requestWithComments(),
|
||||
output,
|
||||
[],
|
||||
output.meta.sections
|
||||
)
|
||||
|
||||
expect(output.comments).toHaveLength(150)
|
||||
expect(output.meta.sections.comments).toMatchObject({
|
||||
returned: 150,
|
||||
cap: 500,
|
||||
capReached: false
|
||||
})
|
||||
expect(rawRequest).toHaveBeenCalledTimes(3)
|
||||
expect(rawRequest.mock.calls[2]?.[1]).toEqual({
|
||||
id: 'parent',
|
||||
first: 50,
|
||||
after: 'comment-cursor-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('marks section metadata when children are truncated by requested depth', async () => {
|
||||
rawRequest.mockResolvedValueOnce({
|
||||
data: {
|
||||
issue: {
|
||||
children: {
|
||||
nodes: [rawChild(1)],
|
||||
pageInfo: { hasNextPage: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
const { readOptionalIncludes } = await import('./issue-context-includes')
|
||||
const output = result()
|
||||
|
||||
await readOptionalIncludes(
|
||||
resolvedIssue(),
|
||||
requestWithDepth(1),
|
||||
output,
|
||||
[],
|
||||
output.meta.sections
|
||||
)
|
||||
|
||||
expect(output.children).toHaveLength(1)
|
||||
expect(output.children?.[0]?.mayHaveMore).toBe(true)
|
||||
expect(output.meta.sections.children).toMatchObject({
|
||||
returned: 1,
|
||||
capReached: false,
|
||||
mayHaveMore: true
|
||||
})
|
||||
expect(rawRequest).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,273 @@
|
||||
import type {
|
||||
LinearCollectionMeta,
|
||||
LinearIssueAttachment,
|
||||
LinearIssueChildNode,
|
||||
LinearIssueCommentNode,
|
||||
LinearIssueContextResult,
|
||||
LinearIssueInclude,
|
||||
LinearIssueRelation,
|
||||
LinearIssueRequest
|
||||
} from '../../shared/linear-agent-access'
|
||||
import {
|
||||
LINEAR_ATTACHMENTS_CAP,
|
||||
LINEAR_CHILDREN_NODE_CAP,
|
||||
LINEAR_COMMENTS_CAP,
|
||||
LINEAR_COMMENT_BODY_CAP,
|
||||
LINEAR_RELATIONS_CAP,
|
||||
clampLinearIssueDepth
|
||||
} from '../../shared/linear-agent-access'
|
||||
import type { ResolvedIssue } from './issue-context-client'
|
||||
import { getRequiredEntry, withLinearRead } from './issue-context-client'
|
||||
import { includeErrorCode } from './issue-context-errors'
|
||||
import { readConnectionPages } from './issue-context-pagination'
|
||||
import {
|
||||
ATTACHMENTS_QUERY,
|
||||
CHILDREN_QUERY,
|
||||
COMMENTS_QUERY,
|
||||
RELATIONS_QUERY,
|
||||
collectionMeta,
|
||||
mapIssue,
|
||||
type RawAttachmentsResponse,
|
||||
type RawChildrenResponse,
|
||||
type RawCommentsResponse,
|
||||
type RawRelationsResponse
|
||||
} from './issue-context-raw'
|
||||
|
||||
export async function readOptionalIncludes(
|
||||
resolved: ResolvedIssue,
|
||||
request: LinearIssueRequest,
|
||||
result: LinearIssueContextResult,
|
||||
includeErrors: LinearIssueContextResult['meta']['includeErrors'],
|
||||
sections: LinearIssueContextResult['meta']['sections']
|
||||
): Promise<void> {
|
||||
const includeTasks: [LinearIssueInclude, () => Promise<void>][] = []
|
||||
if (request.include.comments) {
|
||||
includeTasks.push(['comments', async () => assignComments(resolved, result, sections)])
|
||||
}
|
||||
if (request.include.children) {
|
||||
includeTasks.push([
|
||||
'children',
|
||||
async () => assignChildren(resolved, request.depth, result, sections)
|
||||
])
|
||||
}
|
||||
if (request.include.attachments) {
|
||||
includeTasks.push(['attachments', async () => assignAttachments(resolved, result, sections)])
|
||||
}
|
||||
if (request.include.relations) {
|
||||
includeTasks.push(['relations', async () => assignRelations(resolved, result, sections)])
|
||||
}
|
||||
|
||||
for (const [include, task] of includeTasks) {
|
||||
try {
|
||||
await task()
|
||||
} catch (error) {
|
||||
includeErrors.push({
|
||||
include,
|
||||
code: includeErrorCode(error),
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function assignComments(
|
||||
resolved: ResolvedIssue,
|
||||
result: LinearIssueContextResult,
|
||||
sections: LinearIssueContextResult['meta']['sections']
|
||||
): Promise<void> {
|
||||
const read = await readComments(resolved)
|
||||
result.comments = read.items
|
||||
sections.comments = read.meta
|
||||
}
|
||||
|
||||
async function assignChildren(
|
||||
resolved: ResolvedIssue,
|
||||
depth: number,
|
||||
result: LinearIssueContextResult,
|
||||
sections: LinearIssueContextResult['meta']['sections']
|
||||
): Promise<void> {
|
||||
const read = await readChildren(resolved, clampLinearIssueDepth(depth))
|
||||
result.children = read.items
|
||||
sections.children = read.meta
|
||||
}
|
||||
|
||||
async function assignAttachments(
|
||||
resolved: ResolvedIssue,
|
||||
result: LinearIssueContextResult,
|
||||
sections: LinearIssueContextResult['meta']['sections']
|
||||
): Promise<void> {
|
||||
const read = await readAttachments(resolved)
|
||||
result.attachments = read.items
|
||||
sections.attachments = read.meta
|
||||
}
|
||||
|
||||
async function assignRelations(
|
||||
resolved: ResolvedIssue,
|
||||
result: LinearIssueContextResult,
|
||||
sections: LinearIssueContextResult['meta']['sections']
|
||||
): Promise<void> {
|
||||
const read = await readRelations(resolved)
|
||||
result.relations = read.items
|
||||
sections.relations = read.meta
|
||||
}
|
||||
|
||||
async function readComments(resolved: ResolvedIssue): Promise<{
|
||||
items: LinearIssueCommentNode[]
|
||||
meta: LinearCollectionMeta
|
||||
}> {
|
||||
const entry = getRequiredEntry(resolved.workspace.id)
|
||||
const response = await readConnectionPages(LINEAR_COMMENTS_CAP, async (page) => {
|
||||
return await withLinearRead(entry, async () => {
|
||||
const raw = await entry.client.client.rawRequest<
|
||||
RawCommentsResponse,
|
||||
Record<string, unknown>
|
||||
>(COMMENTS_QUERY, { id: resolved.issue.id, ...page })
|
||||
return raw.data?.issue?.comments ?? null
|
||||
})
|
||||
})
|
||||
const nodes = response.nodes
|
||||
const items = nodes.slice(0, LINEAR_COMMENTS_CAP).map((comment) => {
|
||||
const body = comment.body ?? ''
|
||||
return {
|
||||
id: comment.id,
|
||||
body: body.slice(0, LINEAR_COMMENT_BODY_CAP),
|
||||
bodyTruncated: body.length > LINEAR_COMMENT_BODY_CAP,
|
||||
createdAt: comment.createdAt,
|
||||
updatedAt: comment.updatedAt,
|
||||
parentId: comment.parent?.id ?? null,
|
||||
user: comment.user ?? null
|
||||
}
|
||||
})
|
||||
return {
|
||||
items,
|
||||
meta: collectionMeta(items.length, LINEAR_COMMENTS_CAP, response.hasMore)
|
||||
}
|
||||
}
|
||||
|
||||
async function readChildren(
|
||||
resolved: ResolvedIssue,
|
||||
depth: number
|
||||
): Promise<{ items: LinearIssueChildNode[]; meta: LinearCollectionMeta }> {
|
||||
if (depth <= 0) {
|
||||
return { items: [], meta: collectionMeta(0, LINEAR_CHILDREN_NODE_CAP, false) }
|
||||
}
|
||||
const entry = getRequiredEntry(resolved.workspace.id)
|
||||
let returned = 0
|
||||
let capReached = false
|
||||
let depthReached = false
|
||||
|
||||
const readLevel = async (issueId: string, level: number): Promise<LinearIssueChildNode[]> => {
|
||||
if (level > depth || returned >= LINEAR_CHILDREN_NODE_CAP) {
|
||||
depthReached = true
|
||||
return []
|
||||
}
|
||||
const remaining = LINEAR_CHILDREN_NODE_CAP - returned
|
||||
const response = await readConnectionPages(remaining, async (page) => {
|
||||
return await withLinearRead(entry, async () => {
|
||||
const raw = await entry.client.client.rawRequest<
|
||||
RawChildrenResponse,
|
||||
Record<string, unknown>
|
||||
>(CHILDREN_QUERY, { id: issueId, ...page })
|
||||
return raw.data?.issue?.children ?? null
|
||||
})
|
||||
})
|
||||
const nodes = response.nodes
|
||||
if (response.hasMore || nodes.length > remaining) {
|
||||
capReached = true
|
||||
}
|
||||
const children = nodes.slice(0, remaining).map((node) => {
|
||||
returned += 1
|
||||
return { raw: node, child: mapIssue(node) as LinearIssueChildNode }
|
||||
})
|
||||
if (returned >= LINEAR_CHILDREN_NODE_CAP) {
|
||||
capReached = true
|
||||
}
|
||||
|
||||
// Why: when the current level already exhausts the output cap, fetching
|
||||
// grandchildren would add latency without returning any additional nodes.
|
||||
const canReadNested = level < depth && returned < LINEAR_CHILDREN_NODE_CAP
|
||||
if (!canReadNested && level >= depth && children.length > 0) {
|
||||
depthReached = true
|
||||
}
|
||||
const mappedChildren: LinearIssueChildNode[] = []
|
||||
for (const { raw, child } of children) {
|
||||
const nested = canReadNested ? await readLevel(raw.id, level + 1) : []
|
||||
if (nested.length > 0) {
|
||||
child.children = nested
|
||||
}
|
||||
child.mayHaveMore = level >= depth || returned >= LINEAR_CHILDREN_NODE_CAP || response.hasMore
|
||||
mappedChildren.push(child)
|
||||
}
|
||||
return mappedChildren
|
||||
}
|
||||
|
||||
const items = await readLevel(resolved.issue.id, 1)
|
||||
return {
|
||||
items,
|
||||
meta: {
|
||||
returned,
|
||||
cap: LINEAR_CHILDREN_NODE_CAP,
|
||||
capReached,
|
||||
mayHaveMore: capReached || depthReached
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readAttachments(
|
||||
resolved: ResolvedIssue
|
||||
): Promise<{ items: LinearIssueAttachment[]; meta: LinearCollectionMeta }> {
|
||||
const entry = getRequiredEntry(resolved.workspace.id)
|
||||
const response = await readConnectionPages(LINEAR_ATTACHMENTS_CAP, async (page) => {
|
||||
return await withLinearRead(entry, async () => {
|
||||
const raw = await entry.client.client.rawRequest<
|
||||
RawAttachmentsResponse,
|
||||
Record<string, unknown>
|
||||
>(ATTACHMENTS_QUERY, { id: resolved.issue.id, ...page })
|
||||
return raw.data?.issue?.attachments ?? null
|
||||
})
|
||||
})
|
||||
const items = response.nodes.slice(0, LINEAR_ATTACHMENTS_CAP).map((node) => ({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
url: node.url,
|
||||
source: node.source,
|
||||
subtitle: node.subtitle,
|
||||
createdAt: node.createdAt,
|
||||
metadataOnly: true as const
|
||||
}))
|
||||
return {
|
||||
items,
|
||||
meta: collectionMeta(items.length, LINEAR_ATTACHMENTS_CAP, response.hasMore)
|
||||
}
|
||||
}
|
||||
|
||||
async function readRelations(
|
||||
resolved: ResolvedIssue
|
||||
): Promise<{ items: LinearIssueRelation[]; meta: LinearCollectionMeta }> {
|
||||
const entry = getRequiredEntry(resolved.workspace.id)
|
||||
const response = await readConnectionPages(LINEAR_RELATIONS_CAP, async (page) => {
|
||||
return await withLinearRead(entry, async () => {
|
||||
const raw = await entry.client.client.rawRequest<
|
||||
RawRelationsResponse,
|
||||
Record<string, unknown>
|
||||
>(RELATIONS_QUERY, { id: resolved.issue.id, ...page })
|
||||
return raw.data?.issue?.relations ?? null
|
||||
})
|
||||
})
|
||||
const items = response.nodes.slice(0, LINEAR_RELATIONS_CAP).map((node) => ({
|
||||
id: node.id,
|
||||
type: node.type,
|
||||
relatedIssue: node.relatedIssue
|
||||
? {
|
||||
id: node.relatedIssue.id,
|
||||
identifier: node.relatedIssue.identifier,
|
||||
title: node.relatedIssue.title,
|
||||
url: node.relatedIssue.url
|
||||
}
|
||||
: null
|
||||
}))
|
||||
return {
|
||||
items,
|
||||
meta: collectionMeta(items.length, LINEAR_RELATIONS_CAP, response.hasMore)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { LINEAR_ISSUE_API_PAGE_SIZE_MAX } from '../../shared/linear-issue-read-limits'
|
||||
|
||||
export type LinearPageVariables = { first: number; after?: string }
|
||||
|
||||
export type LinearConnection<T> = {
|
||||
nodes?: T[]
|
||||
pageInfo?: {
|
||||
hasNextPage?: boolean
|
||||
endCursor?: string | null
|
||||
}
|
||||
} | null
|
||||
|
||||
export async function readConnectionPages<T>(
|
||||
limit: number,
|
||||
loadConnection: (page: LinearPageVariables) => Promise<LinearConnection<T>>
|
||||
): Promise<{ nodes: T[]; hasMore: boolean }> {
|
||||
const nodes: T[] = []
|
||||
let after: string | undefined
|
||||
let hasMore = false
|
||||
|
||||
while (nodes.length < limit) {
|
||||
// Why: Linear caps connection page sizes, so the CLI's larger context caps
|
||||
// must be reached by cursor walking rather than one oversized request.
|
||||
const first = Math.min(LINEAR_ISSUE_API_PAGE_SIZE_MAX, limit - nodes.length)
|
||||
const connection = await loadConnection(after ? { first, after } : { first })
|
||||
const pageNodes = connection?.nodes ?? []
|
||||
nodes.push(...pageNodes.slice(0, limit - nodes.length))
|
||||
hasMore = Boolean(connection?.pageInfo?.hasNextPage)
|
||||
|
||||
const nextCursor = connection?.pageInfo?.endCursor ?? undefined
|
||||
if (!hasMore || !nextCursor || nextCursor === after || pageNodes.length === 0) {
|
||||
break
|
||||
}
|
||||
after = nextCursor
|
||||
}
|
||||
|
||||
return { nodes, hasMore }
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import type {
|
||||
LinearCollectionMeta,
|
||||
LinearIssueSummary,
|
||||
LinearSearchIssueSummary
|
||||
} from '../../shared/linear-agent-access'
|
||||
|
||||
export type RawIssueResponse = {
|
||||
issue?: RawIssue | null
|
||||
searchIssues?: { nodes?: RawIssue[] }
|
||||
}
|
||||
|
||||
export type RawIssue = {
|
||||
id: string
|
||||
identifier: string
|
||||
title: string
|
||||
url: string
|
||||
description?: string | null
|
||||
priority?: number | null
|
||||
estimate?: number | null
|
||||
branchName?: string | null
|
||||
createdAt?: string | null
|
||||
updatedAt?: string | null
|
||||
state?: RawNamedEntity | null
|
||||
team?: (RawNamedEntity & { key?: string | null }) | null
|
||||
project?: RawNamedEntity | null
|
||||
cycle?: RawNamedEntity | null
|
||||
assignee?: RawUser | null
|
||||
labels?: { nodes?: RawNamedEntity[]; pageInfo?: RawPageInfo } | null
|
||||
}
|
||||
|
||||
export type RawNamedEntity = {
|
||||
id?: string | null
|
||||
name?: string | null
|
||||
color?: string | null
|
||||
type?: string | null
|
||||
}
|
||||
|
||||
export type RawUser = {
|
||||
id?: string | null
|
||||
displayName?: string | null
|
||||
avatarUrl?: string | null
|
||||
}
|
||||
|
||||
export type RawPageInfo = {
|
||||
hasNextPage?: boolean
|
||||
endCursor?: string | null
|
||||
}
|
||||
|
||||
export type RawCommentsResponse = {
|
||||
issue?: {
|
||||
comments?: {
|
||||
nodes?: {
|
||||
id: string
|
||||
body?: string | null
|
||||
createdAt?: string | null
|
||||
updatedAt?: string | null
|
||||
parent?: { id?: string | null } | null
|
||||
user?: RawUser | null
|
||||
}[]
|
||||
pageInfo?: RawPageInfo
|
||||
} | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export type RawChildrenResponse = {
|
||||
issue?: {
|
||||
children?: {
|
||||
nodes?: RawIssue[]
|
||||
pageInfo?: RawPageInfo
|
||||
} | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export type RawAttachmentsResponse = {
|
||||
issue?: {
|
||||
attachments?: {
|
||||
nodes?: {
|
||||
id: string
|
||||
title?: string | null
|
||||
url?: string | null
|
||||
source?: string | null
|
||||
subtitle?: string | null
|
||||
createdAt?: string | null
|
||||
}[]
|
||||
pageInfo?: RawPageInfo
|
||||
} | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export type RawRelationsResponse = {
|
||||
issue?: {
|
||||
relations?: {
|
||||
nodes?: {
|
||||
id: string
|
||||
type?: string | null
|
||||
relatedIssue?: RawIssue | null
|
||||
}[]
|
||||
pageInfo?: RawPageInfo
|
||||
} | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export const ISSUE_FIELDS = `
|
||||
id
|
||||
identifier
|
||||
title
|
||||
url
|
||||
description
|
||||
priority
|
||||
estimate
|
||||
branchName
|
||||
createdAt
|
||||
updatedAt
|
||||
state { id name type color }
|
||||
team { id name key color }
|
||||
project { id name color }
|
||||
cycle { id name }
|
||||
assignee { id displayName avatarUrl }
|
||||
labels(first: 50) { nodes { id name color } pageInfo { hasNextPage } }
|
||||
`
|
||||
|
||||
export const ISSUE_QUERY = `
|
||||
query OrcaAgentLinearIssue($id: String!) {
|
||||
issue(id: $id) {
|
||||
${ISSUE_FIELDS}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const SEARCH_QUERY = `
|
||||
query OrcaAgentLinearSearch($term: String!, $first: Int) {
|
||||
searchIssues(term: $term, first: $first) {
|
||||
nodes {
|
||||
${ISSUE_FIELDS}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const COMMENTS_QUERY = `
|
||||
query OrcaAgentLinearIssueComments($id: String!, $first: Int, $after: String) {
|
||||
issue(id: $id) {
|
||||
comments(first: $first, after: $after) {
|
||||
nodes {
|
||||
id
|
||||
body
|
||||
createdAt
|
||||
updatedAt
|
||||
parent { id }
|
||||
user { id displayName avatarUrl }
|
||||
}
|
||||
pageInfo { hasNextPage endCursor }
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const CHILDREN_QUERY = `
|
||||
query OrcaAgentLinearIssueChildren($id: String!, $first: Int, $after: String) {
|
||||
issue(id: $id) {
|
||||
children(first: $first, after: $after) {
|
||||
nodes {
|
||||
${ISSUE_FIELDS}
|
||||
}
|
||||
pageInfo { hasNextPage endCursor }
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const ATTACHMENTS_QUERY = `
|
||||
query OrcaAgentLinearIssueAttachments($id: String!, $first: Int, $after: String) {
|
||||
issue(id: $id) {
|
||||
attachments(first: $first, after: $after) {
|
||||
nodes { id title url source subtitle createdAt }
|
||||
pageInfo { hasNextPage endCursor }
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const RELATIONS_QUERY = `
|
||||
query OrcaAgentLinearIssueRelations($id: String!, $first: Int, $after: String) {
|
||||
issue(id: $id) {
|
||||
relations(first: $first, after: $after) {
|
||||
nodes {
|
||||
id
|
||||
type
|
||||
relatedIssue { id identifier title url }
|
||||
}
|
||||
pageInfo { hasNextPage endCursor }
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export function mapIssue(issue: RawIssue): LinearIssueSummary {
|
||||
return {
|
||||
id: issue.id,
|
||||
identifier: issue.identifier,
|
||||
title: issue.title,
|
||||
url: issue.url,
|
||||
description: issue.description,
|
||||
state: issue.state ?? null,
|
||||
team: issue.team ?? null,
|
||||
project: issue.project ?? null,
|
||||
cycle: issue.cycle ?? null,
|
||||
assignee: issue.assignee ?? null,
|
||||
labels: issue.labels?.nodes ?? [],
|
||||
priority: issue.priority,
|
||||
estimate: issue.estimate,
|
||||
branchName: issue.branchName,
|
||||
createdAt: issue.createdAt,
|
||||
updatedAt: issue.updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
export function pickSearchIssue(
|
||||
issue: LinearIssueSummary
|
||||
): Omit<LinearSearchIssueSummary, 'workspace'> {
|
||||
return {
|
||||
id: issue.id,
|
||||
identifier: issue.identifier,
|
||||
title: issue.title,
|
||||
url: issue.url,
|
||||
state: issue.state,
|
||||
team: issue.team,
|
||||
project: issue.project,
|
||||
assignee: issue.assignee,
|
||||
updatedAt: issue.updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
export function collectionMeta(
|
||||
returned: number,
|
||||
cap: number,
|
||||
hasMore?: boolean
|
||||
): LinearCollectionMeta {
|
||||
return {
|
||||
returned,
|
||||
cap,
|
||||
capReached: returned >= cap || hasMore === true,
|
||||
...(hasMore !== undefined ? { hasMore } : {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { LinearWorkspaceCandidate } from '../../shared/linear-agent-access'
|
||||
import type { LinearWorkspace } from '../../shared/types'
|
||||
import { linearError } from './issue-context-errors'
|
||||
|
||||
export function resolveWorkspaceSelector(
|
||||
selectors: {
|
||||
workspaceId?: string | null
|
||||
organizationUrlKey?: string | null
|
||||
},
|
||||
workspaces: LinearWorkspace[]
|
||||
): LinearWorkspace | null {
|
||||
if (workspaces.length === 0) {
|
||||
return null
|
||||
}
|
||||
const byId = selectors.workspaceId
|
||||
? workspaces.find((workspace) => workspace.id === selectors.workspaceId)
|
||||
: null
|
||||
const byOrg = selectors.organizationUrlKey
|
||||
? workspaces.find((workspace) => workspace.organizationUrlKey === selectors.organizationUrlKey)
|
||||
: null
|
||||
|
||||
if (selectors.workspaceId && !byId) {
|
||||
throw unknownWorkspace(selectors.workspaceId)
|
||||
}
|
||||
if (selectors.organizationUrlKey && !byOrg) {
|
||||
throw linearError(
|
||||
'linear_invalid_workspace',
|
||||
`Linear organization ${selectors.organizationUrlKey} is not connected.`,
|
||||
{
|
||||
nextSteps: ['Connect that Linear workspace or pass --workspace for a connected workspace.']
|
||||
}
|
||||
)
|
||||
}
|
||||
if (byId && byOrg && byId.id !== byOrg.id) {
|
||||
throw linearError('linear_invalid_workspace', 'The issue URL and --workspace do not match.', {
|
||||
nextSteps: [
|
||||
`Retry with --workspace ${byOrg.id} or use an issue URL from ${byId.organizationName}.`
|
||||
]
|
||||
})
|
||||
}
|
||||
return byId ?? byOrg ?? null
|
||||
}
|
||||
|
||||
export function unknownWorkspace(workspaceId: string): ReturnType<typeof linearError> {
|
||||
return linearError('linear_invalid_workspace', `Unknown Linear workspace ${workspaceId}.`, {
|
||||
nextSteps: ['Run `orca linear search <query> --workspace all --json` to inspect workspace ids.']
|
||||
})
|
||||
}
|
||||
|
||||
export function ambiguousWorkspace(
|
||||
workspaces: LinearWorkspace[],
|
||||
identifier: string
|
||||
): ReturnType<typeof linearError> {
|
||||
const candidates: LinearWorkspaceCandidate[] = workspaces.map((workspace) => ({
|
||||
id: workspace.id,
|
||||
name: workspace.organizationName
|
||||
}))
|
||||
return linearError(
|
||||
'linear_workspace_ambiguous',
|
||||
`Linear issue ${identifier} exists in more than one workspace.`,
|
||||
{
|
||||
candidates,
|
||||
nextSteps: candidates.map(
|
||||
(candidate) => `Retry with --workspace ${candidate.id} for ${candidate.name}.`
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type {
|
||||
LinearCurrentIssueContextHints,
|
||||
LinearIssueContextResult,
|
||||
LinearIssueRequest
|
||||
} from '../../shared/linear-agent-access'
|
||||
import { parseLinearIssueInput } from '../../shared/linear-links'
|
||||
import {
|
||||
resolveIssue,
|
||||
searchLinearIssuesForAgents,
|
||||
type ResolvedIssue
|
||||
} from './issue-context-client'
|
||||
import {
|
||||
getLinearCurrentIssueFromWorktree,
|
||||
resolveLegacyLinearLinkWorkspace,
|
||||
type CurrentIssueLink
|
||||
} from './issue-context-current'
|
||||
import { LinearAgentAccessError, linearError } from './issue-context-errors'
|
||||
import { readOptionalIncludes } from './issue-context-includes'
|
||||
|
||||
export {
|
||||
LinearAgentAccessError,
|
||||
getLinearCurrentIssueFromWorktree,
|
||||
resolveLegacyLinearLinkWorkspace,
|
||||
searchLinearIssuesForAgents
|
||||
}
|
||||
|
||||
export async function readLinearIssueContext(
|
||||
request: LinearIssueRequest,
|
||||
resolveCurrent: (context?: LinearCurrentIssueContextHints) => Promise<CurrentIssueLink>
|
||||
): Promise<LinearIssueContextResult> {
|
||||
if (request.workspaceId === 'all') {
|
||||
throw linearError('linear_invalid_workspace', '--workspace all is not valid for issue reads.', {
|
||||
nextSteps: ['Pass a concrete Linear workspace id or omit --workspace.']
|
||||
})
|
||||
}
|
||||
|
||||
const parsed = request.input ? parseLinearIssueInput(request.input) : null
|
||||
if (request.input && !parsed) {
|
||||
throw linearError('linear_issue_required', 'Pass a Linear issue identifier or issue URL.', {
|
||||
nextSteps: ['Use a Linear identifier like ENG-123 or a https://linear.app/... issue URL.']
|
||||
})
|
||||
}
|
||||
|
||||
const currentLink = parsed
|
||||
? null
|
||||
: request.current
|
||||
? await resolveCurrent(request.context)
|
||||
: await missingIssueInput()
|
||||
const identifier = parsed?.identifier ?? currentLink?.identifier
|
||||
if (!identifier) {
|
||||
throw linearError('linear_issue_required', 'Pass an issue id or use --current.')
|
||||
}
|
||||
|
||||
const resolved = await resolveIssue(identifier, {
|
||||
workspaceId: request.workspaceId ?? currentLink?.workspaceId ?? undefined,
|
||||
organizationUrlKey: parsed?.organizationUrlKey ?? currentLink?.organizationUrlKey
|
||||
})
|
||||
return buildIssueContextResult(resolved, request, currentLink)
|
||||
}
|
||||
|
||||
async function missingIssueInput(): Promise<CurrentIssueLink> {
|
||||
throw linearError('linear_issue_required', 'Pass an issue id or use --current.', {
|
||||
nextSteps: ['Run `orca linear issue ENG-123` or retry from a linked worktree with --current.']
|
||||
})
|
||||
}
|
||||
|
||||
async function buildIssueContextResult(
|
||||
resolved: ResolvedIssue,
|
||||
request: LinearIssueRequest,
|
||||
currentLink: CurrentIssueLink | null
|
||||
): Promise<LinearIssueContextResult> {
|
||||
const includeErrors: LinearIssueContextResult['meta']['includeErrors'] = []
|
||||
const sections: LinearIssueContextResult['meta']['sections'] = {}
|
||||
const result: LinearIssueContextResult = {
|
||||
issue: resolved.issue,
|
||||
meta: {
|
||||
requested: {
|
||||
id: request.input,
|
||||
current: request.current === true,
|
||||
workspaceId: request.workspaceId,
|
||||
include: request.include,
|
||||
depth: request.depth
|
||||
},
|
||||
resolved: {
|
||||
id: resolved.issue.id,
|
||||
identifier: resolved.issue.identifier,
|
||||
workspaceId: resolved.workspace.id,
|
||||
workspaceName: resolved.workspace.organizationName,
|
||||
...(currentLink?.worktreeId ? { worktreeId: currentLink.worktreeId } : {}),
|
||||
...(currentLink?.worktreePath ? { worktreePath: currentLink.worktreePath } : {})
|
||||
},
|
||||
partial: false,
|
||||
includeErrors,
|
||||
sections
|
||||
}
|
||||
}
|
||||
|
||||
await readOptionalIncludes(resolved, request, result, includeErrors, sections)
|
||||
result.meta.partial = includeErrors.length > 0
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user