mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user