Skip to content

Commit 58137cd

Browse files
committed
improvement(search): lead an overflowing Jira all-terms match with issues whose summary holds every term
1 parent e7076a9 commit 58137cd

3 files changed

Lines changed: 141 additions & 27 deletions

File tree

‎apps/sim/lib/sim-search/live/atlassian.test.ts‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,3 +331,84 @@ describe('Confluence plain search ranking', () => {
331331
expect(own.nextCursor).toBe('page-2')
332332
})
333333
})
334+
335+
describe('Jira plain search ranking', () => {
336+
const search = '/ex/jira/cloud/rest/api/3/search/jql'
337+
/** Answers the all-terms query with `exact` and the summary query with `titled`, or its failure. */
338+
function jira(exact: { ids: string[]; more: boolean }, titled: string[] | (() => never)) {
339+
const issues = (ids: string[]) => ids.map((key) => ({ key, fields: { summary: key } }))
340+
const json = vi.fn(async (path: string, options?: { body?: { jql?: string } }) => {
341+
if (path === '/oauth/token/accessible-resources') return [SITE]
342+
if (path !== search) throw new Error(`Unexpected request: ${path}`)
343+
if (options?.body?.jql?.includes('summary ~'))
344+
return { issues: issues(typeof titled === 'function' ? titled() : titled) }
345+
return { issues: issues(exact.ids), ...(exact.more ? { nextPageToken: 'page-2' } : {}) }
346+
})
347+
return { json, text: vi.fn(), bytes: vi.fn() } satisfies NativeClient
348+
}
349+
const query = { query: 'merged results pipelines', limit: 3, scopes: [] }
350+
const overflowing = { ids: ['NEW-3', 'NEW-2', 'NEW-1'], more: true }
351+
352+
it('leads an all-terms match too large for one page with issues whose summary holds every term', async () => {
353+
const api = jira(overflowing, ['TOP-1', 'NEW-2'])
354+
const page = await searchAtlassian(api, 'jira', query)
355+
expect(page.documents.map((document) => document.id)).toEqual(['TOP-1', 'NEW-2', 'NEW-3'])
356+
expect(page.hasMore).toBe(true)
357+
expect(page.partial).toBe(true)
358+
expect(page.nextCursor).toBeUndefined()
359+
})
360+
361+
it('keeps the all-terms page and its cursor when no summary holds every term', async () => {
362+
const page = await searchAtlassian(jira(overflowing, []), 'jira', query)
363+
expect(page.documents.map((document) => document.id)).toEqual(['NEW-3', 'NEW-2', 'NEW-1'])
364+
expect(page.nextCursor).toBe('page-2')
365+
expect(page.partial).toBe(false)
366+
})
367+
368+
it.each([
369+
['a provider refusal', new NativeSearchError('rate_limited', 'Rate limited.', 30)],
370+
['a request timeout', new Error('Request timed out after 10000ms')],
371+
])(
372+
'keeps the all-terms page and its cursor when the summary search fails on %s',
373+
async (_, failure) => {
374+
const api = jira(overflowing, () => {
375+
throw failure
376+
})
377+
const page = await searchAtlassian(api, 'jira', query)
378+
expect(page.documents.map((document) => document.id)).toEqual(['NEW-3', 'NEW-2', 'NEW-1'])
379+
expect(page.nextCursor).toBe('page-2')
380+
}
381+
)
382+
383+
it('propagates a cancellation during the summary search', async () => {
384+
const caller = new AbortController()
385+
const reason = new DOMException('Search cancelled', 'AbortError')
386+
const api = jira(overflowing, () => {
387+
caller.abort(reason)
388+
throw reason
389+
})
390+
await expect(searchAtlassian(api, 'jira', { ...query, signal: caller.signal })).rejects.toBe(
391+
reason
392+
)
393+
})
394+
395+
it('keeps an all-terms match that fits on one page without a summary search', async () => {
396+
const api = jira({ ids: ['ONLY-1'], more: false }, ['TOP-1'])
397+
const page = await searchAtlassian(api, 'jira', query)
398+
expect(page.documents.map((document) => document.id)).toEqual(['ONLY-1'])
399+
expect(api.json).toHaveBeenCalledTimes(2)
400+
})
401+
402+
it('keeps date order, native queries and any-term matches on their own page', async () => {
403+
for (const input of [
404+
{ ...query, filters: { sortBy: 'newest' as const } },
405+
{ ...query, native: { provider: 'jira' as const, query: 'text ~ "merged results"' } },
406+
{ ...query, textMatch: 'any' as const },
407+
]) {
408+
const api = jira({ ids: ['NEW-3', 'NEW-2'], more: true }, ['TOP-1'])
409+
const page = await searchAtlassian(api, 'jira', input)
410+
expect(page.documents.map((document) => document.id)).not.toContain('TOP-1')
411+
expect(api.json).toHaveBeenCalledTimes(2)
412+
}
413+
})
414+
})

‎apps/sim/lib/sim-search/live/atlassian.ts‎

Lines changed: 52 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { providerText } from '@/lib/sim-search/live/text'
1212
import {
1313
confluenceTextClause,
1414
holdsHalfTheTerms,
15+
jiraSummaryClause,
1516
jiraTextClause,
1617
} from '@/lib/sim-search/live/text-match'
1718
import type {
@@ -175,33 +176,55 @@ export async function searchAtlassian(
175176
const cloudId = string(site.id)
176177
const origin = string(site.url).replace(/\/$/, '')
177178
if (provider === 'jira') {
178-
const data = object(
179-
await client.json(`/ex/jira/${segment(cloudId)}/rest/api/3/search/jql`, {
180-
body: {
181-
jql: scopeAtlassianQuery(
182-
input.native?.query || (text ? jiraTextClause(text, input.textMatch) : ''),
183-
scope,
184-
order
185-
),
186-
maxResults: input.limit,
187-
fields: ['summary', 'description', 'updated', 'creator', 'status'],
188-
...(input.native?.cursor && single ? { nextPageToken: input.native.cursor } : {}),
189-
},
190-
signal: input.signal,
191-
})
192-
)
193-
return {
194-
documents: array(data.issues)
195-
.filter((row) => {
196-
const fields = object(row.fields)
197-
return (
198-
!relevant ||
199-
relevant(`${string(fields.summary)}\n${textContent(fields.description)}`)
200-
)
179+
const searchIssues = async (jql: string, cursor?: string): Promise<SitePage> => {
180+
const data = object(
181+
await client.json(`/ex/jira/${segment(cloudId)}/rest/api/3/search/jql`, {
182+
body: {
183+
jql: scopeAtlassianQuery(jql, scope, order),
184+
maxResults: input.limit,
185+
fields: ['summary', 'description', 'updated', 'creator', 'status'],
186+
...(cursor ? { nextPageToken: cursor } : {}),
187+
},
188+
signal: input.signal,
201189
})
202-
.map((row) => issue(row, cloudId, origin)),
203-
excluded: false,
204-
next: string(data.nextPageToken) || undefined,
190+
)
191+
return {
192+
documents: array(data.issues)
193+
.filter((row) => {
194+
const fields = object(row.fields)
195+
return (
196+
!relevant ||
197+
relevant(`${string(fields.summary)}\n${textContent(fields.description)}`)
198+
)
199+
})
200+
.map((row) => issue(row, cloudId, origin)),
201+
excluded: false,
202+
next: string(data.nextPageToken) || undefined,
203+
}
204+
}
205+
const exact = await searchIssues(
206+
input.native?.query || (text ? jiraTextClause(text, input.textMatch) : ''),
207+
input.native?.cursor && single ? input.native.cursor : undefined
208+
)
209+
// JQL has no relevance order, so an all-terms match larger than a page comes back newest
210+
// first; issues whose summary holds every term lead it instead.
211+
if (!text || input.native || order || input.textMatch === 'any' || !exact.next) return exact
212+
try {
213+
const titled = await searchIssues(jiraSummaryClause(text))
214+
if (!titled.documents.length) return exact
215+
const leading = new Set(titled.documents.map((document) => document.id))
216+
return {
217+
documents: [
218+
...titled.documents,
219+
...exact.documents.filter((document) => !leading.has(document.id)),
220+
].slice(0, input.limit),
221+
excluded: false,
222+
ranking: 'relevance',
223+
}
224+
} catch {
225+
// A cancelled search stops here; any other failure keeps the all-terms page.
226+
input.signal?.throwIfAborted()
227+
return exact
205228
}
206229
}
207230
const searchPages = async (clause: string, cursor?: string) => {
@@ -265,7 +288,9 @@ export async function searchAtlassian(
265288
? ' Matches other than pages, blog posts, and spaces (attachments, comments, whiteboards, folders, databases) were excluded because they cannot be read.'
266289
: '') +
267290
(pages.some((result) => result.ranking === 'relevance')
268-
? ' More pages contained every word than fit on one page, so this page is ordered by Confluence relevance search; refine the query, or page with a native CQL query.'
291+
? provider === 'jira'
292+
? ' More issues contained every word than fit on one page, so issues whose summary holds every word come first; refine the query, or page with a native JQL query.'
293+
: ' More pages contained every word than fit on one page, so this page is ordered by Confluence relevance search; refine the query, or page with a native CQL query.'
269294
: '') +
270295
(pages.some((result) => result.ranking === 'unavailable')
271296
? ' Relevance ranking was unavailable, so this page keeps the order of the all-terms search.'

‎apps/sim/lib/sim-search/live/text-match.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,14 @@ export function jiraTextClause(text: string, match: TextMatch = 'all'): string {
106106
return `(${groups.join(' OR ')})`
107107
}
108108

109+
/**
110+
* The `summary ~` clause for a plain query in Jira JQL: issues whose summary holds every word.
111+
* JQL has no relevance order, so this stands in for a title boost.
112+
*/
113+
export function jiraSummaryClause(text: string): string {
114+
return `summary ~ ${escapeSearchPhrase(text)}`
115+
}
116+
109117
/**
110118
* The text clause for a plain query in Confluence CQL. `any` uses `siteSearch`, Confluence's own
111119
* relevance search, which ranks a page with the query's rarer words above the many pages sharing

0 commit comments

Comments
 (0)