fix(linear): enforce list-issues pagination backstops

Stop non-progressing issue pages before the page ceiling, abort in-flight reads at the shared deadline, and keep partial empty results visibly truncated. Tighten the SSH result guard without requiring legacy row-count metadata, and document mixed-version fallbacks.
This commit is contained in:
Brennan Benson
2026-08-23 13:09:24 -07:00
parent d1127b9939
commit ea5bd02c5f
14 changed files with 227 additions and 33 deletions
+1 -1
View File
@@ -125,7 +125,7 @@ orca linear list --filter assigned --limit 10 --workspace all --json
orca linear list --filter open --team <key-or-id> --workspace <workspaceId> --json
```
Use `list-issues` when MCP-compatible filters or cursor pagination are needed. Omitting `--limit` returns every match (`result.meta.limit` is `null`), so filter before listing a large workspace; `--limit <n>` caps the read. `--json` sets `result.truncated` (and `result.meta.hasMore`) when a cap held results back; human output prints `truncated: showing N`. Check `truncated` before reporting a count, then page with `--cursor` until `truncated` is false. Issued `--cursor` values bind the workspace; `--workspace all` cannot page; a raw Linear cursor still needs a concrete `--workspace`. Replay `--cursor` against the same Orca runtime that issued it. `--priority` is `0=none`, `1=urgent`, `2=high`, `3=medium`, `4=low`; JSON includes `priorityLabel` on each issue (CLI setter vocabulary). `orca linear search`, `orca linear list`, and `orca linear project list` still cap at their own `--limit` and set `result.truncated` when the cap is hit. Project JSON `priorityLabel` stays Linear's title-case provider string.
Use `list-issues` when MCP-compatible filters or cursor pagination are needed. Omitting `--limit` removes the caller cap (`result.meta.limit` is `null`), so filter before listing a large workspace; safety backstops can still stop the walk early. `--limit <n>` caps the read. Before reporting a count, check `result.truncated ?? result.meta.hasMore`; human output prints `truncated: showing N`. Page with `--cursor` until truncation is false. Issued `--cursor` values bind the workspace; `--workspace all` cannot page; a raw Linear cursor still needs a concrete `--workspace`. Replay `--cursor` against the same Orca runtime that issued it. `--priority` is `0=none`, `1=urgent`, `2=high`, `3=medium`, `4=low`; current-host JSON includes `priorityLabel` on each issue, while older hosts may omit it. `orca linear search`, `orca linear list`, and `orca linear project list` still cap at their own `--limit` and set `result.truncated` when the cap is hit. Project JSON `priorityLabel` stays Linear's title-case provider string.
Prefer `label add` and `label remove` for incremental edits. `label set` replaces the full label set and should be used only when deliberate cleanup is intended.
+1 -1
View File
@@ -122,7 +122,7 @@ orca linear list --filter assigned --limit 10 --workspace all --json
orca linear list --filter open --team <key-or-id> --workspace <workspaceId> --json
```
Use `list-issues` when MCP-compatible filters or cursor pagination are needed. Omitting `--limit` returns every match (`result.meta.limit` is `null`), so filter before listing a large workspace; `--limit <n>` caps the read. `--json` sets `result.truncated` (and `result.meta.hasMore`) when a cap held results back; human output prints `truncated: showing N`. Check `truncated` before reporting a count, then page with `--cursor` until `truncated` is false. Issued `--cursor` values bind the workspace; `--workspace all` cannot page; a raw Linear cursor still needs a concrete `--workspace`. Replay `--cursor` against the same Orca runtime that issued it. `--priority` is `0=none`, `1=urgent`, `2=high`, `3=medium`, `4=low`; JSON includes `priorityLabel` on each issue (CLI setter vocabulary). `orca linear search`, `orca linear list`, and `orca linear project list` still cap at their own `--limit` and set `result.truncated` when the cap is hit. Project JSON `priorityLabel` stays Linear's title-case provider string.
Use `list-issues` when MCP-compatible filters or cursor pagination are needed. Omitting `--limit` removes the caller cap (`result.meta.limit` is `null`), so filter before listing a large workspace; safety backstops can still stop the walk early. `--limit <n>` caps the read. Before reporting a count, check `result.truncated ?? result.meta.hasMore`; human output prints `truncated: showing N`. Page with `--cursor` until truncation is false. Issued `--cursor` values bind the workspace; `--workspace all` cannot page; a raw Linear cursor still needs a concrete `--workspace`. Replay `--cursor` against the same Orca runtime that issued it. `--priority` is `0=none`, `1=urgent`, `2=high`, `3=medium`, `4=low`; current-host JSON includes `priorityLabel` on each issue, while older hosts may omit it. `orca linear search`, `orca linear list`, and `orca linear project list` still cap at their own `--limit` and set `result.truncated` when the cap is hit. Project JSON `priorityLabel` stays Linear's title-case provider string.
Prefer `label add` and `label remove` for incremental edits. `label set` replaces the full label set and should be used only when deliberate cleanup is intended.
File diff suppressed because one or more lines are too long
+18
View File
@@ -120,6 +120,24 @@ describe('linear-format', () => {
expect(output).not.toContain('truncated:')
})
it('marks an empty list-issues result when it was truncated', () => {
const output = formatLinearMcpIssueList({
issues: [],
truncated: true,
meta: {
limit: null,
returned: 0,
hasMore: true,
orderBy: 'updatedAt',
workspaceId: 'workspace-1',
partial: false,
workspaceErrors: []
}
})
expect(output).toBe('No Linear issues found.\ntruncated: showing 0')
})
it('includes task fields in issue readback text', () => {
const result = {
issue: {
+5 -4
View File
@@ -128,11 +128,12 @@ export function formatLinearIssueList(result: LinearIssueListResult): string {
}
export function formatLinearMcpIssueList(result: LinearMcpIssueListResult): string {
if (result.issues.length === 0) {
return 'No Linear issues found.'
}
const body =
result.issues.length === 0
? 'No Linear issues found.'
: result.issues.map(formatSearchRow).join('\n')
return appendLinearListTruncation(
result.issues.map(formatSearchRow).join('\n'),
body,
result.issues.length,
result.truncated ?? result.meta.hasMore
)
+3 -3
View File
@@ -72,10 +72,10 @@ export const LINEAR_MCP_COMMAND_SPECS: CommandSpec[] = [
'orca linear list-issues --cursor <cursor> --json'
],
notes: [
'Omitting --limit returns every match (result.meta.limit is null); --limit <n> caps the read.',
'JSON sets result.truncated when a cap held results back; text prints truncated: showing N.',
'Omitting --limit removes the caller cap (result.meta.limit is null); safety backstops can still truncate.',
'JSON sets result.truncated when a cap or backstop held results back; older hosts require the meta.hasMore fallback.',
'Reuse --cursor from the previous page. Issued cursors bind the workspace; raw Linear cursors still need --workspace.',
'--priority is 0=none, 1=urgent, 2=high, 3=medium, 4=low. JSON includes priorityLabel on each issue.'
'--priority is 0=none, 1=urgent, 2=high, 3=medium, 4=low. Current hosts include priorityLabel; older hosts may omit it.'
]
},
{
+25
View File
@@ -146,6 +146,31 @@ function mkdtempLike(prefix: string): string {
}
describe('Linear client workspace storage', () => {
it('binds an abort signal to a request-scoped client', async () => {
const linear = await loadClientModule()
const controller = new AbortController()
linear.createSignalBoundLinearClient(
{
workspace: {
id: 'org-alpha',
organizationId: 'org-alpha',
organizationName: 'Alpha',
displayName: 'Ada',
email: 'ada@example.com'
},
client: {} as never,
apiKey: 'token-alpha'
},
controller.signal
)
expect(linearClientMock).toHaveBeenCalledWith({
apiKey: 'token-alpha',
signal: controller.signal
})
})
it('stores multiple workspaces and remembers the selected workspace', async () => {
const linear = await loadClientModule()
+7
View File
@@ -99,6 +99,13 @@ export function getPublicFileUrlClient(entry: LinearClientForWorkspace): LinearC
})
}
export function createSignalBoundLinearClient(
entry: LinearClientForWorkspace,
signal: AbortSignal
): LinearClient {
return new (loadLinearSdk().LinearClient)({ apiKey: entry.apiKey, signal })
}
// ── Auth error detection ─────────────────────────────────────────────
// Why: 401 errors must trigger token clearing and a re-auth prompt in the
// renderer. All other errors are swallowed with console.warn to match GitHub
@@ -6,6 +6,7 @@ const getStatus = vi.fn()
const acquire = vi.fn()
const release = vi.fn()
const clearToken = vi.fn()
let requestSignal: AbortSignal | undefined
const workspace = (id: string, organizationName: string) => ({
id,
@@ -17,9 +18,17 @@ const workspace = (id: string, organizationName: string) => ({
const clientEntry = (id: string, organizationName: string) => ({
workspace: workspace(id, organizationName),
client: { client: { rawRequest } }
client: { client: { rawRequest } },
apiKey: 'token'
})
const createSignalBoundLinearClient = vi.fn(
(entry: ReturnType<typeof clientEntry>, signal: AbortSignal) => {
requestSignal = signal
return entry.client
}
)
vi.mock('./linear-request-concurrency', () => ({
acquire,
release
@@ -30,6 +39,7 @@ vi.mock('./linear-token-store', () => ({
}))
vi.mock('./client', () => ({
createSignalBoundLinearClient,
getClients,
getStatus,
isAuthError: () => false
@@ -66,6 +76,7 @@ function pageResponse(
describe('list-issues pagination contract', () => {
beforeEach(() => {
vi.clearAllMocks()
requestSignal = undefined
const entry = clientEntry('workspace-1', 'Acme')
getClients.mockReturnValue([entry])
getStatus.mockReturnValue({ workspaces: [entry.workspace] })
@@ -211,7 +222,7 @@ describe('list-issues pagination contract', () => {
expect(rawRequest.mock.calls.map((call) => call[1]?.first)).toEqual([250, 50])
})
it('stops and reports truncation when a provider never stops offering pages', async () => {
it('stops, deduplicates, and reports truncation when a provider repeats a cursor', async () => {
rawRequest.mockResolvedValue(
pageResponse([issueNode('issue-1', 'ENG-1', '2026-07-01T00:00:00.000Z')], true, 'same-cursor')
)
@@ -221,7 +232,20 @@ describe('list-issues pagination contract', () => {
expect(result.truncated).toBe(true)
expect(result.meta.hasMore).toBe(true)
expect(rawRequest.mock.calls.length).toBeLessThanOrEqual(200)
expect(rawRequest).toHaveBeenCalledTimes(2)
expect(result.issues.map((issue) => issue.identifier)).toEqual(['ENG-1'])
expect(result.meta.nextCursor).toMatch(/^orca\.linear\.v1\./)
})
it('stops on an empty page even when the provider offers another cursor', async () => {
rawRequest.mockResolvedValue(pageResponse([], true, 'next-page'))
const { listMcpIssues } = await import('./mcp-issue-list')
const result = await listMcpIssues({})
expect(rawRequest).toHaveBeenCalledTimes(1)
expect(result.issues).toEqual([])
expect(result.truncated).toBe(true)
expect(result.meta.nextCursor).toMatch(/^orca\.linear\.v1\./)
})
@@ -265,6 +289,50 @@ describe('list-issues pagination contract', () => {
}
})
it('aborts an in-flight provider request at the shared read deadline', async () => {
vi.useFakeTimers()
try {
rawRequest.mockImplementation(
() =>
new Promise((_resolve, reject) => {
requestSignal?.addEventListener(
'abort',
() => {
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }))
},
{ once: true }
)
})
)
const { listMcpIssues } = await import('./mcp-issue-list')
const pending = listMcpIssues({})
let settled = false
void pending.then(
() => {
settled = true
},
() => {
settled = true
}
)
await vi.advanceTimersByTimeAsync(20_000)
expect(settled).toBe(true)
const result = await pending
expect(requestSignal?.aborted).toBe(true)
expect(rawRequest).toHaveBeenCalledTimes(1)
expect(result).toMatchObject({
issues: [],
truncated: true,
meta: { hasMore: true }
})
expect(result.meta.nextCursor).toBeUndefined()
} finally {
vi.useRealTimers()
}
})
it('refuses a hand-crafted cursor that smuggles a fan-out or empty workspace', async () => {
const { encodeIssueListCursor } = await import('./mcp-issue-list-cursor')
const { listMcpIssues } = await import('./mcp-issue-list')
+3 -1
View File
@@ -21,7 +21,8 @@ const clientEntry = (
request: ReturnType<typeof vi.fn> = rawRequest
) => ({
workspace: workspace(id, organizationName),
client: { client: { rawRequest: request } }
client: { client: { rawRequest: request } },
apiKey: 'token'
})
vi.mock('./linear-request-concurrency', () => ({
@@ -34,6 +35,7 @@ vi.mock('./linear-token-store', () => ({
}))
vi.mock('./client', () => ({
createSignalBoundLinearClient: (entry: ReturnType<typeof clientEntry>) => entry.client,
getClients,
getStatus,
isAuthError: () => false
+61 -15
View File
@@ -2,7 +2,12 @@ import type {
LinearMcpIssueListRequest,
LinearMcpIssueListResult
} from '../../shared/linear/agent-access'
import { getClients, getStatus, type LinearClientForWorkspace } from './client'
import {
createSignalBoundLinearClient,
getClients,
getStatus,
type LinearClientForWorkspace
} from './client'
import { withLinearRead } from './issue-context-client'
import { linearError } from './issue-context-errors'
import {
@@ -30,6 +35,8 @@ type RawListIssuesResponse = {
} | null
}
type IssueListPageRead = { connection: RawListIssuesResponse['issues'] } | { budgetExhausted: true }
type WorkspaceIssuePage = {
issues: LinearMcpIssueListResult['issues']
hasMore: boolean
@@ -174,6 +181,7 @@ async function readIssueListWorkspace(
): Promise<WorkspaceIssuePage> {
const filter = buildIssueFilter(request)
const issues: WorkspaceIssuePage['issues'] = []
const issueIds = new Set<string>()
let after = request.cursor
let hasMore = false
let nextCursor: string | undefined
@@ -183,20 +191,25 @@ async function readIssueListWorkspace(
? LIST_ISSUES_PAGE_SIZE
: Math.min(limit - issues.length, LIST_ISSUES_PAGE_SIZE)
// Each page takes its own concurrency slot so a long walk cannot starve other reads.
const connection = await withLinearRead(entry, async () => {
const raw = await entry.client.client.rawRequest<
RawListIssuesResponse,
Record<string, unknown>
>(LIST_ISSUES_QUERY, {
first,
after,
filter,
orderBy,
includeArchived: request.includeArchived ?? false
})
return raw.data?.issues
const read = await readIssueListPage(entry, deadline, {
first,
after,
filter,
orderBy,
includeArchived: request.includeArchived ?? false
})
for (const issue of connection?.nodes ?? []) {
if ('budgetExhausted' in read) {
hasMore = true
nextCursor = after
break
}
const connection = read.connection
const pageNodes = connection?.nodes ?? []
for (const issue of pageNodes) {
if (issueIds.has(issue.id)) {
continue
}
issueIds.add(issue.id)
issues.push({
...mapIssue(issue),
workspace: { id: entry.workspace.id, name: entry.workspace.organizationName }
@@ -204,7 +217,7 @@ async function readIssueListWorkspace(
}
hasMore = connection?.pageInfo?.hasNextPage === true
nextCursor = connection?.pageInfo?.endCursor ?? undefined
if (!hasMore || !nextCursor) {
if (!hasMore || !nextCursor || nextCursor === after || pageNodes.length === 0) {
break
}
if (limit !== null && issues.length >= limit) {
@@ -218,6 +231,39 @@ async function readIssueListWorkspace(
return { issues, hasMore, nextCursor }
}
async function readIssueListPage(
entry: LinearClientForWorkspace,
deadline: number,
variables: Record<string, unknown>
): Promise<IssueListPageRead> {
return withLinearRead(entry, async () => {
const remaining = deadline - Date.now()
if (remaining <= 0) {
return { budgetExhausted: true }
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), remaining)
timeout.unref?.()
try {
const client = createSignalBoundLinearClient(entry, controller.signal)
const raw = await client.client.rawRequest<RawListIssuesResponse, Record<string, unknown>>(
LIST_ISSUES_QUERY,
variables
)
return controller.signal.aborted
? { budgetExhausted: true }
: { connection: raw.data?.issues }
} catch (error) {
if (controller.signal.aborted) {
return { budgetExhausted: true }
}
throw error
} finally {
clearTimeout(timeout)
}
})
}
// null means unbounded: read until Linear stops handing out pages.
function resolveLimit(limit: number | undefined): number | null {
return limit === undefined ? null : Math.max(1, Math.floor(limit))
@@ -49,4 +49,27 @@ describe('formatRemoteCli', () => {
stderr: ''
})
})
it('falls back to JSON for malformed Linear list-issues metadata', () => {
const response: RpcResponse = {
id: 'rpc-1',
ok: true,
_meta: meta,
result: {
issues: [],
meta: {
limit: null,
returned: 0,
orderBy: 'updatedAt',
partial: false,
workspaceErrors: []
}
}
}
expect(formatRemoteCli(response)).toEqual({
stdout: `${JSON.stringify(response.result)}\n`,
stderr: ''
})
})
})
@@ -54,8 +54,12 @@ export function isLinearMcpIssueListResult(result: unknown): result is LinearMcp
isRecord(result) &&
Array.isArray(result.issues) &&
isRecord(result.meta) &&
typeof result.meta.orderBy === 'string' &&
Array.isArray(result.meta.workspaceErrors)
(typeof result.meta.limit === 'number' || result.meta.limit === null) &&
typeof result.meta.hasMore === 'boolean' &&
(result.meta.orderBy === 'createdAt' || result.meta.orderBy === 'updatedAt') &&
typeof result.meta.partial === 'boolean' &&
Array.isArray(result.meta.workspaceErrors) &&
result.meta.workspaceErrors.every(isLinearWorkspaceError)
)
}
+1 -1
View File
@@ -28,7 +28,7 @@ export type LinearMcpIssueListResult = {
// must fall back to meta rather than read absence as "complete".
truncated?: boolean
meta: {
// null when the caller set no --limit, i.e. every matching issue was read.
// null means no caller cap; hasMore still reports safety-backstop truncation.
limit: number | null
returned: number
hasMore: boolean