mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 00:04:01 +00:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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.'
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user