Fix Jira Cloud All and classify presets by ticket status (#28579)

Complete Jira Cloud discovery and token pagination, reject incomplete pages, and classify presets by status while retaining strict CLI reads.

Co-authored-by: ggbdpq <ggbdpq@gmail.com>
This commit is contained in:
Neil
2026-10-14 03:19:31 -07:00
committed by GitHub
co-authored by ggbdpq
parent 51173e45d4
commit ba81b43c26
5 changed files with 523 additions and 42 deletions
+83 -22
View File
@@ -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<JiraIssue[]> {
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<JiraIssue[]> {
// 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<JiraSearchResponse>(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<string>()
let nextPageToken: string | undefined
for (let page = 0; page < 100; page += 1) {
const result = await jiraRequest<JiraSearchResponse>(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<JiraIssue[]> {
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<JiraIssue[]> {
return runIssueSearch({ jql: jql.trim() }, limit, siteId, signal, strict)
}
async function runIssueSearch(
query: JiraIssueQuery,
limit: number,
siteId?: JiraSiteSelection | null,
signal?: AbortSignal,
strict = false
): Promise<JiraIssue[]> {
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.
+371
View File
@@ -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<JiraIssueFilter>(['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<string, string>()
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()
}
})
})
+29 -14
View File
@@ -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<JiraProject[]> {
// Server/DC exposes the complete array through the classic project resource.
const projects =
entry.site.authType === 'server'
? signal
? await jiraRequest<JiraRecord[]>(entry, `${apiBasePath(entry.site)}/project`, { signal })
: await jiraRequest<JiraRecord[]>(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<JiraRecord[]>(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)
+26 -6
View File
@@ -4,6 +4,8 @@ export type JiraRecord = Record<string, unknown>
export type JiraSearchResponse = {
issues?: JiraRecord[]
nextPageToken?: string
isLast?: boolean
}
export type JiraPagedResponse<T> = {
@@ -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<JiraRecord[]> {
const records: JiraRecord[] = []
let startAt = 0
for (let guard = 0; guard < 100; guard += 1) {
const response = await jiraRequest<JiraPagedResponse<JiraRecord>>(
entry,
pathForPage(startAt, maxResults)
)
const path = pathForPage(startAt, maxResults)
const response = options?.signal
? await jiraRequest<JiraPagedResponse<JiraRecord>>(entry, path, { signal: options.signal })
: await jiraRequest<JiraPagedResponse<JiraRecord>>(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
}
+14
View File
@@ -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'
}