fix(linear): harden exhaustive pagination

This commit is contained in:
Brennan Benson
2026-08-23 14:22:20 -07:00
parent 62623ad452
commit d3ee631e27
21 changed files with 456 additions and 132 deletions
+1 -1
View File
@@ -127,7 +127,7 @@ orca linear list --filter open --team <key-or-id> --workspace <workspaceId> --js
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 or safety backstop 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 list` and `orca linear project list` follow the same omitted-limit contract: they walk every provider page, return `meta.limit: null`, and set `truncated`/`meta.hasMore` only when a safety backstop stops the walk. An explicit `--limit <n>` is preserved as the caller cap. `orca linear search` stays capped at 50; JSON reports the provider's pre-pagination `totalCount`, and truncated human output says `truncated: showing N of M`. Older hosts may omit `totalCount`, in which case output falls back to `truncated: showing N`. Project JSON `priorityLabel` stays Linear's title-case provider string.
`orca linear list` and `orca linear project list` follow the same omitted-limit contract: they walk provider pages until exhaustion or a safety backstop, return `meta.limit: null`, and report truncation when stopped early. An explicit `--limit <n>` is preserved as the caller cap. `orca linear search` stays capped at 50; JSON reports the provider's pre-pagination `totalCount`, and truncated human output says `truncated: showing N of M`. Older hosts may omit `totalCount`, in which case output falls back to `truncated: showing N`. Project JSON `priorityLabel` stays Linear's title-case provider string.
```json
{ "meta": { "limit": null, "returned": 87, "hasMore": false } }
+1 -1
View File
@@ -124,7 +124,7 @@ orca linear list --filter open --team <key-or-id> --workspace <workspaceId> --js
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 or safety backstop 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 list` and `orca linear project list` follow the same omitted-limit contract: they walk every provider page, return `meta.limit: null`, and set `truncated`/`meta.hasMore` only when a safety backstop stops the walk. An explicit `--limit <n>` is preserved as the caller cap. `orca linear search` stays capped at 50; JSON reports the provider's pre-pagination `totalCount`, and truncated human output says `truncated: showing N of M`. Older hosts may omit `totalCount`, in which case output falls back to `truncated: showing N`. Project JSON `priorityLabel` stays Linear's title-case provider string.
`orca linear list` and `orca linear project list` follow the same omitted-limit contract: they walk provider pages until exhaustion or a safety backstop, return `meta.limit: null`, and report truncation when stopped early. An explicit `--limit <n>` is preserved as the caller cap. `orca linear search` stays capped at 50; JSON reports the provider's pre-pagination `totalCount`, and truncated human output says `truncated: showing N of M`. Older hosts may omit `totalCount`, in which case output falls back to `truncated: showing N`. Project JSON `priorityLabel` stays Linear's title-case provider string.
```json
{ "meta": { "limit": null, "returned": 87, "hasMore": false } }
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -1,4 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const callMock = vi.fn()
@@ -274,7 +276,7 @@ describe('orca linear CLI handlers', () => {
})
)
await main(['linear', 'project', 'list'], '/tmp/repo')
await main(['linear', 'project', 'list'], join(tmpdir(), 'repo'))
expect(callMock).toHaveBeenCalledWith('linear.agentProjectList', {
query: undefined,
+2 -2
View File
@@ -464,10 +464,10 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string {
return '--limit <n> Max issues to return; omit to return every match'
}
if (command === 'linear list' && flag === 'limit') {
return '--limit <n> Max issues to return; omit to walk every page'
return '--limit <n> Max issues to return; omit to walk pages until exhaustion or a safety backstop'
}
if (command === 'linear project list' && flag === 'limit') {
return '--limit <n> Max projects to return; omit to walk every page'
return '--limit <n> Max projects to return; omit to walk pages until exhaustion or a safety backstop'
}
if (command === 'artifacts list' && flag === 'cursor') {
return '--cursor <cursor> Opaque cursor returned by a previous artifacts page'
+2 -2
View File
@@ -415,13 +415,13 @@ describe('orca root help', () => {
logSpy.mockClear()
await main(['linear', 'list', '--help'], '/tmp/repo')
expect(String(logSpy.mock.calls[0][0])).toContain(
'--limit <n> Max issues to return; omit to walk every page'
'--limit <n> Max issues to return; omit to walk pages until exhaustion or a safety backstop'
)
logSpy.mockClear()
await main(['linear', 'project', 'list', '--help'], '/tmp/repo')
expect(String(logSpy.mock.calls[0][0])).toContain(
'--limit <n> Max projects to return; omit to walk every page'
'--limit <n> Max projects to return; omit to walk pages until exhaustion or a safety backstop'
)
expect(callMock).not.toHaveBeenCalled()
})
+2 -2
View File
@@ -67,7 +67,7 @@ export const LINEAR_COMMAND_SPECS: CommandSpec[] = [
},
{
path: ['linear', 'project', 'list'],
summary: 'List Linear projects; omit --limit to walk every page',
summary: 'List Linear projects; omit --limit to walk until exhaustion or a safety backstop',
usage:
'orca linear project list [--query <text>] [--limit <n>] [--workspace <id>|all] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'query', 'limit', 'workspace'],
@@ -78,7 +78,7 @@ export const LINEAR_COMMAND_SPECS: CommandSpec[] = [
},
{
path: ['linear', 'list'],
summary: 'List Linear issues; omit --limit to walk every page',
summary: 'List Linear issues; omit --limit to walk until exhaustion or a safety backstop',
usage:
'orca linear list [--filter assigned|created|all|completed|open] [--team <key|id>] [--limit <n>] [--workspace <id>|all] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'filter', 'team', 'limit', 'workspace'],
+16 -1
View File
@@ -74,7 +74,7 @@ async function loadClientModule(options: SafeStorageMockOptions = {}) {
vi.resetModules()
linearClientMock = vi.fn(function LinearClient(
this: { viewer: Promise<unknown> },
{ apiKey }: { apiKey: string }
{ apiKey }: { apiKey: string; signal?: AbortSignal }
) {
const fixture = fixtures.get(apiKey)
if (!fixture) {
@@ -146,6 +146,21 @@ function mkdtempLike(prefix: string): string {
}
describe('Linear client workspace storage', () => {
it('constructs signal-bound clients with the workspace API key', async () => {
writeMultiWorkspaceFiles([{ id: 'org-alpha', token: 'token-alpha' }], 'org-alpha')
const linear = await loadClientModule()
const entry = linear.getClients('org-alpha')[0]
const controller = new AbortController()
linearClientMock.mockClear()
linear.createSignalBoundLinearClient(entry, 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
+100 -2
View File
@@ -5,6 +5,8 @@ type RawRequest = (query: string, variables: Record<string, unknown>) => Promise
const rawRequest = vi.fn<RawRequest>()
const getClients = vi.fn()
const clearToken = vi.fn()
const isAuthError = vi.fn()
const acquire = vi.fn().mockResolvedValue(undefined)
const release = vi.fn()
@@ -13,10 +15,13 @@ vi.mock('./linear-request-concurrency', () => ({
release: (...args: unknown[]) => release(...args)
}))
vi.mock('./linear-token-store', () => ({ clearToken: vi.fn() }))
vi.mock('./linear-token-store', () => ({
clearToken: (...args: unknown[]) => clearToken(...args)
}))
vi.mock('./client', () => ({
createSignalBoundLinearClient: (entry: LinearClientForWorkspace) => entry.client,
getClients: (...args: unknown[]) => getClients(...args),
isAuthError: () => false
isAuthError: (...args: unknown[]) => isAuthError(...args)
}))
function makeEntry(options?: {
@@ -63,6 +68,7 @@ function response(
describe('Linear agent issue pagination', () => {
beforeEach(() => {
vi.clearAllMocks()
isAuthError.mockReturnValue(false)
getClients.mockReturnValue([makeEntry()])
})
@@ -150,6 +156,98 @@ describe('Linear agent issue pagination', () => {
expect(firstRequest).toHaveBeenCalledTimes(2)
expect(secondRequest).toHaveBeenCalledTimes(2)
})
it('retains concrete-workspace rows and truncation after a later page fails', async () => {
rawRequest
.mockResolvedValueOnce(response(['LIN-1'], { hasNextPage: true, endCursor: 'cursor-1' }))
.mockRejectedValueOnce(new Error('fetch failed'))
const { listIssues } = await import('./issues')
const result = await listIssues('all', null, 'workspace-1')
expect(result.items.map((issue) => issue.id)).toEqual(['LIN-1'])
expect(result.hasMore).toBe(true)
expect(result.errors).toMatchObject([
{ workspaceId: 'workspace-1', type: 'network', message: 'fetch failed' }
])
})
it('retains healthy and prior failed-workspace rows after a later page fails', async () => {
const firstRequest = vi
.fn<RawRequest>()
.mockResolvedValueOnce(
response(['W1-1'], { hasNextPage: true, endCursor: 'workspace-1-cursor-1' })
)
.mockRejectedValueOnce(new Error('fetch failed'))
const secondRequest = vi.fn<RawRequest>().mockResolvedValueOnce(response(['W2-1']))
getClients.mockReturnValue([
makeEntry({ request: firstRequest }),
makeEntry({
workspaceId: 'workspace-2',
organizationName: 'Second Workspace',
request: secondRequest
})
])
const { listIssues } = await import('./issues')
const result = await listIssues('all', null, 'all')
expect(result.items.map((issue) => issue.id)).toEqual(['W1-1', 'W2-1'])
expect(result.hasMore).toBe(true)
expect(result.errors).toMatchObject([
{ workspaceId: 'workspace-1', type: 'network', message: 'fetch failed' }
])
})
it('keeps concrete list authentication failures throwing and clears the token', async () => {
const authError = new Error('authentication expired')
rawRequest.mockRejectedValueOnce(authError)
isAuthError.mockImplementation((error) => error === authError)
const { listIssues } = await import('./issues')
await expect(listIssues('all', null, 'workspace-1')).rejects.toThrow(authError)
expect(clearToken).toHaveBeenCalledWith('workspace-1')
})
it('reports deadline exhaustion as truncation without a workspace error', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
let grantPermit!: () => void
acquire.mockReturnValueOnce(
new Promise<void>((resolve) => {
grantPermit = resolve
})
)
const { listIssues } = await import('./issues')
const resultPromise = listIssues('all', null, 'workspace-1')
await vi.advanceTimersByTimeAsync(20_000)
const result = await resultPromise
expect(result).toMatchObject({ items: [], hasMore: true })
expect(result.errors).toEqual([])
expect(rawRequest).not.toHaveBeenCalled()
grantPermit()
await Promise.resolve()
expect(release).toHaveBeenCalledTimes(1)
vi.useRealTimers()
})
it('deduplicates overlapping issue pages and stops a nonconsecutive cursor cycle', async () => {
rawRequest
.mockResolvedValueOnce(response(['LIN-1'], { hasNextPage: true, endCursor: 'cursor-a' }))
.mockResolvedValueOnce(
response(['LIN-1', 'LIN-2'], { hasNextPage: true, endCursor: 'cursor-b' })
)
.mockResolvedValueOnce(response(['LIN-3'], { hasNextPage: true, endCursor: 'cursor-a' }))
const { listIssues } = await import('./issues')
const result = await listIssues('all', null, 'workspace-1')
expect(result.items.map((issue) => issue.id)).toEqual(['LIN-1', 'LIN-2', 'LIN-3'])
expect(result.hasMore).toBe(true)
expect(rawRequest).toHaveBeenCalledTimes(3)
})
})
function workspacePages(prefix: string): typeof rawRequest {
+1
View File
@@ -16,6 +16,7 @@ vi.mock('./linear-token-store', () => ({
}))
vi.mock('./client', () => ({
createSignalBoundLinearClient: (entry: LinearClientForWorkspace) => entry.client,
getClients: (...args: unknown[]) => getClients(...args),
isAuthError: (...args: unknown[]) => isAuthError(...args)
}))
+44 -95
View File
@@ -19,6 +19,7 @@ import { acquire, release } from './linear-request-concurrency'
import { clearToken } from './linear-token-store'
import { getClients, isAuthError, type LinearClientForWorkspace } from './client'
import { buildLinearListIssueFilter } from './issue-list-filter'
import { readLinearBeforeDeadline } from './linear-read-deadline'
import { mapLinearIssue } from './mappers'
export type LinearIssueListOptions = {
@@ -81,6 +82,7 @@ type LinearIssuePageRequest = {
after?: string
}
type LinearIssueConnectionLoader = (
client: LinearClient,
page: LinearIssuePageRequest
) => Promise<LinearIssueConnection | null | undefined>
@@ -426,54 +428,12 @@ function mapRawIssueForWorkspace(
}
}
async function readIssueConnectionPages(
entry: LinearClientForWorkspace,
limit: number | null,
loadConnection: LinearIssueConnectionLoader,
budget: LinearAgentListBudget
): Promise<{ items: LinearIssue[]; hasMore: boolean }> {
const items: LinearIssue[] = []
let after: string | undefined
let hasMore = false
while (limit === null || items.length < limit) {
if (Date.now() >= budget.deadline || budget.pages >= LINEAR_AGENT_LIST_MAX_PAGES) {
hasMore = true
break
}
budget.pages += 1
const first =
limit === null
? LINEAR_ISSUE_API_PAGE_SIZE_MAX
: Math.min(LINEAR_ISSUE_API_PAGE_SIZE_MAX, limit - items.length)
await acquire()
let connection: LinearIssueConnection | null | undefined
try {
connection = await loadConnection(after ? { first, after } : { first })
} finally {
release()
}
const nodes = connection?.nodes ?? []
items.push(...nodes.map((issue) => mapRawIssueForWorkspace(entry, issue)))
hasMore = Boolean(connection?.pageInfo?.hasNextPage)
const nextCursor = connection?.pageInfo?.endCursor ?? undefined
if (!hasMore || !nextCursor || nextCursor === after || nodes.length === 0) {
break
}
after = nextCursor
}
return { items, hasMore }
}
function getOldestIssueTime(issues: LinearIssue[]): number {
const oldestIssue = issues.at(-1)
return oldestIssue ? new Date(oldestIssue.updatedAt).getTime() : Number.POSITIVE_INFINITY
}
function getListIssueConnectionLoader(
entry: LinearClientForWorkspace,
filter: LinearListFilter,
options?: LinearIssueListOptions
): LinearIssueConnectionLoader {
@@ -488,8 +448,8 @@ function getListIssueConnectionLoader(
})
if (filter === 'assigned') {
return async (page) => {
const result = await entry.client.client.rawRequest<
return async (client, page) => {
const result = await client.client.rawRequest<
LinearIssueConnectionResponse,
LinearRawVariables
>(VIEWER_ASSIGNED_ISSUES_QUERY, {
@@ -502,8 +462,8 @@ function getListIssueConnectionLoader(
}
if (filter === 'created') {
return async (page) => {
const result = await entry.client.client.rawRequest<
return async (client, page) => {
const result = await client.client.rawRequest<
LinearIssueConnectionResponse,
LinearRawVariables
>(VIEWER_CREATED_ISSUES_QUERY, {
@@ -516,8 +476,8 @@ function getListIssueConnectionLoader(
}
if (filter === 'completed') {
return async (page) => {
const result = await entry.client.client.rawRequest<
return async (client, page) => {
const result = await client.client.rawRequest<
LinearIssueConnectionResponse,
LinearRawVariables
>(VIEWER_ASSIGNED_ISSUES_QUERY, {
@@ -529,8 +489,8 @@ function getListIssueConnectionLoader(
}
}
return async (page) => {
const result = await entry.client.client.rawRequest<
return async (client, page) => {
const result = await client.client.rawRequest<
LinearIssueConnectionResponse,
LinearRawVariables
>(ALL_ISSUES_QUERY, { ...variables, ...page, filter: filterInput })
@@ -871,6 +831,8 @@ type LinearIssueWorkspacePageState = {
items: LinearIssue[]
hasMore: boolean
canPage: boolean
itemIds: Set<string>
seenCursors: Set<string>
error?: LinearWorkspaceError
after?: string
}
@@ -904,39 +866,13 @@ function linearWorkspaceError(
}
}
async function readListIssuesForWorkspace(
entry: LinearClientForWorkspace,
filter: LinearListFilter,
limit: number | null,
workspaceId: LinearWorkspaceSelection | null | undefined,
options?: LinearIssueListOptions
): Promise<LinearCollectionResult<LinearIssue>> {
try {
return await readIssueConnectionPages(
entry,
limit,
getListIssueConnectionLoader(entry, filter, options),
{ deadline: Date.now() + LINEAR_AGENT_LIST_READ_BUDGET_MS, pages: 0 }
)
} catch (error) {
if (isAuthError(error)) {
clearToken(entry.workspace.id)
if (shouldThrowAuthError(workspaceId)) {
throw error
}
} else {
console.warn('[linear] listIssues failed:', error)
}
return { items: [], hasMore: false, errors: [linearWorkspaceError(entry, error)] }
}
}
async function readIssueConnectionPage(
entry: LinearClientForWorkspace,
loadConnection: LinearIssueConnectionLoader,
client: LinearClient,
page: LinearIssuePageRequest
): Promise<LinearIssuePageResult> {
const connection = await loadConnection(page)
const connection = await loadConnection(client, page)
const nodes = connection?.nodes ?? []
return {
items: nodes.map((issue) => mapRawIssueForWorkspace(entry, issue)),
@@ -958,22 +894,39 @@ async function readListIssuesPageForState(
}
budget.pages += 1
const previousCursor = state.after
await acquire()
try {
const page = await readIssueConnectionPage(
state.entry,
state.loadConnection,
previousCursor ? { first, after: previousCursor } : { first }
const read = await readLinearBeforeDeadline(state.entry, budget.deadline, async (client) =>
readIssueConnectionPage(
state.entry,
state.loadConnection,
client,
previousCursor ? { first, after: previousCursor } : { first }
)
)
state.items.push(...page.items)
if (!read.completed) {
state.hasMore = true
state.canPage = false
return
}
const page = read.value
for (const item of page.items) {
if (!state.itemIds.has(item.id)) {
state.itemIds.add(item.id)
state.items.push(item)
}
}
state.hasMore = page.hasMore
state.after = page.endCursor
state.canPage = Boolean(
page.hasMore && page.endCursor && page.endCursor !== previousCursor && page.items.length > 0
page.hasMore &&
page.endCursor &&
!state.seenCursors.has(page.endCursor) &&
page.items.length > 0
)
if (state.canPage && page.endCursor) {
state.seenCursors.add(page.endCursor)
}
} catch (error) {
state.items = []
state.hasMore = false
state.canPage = false
state.error = linearWorkspaceError(state.entry, error)
if (isAuthError(error)) {
@@ -984,8 +937,6 @@ async function readListIssuesPageForState(
} else {
console.warn('[linear] listIssues failed:', error)
}
} finally {
release()
}
}
@@ -1030,10 +981,12 @@ async function readListIssuesAcrossWorkspaces(
): Promise<LinearCollectionResult<LinearIssue>> {
const states: LinearIssueWorkspacePageState[] = entries.map((entry) => ({
entry,
loadConnection: getListIssueConnectionLoader(entry, filter, options),
loadConnection: getListIssueConnectionLoader(filter, options),
items: [],
hasMore: false,
canPage: false
canPage: false,
itemIds: new Set(),
seenCursors: new Set()
}))
const first =
limit === null
@@ -1119,10 +1072,6 @@ export async function listIssues(
return { items: [] }
}
if (entries.length === 1) {
return readListIssuesForWorkspace(entries[0], filter, effectiveLimit, workspaceId, options)
}
return readListIssuesAcrossWorkspaces(entries, filter, effectiveLimit, workspaceId, options)
}
@@ -0,0 +1,72 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LinearClientForWorkspace } from './client'
const acquire = vi.fn()
const release = vi.fn()
const createSignalBoundLinearClient = vi.fn()
vi.mock('./linear-request-concurrency', () => ({
acquire: (...args: unknown[]) => acquire(...args),
release: (...args: unknown[]) => release(...args)
}))
vi.mock('./client', () => ({
createSignalBoundLinearClient: (...args: unknown[]) => createSignalBoundLinearClient(...args)
}))
const entry = {
workspace: { id: 'workspace-1' },
apiKey: 'token'
} as LinearClientForWorkspace
function deferred(): { promise: Promise<void>; resolve: () => void } {
let resolve!: () => void
const promise = new Promise<void>((complete) => {
resolve = complete
})
return { promise, resolve }
}
describe('Linear read deadline', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
vi.clearAllMocks()
})
afterEach(() => {
vi.useRealTimers()
})
it('times out while queued and releases a late permit', async () => {
const queuedPermit = deferred()
const read = vi.fn()
acquire.mockReturnValueOnce(queuedPermit.promise)
const { readLinearBeforeDeadline } = await import('./linear-read-deadline')
const resultPromise = readLinearBeforeDeadline(entry, Date.now() + 20_000, read)
await vi.advanceTimersByTimeAsync(20_000)
await expect(resultPromise).resolves.toEqual({ completed: false, deadlineReached: true })
expect(read).not.toHaveBeenCalled()
expect(release).not.toHaveBeenCalled()
queuedPermit.resolve()
await vi.waitFor(() => expect(release).toHaveBeenCalledTimes(1))
})
it('settles a stalled in-flight read at the deadline', async () => {
const stalled = new Promise<never>(() => undefined)
const client = { client: { rawRequest: vi.fn() } }
acquire.mockResolvedValueOnce(undefined)
createSignalBoundLinearClient.mockReturnValueOnce(client)
const { readLinearBeforeDeadline } = await import('./linear-read-deadline')
const resultPromise = readLinearBeforeDeadline(entry, Date.now() + 20_000, () => stalled)
await vi.advanceTimersByTimeAsync(20_000)
await expect(resultPromise).resolves.toEqual({ completed: false, deadlineReached: true })
expect(createSignalBoundLinearClient).toHaveBeenCalledWith(entry, expect.any(AbortSignal))
expect(release).toHaveBeenCalledTimes(1)
})
})
+59
View File
@@ -0,0 +1,59 @@
import type { LinearClient } from '@linear/sdk'
import { createSignalBoundLinearClient, type LinearClientForWorkspace } from './client'
import { acquire, release } from './linear-request-concurrency'
export type LinearDeadlineRead<T> =
| { completed: true; value: T }
| { completed: false; deadlineReached: true }
export async function readLinearBeforeDeadline<T>(
entry: LinearClientForWorkspace,
deadline: number,
read: (client: LinearClient) => Promise<T>
): Promise<LinearDeadlineRead<T>> {
const remaining = deadline - Date.now()
if (remaining <= 0) {
return { completed: false, deadlineReached: true }
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), remaining)
timeout.unref?.()
const deadlineReached = new Promise<false>((resolve) => {
controller.signal.addEventListener('abort', () => resolve(false), { once: true })
})
const permit = acquire().then(() => {
if (controller.signal.aborted) {
release()
return false
}
return true
})
try {
const acquired = await Promise.race([permit, deadlineReached])
if (!acquired) {
return { completed: false, deadlineReached: true }
}
try {
const completed = await Promise.race([
read(createSignalBoundLinearClient(entry, controller.signal)).then((value) => ({
completed: true as const,
value
})),
deadlineReached.then(() => ({ completed: false as const, deadlineReached: true as const }))
])
return completed
} catch (error) {
if (controller.signal.aborted) {
return { completed: false, deadlineReached: true }
}
throw error
} finally {
release()
}
} finally {
clearTimeout(timeout)
}
}
+2 -5
View File
@@ -15,11 +15,8 @@ import { resolveWorkspaceSelector } from './issue-context-workspaces'
import { encodeIssueListCursor, resolveIssueListCursor } from './mcp-issue-list-cursor'
import { buildIssueFilter } from './mcp-issue-list-filter'
// Why: `--limit` is opt-in, so the default read walks every page instead of quietly cutting
// the answer off. The 250-row request size is conservative: Linear documents complexity-based
// GraphQL limits, not a fixed 250-row connection maximum. The budget and page ceiling are
// backstops, not caps: the CLI abandons an RPC at 60s, so a longer walk stops early and says
// `truncated` with a continuation cursor rather than failing the whole command.
// Why: omitted limits walk pages; this size balances throughput against GraphQL complexity.
// The deadline and page ceiling stop before the CLI timeout and preserve a continuation cursor.
const LIST_ISSUES_PAGE_SIZE = 250
const LIST_ISSUES_MAX_PAGES = 200
const LIST_ISSUES_READ_BUDGET_MS = 20_000
+80
View File
@@ -19,6 +19,7 @@ vi.mock('./linear-token-store', () => ({
}))
vi.mock('./client', () => ({
createSignalBoundLinearClient: (entry: LinearClientForWorkspace) => entry.client,
getClients: (...args: unknown[]) => getClients(...args),
isAuthError: (...args: unknown[]) => isAuthError(...args)
}))
@@ -329,6 +330,85 @@ describe('Linear project queries', () => {
expect(secondRequest).toHaveBeenCalledTimes(2)
})
it('retains all-workspace project rows when a later page fails', async () => {
const firstRequest = vi
.fn()
.mockResolvedValueOnce(
projectConnectionResponse([rawProject('W1-1')], {
hasNextPage: true,
endCursor: 'workspace-1-cursor-1'
})
)
.mockRejectedValueOnce(new Error('fetch failed'))
const secondRequest = vi
.fn()
.mockResolvedValueOnce(projectConnectionResponse([rawProject('W2-1')]))
getClients.mockReturnValue([
makeEntry({ request: firstRequest }),
makeEntry({
workspaceId: 'workspace-2',
organizationName: 'Second Workspace',
request: secondRequest
})
])
const { listProjects } = await import('./projects')
const result = await listProjects(undefined, null, 'all', true)
expect(result.items.map((project) => project.id)).toEqual(['W1-1', 'W2-1'])
expect(result.hasMore).toBe(true)
expect(result.errors).toMatchObject([
{ workspaceId: 'workspace-1', type: 'unknown', message: 'fetch failed' }
])
})
it('still rejects concrete project lists when a later page fails', async () => {
rawRequest
.mockResolvedValueOnce(
projectConnectionResponse([rawProject('project-1')], {
hasNextPage: true,
endCursor: 'project-cursor-1'
})
)
.mockRejectedValueOnce(new Error('fetch failed'))
const { listProjects } = await import('./projects')
await expect(listProjects(undefined, null, 'workspace-1', true)).rejects.toThrow('fetch failed')
})
it('deduplicates project pages and stops a nonconsecutive cursor cycle', async () => {
rawRequest
.mockResolvedValueOnce(
projectConnectionResponse([rawProject('project-1')], {
hasNextPage: true,
endCursor: 'cursor-a'
})
)
.mockResolvedValueOnce(
projectConnectionResponse([rawProject('project-1'), rawProject('project-2')], {
hasNextPage: true,
endCursor: 'cursor-b'
})
)
.mockResolvedValueOnce(
projectConnectionResponse([rawProject('project-3')], {
hasNextPage: true,
endCursor: 'cursor-a'
})
)
const { listProjects } = await import('./projects')
const result = await listProjects(undefined, null, 'workspace-1', true)
expect(result.items.map((project) => project.id)).toEqual([
'project-1',
'project-2',
'project-3'
])
expect(result.hasMore).toBe(true)
expect(rawRequest).toHaveBeenCalledTimes(3)
})
it('lets manual project issue refresh bypass older in-flight reads', async () => {
const staleRequest = deferred<ReturnType<typeof projectIssuesResponse>>()
const refreshRequest = deferred<ReturnType<typeof projectIssuesResponse>>()
+32 -13
View File
@@ -21,6 +21,7 @@ import {
import { acquire, release } from './linear-request-concurrency'
import { clearToken } from './linear-token-store'
import { getClients, isAuthError, type LinearClientForWorkspace } from './client'
import { readLinearBeforeDeadline } from './linear-read-deadline'
type LinearRawVariables = Record<string, unknown>
@@ -861,6 +862,8 @@ type ProjectListPageState = {
items: LinearProjectSummary[]
hasMore: boolean
canPage: boolean
itemIds: Set<string>
seenCursors: Set<string>
after?: string
error?: LinearWorkspaceError
}
@@ -881,7 +884,6 @@ async function readProjectListPage(
}
budget.pages += 1
const previousCursor = state.after
await acquire()
try {
const variables = query
? { term: query, first, ...(previousCursor ? { after: previousCursor } : {}) }
@@ -890,18 +892,34 @@ async function readProjectListPage(
...(previousCursor ? { after: previousCursor } : {}),
orderBy: 'updatedAt'
}
const result = await state.entry.client.client.rawRequest<
ProjectConnectionResponse,
LinearRawVariables
>(query ? SEARCH_PROJECTS_QUERY : PROJECTS_QUERY, variables)
const read = await readLinearBeforeDeadline(state.entry, budget.deadline, (client) =>
client.client.rawRequest<ProjectConnectionResponse, LinearRawVariables>(
query ? SEARCH_PROJECTS_QUERY : PROJECTS_QUERY,
variables
)
)
if (!read.completed) {
state.hasMore = true
state.canPage = false
return
}
const result = read.value
const connection = query ? result.data?.searchProjects : result.data?.projects
const nodes = connection?.nodes ?? []
state.items.push(...nodes.map((project) => mapProjectForWorkspace(state.entry, project)))
for (const node of nodes) {
if (!state.itemIds.has(node.id)) {
state.itemIds.add(node.id)
state.items.push(mapProjectForWorkspace(state.entry, node))
}
}
state.hasMore = Boolean(connection?.pageInfo?.hasNextPage)
state.after = connection?.pageInfo?.endCursor ?? undefined
state.canPage = Boolean(
state.hasMore && state.after && state.after !== previousCursor && nodes.length > 0
state.hasMore && state.after && !state.seenCursors.has(state.after) && nodes.length > 0
)
if (state.canPage && state.after) {
state.seenCursors.add(state.after)
}
} catch (error) {
if (isAuthError(error)) {
clearToken(state.entry.workspace.id)
@@ -912,11 +930,7 @@ async function readProjectListPage(
throw error
}
state.error = workspaceError(state.entry, error)
state.items = []
state.hasMore = false
state.canPage = false
} finally {
release()
}
}
@@ -933,10 +947,15 @@ async function readProjectListPages(
entry,
items: [],
hasMore: false,
canPage: false
canPage: false,
itemIds: new Set(),
seenCursors: new Set()
}))
const budget = { deadline: Date.now() + LINEAR_AGENT_PROJECT_READ_BUDGET_MS, pages: 0 }
const first = limit === null ? LINEAR_PROJECT_API_PAGE_SIZE_MAX : Math.min(50, limit)
const first =
limit === null
? LINEAR_PROJECT_API_PAGE_SIZE_MAX
: Math.min(LINEAR_PROJECT_API_PAGE_SIZE_MAX, limit)
await Promise.all(
states.map((state) => readProjectListPage(state, query, first, workspaceId, budget))
)
@@ -41,7 +41,7 @@ describe('SSH Linear pagination contract', () => {
)
const issueHelp = getRemoteLinearHelp(parseRemoteCliArgs(['linear', 'list', '--help']))
expect(projectHelp).toContain('Omit --limit to walk every page')
expect(issueHelp).toContain('Omit --limit to walk every page')
expect(projectHelp).toContain('Omit --limit to walk until exhaustion or a safety backstop')
expect(issueHelp).toContain('Omit --limit to walk until exhaustion or a safety backstop')
})
})
+2 -2
View File
@@ -154,10 +154,10 @@ const LINEAR_PROJECT_LIST_HELP = `orca linear project list
Usage: orca linear project list [--query <text>] [--limit <n>] [--workspace <id>|all] [--json]
List Linear projects. Omit --limit to walk every page; safety backstops report truncation.`
List Linear projects. Omit --limit to walk until exhaustion or a safety backstop.`
const LINEAR_LIST_HELP = `orca linear list
Usage: orca linear list [--filter assigned|created|all|completed|open] [--team <key|id>] [--limit <n>] [--workspace <id>|all] [--json]
List Linear issues. Omit --limit to walk every page; safety backstops report truncation.`
List Linear issues. Omit --limit to walk until exhaustion or a safety backstop.`
@@ -35,7 +35,11 @@ export function isLinearSearchResult(result: unknown): result is LinearSearchRes
Array.isArray(result.issues) &&
isRecord(result.meta) &&
typeof result.meta.query === 'string' &&
typeof result.meta.returned === 'number'
typeof result.meta.returned === 'number' &&
(result.totalCount === undefined ||
(typeof result.totalCount === 'number' &&
Number.isFinite(result.totalCount) &&
result.totalCount >= 0))
)
}
@@ -69,6 +69,27 @@ describe('remote Linear list truncation output', () => {
)
})
it('formats search output from an older host without totalCount', () => {
const result = {
issues: [issue],
truncated: true,
meta: { query: 'auth', limit: 1, returned: 1, limitReached: true }
}
expect(formatRemoteLinearCli(result)?.stdout).toContain('truncated: showing 1')
})
it('falls back to raw JSON for malformed provider totals', () => {
const result = {
issues: [issue],
totalCount: '349',
truncated: true,
meta: { query: 'auth', limit: 1, returned: 1, limitReached: true }
}
expect(formatRemoteLinearCli(result)).toBeNull()
})
it('accepts null limits from a new project-list host', () => {
const result = {
projects: [{ id: 'project-1', name: 'Launch' }],