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:
parent
d10d01919a
commit
4f18c7e79d
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Reference in New Issue