Files
orca/src/cli/linear-format.test.ts
Brennan Benson 3fca1d1648 fix(linear): unbound list-issues by default, surface truncation, bind cursor workspace (#15824)
Fixes STA-5076.

list-issues capped at 50 by default and hard-clamped at 250, with hasMore buried
under result.meta and no stderr warning for --json, so a page that stopped early
read as a complete answer. Omitting --limit now walks Linear's pages until they
run out (meta.limit is null), and --limit <n> is the only cap, paging past
Linear's 250-per-request maximum to reach it. result.truncated sits next to
result.issues and is set only when a cap actually held results back; human output
prints "truncated: showing N".

The read still has to fit the CLI's 60s RPC budget, so a 20s wall-clock deadline
and a 200-page ceiling stop the walk early and report truncated with a
continuation cursor rather than failing the command.

Also:
- issued --cursor values bind the resolved workspace, so call -> nextCursor ->
  call works without --workspace; raw Linear cursors still need one and now carry
  nextSteps
- issued cursors whose payload smuggles back `all` or an empty workspace are
  rejected at decode, since either would widen the read past the bound workspace
- JSON issue rows carry priorityLabel (none/urgent/high/medium/low), matching
  orca linear priority set
- truncated and priorityLabel are optional on the wire, so a host that predates
  either is not read as "complete"; readers fall back to meta.hasMore
- the truncation line prints the rows actually rendered, so a remote result with
  no meta.returned cannot print "showing undefined"
2026-08-21 14:28:55 -07:00

195 lines
5.2 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
LinearCreateResult,
LinearIssueContextResult,
LinearMcpIssueListResult,
LinearProjectListResult,
LinearSearchResult
} from '../shared/linear/agent-access'
import {
formatLinearCreate,
formatLinearIssue,
formatLinearMcpIssueList,
formatLinearProjectList,
printLinearMcpIssueListWarnings,
printLinearSearchWarnings
} from './linear-format'
describe('linear-format', () => {
beforeEach(() => {
vi.restoreAllMocks()
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('treats older search results without workspaceErrors as non-partial', () => {
const result = {
issues: [],
meta: {
query: 'auth',
workspaceId: 'all',
limit: 20,
returned: 0,
limitReached: false,
partial: false
}
} as unknown as LinearSearchResult
printLinearSearchWarnings(result)
expect(console.error).not.toHaveBeenCalled()
})
it('binds a list continuation hint to its Linear workspace', () => {
printLinearMcpIssueListWarnings({
issues: [],
truncated: true,
meta: {
limit: 20,
returned: 20,
hasMore: true,
nextCursor: 'next-page',
orderBy: 'updatedAt',
workspaceId: 'workspace-1',
partial: false,
workspaceErrors: []
}
} as LinearMcpIssueListResult)
expect(console.error).toHaveBeenCalledWith(
'warning: more results available; next cursor: next-page; continue with --workspace workspace-1'
)
})
it('prints a stdout truncation marker when a list-issues page is partial', () => {
const output = formatLinearMcpIssueList({
issues: [
{
id: 'issue-1',
identifier: 'ENG-1',
title: 'Fix auth',
url: 'https://linear.app/acme/issue/ENG-1',
labels: [],
state: { name: 'In Progress' },
assignee: { displayName: 'Ada' },
workspace: { id: 'workspace-1', name: 'Acme' }
}
],
truncated: true,
meta: {
limit: 1,
returned: 1,
hasMore: true,
nextCursor: 'next-page',
orderBy: 'updatedAt',
workspaceId: 'workspace-1',
partial: false,
workspaceErrors: []
}
})
expect(output).toContain('ENG-1')
expect(output).toContain('truncated: showing 1')
expect(output).not.toContain(' of ')
})
it('omits the stdout truncation marker when the page is complete', () => {
const output = formatLinearMcpIssueList({
issues: [
{
id: 'issue-1',
identifier: 'ENG-1',
title: 'Fix auth',
url: 'https://linear.app/acme/issue/ENG-1',
labels: [],
workspace: { id: 'workspace-1', name: 'Acme' }
}
],
truncated: false,
meta: {
limit: 50,
returned: 1,
hasMore: false,
orderBy: 'updatedAt',
workspaceId: 'workspace-1',
partial: false,
workspaceErrors: []
}
})
expect(output).toContain('ENG-1')
expect(output).not.toContain('truncated:')
})
it('includes task fields in issue readback text', () => {
const result = {
issue: {
id: 'issue-1',
identifier: 'ENG-123',
title: 'Fix task fields',
url: 'https://linear.app/acme/issue/ENG-123',
state: { name: 'In Progress' },
assignee: { displayName: 'Ada' },
project: null,
labels: [],
priority: 2,
estimate: 5,
dueDate: '2026-06-30'
},
meta: {
sections: {}
}
} as unknown as LinearIssueContextResult
expect(formatLinearIssue(result)).toContain('Priority: high')
expect(formatLinearIssue(result)).toContain('Estimate: 5')
expect(formatLinearIssue(result)).toContain('Due: 2026-06-30')
})
it('formats project rows with names, ids, teams, and workspace', () => {
const result = {
projects: [
{
id: 'project-1',
name: 'Launch',
workspaceName: 'Acme',
teams: [
{ id: 'team-1', name: 'Engineering', key: 'ENG' },
{ id: 'team-2', name: 'Product', key: '' }
]
}
],
meta: { limit: 20, returned: 1, hasMore: false, partial: false, workspaceErrors: [] }
} as unknown as LinearProjectListResult
const output = formatLinearProjectList(result)
expect(output).toContain('Launch')
expect(output).toContain('project-1')
expect(output).toContain('ENG')
expect(output).toContain('Product')
expect(output).toContain('Acme')
})
it('includes the project in create output when present', () => {
const result = {
issue: {
id: 'issue-1',
identifier: 'ENG-123',
title: 'Follow up',
url: 'https://linear.app/acme/issue/ENG-123',
team: { id: 'team-1', key: 'ENG', name: 'Engineering' },
state: null,
parent: null,
project: { id: 'project-1', name: 'Launch' }
},
meta: {
workspaceId: 'workspace-1',
writeId: '11111111-1111-4111-8111-111111111111',
deduplicated: false
}
} as LinearCreateResult
expect(formatLinearCreate(result)).toBe('Created ENG-123 in Launch: Follow up.')
})
})