mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
fix(linear): harden exhaustive pagination
This commit is contained in:
@@ -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 } }
|
||||
|
||||
@@ -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
@@ -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
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' }],
|
||||
|
||||
Reference in New Issue
Block a user