Allow Linear context issue lists to load more (#4512)

* Allow Linear context issue lists to load more

Co-authored-by: Orca <help@stably.ai>

* Simplify Linear load more footer copy

Co-authored-by: Orca <help@stably.ai>

* Page Linear issue reads past backend cap

Co-authored-by: Orca <help@stably.ai>

* Align Linear load more footer with GitHub pager

Co-authored-by: Orca <help@stably.ai>

* Use pager for Linear issue lists

Co-authored-by: Orca <help@stably.ai>

* Avoid phantom Linear issue pages

Co-authored-by: Orca <help@stably.ai>

* Fix local Linear issue pagination cap

Co-authored-by: Orca <help@stably.ai>

* Fix Linear pagination review issues

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-06-02 21:17:20 -04:00 committed by GitHub
parent d10d01919a
commit 4f18c7e79d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1236 additions and 206 deletions

128
src/main/ipc/linear.test.ts Normal file
View File

@ -0,0 +1,128 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
handleMock,
listIssuesMock,
listProjectIssuesMock,
listCustomViewIssuesMock,
connectMock,
disconnectMock,
getStatusMock,
selectWorkspaceMock,
testConnectionMock
} = vi.hoisted(() => ({
handleMock: vi.fn(),
listIssuesMock: vi.fn(),
listProjectIssuesMock: vi.fn(),
listCustomViewIssuesMock: vi.fn(),
connectMock: vi.fn(),
disconnectMock: vi.fn(),
getStatusMock: vi.fn(),
selectWorkspaceMock: vi.fn(),
testConnectionMock: vi.fn()
}))
vi.mock('electron', () => ({
ipcMain: {
handle: handleMock
}
}))
vi.mock('../linear/client', () => ({
connect: connectMock,
disconnect: disconnectMock,
getStatus: getStatusMock,
selectWorkspace: selectWorkspaceMock,
testConnection: testConnectionMock
}))
vi.mock('../linear/issues', () => ({
getIssue: vi.fn(),
searchIssues: vi.fn(),
listIssues: listIssuesMock,
createIssue: vi.fn(),
updateIssue: vi.fn(),
addIssueComment: vi.fn(),
getIssueComments: vi.fn()
}))
vi.mock('../linear/projects', () => ({
getCustomView: vi.fn(),
getProject: vi.fn(),
listCustomViewIssues: listCustomViewIssuesMock,
listCustomViewProjects: vi.fn(),
listCustomViews: vi.fn(),
listProjectIssues: listProjectIssuesMock,
listProjects: vi.fn()
}))
vi.mock('../linear/teams', () => ({
listTeams: vi.fn(),
getTeamStates: vi.fn(),
getTeamLabels: vi.fn(),
getTeamMembers: vi.fn()
}))
vi.mock('./preflight', () => ({
_resetPreflightCache: vi.fn()
}))
import { registerLinearHandlers } from './linear'
type HandlerMap = Record<string, (_event: unknown, args: unknown) => unknown>
describe('registerLinearHandlers', () => {
const handlers: HandlerMap = {}
beforeEach(() => {
handleMock.mockReset()
listIssuesMock.mockReset()
listProjectIssuesMock.mockReset()
listCustomViewIssuesMock.mockReset()
for (const key of Object.keys(handlers)) {
delete handlers[key]
}
handleMock.mockImplementation((channel, handler) => {
handlers[channel] = handler
})
})
it('forwards expanded Linear issue list limits through local IPC', async () => {
listIssuesMock.mockResolvedValue({ items: [], hasMore: true })
registerLinearHandlers()
await handlers['linear:listIssues'](null, {
filter: 'all',
limit: 216,
workspaceId: 'workspace-1'
})
expect(listIssuesMock).toHaveBeenCalledWith('all', 216, 'workspace-1')
})
it('forwards expanded Linear project issue limits through local IPC', async () => {
listProjectIssuesMock.mockResolvedValue({ items: [], hasMore: true })
registerLinearHandlers()
await handlers['linear:listProjectIssues'](null, {
projectId: 'project-1',
limit: 216,
workspaceId: 'workspace-1'
})
expect(listProjectIssuesMock).toHaveBeenCalledWith('project-1', 216, 'workspace-1', false)
})
it('forwards expanded Linear custom view issue limits through local IPC', async () => {
listCustomViewIssuesMock.mockResolvedValue({ items: [], hasMore: true })
registerLinearHandlers()
await handlers['linear:listCustomViewIssues'](null, {
viewId: 'view-1',
limit: 216,
workspaceId: 'workspace-1'
})
expect(listCustomViewIssuesMock).toHaveBeenCalledWith('view-1', 216, 'workspace-1', false)
})
})

View File

@ -23,6 +23,7 @@ import {
} from '../linear/projects'
import { listTeams, getTeamStates, getTeamLabels, getTeamMembers } from '../linear/teams'
import type { LinearListFilter } from '../linear/issues'
import { clampLinearIssueListLimit } from '../../shared/linear-issue-read-limits'
import type {
LinearCustomViewModel,
LinearIssueUpdate,
@ -111,7 +112,7 @@ export function registerLinearHandlers(): void {
const filter = VALID_FILTERS.has(args?.filter as LinearListFilter)
? (args!.filter as LinearListFilter)
: undefined
const limit = Math.min(Math.max(1, args?.limit ?? 20), 50)
const limit = clampLinearIssueListLimit(args?.limit)
return listIssues(filter, limit, normalizeWorkspaceSelection(args?.workspaceId))
}
)
@ -306,7 +307,7 @@ export function registerLinearHandlers(): void {
if (typeof args?.projectId !== 'string' || !args.projectId.trim()) {
throw new Error('Project ID is required')
}
const limit = Math.min(Math.max(1, args?.limit ?? 20), 50)
const limit = clampLinearIssueListLimit(args?.limit)
return listProjectIssues(
args.projectId.trim(),
limit,
@ -369,7 +370,7 @@ export function registerLinearHandlers(): void {
if (typeof args?.viewId !== 'string' || !args.viewId.trim()) {
throw new Error('Custom view ID is required')
}
const limit = Math.min(Math.max(1, args?.limit ?? 20), 50)
const limit = clampLinearIssueListLimit(args?.limit)
return listCustomViewIssues(
args.viewId.trim(),
limit,

View File

@ -13,17 +13,21 @@ vi.mock('./client', () => ({
clearToken: (...args: unknown[]) => clearToken(...args)
}))
function makeEntry(): LinearClientForWorkspace {
function makeEntry(options?: {
workspaceId?: string
organizationName?: string
request?: typeof rawRequest
}): LinearClientForWorkspace {
return {
workspace: {
id: 'workspace-1',
organizationId: 'workspace-1',
organizationName: 'Workspace',
id: options?.workspaceId ?? 'workspace-1',
organizationId: options?.workspaceId ?? 'workspace-1',
organizationName: options?.organizationName ?? 'Workspace',
displayName: 'Ada',
email: 'ada@example.com'
},
client: {
client: { rawRequest }
client: { rawRequest: options?.request ?? rawRequest }
}
} as unknown as LinearClientForWorkspace
}
@ -46,6 +50,40 @@ function rawIssue(id: string, updatedAt = '2026-01-01T00:00:00.000Z') {
}
}
function issueConnectionResponse(
ids: string[],
pageInfo: { hasNextPage: boolean; endCursor?: string | null } = { hasNextPage: false }
) {
return {
data: {
issues: {
nodes: ids.map((id) => rawIssue(id)),
pageInfo
}
}
}
}
function issueConnectionResponseFromIssues(
issues: ReturnType<typeof rawIssue>[],
pageInfo: { hasNextPage: boolean; endCursor?: string | null } = { hasNextPage: false }
) {
return {
data: {
issues: {
nodes: issues,
pageInfo
}
}
}
}
function datedIssues(prefix: string, count: number, startMs: number, startIndex = 1) {
return Array.from({ length: count }, (_, index) =>
rawIssue(`${prefix}-${startIndex + index}`, new Date(startMs - index * 1000).toISOString())
)
}
describe('Linear issue queries', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -141,17 +179,40 @@ describe('Linear issue queries', () => {
})
})
it('loads plain issue lists past Linear connection page size with cursors', async () => {
rawRequest
.mockResolvedValueOnce(
issueConnectionResponse(
Array.from({ length: 50 }, (_, index) => `LIN-${index + 1}`),
{ hasNextPage: true, endCursor: 'cursor-50' }
)
)
.mockResolvedValueOnce(
issueConnectionResponse(
Array.from({ length: 22 }, (_, index) => `LIN-${index + 51}`),
{ hasNextPage: false, endCursor: null }
)
)
const { listIssues } = await import('./issues')
const result = await listIssues('all', 72, 'workspace-1')
expect(result.items).toHaveLength(72)
expect(result.hasMore).toBe(false)
expect(rawRequest).toHaveBeenCalledTimes(2)
expect(rawRequest.mock.calls[0][1]).toMatchObject({ first: 50, orderBy: 'updatedAt' })
expect(rawRequest.mock.calls[0][1]).not.toHaveProperty('after')
expect(rawRequest.mock.calls[1][1]).toMatchObject({
first: 22,
after: 'cursor-50',
orderBy: 'updatedAt'
})
})
it('marks multi-workspace plain lists as having more when the merged result is clipped', async () => {
getClients.mockReturnValue([
makeEntry(),
{
...makeEntry(),
workspace: {
...makeEntry().workspace,
id: 'workspace-2',
organizationName: 'Second Workspace'
}
}
makeEntry({ workspaceId: 'workspace-2', organizationName: 'Second Workspace' })
])
rawRequest
.mockResolvedValueOnce({
@ -178,6 +239,64 @@ describe('Linear issue queries', () => {
})
})
it('pages only workspaces that can affect the global multi-workspace cutoff', async () => {
const firstWorkspaceRequest = vi.fn()
const secondWorkspaceRequest = vi.fn()
getClients.mockReturnValue([
makeEntry({ request: firstWorkspaceRequest }),
makeEntry({
workspaceId: 'workspace-2',
organizationName: 'Second Workspace',
request: secondWorkspaceRequest
})
])
firstWorkspaceRequest
.mockResolvedValueOnce(
issueConnectionResponseFromIssues(datedIssues('W1', 50, Date.UTC(2026, 3, 1)), {
hasNextPage: true,
endCursor: 'workspace-1-cursor-50'
})
)
.mockResolvedValueOnce(
issueConnectionResponseFromIssues(
datedIssues('W1', 22, Date.UTC(2026, 3, 1) - 50_000, 51),
{ hasNextPage: true, endCursor: 'workspace-1-cursor-72' }
)
)
secondWorkspaceRequest.mockResolvedValueOnce(
issueConnectionResponseFromIssues(datedIssues('W2', 50, Date.UTC(2026, 0, 1)), {
hasNextPage: true,
endCursor: 'workspace-2-cursor-50'
})
)
const { listIssues } = await import('./issues')
const result = await listIssues('all', 72, 'all')
expect(result.items).toHaveLength(72)
expect(result.items.map((issue) => issue.id)).toEqual(
Array.from({ length: 72 }, (_, index) => `W1-${index + 1}`)
)
expect(result.hasMore).toBe(true)
expect(firstWorkspaceRequest).toHaveBeenCalledTimes(2)
expect(firstWorkspaceRequest.mock.calls[0][1]).toMatchObject({
first: 50,
orderBy: 'updatedAt'
})
expect(firstWorkspaceRequest.mock.calls[0][1]).not.toHaveProperty('after')
expect(firstWorkspaceRequest.mock.calls[1][1]).toMatchObject({
first: 22,
after: 'workspace-1-cursor-50',
orderBy: 'updatedAt'
})
expect(secondWorkspaceRequest).toHaveBeenCalledTimes(1)
expect(secondWorkspaceRequest.mock.calls[0][1]).toMatchObject({
first: 50,
orderBy: 'updatedAt'
})
expect(secondWorkspaceRequest.mock.calls[0][1]).not.toHaveProperty('after')
})
it('sends estimate updates through to Linear', async () => {
const updateIssue = vi.fn().mockResolvedValue({ success: true })
getClients.mockReturnValue([

View File

@ -8,7 +8,10 @@ import type {
LinearCollectionResult,
LinearWorkspaceSelection
} from '../../shared/types'
import { clampLinearPlainIssueListLimit } from '../../shared/linear-issue-list-limits'
import {
LINEAR_ISSUE_API_PAGE_SIZE_MAX,
clampLinearIssueListLimit
} from '../../shared/linear-issue-read-limits'
import {
acquire,
release,
@ -62,10 +65,18 @@ type LinearIssueConnection = {
nodes?: LinearIssueNode[]
pageInfo?: {
hasNextPage?: boolean
endCursor?: string | null
}
}
type LinearRawVariables = Record<string, unknown>
type LinearIssuePageRequest = {
first: number
after?: string
}
type LinearIssueConnectionLoader = (
page: LinearIssuePageRequest
) => Promise<LinearIssueConnection | null | undefined>
const LINEAR_ISSUE_NODE_FIELDS = `
id
@ -111,13 +122,19 @@ const SEARCH_ISSUES_QUERY = `
`
const ALL_ISSUES_QUERY = `
query OrcaLinearIssues($first: Int, $filter: IssueFilter, $orderBy: PaginationOrderBy) {
issues(first: $first, filter: $filter, orderBy: $orderBy) {
query OrcaLinearIssues(
$first: Int,
$after: String,
$filter: IssueFilter,
$orderBy: PaginationOrderBy
) {
issues(first: $first, after: $after, filter: $filter, orderBy: $orderBy) {
nodes {
${LINEAR_ISSUE_NODE_FIELDS}
}
pageInfo {
hasNextPage
endCursor
}
}
}
@ -126,16 +143,18 @@ const ALL_ISSUES_QUERY = `
const VIEWER_ASSIGNED_ISSUES_QUERY = `
query OrcaLinearViewerAssignedIssues(
$first: Int,
$after: String,
$filter: IssueFilter,
$orderBy: PaginationOrderBy
) {
viewer {
assignedIssues(first: $first, filter: $filter, orderBy: $orderBy) {
assignedIssues(first: $first, after: $after, filter: $filter, orderBy: $orderBy) {
nodes {
${LINEAR_ISSUE_NODE_FIELDS}
}
pageInfo {
hasNextPage
endCursor
}
}
}
@ -145,16 +164,18 @@ const VIEWER_ASSIGNED_ISSUES_QUERY = `
const VIEWER_CREATED_ISSUES_QUERY = `
query OrcaLinearViewerCreatedIssues(
$first: Int,
$after: String,
$filter: IssueFilter,
$orderBy: PaginationOrderBy
) {
viewer {
createdIssues(first: $first, filter: $filter, orderBy: $orderBy) {
createdIssues(first: $first, after: $after, filter: $filter, orderBy: $orderBy) {
nodes {
${LINEAR_ISSUE_NODE_FIELDS}
}
pageInfo {
hasNextPage
endCursor
}
}
}
@ -233,6 +254,97 @@ function mapRawIssueForWorkspace(
}
}
async function readIssueConnectionPages(
entry: LinearClientForWorkspace,
limit: number,
loadConnection: LinearIssueConnectionLoader
): Promise<{ items: LinearIssue[]; hasMore: boolean }> {
const items: LinearIssue[] = []
let after: string | undefined
let hasMore = false
while (items.length < limit) {
// Why: Linear caps connection pages at 50, so larger Orca reads must walk
// cursors instead of asking for the whole expanded limit in one request.
const first = Math.min(LINEAR_ISSUE_API_PAGE_SIZE_MAX, limit - items.length)
const connection = await loadConnection(after ? { first, after } : { first })
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
): LinearIssueConnectionLoader {
const orderBy = 'updatedAt'
const variables = { orderBy }
if (filter === 'assigned') {
return async (page) => {
const result = await entry.client.client.rawRequest<
LinearIssueConnectionResponse,
LinearRawVariables
>(VIEWER_ASSIGNED_ISSUES_QUERY, {
...variables,
...page,
filter: ACTIVE_STATE_FILTER
})
return result.data?.viewer?.assignedIssues
}
}
if (filter === 'created') {
return async (page) => {
const result = await entry.client.client.rawRequest<
LinearIssueConnectionResponse,
LinearRawVariables
>(VIEWER_CREATED_ISSUES_QUERY, {
...variables,
...page,
filter: ACTIVE_STATE_FILTER
})
return result.data?.viewer?.createdIssues
}
}
if (filter === 'completed') {
return async (page) => {
const result = await entry.client.client.rawRequest<
LinearIssueConnectionResponse,
LinearRawVariables
>(VIEWER_ASSIGNED_ISSUES_QUERY, {
...variables,
...page,
filter: COMPLETED_STATE_FILTER
})
return result.data?.viewer?.assignedIssues
}
}
return async (page) => {
const result = await entry.client.client.rawRequest<
LinearIssueConnectionResponse,
LinearRawVariables
>(ALL_ISSUES_QUERY, { ...variables, ...page, filter: ACTIVE_STATE_FILTER })
return result.data?.issues
}
}
function shouldThrowAuthError(selection: LinearWorkspaceSelection | null | undefined): boolean {
return selection !== 'all'
}
@ -319,91 +431,189 @@ export type LinearListFilter = 'assigned' | 'created' | 'all' | 'completed'
const ACTIVE_STATE_FILTER = { state: { type: { nin: ['completed', 'canceled'] } } }
const COMPLETED_STATE_FILTER = { state: { type: { in: ['completed', 'canceled'] } } }
type LinearIssuePageResult = {
items: LinearIssue[]
hasMore: boolean
endCursor?: string
}
type LinearIssueWorkspacePageState = {
entry: LinearClientForWorkspace
loadConnection: LinearIssueConnectionLoader
items: LinearIssue[]
hasMore: boolean
canPage: boolean
after?: string
}
async function readListIssuesForWorkspace(
entry: LinearClientForWorkspace,
filter: LinearListFilter,
limit: number,
workspaceId: LinearWorkspaceSelection | null | undefined
): Promise<{ items: LinearIssue[]; hasMore: boolean }> {
await acquire()
try {
return readIssueConnectionPages(entry, limit, getListIssueConnectionLoader(entry, filter))
} 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 }
} finally {
release()
}
}
async function readIssueConnectionPage(
entry: LinearClientForWorkspace,
loadConnection: LinearIssueConnectionLoader,
page: LinearIssuePageRequest
): Promise<LinearIssuePageResult> {
const connection = await loadConnection(page)
const nodes = connection?.nodes ?? []
return {
items: nodes.map((issue) => mapRawIssueForWorkspace(entry, issue)),
hasMore: Boolean(connection?.pageInfo?.hasNextPage),
endCursor: connection?.pageInfo?.endCursor ?? undefined
}
}
async function readListIssuesPageForState(
state: LinearIssueWorkspacePageState,
first: number,
workspaceId: LinearWorkspaceSelection | null | undefined
): Promise<void> {
const previousCursor = state.after
await acquire()
try {
const page = await readIssueConnectionPage(
state.entry,
state.loadConnection,
previousCursor ? { first, after: previousCursor } : { first }
)
state.items.push(...page.items)
state.hasMore = page.hasMore
state.after = page.endCursor
state.canPage = Boolean(
page.hasMore && page.endCursor && page.endCursor !== previousCursor && page.items.length > 0
)
} catch (error) {
state.items = []
state.hasMore = false
state.canPage = false
if (isAuthError(error)) {
clearToken(state.entry.workspace.id)
if (shouldThrowAuthError(workspaceId)) {
throw error
}
} else {
console.warn('[linear] listIssues failed:', error)
}
} finally {
release()
}
}
function findWorkspaceToPageForLimit(
states: LinearIssueWorkspacePageState[],
limit: number
): LinearIssueWorkspacePageState | undefined {
const merged = sortAndLimitIssues(
states.flatMap((state) => state.items),
limit
)
if (merged.length < limit) {
return states
.filter((state) => state.canPage)
.sort((a, b) => getOldestIssueTime(b.items) - getOldestIssueTime(a.items))[0]
}
const cutoff = new Date(merged[limit - 1].updatedAt).getTime()
return states
.filter((state) => state.canPage && getOldestIssueTime(state.items) > cutoff)
.sort((a, b) => getOldestIssueTime(b.items) - getOldestIssueTime(a.items))[0]
}
function countSelectedIssuesOlderThanWorkspaceBoundary(
states: LinearIssueWorkspacePageState[],
stateToPage: LinearIssueWorkspacePageState,
limit: number
): number {
const boundary = getOldestIssueTime(stateToPage.items)
return sortAndLimitIssues(
states.flatMap((state) => state.items),
limit
).filter((issue) => new Date(issue.updatedAt).getTime() < boundary).length
}
async function readListIssuesAcrossWorkspaces(
entries: LinearClientForWorkspace[],
filter: LinearListFilter,
limit: number,
workspaceId: LinearWorkspaceSelection | null | undefined
): Promise<LinearCollectionResult<LinearIssue>> {
const states: LinearIssueWorkspacePageState[] = entries.map((entry) => ({
entry,
loadConnection: getListIssueConnectionLoader(entry, filter),
items: [],
hasMore: false,
canPage: false
}))
const first = Math.min(LINEAR_ISSUE_API_PAGE_SIZE_MAX, limit)
// Why: "all workspaces" is a global sorted list. Pull one bounded page per
// workspace first, then spend additional API calls only where unseen issues
// can still change the global updatedAt cutoff.
await Promise.all(states.map((state) => readListIssuesPageForState(state, first, workspaceId)))
for (;;) {
const nextState = findWorkspaceToPageForLimit(states, limit)
if (!nextState) {
break
}
const itemCount = states.reduce((count, state) => count + state.items.length, 0)
const pageSize =
itemCount < limit
? Math.min(LINEAR_ISSUE_API_PAGE_SIZE_MAX, limit - itemCount)
: Math.min(
LINEAR_ISSUE_API_PAGE_SIZE_MAX,
Math.max(1, countSelectedIssuesOlderThanWorkspaceBoundary(states, nextState, limit))
)
await readListIssuesPageForState(nextState, pageSize, workspaceId)
}
const limited = sortLimitAndDescribeIssues(
states.flatMap((state) => state.items),
limit
)
return {
items: limited.items,
hasMore: states.some((state) => state.hasMore) || limited.clipped
}
}
export async function listIssues(
filter: LinearListFilter = 'assigned',
limit = 20,
workspaceId?: LinearWorkspaceSelection | null
): Promise<LinearCollectionResult<LinearIssue>> {
const effectiveLimit = clampLinearPlainIssueListLimit(limit)
const effectiveLimit = clampLinearIssueListLimit(limit)
const entries = getClients(workspaceId)
if (entries.length === 0) {
return { items: [] }
}
const results = await Promise.all(
entries.map(async (entry) => {
await acquire()
try {
const orderBy = 'updatedAt'
const variables = { first: effectiveLimit, orderBy }
if (filter === 'assigned') {
const result = await entry.client.client.rawRequest<
LinearIssueConnectionResponse,
LinearRawVariables
>(VIEWER_ASSIGNED_ISSUES_QUERY, { ...variables, filter: ACTIVE_STATE_FILTER })
const connection = result.data?.viewer?.assignedIssues
return {
items: (connection?.nodes ?? []).map((issue) => mapRawIssueForWorkspace(entry, issue)),
hasMore: Boolean(connection?.pageInfo?.hasNextPage)
}
}
if (filter === 'created') {
const result = await entry.client.client.rawRequest<
LinearIssueConnectionResponse,
LinearRawVariables
>(VIEWER_CREATED_ISSUES_QUERY, { ...variables, filter: ACTIVE_STATE_FILTER })
const connection = result.data?.viewer?.createdIssues
return {
items: (connection?.nodes ?? []).map((issue) => mapRawIssueForWorkspace(entry, issue)),
hasMore: Boolean(connection?.pageInfo?.hasNextPage)
}
}
if (filter === 'completed') {
const result = await entry.client.client.rawRequest<
LinearIssueConnectionResponse,
LinearRawVariables
>(VIEWER_ASSIGNED_ISSUES_QUERY, { ...variables, filter: COMPLETED_STATE_FILTER })
const connection = result.data?.viewer?.assignedIssues
return {
items: (connection?.nodes ?? []).map((issue) => mapRawIssueForWorkspace(entry, issue)),
hasMore: Boolean(connection?.pageInfo?.hasNextPage)
}
}
// 'all' — all active issues across the workspace
const result = await entry.client.client.rawRequest<
LinearIssueConnectionResponse,
LinearRawVariables
>(ALL_ISSUES_QUERY, { ...variables, filter: ACTIVE_STATE_FILTER })
const connection = result.data?.issues
return {
items: (connection?.nodes ?? []).map((issue) => mapRawIssueForWorkspace(entry, issue)),
hasMore: Boolean(connection?.pageInfo?.hasNextPage)
}
} 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 }
} finally {
release()
}
})
)
const merged = results.flatMap((result) => result.items)
const limited = sortLimitAndDescribeIssues(merged, effectiveLimit)
return {
items: limited.items,
hasMore: results.some((result) => result.hasMore) || limited.clipped
if (entries.length === 1) {
return readListIssuesForWorkspace(entries[0], filter, effectiveLimit, workspaceId)
}
return readListIssuesAcrossWorkspaces(entries, filter, effectiveLimit, workspaceId)
}
export async function createIssue(

View File

@ -59,12 +59,19 @@ function rawCustomView(id: string) {
}
function projectIssuesResponse(issueId: string) {
return projectIssuesConnectionResponse([issueId])
}
function projectIssuesConnectionResponse(
issueIds: string[],
pageInfo: { hasNextPage: boolean; endCursor?: string | null } = { hasNextPage: false }
) {
return {
data: {
project: {
issues: {
nodes: [rawIssue(issueId)],
pageInfo: { hasNextPage: false }
nodes: issueIds.map((issueId) => rawIssue(issueId)),
pageInfo
}
}
}
@ -105,13 +112,20 @@ function customViewProjectsResponse(projectId: string) {
}
function customViewIssuesResponse(issueId: string) {
return customViewIssuesConnectionResponse([issueId])
}
function customViewIssuesConnectionResponse(
issueIds: string[],
pageInfo: { hasNextPage: boolean; endCursor?: string | null } = { hasNextPage: false }
) {
return {
data: {
customView: {
modelName: 'Issue',
issues: {
nodes: [rawIssue(issueId)],
pageInfo: { hasNextPage: false }
nodes: issueIds.map((issueId) => rawIssue(issueId)),
pageInfo
}
}
}
@ -155,6 +169,47 @@ describe('Linear project queries', () => {
})
})
it('loads project issue reads above Linear connection page size', async () => {
rawRequest
.mockResolvedValueOnce(
projectIssuesConnectionResponse(
Array.from({ length: 50 }, (_, index) => `LIN-${index + 1}`),
{ hasNextPage: true, endCursor: 'project-cursor-50' }
)
)
.mockResolvedValueOnce(
projectIssuesConnectionResponse(
Array.from({ length: 50 }, (_, index) => `LIN-${index + 51}`),
{ hasNextPage: true, endCursor: 'project-cursor-100' }
)
)
.mockResolvedValueOnce(
projectIssuesConnectionResponse(
Array.from({ length: 20 }, (_, index) => `LIN-${index + 101}`),
{ hasNextPage: false }
)
)
const { listProjectIssues } = await import('./projects')
const result = await listProjectIssues('project-1', 120, 'workspace-1')
expect(result.items).toHaveLength(120)
expect(result.hasMore).toBe(false)
expect(rawRequest).toHaveBeenCalledTimes(3)
expect(rawRequest.mock.calls[0]?.[1]).toMatchObject({ id: 'project-1', first: 50 })
expect(rawRequest.mock.calls[0]?.[1]).not.toHaveProperty('after')
expect(rawRequest.mock.calls[1]?.[1]).toMatchObject({
id: 'project-1',
first: 50,
after: 'project-cursor-50'
})
expect(rawRequest.mock.calls[2]?.[1]).toMatchObject({
id: 'project-1',
first: 20,
after: 'project-cursor-100'
})
})
it('lets manual custom view list refresh bypass older in-flight reads', async () => {
const staleRequest = deferred<ReturnType<typeof customViewsResponse>>()
const refreshRequest = deferred<ReturnType<typeof customViewsResponse>>()
@ -238,4 +293,45 @@ describe('Linear project queries', () => {
items: [{ id: 'ISSUE-STALE' }]
})
})
it('loads issue custom view reads above Linear connection page size', async () => {
rawRequest
.mockResolvedValueOnce(
customViewIssuesConnectionResponse(
Array.from({ length: 50 }, (_, index) => `ISSUE-${index + 1}`),
{ hasNextPage: true, endCursor: 'view-cursor-50' }
)
)
.mockResolvedValueOnce(
customViewIssuesConnectionResponse(
Array.from({ length: 50 }, (_, index) => `ISSUE-${index + 51}`),
{ hasNextPage: true, endCursor: 'view-cursor-100' }
)
)
.mockResolvedValueOnce(
customViewIssuesConnectionResponse(
Array.from({ length: 20 }, (_, index) => `ISSUE-${index + 101}`),
{ hasNextPage: false }
)
)
const { listCustomViewIssues } = await import('./projects')
const result = await listCustomViewIssues('view-1', 120, 'workspace-1')
expect(result.items).toHaveLength(120)
expect(result.hasMore).toBe(false)
expect(rawRequest).toHaveBeenCalledTimes(3)
expect(rawRequest.mock.calls[0]?.[1]).toMatchObject({ id: 'view-1', first: 50 })
expect(rawRequest.mock.calls[0]?.[1]).not.toHaveProperty('after')
expect(rawRequest.mock.calls[1]?.[1]).toMatchObject({
id: 'view-1',
first: 50,
after: 'view-cursor-50'
})
expect(rawRequest.mock.calls[2]?.[1]).toMatchObject({
id: 'view-1',
first: 20,
after: 'view-cursor-100'
})
})
})

View File

@ -12,6 +12,10 @@ import type {
LinearWorkspaceError,
LinearWorkspaceSelection
} from '../../shared/types'
import {
LINEAR_ISSUE_API_PAGE_SIZE_MAX,
clampLinearIssueListLimit
} from '../../shared/linear-issue-read-limits'
import {
acquire,
clearToken,
@ -25,6 +29,7 @@ type LinearRawVariables = Record<string, unknown>
type PageInfoNode = {
hasNextPage?: boolean | null
endCursor?: string | null
}
type LinearConnection<T> = {
@ -312,14 +317,20 @@ const PROJECT_QUERY = `
`
const PROJECT_ISSUES_QUERY = `
query OrcaLinearProjectIssues($id: String!, $first: Int, $orderBy: PaginationOrderBy) {
query OrcaLinearProjectIssues(
$id: String!,
$first: Int,
$after: String,
$orderBy: PaginationOrderBy
) {
project(id: $id) {
issues(first: $first, orderBy: $orderBy) {
issues(first: $first, after: $after, orderBy: $orderBy) {
nodes {
${ORCA_ISSUE_FIELDS}
}
pageInfo {
hasNextPage
endCursor
}
}
}
@ -400,16 +411,22 @@ const CUSTOM_VIEW_QUERY = `
`
const CUSTOM_VIEW_ISSUES_QUERY = `
query OrcaLinearCustomViewIssues($id: String!, $first: Int, $orderBy: PaginationOrderBy) {
query OrcaLinearCustomViewIssues(
$id: String!,
$first: Int,
$after: String,
$orderBy: PaginationOrderBy
) {
customView(id: $id) {
id
modelName
issues(first: $first, orderBy: $orderBy) {
issues(first: $first, after: $after, orderBy: $orderBy) {
nodes {
${ORCA_ISSUE_FIELDS}
}
pageInfo {
hasNextPage
endCursor
}
}
}
@ -691,6 +708,37 @@ function mapCustomViewForWorkspace(
}
}
async function readIssueConnectionPages(
entry: LinearClientForWorkspace,
limit: number,
loadConnection: (variables: {
first: number
after?: string
}) => Promise<LinearConnection<LinearIssueNode> | null | undefined>
): Promise<LinearCollectionResult<LinearIssue>> {
const items: LinearIssue[] = []
let after: string | undefined
let hasMore = false
while (items.length < limit) {
// Why: Linear returns issue connections in pages of up to 50; expanded
// Orca reads must follow cursors to show more than one backend page.
const first = Math.min(LINEAR_ISSUE_API_PAGE_SIZE_MAX, limit - items.length)
const connection = await loadConnection(after ? { first, after } : { first })
const nodes = connection?.nodes ?? []
items.push(...nodes.map((issue) => mapIssueForWorkspace(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 }
}
async function readCollection<T>(
key: string,
workspaceId: LinearWorkspaceSelection | null | undefined,
@ -827,25 +875,23 @@ export async function listProjectIssues(
if (!id) {
throw new Error('Project ID is required')
}
const first = clampLimit(limit)
const first = clampLinearIssueListLimit(limit)
const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId)
return readConcreteCollection(
`listProjectIssues:${concreteWorkspaceId}:${id}:${first}`,
concreteWorkspaceId,
async (entry) => {
const result = await entry.client.client.rawRequest<
ProjectIssueConnectionResponse,
LinearRawVariables
>(PROJECT_ISSUES_QUERY, { id, first, orderBy: 'updatedAt' })
const project = result.data?.project
if (!project) {
throw new Error('Project was not found')
}
const connection = project.issues
return {
items: (connection?.nodes ?? []).map((issue) => mapIssueForWorkspace(entry, issue)),
hasMore: !!connection?.pageInfo?.hasNextPage
}
return readIssueConnectionPages(entry, first, async (page) => {
const result = await entry.client.client.rawRequest<
ProjectIssueConnectionResponse,
LinearRawVariables
>(PROJECT_ISSUES_QUERY, { id, ...page, orderBy: 'updatedAt' })
const project = result.data?.project
if (!project) {
throw new Error('Project was not found')
}
return project.issues
})
},
force
)
@ -932,25 +978,23 @@ export async function listCustomViewIssues(
if (!id) {
throw new Error('Custom view ID is required')
}
const first = clampLimit(limit)
const first = clampLinearIssueListLimit(limit)
const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId)
return readConcreteCollection(
`listCustomViewIssues:${concreteWorkspaceId}:${id}:${first}`,
concreteWorkspaceId,
async (entry) => {
const result = await entry.client.client.rawRequest<
CustomViewConnectionResponse,
LinearRawVariables
>(CUSTOM_VIEW_ISSUES_QUERY, { id, first, orderBy: 'updatedAt' })
const view = result.data?.customView
if (mapCustomViewModel(view?.modelName) !== 'issue') {
throw new Error('Custom view does not contain issues')
}
const connection = view?.issues
return {
items: (connection?.nodes ?? []).map((issue) => mapIssueForWorkspace(entry, issue)),
hasMore: !!connection?.pageInfo?.hasNextPage
}
return readIssueConnectionPages(entry, first, async (page) => {
const result = await entry.client.client.rawRequest<
CustomViewConnectionResponse,
LinearRawVariables
>(CUSTOM_VIEW_ISSUES_QUERY, { id, ...page, orderBy: 'updatedAt' })
const view = result.data?.customView
if (mapCustomViewModel(view?.modelName) !== 'issue') {
throw new Error('Custom view does not contain issues')
}
return view?.issues
})
},
force
)

View File

@ -73,7 +73,7 @@ import type {
import type { FeatureInteractionId } from '../../shared/feature-interactions'
import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry'
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, splitWorktreeId } from '../../shared/worktree-id'
import { clampLinearPlainIssueListLimit } from '../../shared/linear-issue-list-limits'
import { clampLinearIssueListLimit } from '../../shared/linear-issue-read-limits'
import { isFolderRepo } from '../../shared/repo-kind'
import { getNextProjectGroupOrder } from '../../shared/project-groups'
import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses'
@ -12693,7 +12693,7 @@ export class OrcaRuntimeService {
limit = 20,
workspaceId?: LinearWorkspaceSelection
): ReturnType<typeof listLinearIssues> {
return listLinearIssues(filter, clampLinearPlainIssueListLimit(limit), workspaceId)
return listLinearIssues(filter, clampLinearIssueListLimit(limit), workspaceId)
}
linearCreateIssue(
@ -12771,7 +12771,7 @@ export class OrcaRuntimeService {
workspaceId: string,
force?: boolean
): ReturnType<typeof listLinearProjectIssues> {
return listLinearProjectIssues(projectId, Math.min(Math.max(1, limit), 50), workspaceId, force)
return listLinearProjectIssues(projectId, clampLinearIssueListLimit(limit), workspaceId, force)
}
linearListCustomViews(
@ -12798,7 +12798,7 @@ export class OrcaRuntimeService {
workspaceId: string,
force?: boolean
): ReturnType<typeof listLinearCustomViewIssues> {
return listLinearCustomViewIssues(viewId, Math.min(Math.max(1, limit), 50), workspaceId, force)
return listLinearCustomViewIssues(viewId, clampLinearIssueListLimit(limit), workspaceId, force)
}
linearListCustomViewProjects(

View File

@ -188,9 +188,9 @@ import type {
TaskViewPresetId
} from '../../../shared/types'
import {
LINEAR_PLAIN_ISSUE_LIST_MAX,
clampLinearPlainIssueListLimit
} from '../../../shared/linear-issue-list-limits'
LINEAR_ISSUE_LIST_MAX,
clampLinearIssueListLimit
} from '../../../shared/linear-issue-read-limits'
import { shouldSuppressEnterSubmit } from '@/lib/new-workspace-enter-guard'
import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour'
import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
@ -3123,6 +3123,10 @@ export default function TaskPage(): React.JSX.Element {
const [linearMode, setLinearMode] = useState<LinearMode>('issues')
const [linearIssues, setLinearIssues] = useState<LinearIssue[]>([])
const [linearIssueLimit, setLinearIssueLimit] = useState(LINEAR_ITEM_LIMIT)
const [linearIssuePage, setLinearIssuePage] = useState(0)
const [linearIssueLoadingTargetPage, setLinearIssueLoadingTargetPage] = useState<number | null>(
null
)
const [linearIssuesHasMore, setLinearIssuesHasMore] = useState(false)
const [linearLoading, setLinearLoading] = useState(false)
const [linearError, setLinearError] = useState<string | null>(null)
@ -3154,6 +3158,11 @@ export default function TaskPage(): React.JSX.Element {
const [linearProjectIssuesResult, setLinearProjectIssuesResult] = useState<
LinearCollectionResult<LinearIssue>
>({ items: [] })
const [linearProjectIssueLimit, setLinearProjectIssueLimit] = useState(LINEAR_ITEM_LIMIT)
const [linearProjectIssuePage, setLinearProjectIssuePage] = useState(0)
const [linearProjectIssueLoadingTargetPage, setLinearProjectIssueLoadingTargetPage] = useState<
number | null
>(null)
const [linearProjectIssuesLoading, setLinearProjectIssuesLoading] = useState(false)
const [linearProjectIssuesError, setLinearProjectIssuesError] = useState<string | null>(null)
const [linearCustomViewModel, setLinearCustomViewModel] = useState<LinearCustomViewModel>('issue')
@ -3169,6 +3178,10 @@ export default function TaskPage(): React.JSX.Element {
const [linearCustomViewIssuesResult, setLinearCustomViewIssuesResult] = useState<
LinearCollectionResult<LinearIssue>
>({ items: [] })
const [linearCustomViewIssueLimit, setLinearCustomViewIssueLimit] = useState(LINEAR_ITEM_LIMIT)
const [linearCustomViewIssuePage, setLinearCustomViewIssuePage] = useState(0)
const [linearCustomViewIssueLoadingTargetPage, setLinearCustomViewIssueLoadingTargetPage] =
useState<number | null>(null)
const [linearCustomViewProjectsResult, setLinearCustomViewProjectsResult] = useState<
LinearCollectionResult<LinearProjectSummary>
>({ items: [] })
@ -3202,7 +3215,13 @@ export default function TaskPage(): React.JSX.Element {
setSelectedLinearCustomView(null)
setLinearProjectParentView(null)
setLinearProjectIssuesResult({ items: [] })
setLinearProjectIssueLimit(LINEAR_ITEM_LIMIT)
setLinearProjectIssuePage(0)
setLinearProjectIssueLoadingTargetPage(null)
setLinearCustomViewIssuesResult({ items: [] })
setLinearCustomViewIssueLimit(LINEAR_ITEM_LIMIT)
setLinearCustomViewIssuePage(0)
setLinearCustomViewIssueLoadingTargetPage(null)
setLinearCustomViewProjectsResult({ items: [] })
setLinearMode(mode)
setTaskResumeState({ linearMode: mode, linearContext: undefined })
@ -3226,7 +3245,13 @@ export default function TaskPage(): React.JSX.Element {
setLinearCustomViewProjectsResult({ items: [] })
}
setLinearProjectIssuesResult({ items: [] })
setLinearProjectIssueLimit(LINEAR_ITEM_LIMIT)
setLinearProjectIssuePage(0)
setLinearProjectIssueLoadingTargetPage(null)
setLinearCustomViewIssuesResult({ items: [] })
setLinearCustomViewIssueLimit(LINEAR_ITEM_LIMIT)
setLinearCustomViewIssuePage(0)
setLinearCustomViewIssueLoadingTargetPage(null)
setSelectedLinearProject(project)
setLinearProjectTab('overview')
setLinearMode('projects')
@ -3249,7 +3274,13 @@ export default function TaskPage(): React.JSX.Element {
setSelectedLinearProjectDetail(null)
setLinearProjectParentView(null)
setLinearProjectIssuesResult({ items: [] })
setLinearProjectIssueLimit(LINEAR_ITEM_LIMIT)
setLinearProjectIssuePage(0)
setLinearProjectIssueLoadingTargetPage(null)
setLinearCustomViewIssuesResult({ items: [] })
setLinearCustomViewIssueLimit(LINEAR_ITEM_LIMIT)
setLinearCustomViewIssuePage(0)
setLinearCustomViewIssueLoadingTargetPage(null)
setLinearCustomViewProjectsResult({ items: [] })
setSelectedLinearCustomView(view)
setLinearMode('views')
@ -3701,15 +3732,42 @@ export default function TaskPage(): React.JSX.Element {
!activeLinearIssueContextLabel &&
appliedLinearSearch.trim().length === 0 &&
linearIssuesHasMore &&
linearIssueLimit < LINEAR_PLAIN_ISSUE_LIST_MAX
const handleLoadMoreLinearIssues = useCallback(() => {
setLinearIssueLimit((limit) =>
Math.min(
clampLinearPlainIssueListLimit(limit) + LINEAR_ITEM_LIMIT,
LINEAR_PLAIN_ISSUE_LIST_MAX
)
)
}, [])
linearIssueLimit < LINEAR_ISSUE_LIST_MAX
const canLoadMoreLinearProjectIssues =
selectedLinearProject !== null &&
linearProjectTab === 'issues' &&
Boolean(linearProjectIssuesResult.hasMore) &&
linearProjectIssueLimit < LINEAR_ISSUE_LIST_MAX
const canLoadMoreLinearCustomViewIssues =
selectedLinearCustomView?.model === 'issue' &&
Boolean(linearCustomViewIssuesResult.hasMore) &&
linearCustomViewIssueLimit < LINEAR_ISSUE_LIST_MAX
const activeLinearIssuePage =
selectedLinearProject && linearProjectTab === 'issues'
? linearProjectIssuePage
: selectedLinearCustomView?.model === 'issue'
? linearCustomViewIssuePage
: linearIssuePage
const activeLinearIssueLoadingTargetPage =
selectedLinearProject && linearProjectTab === 'issues'
? linearProjectIssueLoadingTargetPage
: selectedLinearCustomView?.model === 'issue'
? linearCustomViewIssueLoadingTargetPage
: linearIssueLoadingTargetPage
const activeLinearIssueCanLoadMore =
selectedLinearProject && linearProjectTab === 'issues'
? canLoadMoreLinearProjectIssues
: selectedLinearCustomView?.model === 'issue'
? canLoadMoreLinearCustomViewIssues
: canLoadMorePlainLinearIssues
const activeLinearIssueCanRequestMore =
activeLinearIssueCanLoadMore && !activeLinearIssueHasCollectionError
const activeLinearIssueLimit =
selectedLinearProject && linearProjectTab === 'issues'
? linearProjectIssueLimit
: selectedLinearCustomView?.model === 'issue'
? linearCustomViewIssueLimit
: linearIssueLimit
const displayedLinearIssues = useMemo(
() =>
@ -3795,6 +3853,147 @@ export default function TaskPage(): React.JSX.Element {
return displayedLinearIssues.filter((issue) => linearTeamSelection.has(issue.team.id))
}, [activeLinearIssueContextLabel, displayedLinearIssues, linearTeamSelection])
const orderedLinearIssues = useMemo(
() => [...filteredLinearIssues].sort((a, b) => compareLinearIssues(a, b, linearOrderBy)),
[filteredLinearIssues, linearOrderBy]
)
const loadedLinearIssuePages = Math.max(
1,
Math.ceil(orderedLinearIssues.length / LINEAR_ITEM_LIMIT)
)
const linearIssueTotalPages =
orderedLinearIssues.length === 0
? 1
: loadedLinearIssuePages + (activeLinearIssueCanRequestMore ? 1 : 0)
const visibleLinearIssuePage = Math.min(
activeLinearIssuePage,
Math.max(0, loadedLinearIssuePages - 1)
)
const pagedLinearIssues = useMemo(() => {
const start = visibleLinearIssuePage * LINEAR_ITEM_LIMIT
return orderedLinearIssues.slice(start, start + LINEAR_ITEM_LIMIT)
}, [orderedLinearIssues, visibleLinearIssuePage])
const showLinearIssuePagination =
orderedLinearIssues.length > 0 &&
!activeLinearIssueError &&
linearIssueTotalPages > 1 &&
!(activeLinearIssueLoading && activeLinearIssues.length === 0)
const setActiveLinearIssuePage = useCallback(
(page: number) => {
if (selectedLinearProject && linearProjectTab === 'issues') {
setLinearProjectIssuePage(page)
} else if (selectedLinearCustomView?.model === 'issue') {
setLinearCustomViewIssuePage(page)
} else {
setLinearIssuePage(page)
}
},
[linearProjectTab, selectedLinearCustomView?.model, selectedLinearProject]
)
const setActiveLinearIssueLoadingTargetPage = useCallback(
(page: number | null) => {
if (selectedLinearProject && linearProjectTab === 'issues') {
setLinearProjectIssueLoadingTargetPage(page)
} else if (selectedLinearCustomView?.model === 'issue') {
setLinearCustomViewIssueLoadingTargetPage(page)
} else {
setLinearIssueLoadingTargetPage(page)
}
},
[linearProjectTab, selectedLinearCustomView?.model, selectedLinearProject]
)
const ensureActiveLinearIssueLimit = useCallback(
(targetLimit: number) => {
const nextLimit = Math.min(clampLinearIssueListLimit(targetLimit), LINEAR_ISSUE_LIST_MAX)
if (selectedLinearProject && linearProjectTab === 'issues') {
setLinearProjectIssueLimit((limit) => Math.max(limit, nextLimit))
} else if (selectedLinearCustomView?.model === 'issue') {
setLinearCustomViewIssueLimit((limit) => Math.max(limit, nextLimit))
} else {
setLinearIssueLimit((limit) => Math.max(limit, nextLimit))
}
},
[linearProjectTab, selectedLinearCustomView?.model, selectedLinearProject]
)
const handleLinearIssuePageChange = useCallback(
(page: number) => {
if (page < loadedLinearIssuePages) {
setActiveLinearIssuePage(page)
setActiveLinearIssueLoadingTargetPage(null)
return
}
// Why: unlike GitHub's cursor pages, Linear reads are cached as an
// expanded prefix. Jumping to a new page first expands the prefix, then
// commits the page when the fetch returns enough rows.
setActiveLinearIssueLoadingTargetPage(page)
ensureActiveLinearIssueLimit((page + 1) * LINEAR_ITEM_LIMIT)
},
[
ensureActiveLinearIssueLimit,
loadedLinearIssuePages,
setActiveLinearIssueLoadingTargetPage,
setActiveLinearIssuePage
]
)
const showLinearEmptyFilteredLoadMore =
orderedLinearIssues.length === 0 && !activeLinearIssueError && activeLinearIssueCanRequestMore
const handleLinearEmptyFilteredLoadMore = useCallback(() => {
setActiveLinearIssueLoadingTargetPage(null)
ensureActiveLinearIssueLimit(activeLinearIssueLimit + LINEAR_ITEM_LIMIT)
}, [activeLinearIssueLimit, ensureActiveLinearIssueLimit, setActiveLinearIssueLoadingTargetPage])
useEffect(() => {
if (activeLinearIssueLoading || activeLinearIssueLoadingTargetPage === null) {
return
}
const maxLoadedPage = Math.max(0, loadedLinearIssuePages - 1)
const targetPageLoaded = activeLinearIssueLoadingTargetPage <= maxLoadedPage
const targetPageCannotLoad =
!activeLinearIssueCanRequestMore || activeLinearIssueLimit >= LINEAR_ISSUE_LIST_MAX
if (targetPageLoaded || targetPageCannotLoad) {
setActiveLinearIssuePage(Math.min(activeLinearIssueLoadingTargetPage, maxLoadedPage))
setActiveLinearIssueLoadingTargetPage(null)
return
}
// Why: Linear can return more backend rows without immediately filling the
// next visible page after local team filtering. Keep expanding the prefix
// until the requested page exists or Linear reports exhaustion.
ensureActiveLinearIssueLimit(activeLinearIssueLimit + LINEAR_ITEM_LIMIT)
}, [
activeLinearIssueCanRequestMore,
activeLinearIssueHasCollectionError,
activeLinearIssueLimit,
activeLinearIssueLoading,
activeLinearIssueLoadingTargetPage,
ensureActiveLinearIssueLimit,
loadedLinearIssuePages,
setActiveLinearIssueLoadingTargetPage,
setActiveLinearIssuePage
])
useEffect(() => {
if (
activeLinearIssueLoadingTargetPage !== null ||
activeLinearIssuePage <= visibleLinearIssuePage
) {
return
}
setActiveLinearIssuePage(visibleLinearIssuePage)
}, [
activeLinearIssueLoadingTargetPage,
activeLinearIssuePage,
setActiveLinearIssuePage,
visibleLinearIssuePage
])
const selectedLinearTeamForExternalLink = useMemo(() => {
if (linearTeamSelection.size !== 1) {
return null
@ -3836,8 +4035,8 @@ export default function TaskPage(): React.JSX.Element {
[linearIssueGridTemplate]
)
const linearIssueSections = useMemo(
() => groupLinearIssues(filteredLinearIssues, linearGroupBy, linearOrderBy),
[filteredLinearIssues, linearGroupBy, linearOrderBy]
() => groupLinearIssues(pagedLinearIssues, linearGroupBy, linearOrderBy),
[pagedLinearIssues, linearGroupBy, linearOrderBy]
)
const linearIssueListRows = useMemo<LinearIssueListRow[]>(
() =>
@ -3861,11 +4060,11 @@ export default function TaskPage(): React.JSX.Element {
const linearBoardSections = useMemo(
() =>
groupLinearIssues(
filteredLinearIssues,
pagedLinearIssues,
linearGroupBy === 'none' ? 'status' : linearGroupBy,
linearOrderBy
),
[filteredLinearIssues, linearGroupBy, linearOrderBy]
[pagedLinearIssues, linearGroupBy, linearOrderBy]
)
const linearStatusBoardEnabled = linearGroupBy === 'none' || linearGroupBy === 'status'
@ -5360,6 +5559,8 @@ export default function TaskPage(): React.JSX.Element {
useEffect(() => {
setLinearIssueLimit(LINEAR_ITEM_LIMIT)
setLinearIssuePage(0)
setLinearIssueLoadingTargetPage(null)
}, [
appliedLinearSearch,
linearMode,
@ -5390,7 +5591,7 @@ export default function TaskPage(): React.JSX.Element {
setLinearError(null)
const trimmed = appliedLinearSearch.trim()
const effectiveLinearIssueLimit = clampLinearPlainIssueListLimit(linearIssueLimit)
const effectiveLinearIssueLimit = clampLinearIssueListLimit(linearIssueLimit)
const readArgs =
trimmed.length > 0
? ({ kind: 'search', query: trimmed, limit: LINEAR_ITEM_LIMIT } as const)
@ -5405,7 +5606,7 @@ export default function TaskPage(): React.JSX.Element {
const collection = cachedResult as LinearCollectionResult<LinearIssue>
setLinearIssues(collection.items)
setLinearIssuesHasMore(
Boolean(collection.hasMore) && effectiveLinearIssueLimit < LINEAR_PLAIN_ISSUE_LIST_MAX
Boolean(collection.hasMore) && effectiveLinearIssueLimit < LINEAR_ISSUE_LIST_MAX
)
}
@ -5465,7 +5666,7 @@ export default function TaskPage(): React.JSX.Element {
} else {
const collection = result as LinearCollectionResult<LinearIssue>
setLinearIssuesHasMore(
Boolean(collection.hasMore) && effectiveLinearIssueLimit < LINEAR_PLAIN_ISSUE_LIST_MAX
Boolean(collection.hasMore) && effectiveLinearIssueLimit < LINEAR_ISSUE_LIST_MAX
)
setLinearIssues((current) =>
shouldProbeOnLanding
@ -5608,10 +5809,11 @@ export default function TaskPage(): React.JSX.Element {
let cancelled = false
setLinearProjectIssuesLoading(true)
setLinearProjectIssuesError(null)
const effectiveLimit = clampLinearIssueListLimit(linearProjectIssueLimit)
void listLinearProjectIssues(
selectedLinearProject.id,
selectedLinearProject.workspaceId,
LINEAR_ITEM_LIMIT,
effectiveLimit,
{ force: linearRefreshNonce > 0 }
)
.then((result) => {
@ -5631,7 +5833,13 @@ export default function TaskPage(): React.JSX.Element {
return () => {
cancelled = true
}
}, [linearProjectTab, linearRefreshNonce, listLinearProjectIssues, selectedLinearProject])
}, [
linearProjectIssueLimit,
linearProjectTab,
linearRefreshNonce,
listLinearProjectIssues,
selectedLinearProject
])
useEffect(() => {
if (!taskResumeApplied || taskSource !== 'linear' || linearMode !== 'views') {
@ -5688,12 +5896,13 @@ export default function TaskPage(): React.JSX.Element {
let cancelled = false
setLinearCustomViewContentsLoading(true)
setLinearCustomViewContentsError(null)
const issueLimit = clampLinearIssueListLimit(linearCustomViewIssueLimit)
const request =
selectedLinearCustomView.model === 'issue'
? listLinearCustomViewIssues(
selectedLinearCustomView.id,
selectedLinearCustomView.workspaceId,
LINEAR_ITEM_LIMIT,
issueLimit,
{ force: linearRefreshNonce > 0 }
)
: listLinearCustomViewProjects(
@ -5727,6 +5936,7 @@ export default function TaskPage(): React.JSX.Element {
}
}, [
linearRefreshNonce,
linearCustomViewIssueLimit,
listLinearCustomViewIssues,
listLinearCustomViewProjects,
selectedLinearCustomView
@ -8228,7 +8438,7 @@ export default function TaskPage(): React.JSX.Element {
</DropdownMenuContent>
</DropdownMenu>
<div className="text-[11px] text-muted-foreground">
{filteredLinearIssues.length} shown
{pagedLinearIssues.length} shown
</div>
</div>
</div>
@ -8665,27 +8875,70 @@ export default function TaskPage(): React.JSX.Element {
)}
</div>
{selectedLinearProject && linearProjectTab === 'issues' ? (
<LinearCollectionNotice
errors={linearProjectIssuesResult.errors}
hasMore={linearProjectIssuesResult.hasMore}
count={linearProjectIssuesResult.items.length}
label="project issues"
/>
<>
<LinearCollectionNotice
errors={linearProjectIssuesResult.errors}
hasMore={showLinearEmptyFilteredLoadMore}
count={linearProjectIssuesResult.items.length}
label="project issues"
onLoadMore={handleLinearEmptyFilteredLoadMore}
loading={activeLinearIssueLoading}
loadMoreLabel="Fetch more"
/>
{showLinearIssuePagination ? (
<div className="flex-none border-t border-border/50 bg-muted/50">
<PaginationBar
currentPage={visibleLinearIssuePage}
totalPages={linearIssueTotalPages}
loadingTarget={activeLinearIssueLoadingTargetPage}
onPageChange={handleLinearIssuePageChange}
/>
</div>
) : null}
</>
) : selectedLinearCustomView?.model === 'issue' ? (
<LinearCollectionNotice
errors={linearCustomViewIssuesResult.errors}
hasMore={linearCustomViewIssuesResult.hasMore}
count={linearCustomViewIssuesResult.items.length}
label="view issues"
/>
<>
<LinearCollectionNotice
errors={linearCustomViewIssuesResult.errors}
hasMore={showLinearEmptyFilteredLoadMore}
count={linearCustomViewIssuesResult.items.length}
label="view issues"
onLoadMore={handleLinearEmptyFilteredLoadMore}
loading={activeLinearIssueLoading}
loadMoreLabel="Fetch more"
/>
{showLinearIssuePagination ? (
<div className="flex-none border-t border-border/50 bg-muted/50">
<PaginationBar
currentPage={visibleLinearIssuePage}
totalPages={linearIssueTotalPages}
loadingTarget={activeLinearIssueLoadingTargetPage}
onPageChange={handleLinearIssuePageChange}
/>
</div>
) : null}
</>
) : (
<LinearCollectionNotice
hasMore={canLoadMorePlainLinearIssues}
count={linearIssues.length}
label="issues"
onLoadMore={handleLoadMoreLinearIssues}
loading={linearLoading}
/>
<>
<LinearCollectionNotice
hasMore={showLinearEmptyFilteredLoadMore}
count={linearIssues.length}
label="issues"
onLoadMore={handleLinearEmptyFilteredLoadMore}
loading={activeLinearIssueLoading}
loadMoreLabel="Fetch more"
/>
{showLinearIssuePagination ? (
<div className="flex-none border-t border-border/50 bg-muted/50">
<PaginationBar
currentPage={visibleLinearIssuePage}
totalPages={linearIssueTotalPages}
loadingTarget={activeLinearIssueLoadingTargetPage}
onPageChange={handleLinearIssuePageChange}
/>
</div>
) : null}
</>
)}
</div>
)}

View File

@ -9,6 +9,7 @@ import {
FileText,
FolderKanban,
Layers3,
LoaderCircle,
RefreshCw,
UserRound
} from 'lucide-react'
@ -178,9 +179,9 @@ export function LinearCollectionNotice({
}
return (
<div className="flex flex-none flex-col gap-2 border-t border-border/50 bg-muted/25 px-3 py-2 text-xs text-muted-foreground">
<div className="flex flex-none flex-col gap-2 border-t border-border/50 bg-muted/50 text-xs text-muted-foreground">
{errors && errors.length > 0 ? (
<div className="flex flex-wrap gap-2">
<div className={cn('flex flex-wrap gap-2 px-3', hasMore ? 'pt-2' : 'py-2')}>
{errors.map((error) => (
<Badge key={`${error.workspaceId}-${error.type}`} variant="outline">
{error.workspaceName ?? error.workspaceId}: {error.message}
@ -189,11 +190,12 @@ export function LinearCollectionNotice({
</div>
) : null}
{hasMore ? (
<div className="flex flex-wrap items-center justify-between gap-2">
<span>
Showing first {count} {label}.
{onLoadMore ? ' Fetch more in Orca.' : ' Search or open Linear for the full set.'}
</span>
<div className="flex flex-wrap items-center justify-center gap-2 px-4 py-3">
{onLoadMore ? null : (
<span>
Showing first {count} {label}. Search or open Linear for the full set.
</span>
)}
{onLoadMore ? (
<Button
type="button"
@ -201,15 +203,18 @@ export function LinearCollectionNotice({
size="xs"
onClick={onLoadMore}
disabled={loading}
className="h-7 shrink-0 gap-1 border-border/60 bg-background/70"
className="inline-flex h-auto w-24 shrink-0 items-center justify-center gap-0.5 rounded-md border-0 bg-transparent px-2 py-1 text-sm text-muted-foreground shadow-none transition hover:bg-muted/60 hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
>
{loading ? (
<>
<RefreshCw className="size-3.5 animate-spin" />
<LoaderCircle className="size-3.5 animate-spin" />
Loading
</>
) : (
loadMoreLabel
<>
{loadMoreLabel}
<ArrowRight className="size-4" />
</>
)}
</Button>
) : null}

View File

@ -216,6 +216,60 @@ describe('createLinearSlice caching', () => {
expect(linearListProjectIssues.mock.calls[0][4]).toEqual({ force: true })
})
it('falls back to the largest smaller cached project issue limit when expansion fails', async () => {
const store = createTestStore()
store.setState({
linearProjectIssueCache: {
'workspace-1::project-issues::project-1::20': {
data: { items: [issue('LIN-SMALLER')] },
fetchedAt: 1
},
'workspace-1::project-issues::project-1::36': {
data: { items: [issue('LIN-CACHED-36')] },
fetchedAt: 1
},
'workspace-1::project-issues::project-2::36': {
data: { items: [issue('LIN-OTHER-PROJECT')] },
fetchedAt: 1
},
'workspace-2::project-issues::project-1::36': {
data: { items: [issue('LIN-OTHER-WORKSPACE')] },
fetchedAt: 1
}
}
})
linearListProjectIssues.mockRejectedValueOnce(new Error('network down'))
await expect(
store.getState().listLinearProjectIssues('project-1', 'workspace-1', 72, {
force: true
})
).resolves.toMatchObject({
items: [{ id: 'LIN-CACHED-36' }],
errors: [{ workspaceId: 'workspace-1', type: 'unknown', message: 'network down' }]
})
expect(linearListProjectIssues.mock.calls[0][2]).toBe(72)
})
it('caches project issue reads by the expanded effective limit', async () => {
const store = createTestStore()
linearListProjectIssues.mockResolvedValueOnce({ items: [issue('LIN-120')], hasMore: true })
await expect(
store.getState().listLinearProjectIssues('project-1', 'workspace-1', 120)
).resolves.toMatchObject({
items: [{ id: 'LIN-120' }],
hasMore: true
})
expect(linearListProjectIssues).toHaveBeenCalledWith(null, 'project-1', 120, 'workspace-1', {
force: undefined
})
expect(
store.getState().linearProjectIssueCache['workspace-1::project-issues::project-1::120']?.data
).toMatchObject({ items: [{ id: 'LIN-120' }] })
})
it('surfaces scoped custom-view project failures alongside cached rows', async () => {
const store = createTestStore()
const rateLimitError = Object.assign(new Error('slow down'), { status: 429 })
@ -263,6 +317,61 @@ describe('createLinearSlice caching', () => {
expect(linearListCustomViewIssues.mock.calls[0][4]).toEqual({ force: true })
})
it('falls back to the largest smaller cached custom-view issue limit when expansion fails', async () => {
const store = createTestStore()
store.setState({
linearCustomViewIssueCache: {
'workspace-1::custom-view-issues::view-1::20': {
data: { items: [issue('LIN-SMALLER')] },
fetchedAt: 1
},
'workspace-1::custom-view-issues::view-1::36': {
data: { items: [issue('LIN-CACHED-36')] },
fetchedAt: 1
},
'workspace-1::custom-view-issues::view-2::36': {
data: { items: [issue('LIN-OTHER-VIEW')] },
fetchedAt: 1
},
'workspace-2::custom-view-issues::view-1::36': {
data: { items: [issue('LIN-OTHER-WORKSPACE')] },
fetchedAt: 1
}
}
})
linearListCustomViewIssues.mockRejectedValueOnce(new Error('network down'))
await expect(
store.getState().listLinearCustomViewIssues('view-1', 'workspace-1', 72, {
force: true
})
).resolves.toMatchObject({
items: [{ id: 'LIN-CACHED-36' }],
errors: [{ workspaceId: 'workspace-1', type: 'unknown', message: 'network down' }]
})
expect(linearListCustomViewIssues.mock.calls[0][2]).toBe(72)
})
it('caches issue custom-view reads by the expanded effective limit', async () => {
const store = createTestStore()
linearListCustomViewIssues.mockResolvedValueOnce({ items: [issue('LIN-120')], hasMore: true })
await expect(
store.getState().listLinearCustomViewIssues('view-1', 'workspace-1', 120)
).resolves.toMatchObject({
items: [{ id: 'LIN-120' }],
hasMore: true
})
expect(linearListCustomViewIssues).toHaveBeenCalledWith(null, 'view-1', 120, 'workspace-1', {
force: undefined
})
expect(
store.getState().linearCustomViewIssueCache['workspace-1::custom-view-issues::view-1::120']
?.data
).toMatchObject({ items: [{ id: 'LIN-120' }] })
})
it('surfaces top-level project list failures alongside cached rows', async () => {
const store = createTestStore()
store.setState({

View File

@ -18,7 +18,7 @@ import type {
LinearWorkspaceSelection
} from '../../../../shared/types'
import type { CacheEntry } from './github'
import { clampLinearPlainIssueListLimit } from '../../../../shared/linear-issue-list-limits'
import { clampLinearIssueListLimit } from '../../../../shared/linear-issue-read-limits'
import { clearLinearMetadataCache } from '../../hooks/useIssueMetadata'
import {
linearConnect,
@ -270,6 +270,30 @@ function collectionWithWorkspaceError<T>(
}
}
function largestCachedCollectionBelowLimit<T>(
cache: Record<string, CacheEntry<LinearCollectionResult<T>>>,
workspaceId: LinearWorkspaceSelection | null | undefined,
mode: string,
scopeId: string,
limit: number
): LinearCollectionResult<T> | null {
const keyPrefix = `${linearCollectionCacheKey(workspaceId, mode, scopeId)}::`
let best: { limit: number; data: LinearCollectionResult<T> } | null = null
for (const [key, entry] of Object.entries(cache)) {
if (!entry?.data || !key.startsWith(keyPrefix)) {
continue
}
const cachedLimit = Number(key.slice(keyPrefix.length))
if (!Number.isFinite(cachedLimit) || cachedLimit >= limit) {
continue
}
if (!best || cachedLimit > best.limit) {
best = { limit: cachedLimit, data: entry.data }
}
}
return best?.data ?? null
}
function patchLinearIssueCollectionCache(
cache: Record<string, CacheEntry<LinearCollectionResult<LinearIssue>>>,
issueId: string,
@ -708,7 +732,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
const cacheKey = linearSearchCacheKey(workspaceId, args.query, args.limit ?? 20)
return get().linearSearchCache[cacheKey]?.data ?? null
}
const limit = clampLinearPlainIssueListLimit(args.limit)
const limit = clampLinearIssueListLimit(args.limit)
const cacheKey = linearListCacheKey(workspaceId, args.filter ?? 'assigned', limit)
return get().linearListCache[cacheKey]?.data ?? null
},
@ -726,7 +750,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
.catch(() => {})
return
}
const limit = clampLinearPlainIssueListLimit(args.limit)
const limit = clampLinearIssueListLimit(args.limit)
const cacheKey = linearListCacheKey(workspaceId, args.filter ?? 'assigned', limit)
if (isFresh(get().linearListCache[cacheKey]) || inflightListRequests.has(cacheKey)) {
return
@ -796,7 +820,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
listLinearIssues: async (filter = 'assigned', limit = 20, options) => {
const workspaceId = getSelectedWorkspaceId(get().linearStatus)
const effectiveLimit = clampLinearPlainIssueListLimit(limit)
const effectiveLimit = clampLinearIssueListLimit(limit)
const cacheKey = linearListCacheKey(workspaceId, filter, effectiveLimit)
const cached = get().linearListCache[cacheKey]
if (!options?.force && isFresh(cached)) {
@ -1039,7 +1063,13 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
},
listLinearProjectIssues: async (projectId, workspaceId, limit = 20, options) => {
const cacheKey = linearCollectionCacheKey(workspaceId, 'project-issues', projectId, limit)
const effectiveLimit = clampLinearIssueListLimit(limit)
const cacheKey = linearCollectionCacheKey(
workspaceId,
'project-issues',
projectId,
effectiveLimit
)
const cached = get().linearProjectIssueCache[cacheKey]
if (!options?.force && isFresh(cached)) {
return cached.data ?? emptyLinearCollection<LinearIssue>()
@ -1052,9 +1082,15 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
let entry: InflightLinearCollectionRequest<LinearIssue>
const requestCacheGeneration = linearCacheGeneration
const promise = linearListProjectIssues(get().settings, projectId, limit, workspaceId, {
force: options?.force
})
const promise = linearListProjectIssues(
get().settings,
projectId,
effectiveLimit,
workspaceId,
{
force: options?.force
}
)
.then((result) => {
if (
inflightProjectIssueRequests.get(cacheKey) === entry &&
@ -1075,7 +1111,15 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
set({ linearStatus: { connected: false, viewer: null } })
}
const fallback =
get().linearProjectIssueCache[cacheKey]?.data ?? emptyLinearCollection<LinearIssue>()
get().linearProjectIssueCache[cacheKey]?.data ??
largestCachedCollectionBelowLimit(
get().linearProjectIssueCache,
workspaceId,
'project-issues',
projectId,
effectiveLimit
) ??
emptyLinearCollection<LinearIssue>()
return collectionWithWorkspaceError(fallback, workspaceId, error)
})
.finally(() => {
@ -1207,7 +1251,13 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
},
listLinearCustomViewIssues: async (viewId, workspaceId, limit = 20, options) => {
const cacheKey = linearCollectionCacheKey(workspaceId, 'custom-view-issues', viewId, limit)
const effectiveLimit = clampLinearIssueListLimit(limit)
const cacheKey = linearCollectionCacheKey(
workspaceId,
'custom-view-issues',
viewId,
effectiveLimit
)
const cached = get().linearCustomViewIssueCache[cacheKey]
if (!options?.force && isFresh(cached)) {
return cached.data ?? emptyLinearCollection<LinearIssue>()
@ -1220,9 +1270,15 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
let entry: InflightLinearCollectionRequest<LinearIssue>
const requestCacheGeneration = linearCacheGeneration
const promise = linearListCustomViewIssues(get().settings, viewId, limit, workspaceId, {
force: options?.force
})
const promise = linearListCustomViewIssues(
get().settings,
viewId,
effectiveLimit,
workspaceId,
{
force: options?.force
}
)
.then((result) => {
if (
inflightCustomViewIssueRequests.get(cacheKey) === entry &&
@ -1243,7 +1299,15 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
set({ linearStatus: { connected: false, viewer: null } })
}
const fallback =
get().linearCustomViewIssueCache[cacheKey]?.data ?? emptyLinearCollection<LinearIssue>()
get().linearCustomViewIssueCache[cacheKey]?.data ??
largestCachedCollectionBelowLimit(
get().linearCustomViewIssueCache,
workspaceId,
'custom-view-issues',
viewId,
effectiveLimit
) ??
emptyLinearCollection<LinearIssue>()
return collectionWithWorkspaceError(fallback, workspaceId, error)
})
.finally(() => {

View File

@ -1,5 +0,0 @@
export const LINEAR_PLAIN_ISSUE_LIST_MAX = 216
export function clampLinearPlainIssueListLimit(limit: number | null | undefined): number {
return Math.min(Math.max(1, Math.floor(limit ?? 20)), LINEAR_PLAIN_ISSUE_LIST_MAX)
}

View File

@ -0,0 +1,6 @@
export const LINEAR_ISSUE_LIST_MAX = 216
export const LINEAR_ISSUE_API_PAGE_SIZE_MAX = 50
export function clampLinearIssueListLimit(limit: number | null | undefined): number {
return Math.min(Math.max(1, Math.floor(limit ?? 20)), LINEAR_ISSUE_LIST_MAX)
}