diff --git a/src/main/jira/jira-issue-search.ts b/src/main/jira/jira-issue-search.ts index 6972d85a06a..461136bf82b 100644 --- a/src/main/jira/jira-issue-search.ts +++ b/src/main/jira/jira-issue-search.ts @@ -1,10 +1,12 @@ import { sortByUpdatedAtDescending } from '../../shared/updated-at-order' import type { JiraIssue, JiraIssueFilter, JiraSiteSelection } from '../../shared/jira-types' +import { buildJiraPresetJql } from '../../shared/jira-preset-jql' import { acquire, release } from './request-queue' import { apiBasePath, jiraRequest, type JiraClientForSite } from './authenticated-request' import { clearToken, getClients, isAuthError } from './client' import { ISSUE_LIST_FIELDS, mapJiraIssue } from './jira-issue-mapping' import type { JiraSearchResponse } from './jira-record-pages' +import { readProjectsForClient } from './jira-project-queries' import { requireStrictJiraSite, shouldSurfaceSiteFailure, @@ -14,6 +16,8 @@ import { } from './jira-read-failure' const ISSUE_SEARCH_TIMEOUT_MS = 30_000 +const PROJECTS_PER_QUERY = 100 +type JiraIssueQuery = { filter: JiraIssueFilter } | { jql: string } function clampLimit(limit: number | undefined, fallback = 30): number { return Math.min(Math.max(1, Number.isFinite(limit) ? Number(limit) : fallback), 100) @@ -23,40 +27,87 @@ function sortAndLimitIssues(issues: JiraIssue[], limit: number): JiraIssue[] { return sortByUpdatedAtDescending(issues).slice(0, limit) } -function filterToJql(filter: JiraIssueFilter): string { - if (filter === 'assigned') { - return 'assignee = currentUser() AND resolution = Unresolved ORDER BY updated DESC' +async function readIssuesForClient( + entry: JiraClientForSite, + query: JiraIssueQuery, + limit: number, + signal: AbortSignal +): Promise { + if ('jql' in query) { + return searchIssuesForClient(entry, query.jql, limit, signal) } - if (filter === 'reported') { - return 'reporter = currentUser() AND resolution = Unresolved ORDER BY updated DESC' + const jql = buildJiraPresetJql(query.filter) + if (query.filter !== 'all' || entry.site.authType === 'server') { + return searchIssuesForClient(entry, jql, limit, signal) } - if (filter === 'done') { - return 'assignee = currentUser() AND resolution IS NOT EMPTY ORDER BY updated DESC' + const projects = await readProjectsForClient(entry, signal, true) + const projectIds = [...new Set(projects.map((project) => project.id || project.key))] + if (projectIds.some((id) => !id)) { + throw new Error('Jira returned a project without an ID or key.') } - return 'resolution = Unresolved ORDER BY updated DESC' + const issues: JiraIssue[] = [] + // Cloud requires a search restriction; project bounds preserve older open work. + for (let start = 0; start < projectIds.length; start += PROJECTS_PER_QUERY) { + const ids = projectIds.slice(start, start + PROJECTS_PER_QUERY).map((id) => JSON.stringify(id)) + issues.push( + ...(await searchIssuesForClient( + entry, + `project in (${ids.join(', ')}) AND ${jql}`, + limit, + signal, + true + )) + ) + } + return sortAndLimitIssues(issues, limit) } async function searchIssuesForClient( entry: JiraClientForSite, jql: string, limit: number, - signal?: AbortSignal + signal?: AbortSignal, + collectPages = false ): Promise { // Server/DC only has the classic /search resource; /search/jql is Cloud-only. const searchPath = entry.site.authType === 'server' ? `${apiBasePath(entry.site)}/search` : '/rest/api/3/search/jql' - const result = await jiraRequest(entry, searchPath, { - method: 'POST', - body: JSON.stringify({ - jql, - maxResults: limit, - fields: ISSUE_LIST_FIELDS - }), - signal - }) - return (result.issues ?? []).map((issue) => mapJiraIssue(entry.site, issue)) + const issues: JiraIssue[] = [] + const seenTokens = new Set() + let nextPageToken: string | undefined + for (let page = 0; page < 100; page += 1) { + const result = await jiraRequest(entry, searchPath, { + method: 'POST', + body: JSON.stringify({ + jql, + maxResults: limit - issues.length, + fields: ISSUE_LIST_FIELDS, + ...(nextPageToken ? { nextPageToken } : {}) + }), + signal + }) + if (collectPages && !Array.isArray(result.issues)) { + throw new Error('Jira returned an invalid issue page.') + } + issues.push(...(result.issues ?? []).map((issue) => mapJiraIssue(entry.site, issue))) + if (!collectPages || issues.length >= limit || result.isLast === true) { + return issues.slice(0, limit) + } + if (!result.nextPageToken) { + if (result.isLast === false) { + throw new Error('Jira returned an incomplete issue page.') + } + return issues.slice(0, limit) + } + if (typeof result.nextPageToken !== 'string' || seenTokens.has(result.nextPageToken)) { + throw new Error('Jira returned an invalid or repeated issue page token.') + } + nextPageToken = result.nextPageToken + seenTokens.add(nextPageToken) + } + throw new Error('Jira issue search exceeded the page limit. Select a project with JQL instead.') } export async function listIssues( @@ -65,7 +116,7 @@ export async function listIssues( siteId?: JiraSiteSelection | null, strict = false ): Promise { - return searchIssues(filterToJql(filter), limit, siteId, undefined, strict) + return runIssueSearch({ filter }, limit, siteId, undefined, strict) } export async function searchIssues( @@ -74,10 +125,20 @@ export async function searchIssues( siteId?: JiraSiteSelection | null, signal?: AbortSignal, strict = false +): Promise { + return runIssueSearch({ jql: jql.trim() }, limit, siteId, signal, strict) +} + +async function runIssueSearch( + query: JiraIssueQuery, + limit: number, + siteId?: JiraSiteSelection | null, + signal?: AbortSignal, + strict = false ): Promise { const entries = getClients(siteId) requireStrictJiraSite(entries, siteId, strict) - if (entries.length === 0 || !jql.trim()) { + if (entries.length === 0 || ('jql' in query && !query.jql)) { return [] } const safeLimit = clampLimit(limit) @@ -89,7 +150,7 @@ export async function searchIssues( // Why: queueing on an abandoned search would keep occupying the shared Jira pool. await acquire(requestSignal) try { - return await searchIssuesForClient(entry, jql.trim(), safeLimit, requestSignal) + return await readIssuesForClient(entry, query, safeLimit, requestSignal) } catch (error) { if (requestSignal.aborted) { // Abandoned by the caller: not a site failure, so don't clear tokens or mask a real one. diff --git a/src/main/jira/jira-preset-filters.test.ts b/src/main/jira/jira-preset-filters.test.ts new file mode 100644 index 00000000000..d5658480f51 --- /dev/null +++ b/src/main/jira/jira-preset-filters.test.ts @@ -0,0 +1,371 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { JiraIssueFilter } from '../../shared/jira-types' +import type { JiraClientForSite } from './authenticated-request' + +const { getClients, jiraRequest, clearToken } = vi.hoisted(() => ({ + getClients: vi.fn(), + jiraRequest: vi.fn(), + clearToken: vi.fn() +})) +vi.mock('./client', () => ({ getClients, clearToken, isAuthError: () => false })) +vi.mock('./authenticated-request', () => ({ + apiBasePath: (site: { authType?: string }) => + site.authType === 'server' ? '/rest/api/2' : '/rest/api/3', + jiraRequest, + JiraApiError: class extends Error {} +})) +vi.mock('./request-queue', () => ({ acquire: vi.fn(), release: vi.fn() })) + +function site(id: string, authType: 'cloud' | 'server' = 'cloud'): JiraClientForSite { + return { + site: { + id, + authType, + siteUrl: 'https://jira.example.com', + email: '', + displayName: id, + accountId: 'me' + }, + authorization: 'fixture' + } +} + +function issue(key: string, done: boolean, resolved: boolean, updated = '2020-01-01') { + return { + id: key, + key, + fields: { + summary: key, + updated, + status: { + name: done ? 'Complete' : 'Reopened', + statusCategory: { key: done ? 'done' : 'indeterminate' } + }, + resolution: resolved ? { name: 'Fixed' } : null + } + } +} + +function requestJql(init: { body?: string } | undefined): string { + const body: unknown = JSON.parse(init?.body ?? '{}') + if ( + typeof body !== 'object' || + body === null || + !('jql' in body) || + typeof body.jql !== 'string' + ) { + throw new Error('Expected search body') + } + return body.jql +} + +describe('Jira preset filters', () => { + beforeEach(() => { + vi.clearAllMocks() + getClients.mockReturnValue([site('cloud')]) + jiraRequest.mockReset() + }) + + it.each(['assigned', 'reported', 'done'])( + '%s follows status when resolution is stale', + async (filter) => { + const fixtures = [issue('ALP-1', true, false), issue('ALP-2', false, true)] + jiraRequest.mockImplementation(async (_entry, _path, init) => { + const jql = requestJql(init) + return { + issues: fixtures.filter((item) => { + if (jql.includes('statusCategory = Done')) { + return item.fields.status.statusCategory.key === 'done' + } + if (jql.includes('statusCategory != Done')) { + return item.fields.status.statusCategory.key !== 'done' + } + return jql.includes('IS NOT EMPTY') + ? item.fields.resolution !== null + : item.fields.resolution === null + }) + } + }) + const { listIssues } = await import('./jira-issue-search') + await expect(listIssues(filter, 20, 'cloud')).resolves.toMatchObject([ + { key: filter === 'done' ? 'ALP-1' : 'ALP-2' } + ]) + } + ) + + it('bounds Cloud All with every project, preserving old open issues', async () => { + jiraRequest.mockImplementation(async (_entry, path, init) => { + if (path.includes('/project/search')) { + return path.includes('startAt=0') + ? { maxResults: 1, total: 2, values: [{ id: '10', key: 'ALP' }] } + : { maxResults: 1, total: 2, values: [{ id: '20', key: 'BRV' }] } + } + const jql = requestJql(init) + if (!jql.includes('project in (')) { + throw new Error('Unbounded JQL queries are not allowed here') + } + expect(jql).toContain('statusCategory != Done') + expect(jql).toContain('"10"') + expect(jql).toContain('"20"') + expect(jql).not.toMatch(/updated\s*[><=]|resolution/) + return { issues: [issue('ALP-2', false, true)] } + }) + const { listIssues } = await import('./jira-issue-search') + await expect(listIssues('all', 20, 'cloud')).resolves.toMatchObject([ + { key: 'ALP-2', updatedAt: '2020-01-01' } + ]) + expect(jiraRequest).toHaveBeenCalledTimes(3) + }) + + it('keeps Server/DC All on classic search without project discovery', async () => { + getClients.mockReturnValue([site('server', 'server')]) + jiraRequest.mockResolvedValue({ issues: [] }) + const { listIssues } = await import('./jira-issue-search') + await listIssues('all', 20, 'server') + expect(jiraRequest).toHaveBeenCalledWith( + expect.anything(), + '/rest/api/2/search', + expect.objectContaining({ + body: expect.stringContaining('statusCategory != Done ORDER BY updated DESC') + }) + ) + expect(jiraRequest).toHaveBeenCalledTimes(1) + }) + + it('does not send an unbounded search when the Cloud account has no projects', async () => { + jiraRequest.mockResolvedValue({ values: [], isLast: true }) + const { listIssues } = await import('./jira-issue-search') + await expect(listIssues('all', 20, 'cloud')).resolves.toEqual([]) + expect(jiraRequest).toHaveBeenCalledTimes(1) + expect(jiraRequest.mock.calls[0][1]).toContain('/project/search') + }) + + it('surfaces project discovery failures instead of returning an empty list', async () => { + jiraRequest.mockImplementation(async (_entry, path) => { + if (path.includes('/project/search')) { + throw new Error('Project discovery forbidden') + } + return { issues: [] } + }) + const { listIssues } = await import('./jira-issue-search') + await expect(listIssues('all', 20, 'cloud')).rejects.toThrow('Project discovery forbidden') + }) + + it('leaves caller JQL unchanged even when it equals the All preset', async () => { + jiraRequest.mockResolvedValue({ issues: [] }) + const { searchIssues } = await import('./jira-issue-search') + const jql = 'statusCategory != Done ORDER BY updated DESC' + await searchIssues(jql, 20, 'cloud') + expect(requestJql(jiraRequest.mock.calls[0][2])).toBe(jql) + expect(jiraRequest).toHaveBeenCalledTimes(1) + }) + + it('merges project batches by update time before applying the result limit', async () => { + jiraRequest.mockImplementation(async (_entry, path, init) => { + if (path.includes('/project/search')) { + return { + isLast: true, + values: Array.from({ length: 101 }, (_, index) => ({ id: String(index) })) + } + } + return { + issues: [ + requestJql(init).includes('"100"') + ? issue('LAST-1', false, false, '2026-10-01') + : issue('FIRST-1', false, false, '2020-01-01') + ] + } + }) + const { listIssues } = await import('./jira-issue-search') + await expect(listIssues('all', 1, 'cloud')).resolves.toMatchObject([{ key: 'LAST-1' }]) + expect(jiraRequest).toHaveBeenCalledTimes(3) + }) + + it('follows partial issue pages in every batch before merging the newest results', async () => { + const requests: { batch: string; token: string | undefined; limit: number }[] = [] + jiraRequest.mockImplementation(async (_entry, path, init) => { + if (path.includes('/project/search')) { + return { + isLast: true, + values: Array.from({ length: 101 }, (_, index) => ({ id: String(index) })) + } + } + const body = JSON.parse(init.body) + const batch = body.jql.includes('"100"') ? 'last' : 'first' + requests.push({ batch, token: body.nextPageToken, limit: body.maxResults }) + if (!body.nextPageToken) { + return { + issues: [ + issue(`${batch}-1`, false, false, batch === 'last' ? '2026-10-10' : '2026-10-09') + ], + nextPageToken: `${batch}-next` + } + } + expect(body.nextPageToken).toBe(`${batch}-next`) + return { + issues: [ + issue(`${batch}-2`, false, false, batch === 'last' ? '2026-10-08' : '2026-10-07'), + issue(`${batch}-3`, false, false, '2020-01-01') + ], + isLast: true + } + }) + const { listIssues } = await import('./jira-issue-search') + await expect(listIssues('all', 3, 'cloud')).resolves.toMatchObject([ + { key: 'last-1' }, + { key: 'first-1' }, + { key: 'last-2' } + ]) + expect(requests).toEqual([ + { batch: 'first', token: undefined, limit: 3 }, + { batch: 'first', token: 'first-next', limit: 2 }, + { batch: 'last', token: undefined, limit: 3 }, + { batch: 'last', token: 'last-next', limit: 2 } + ]) + }) + + it('continues across an empty issue page with a next token', async () => { + jiraRequest + .mockResolvedValueOnce({ values: [{ id: '10' }], isLast: true }) + .mockResolvedValueOnce({ issues: [], nextPageToken: 'next' }) + .mockResolvedValueOnce({ issues: [issue('ALP-1', false, false)], isLast: true }) + const { listIssues } = await import('./jira-issue-search') + await expect(listIssues('all', 3, 'cloud')).resolves.toMatchObject([{ key: 'ALP-1' }]) + expect(jiraRequest).toHaveBeenCalledTimes(3) + }) + + it.each([ + { isLast: true }, + { issues: null, isLast: true }, + { issues: [issue('ALP-1', false, false)], isLast: false }, + { issues: [], isLast: false, nextPageToken: '' } + ])('rejects a malformed or incomplete Cloud issue page: %j', async (response) => { + jiraRequest + .mockResolvedValueOnce({ values: [{ id: '10' }], isLast: true }) + .mockResolvedValueOnce(response) + const { listIssues } = await import('./jira-issue-search') + await expect(listIssues('all', 3, 'cloud')).rejects.toThrow(/issue page/) + expect(jiraRequest).toHaveBeenCalledTimes(2) + }) + + it('stops when a batch reaches the requested limit even if another token exists', async () => { + jiraRequest + .mockResolvedValueOnce({ values: [{ id: '10' }], isLast: true }) + .mockResolvedValueOnce({ issues: [issue('ALP-1', false, false)], nextPageToken: 'unused' }) + const { listIssues } = await import('./jira-issue-search') + await expect(listIssues('all', 1, 'cloud')).resolves.toMatchObject([{ key: 'ALP-1' }]) + expect(jiraRequest).toHaveBeenCalledTimes(2) + }) + + it('surfaces a repeated issue page token instead of looping or returning an incomplete list', async () => { + jiraRequest + .mockResolvedValueOnce({ values: [{ id: '10' }], isLast: true }) + .mockResolvedValue({ issues: [], nextPageToken: 'repeat' }) + const { listIssues } = await import('./jira-issue-search') + await expect(listIssues('all', 3, 'cloud')).rejects.toThrow('repeated issue page token') + expect(jiraRequest).toHaveBeenCalledTimes(3) + }) + + it('bounds empty token pages with a safety limit', async () => { + let token = 0 + jiraRequest.mockImplementation(async (_entry, path) => + path.includes('/project/search') + ? { values: [{ id: '10' }], isLast: true } + : { issues: [], nextPageToken: String(++token) } + ) + const { listIssues } = await import('./jira-issue-search') + await expect(listIssues('all', 3, 'cloud')).rejects.toThrow( + 'issue search exceeded the page limit' + ) + expect(jiraRequest).toHaveBeenCalledTimes(101) + }) + + it('keeps subsequent issue pages under the original search deadline', async () => { + vi.useFakeTimers() + try { + let firstSignal: AbortSignal | undefined + jiraRequest + .mockResolvedValueOnce({ values: [{ id: '10' }], isLast: true }) + .mockImplementationOnce(async (_entry, _path, init: { signal: AbortSignal }) => { + firstSignal = init.signal + return { issues: [], nextPageToken: 'next' } + }) + .mockImplementationOnce((_entry, _path, init: { signal: AbortSignal }) => { + expect(init.signal).toBe(firstSignal) + return new Promise((_resolve, reject) => { + init.signal.addEventListener('abort', () => reject(init.signal.reason), { once: true }) + }) + }) + const { listIssues } = await import('./jira-issue-search') + const result = listIssues('all', 3, 'cloud').then( + () => null, + (error: unknown) => error + ) + await vi.advanceTimersByTimeAsync(30_000) + expect(await result).toMatchObject({ name: 'AbortError' }) + expect(jiraRequest).toHaveBeenCalledTimes(3) + } finally { + vi.useRealTimers() + } + }) + + it('restricts projects separately for each Cloud site while keeping Server search unchanged', async () => { + getClients.mockReturnValue([site('a'), site('b'), site('server', 'server')]) + const queries = new Map() + jiraRequest.mockImplementation(async (entry: JiraClientForSite, path: string, init) => { + if (path.includes('/project/search')) { + return { isLast: true, values: [{ id: entry.site.id }] } + } + queries.set(entry.site.id, requestJql(init)) + return { issues: [] } + }) + const { listIssues } = await import('./jira-issue-search') + await listIssues('all', 20, 'all') + expect(queries.get('a')).toBe( + 'project in ("a") AND statusCategory != Done ORDER BY updated DESC' + ) + expect(queries.get('b')).toBe( + 'project in ("b") AND statusCategory != Done ORDER BY updated DESC' + ) + expect(queries.get('server')).toBe('statusCategory != Done ORDER BY updated DESC') + }) + + it.each([{ total: 2, values: [] }, { isLast: false, values: [] }, { issues: [] }])( + 'surfaces malformed or incomplete project discovery: %j', + async (response) => { + jiraRequest.mockResolvedValue(response) + const { listIssues } = await import('./jira-issue-search') + await expect(listIssues('all', 20, 'cloud')).rejects.toThrow(/project page/) + expect(jiraRequest).toHaveBeenCalledTimes(1) + } + ) + + it('rejects page-limit exhaustion instead of silently omitting projects', async () => { + jiraRequest.mockResolvedValue({ isLast: false, maxResults: 1, values: [{ id: '10' }] }) + const { listIssues } = await import('./jira-issue-search') + await expect(listIssues('all', 20, 'cloud')).rejects.toThrow('exceeded the page limit') + expect(jiraRequest).toHaveBeenCalledTimes(100) + }) + + it('puts project discovery under the same abortable deadline as issue search', async () => { + vi.useFakeTimers() + try { + jiraRequest.mockImplementation( + (_entry, _path, init: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + init.signal.addEventListener('abort', () => reject(init.signal.reason), { once: true }) + }) + ) + const { listIssues } = await import('./jira-issue-search') + const result = listIssues('all', 20, 'cloud').then( + () => null, + (error: unknown) => error + ) + await vi.advanceTimersByTimeAsync(30_000) + expect(await result).toMatchObject({ name: 'AbortError' }) + expect(jiraRequest).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/jira/jira-project-queries.ts b/src/main/jira/jira-project-queries.ts index d84b82e4532..c5ed13bef4e 100644 --- a/src/main/jira/jira-project-queries.ts +++ b/src/main/jira/jira-project-queries.ts @@ -1,11 +1,38 @@ import type { JiraProject, JiraSiteSelection } from '../../shared/jira-types' import { acquire, release } from './request-queue' -import { apiBasePath, jiraRequest } from './authenticated-request' +import { apiBasePath, jiraRequest, type JiraClientForSite } from './authenticated-request' import { clearToken, getClients, isAuthError } from './client' import { mapProject } from './jira-issue-mapping' import { fetchPagedRecords, type JiraRecord } from './jira-record-pages' import { requireStrictJiraSite, shouldSurfaceSiteFailure } from './jira-read-failure' +export async function readProjectsForClient( + entry: JiraClientForSite, + signal?: AbortSignal, + requireComplete = false +): Promise { + // Server/DC exposes the complete array through the classic project resource. + const projects = + entry.site.authType === 'server' + ? signal + ? await jiraRequest(entry, `${apiBasePath(entry.site)}/project`, { signal }) + : await jiraRequest(entry, `${apiBasePath(entry.site)}/project`) + : await fetchPagedRecords( + entry, + 'values', + (startAt, maxResults) => { + const params = new URLSearchParams({ + maxResults: String(maxResults), + startAt: String(startAt) + }) + return `/rest/api/3/project/search?${params.toString()}` + }, + 100, + { signal, requireComplete } + ) + return projects.map((project) => mapProject(project, entry.site)) +} + export async function listProjects( siteId?: JiraSiteSelection | null, strict = false @@ -19,19 +46,7 @@ export async function listProjects( entries.map(async (entry) => { await acquire() try { - // Server/DC has no /project/search resource; /project returns the - // full list as a plain (unpaged) array. - const projects = - entry.site.authType === 'server' - ? await jiraRequest(entry, `${apiBasePath(entry.site)}/project`) - : await fetchPagedRecords(entry, 'values', (startAt, maxResults) => { - const params = new URLSearchParams({ - maxResults: String(maxResults), - startAt: String(startAt) - }) - return `/rest/api/3/project/search?${params.toString()}` - }) - return projects.map((project) => mapProject(project, entry.site)) + return await readProjectsForClient(entry) } catch (error) { if (isAuthError(error)) { clearToken(entry.site.id) diff --git a/src/main/jira/jira-record-pages.ts b/src/main/jira/jira-record-pages.ts index c6bcba6ea9f..962e0f7ba38 100644 --- a/src/main/jira/jira-record-pages.ts +++ b/src/main/jira/jira-record-pages.ts @@ -4,6 +4,8 @@ export type JiraRecord = Record export type JiraSearchResponse = { issues?: JiraRecord[] + nextPageToken?: string + isLast?: boolean } export type JiraPagedResponse = { @@ -76,21 +78,39 @@ export async function fetchPagedRecords( entry: JiraClientForSite, key: JiraPageItemKey, pathForPage: (startAt: number, maxResults: number) => string, - maxResults = 100 + maxResults = 100, + options?: { signal?: AbortSignal; requireComplete?: boolean } ): Promise { const records: JiraRecord[] = [] let startAt = 0 for (let guard = 0; guard < 100; guard += 1) { - const response = await jiraRequest>( - entry, - pathForPage(startAt, maxResults) - ) + const path = pathForPage(startAt, maxResults) + const response = options?.signal + ? await jiraRequest>(entry, path, { signal: options.signal }) + : await jiraRequest>(entry, path) + if ( + options?.requireComplete && + !Array.isArray(response[key]) && + !Array.isArray(response.values) + ) { + throw new Error('Jira returned an invalid project page.') + } const items = getPageItems(response, key) records.push(...items) if (!shouldFetchNextPage(response, startAt, items, maxResults)) { - break + const total = asFiniteNumber(response.total) + if ( + options?.requireComplete && + ((total !== null && startAt + items.length < total) || response.isLast === false) + ) { + throw new Error('Jira returned an incomplete project page.') + } + return records } startAt += asFiniteNumber(response.maxResults) ?? maxResults } + if (options?.requireComplete) { + throw new Error('Jira project list exceeded the page limit. Select a project with JQL instead.') + } return records } diff --git a/src/shared/jira-preset-jql.ts b/src/shared/jira-preset-jql.ts new file mode 100644 index 00000000000..bc01d439139 --- /dev/null +++ b/src/shared/jira-preset-jql.ts @@ -0,0 +1,14 @@ +import type { JiraIssueFilter } from './jira-types' + +export function buildJiraPresetJql(filter: JiraIssueFilter): string { + if (filter === 'assigned') { + return 'assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC' + } + if (filter === 'reported') { + return 'reporter = currentUser() AND statusCategory != Done ORDER BY updated DESC' + } + if (filter === 'done') { + return 'assignee = currentUser() AND statusCategory = Done ORDER BY updated DESC' + } + return 'statusCategory != Done ORDER BY updated DESC' +}