Skip to content

Commit 282524f

Browse files
authored
revert(search): restore the v0.9.23 Jira text search (#9148)
* revert(search): restore the v0.9.23 Jira text search Reverts the Jira summary tiers (#9111, #9115, #9121, #9129). A plain Jira search again returns the all-terms text match in Jira's order, as in v0.9.23. The stemmedTerms helper stays; cross-source ranking uses it. * chore(search): stop exporting stemmedTerms now only text-match uses it
1 parent e5feb08 commit 282524f

3 files changed

Lines changed: 31 additions & 292 deletions

File tree

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

Lines changed: 0 additions & 189 deletions
Original file line numberDiff line numberDiff line change
@@ -331,192 +331,3 @@ 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-
/** `outsideMatch`: the issue lacks a query term, so a `text ~` all-terms clause drops it. */
338-
type Issue = string | { key: string; summary: string; outsideMatch?: boolean }
339-
/**
340-
* Answers the all-terms query with `exact`, the all-terms summary query with `titled`, and the
341-
* half-the-terms summary query with `half`, or their failures.
342-
*/
343-
function jira(
344-
exact: { ids: string[]; more: boolean },
345-
titled: Issue[] | (() => never),
346-
half: Issue[] | (() => never) = []
347-
) {
348-
const toRow = (row: Issue): Exclude<Issue, string> =>
349-
typeof row === 'string' ? { key: row, summary: row } : row
350-
const issues = (rows: Issue[]) =>
351-
rows.map(toRow).map(({ key, summary }) => ({ key, fields: { summary } }))
352-
const json = vi.fn(
353-
async (path: string, options?: { body?: { jql?: string; maxResults?: number } }) => {
354-
if (path === '/oauth/token/accessible-resources') return [SITE]
355-
if (path !== search) throw new Error(`Unexpected request: ${path}`)
356-
const jql = options?.body?.jql ?? ''
357-
if (jql.includes('summary ~')) {
358-
const rows = jql.includes(' OR ') ? half : titled
359-
const picked = (typeof rows === 'function' ? rows() : rows)
360-
.map(toRow)
361-
.filter((row) => !(row.outsideMatch && jql.includes('text ~')))
362-
return { issues: issues(picked).slice(0, options?.body?.maxResults) }
363-
}
364-
return { issues: issues(exact.ids), ...(exact.more ? { nextPageToken: 'page-2' } : {}) }
365-
}
366-
)
367-
return { json, text: vi.fn(), bytes: vi.fn() } satisfies NativeClient
368-
}
369-
const query = { query: 'merged results pipelines', limit: 3, scopes: [] }
370-
const overflowing = { ids: ['NEW-3', 'NEW-2', 'NEW-1'], more: true }
371-
372-
it('leads an all-terms match too large for one page with issues whose summary holds every term', async () => {
373-
const api = jira(overflowing, ['TOP-1', 'NEW-2'])
374-
const page = await searchAtlassian(api, 'jira', query)
375-
expect(page.documents.map((document) => document.id)).toEqual(['TOP-1', 'NEW-2', 'NEW-3'])
376-
expect(page.hasMore).toBe(true)
377-
expect(page.partial).toBe(true)
378-
expect(page.nextCursor).toBeUndefined()
379-
})
380-
381-
it('keeps the all-terms page and its cursor when no summary holds every term', async () => {
382-
const page = await searchAtlassian(jira(overflowing, []), 'jira', query)
383-
expect(page.documents.map((document) => document.id)).toEqual(['NEW-3', 'NEW-2', 'NEW-1'])
384-
expect(page.nextCursor).toBe('page-2')
385-
expect(page.partial).toBe(false)
386-
})
387-
388-
it.each([
389-
['a provider refusal', new NativeSearchError('rate_limited', 'Rate limited.', 30)],
390-
['a request timeout', new Error('Request timed out after 10000ms')],
391-
])(
392-
'keeps the all-terms page and its cursor when the summary search fails on %s',
393-
async (_, failure) => {
394-
const api = jira(overflowing, () => {
395-
throw failure
396-
})
397-
const page = await searchAtlassian(api, 'jira', query)
398-
expect(page.documents.map((document) => document.id)).toEqual(['NEW-3', 'NEW-2', 'NEW-1'])
399-
expect(page.nextCursor).toBe('page-2')
400-
}
401-
)
402-
403-
it('propagates a cancellation during the summary search', async () => {
404-
const caller = new AbortController()
405-
const reason = new DOMException('Search cancelled', 'AbortError')
406-
const api = jira(overflowing, () => {
407-
caller.abort(reason)
408-
throw reason
409-
})
410-
await expect(searchAtlassian(api, 'jira', { ...query, signal: caller.signal })).rejects.toBe(
411-
reason
412-
)
413-
})
414-
415-
it('keeps an all-terms match that fits on one page without a summary search', async () => {
416-
const api = jira({ ids: ['ONLY-1'], more: false }, ['TOP-1'])
417-
const page = await searchAtlassian(api, 'jira', query)
418-
expect(page.documents.map((document) => document.id)).toEqual(['ONLY-1'])
419-
expect(api.json).toHaveBeenCalledTimes(2)
420-
})
421-
422-
it('keeps date order, native queries and any-term matches on their own page', async () => {
423-
for (const input of [
424-
{ ...query, filters: { sortBy: 'newest' as const } },
425-
{ ...query, native: { provider: 'jira' as const, query: 'text ~ "merged results"' } },
426-
{ ...query, textMatch: 'any' as const },
427-
]) {
428-
const api = jira({ ids: ['NEW-3', 'NEW-2'], more: true }, ['TOP-1'])
429-
const page = await searchAtlassian(api, 'jira', input)
430-
expect(page.documents.map((document) => document.id)).not.toContain('TOP-1')
431-
expect(api.json).toHaveBeenCalledTimes(2)
432-
}
433-
})
434-
435-
it('leads with issues whose summary holds at least half the terms when none holds them all', async () => {
436-
const api = jira(
437-
overflowing,
438-
[],
439-
[
440-
{ key: 'LOOSE-1', summary: 'Pipelines dashboard' },
441-
{ key: 'HALF-1', summary: 'Merged results broke the dashboard' },
442-
]
443-
)
444-
const page = await searchAtlassian(api, 'jira', query)
445-
expect(page.documents.map((document) => document.id)).toEqual(['HALF-1', 'NEW-3', 'NEW-2'])
446-
expect(page.nextCursor).toBeUndefined()
447-
})
448-
449-
it('keeps a two-term query to summaries holding every term', async () => {
450-
const api = jira(overflowing, [], [{ key: 'HALF-1', summary: 'Merged pipelines' }])
451-
const page = await searchAtlassian(api, 'jira', { ...query, query: 'merged pipelines' })
452-
expect(page.documents.map((document) => document.id)).toEqual(['NEW-3', 'NEW-2', 'NEW-1'])
453-
expect(api.json).toHaveBeenCalledTimes(3)
454-
})
455-
456-
it('keeps the all-terms page and its cursor when the half-the-terms summary search fails', async () => {
457-
const api = jira(overflowing, [], () => {
458-
throw new NativeSearchError('rate_limited', 'Rate limited.', 30)
459-
})
460-
const page = await searchAtlassian(api, 'jira', query)
461-
expect(page.documents.map((document) => document.id)).toEqual(['NEW-3', 'NEW-2', 'NEW-1'])
462-
expect(page.nextCursor).toBe('page-2')
463-
})
464-
465-
it('counts a word and its plural as one term before allowing the half-the-terms fallback', async () => {
466-
const api = jira(overflowing, [], [{ key: 'ONE-1', summary: 'Rotation schedule' }])
467-
const page = await searchAtlassian(api, 'jira', { ...query, query: 'token tokens rotation' })
468-
expect(page.documents.map((document) => document.id)).toEqual(['NEW-3', 'NEW-2', 'NEW-1'])
469-
})
470-
471-
it('orders half-the-terms summaries by how many terms they hold', async () => {
472-
const api = jira(
473-
overflowing,
474-
[],
475-
[
476-
{ key: 'TWO-1', summary: 'Merged results dashboard' },
477-
{ key: 'THREE-1', summary: 'Merged results in release pipelines' },
478-
]
479-
)
480-
const page = await searchAtlassian(api, 'jira', {
481-
...query,
482-
query: 'merged results release pipelines',
483-
})
484-
expect(page.documents.map((document) => document.id)).toEqual(['THREE-1', 'TWO-1', 'NEW-3'])
485-
})
486-
487-
it('finds half-the-terms summaries beyond the first page of pair matches', async () => {
488-
const pairs = Array.from({ length: 10 }, (_, index) => ({
489-
key: `PAIR-${index}`,
490-
summary: 'Pipelines dashboard',
491-
}))
492-
const api = jira(overflowing, [], [...pairs, { key: 'HALF-9', summary: 'Merged pipelines' }])
493-
const page = await searchAtlassian(api, 'jira', query)
494-
expect(page.documents.map((document) => document.id)[0]).toBe('HALF-9')
495-
})
496-
497-
it('follows summaries holding every term with those holding half of them', async () => {
498-
const api = jira(
499-
{ ids: ['NEW-3', 'NEW-2', 'NEW-1'], more: true },
500-
['ALL-1'],
501-
[
502-
{ key: 'ALL-1', summary: 'Merged results pipelines' },
503-
{ key: 'HALF-1', summary: 'Merged results dashboard' },
504-
]
505-
)
506-
const page = await searchAtlassian(api, 'jira', query)
507-
expect(page.documents.map((document) => document.id)).toEqual(['ALL-1', 'HALF-1', 'NEW-3'])
508-
})
509-
510-
it('keeps half-the-terms summaries to issues that match every term somewhere', async () => {
511-
const api = jira(
512-
overflowing,
513-
[],
514-
[
515-
{ key: 'OUTSIDE-1', summary: 'Merged results dashboard', outsideMatch: true },
516-
{ key: 'INSIDE-1', summary: 'Merged results broke the dashboard' },
517-
]
518-
)
519-
const page = await searchAtlassian(api, 'jira', query)
520-
expect(page.documents.map((document) => document.id)).toEqual(['INSIDE-1', 'NEW-3', 'NEW-2'])
521-
})
522-
})

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

Lines changed: 24 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import {
1313
confluenceTextClause,
1414
holdsHalfTheTerms,
1515
jiraTextClause,
16-
stemmedTerms,
1716
} from '@/lib/sim-search/live/text-match'
1817
import type {
1918
NativeClient,
@@ -117,14 +116,6 @@ async function sites(client: NativeClient) {
117116
return array(await client.json('/oauth/token/accessible-resources', { memo: true }))
118117
}
119118

120-
/**
121-
* Issues fetched for the half-the-terms summary tier: summary pair matches within the all-terms
122-
* match. Jira returns them newest first, not by relevance, and the page is then filtered to
123-
* summaries holding enough terms, so it starts from a wider pool than the caller's limit or the
124-
* any-term fallback's.
125-
*/
126-
const HALF_TERM_CANDIDATES = 50
127-
128119
/**
129120
* One Atlassian site's page; `ranking` marks a page whose order or completeness differs from the
130121
* all-terms search.
@@ -184,89 +175,33 @@ export async function searchAtlassian(
184175
const cloudId = string(site.id)
185176
const origin = string(site.url).replace(/\/$/, '')
186177
if (provider === 'jira') {
187-
/** One JQL page, each issue with its summary and raw fields. */
188-
const searchIssues = async (
189-
jql: string,
190-
{ cursor, maxResults = input.limit }: { cursor?: string; maxResults?: number } = {}
191-
) => {
192-
const data = object(
193-
await client.json(`/ex/jira/${segment(cloudId)}/rest/api/3/search/jql`, {
194-
body: {
195-
jql: scopeAtlassianQuery(jql, scope, order),
196-
maxResults,
197-
fields: ['summary', 'description', 'updated', 'creator', 'status'],
198-
...(cursor ? { nextPageToken: cursor } : {}),
199-
},
200-
signal: input.signal,
201-
})
202-
)
203-
return {
204-
issues: array(data.issues).map((row) => {
205-
const fields = object(row.fields)
206-
return {
207-
document: issue(row, cloudId, origin),
208-
summary: string(fields.summary),
209-
fields,
210-
}
211-
}),
212-
next: string(data.nextPageToken) || undefined,
213-
}
214-
}
215-
const exact = await searchIssues(
216-
input.native?.query || (text ? jiraTextClause(text, input.textMatch) : ''),
217-
{ cursor: input.native?.cursor && single ? input.native.cursor : undefined }
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+
})
218192
)
219-
const exactPage: SitePage = {
220-
documents: exact.issues
221-
.filter(
222-
({ summary, fields }) =>
223-
!relevant || relevant(`${summary}\n${textContent(fields.description)}`)
224-
)
225-
.map(({ document }) => document),
226-
excluded: false,
227-
next: exact.next,
228-
}
229-
// JQL has no relevance order, so an all-terms match larger than a page comes back newest
230-
// first. Within that match, issues whose summary holds every term lead it instead, then
231-
// those whose summary holds at least half of three or more terms (most terms first); a
232-
// summary match never brings in an issue lacking one of the terms.
233-
if (!text || input.native || order || input.textMatch === 'any' || !exact.next)
234-
return exactPage
235-
const terms = stemmedTerms(text)
236-
const [every, half] = await Promise.allSettled([
237-
searchIssues(jiraTextClause(text, 'all', 'summary')),
238-
terms.count >= 3
239-
? searchIssues(
240-
`${jiraTextClause(text, 'any', 'summary')} AND ${jiraTextClause(text)}`,
241-
{ maxResults: HALF_TERM_CANDIDATES }
242-
)
243-
: undefined,
244-
])
245-
// A cancelled search stops here; any other failure keeps the all-terms page.
246-
input.signal?.throwIfAborted()
247-
const everyTerm =
248-
every.status === 'fulfilled' ? every.value.issues.map(({ document }) => document) : []
249-
const halfTerms =
250-
half.status === 'fulfilled' && half.value
251-
? half.value.issues
252-
.map(({ document, summary }) => ({ document, held: terms.held(summary) }))
253-
.filter(({ held }) => held >= terms.required)
254-
.sort((left, right) => right.held - left.held)
255-
.map(({ document }) => document)
256-
: []
257-
const titled = [...everyTerm, ...halfTerms]
258-
if (!titled.length) return exactPage
259-
const seen = new Set<string>()
260193
return {
261-
documents: [...titled, ...exactPage.documents]
262-
.filter(({ id }) => {
263-
if (seen.has(id)) return false
264-
seen.add(id)
265-
return true
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+
)
266201
})
267-
.slice(0, input.limit),
202+
.map((row) => issue(row, cloudId, origin)),
268203
excluded: false,
269-
ranking: 'relevance',
204+
next: string(data.nextPageToken) || undefined,
270205
}
271206
}
272207
const searchPages = async (clause: string, cursor?: string) => {
@@ -330,9 +265,7 @@ export async function searchAtlassian(
330265
? ' Matches other than pages, blog posts, and spaces (attachments, comments, whiteboards, folders, databases) were excluded because they cannot be read.'
331266
: '') +
332267
(pages.some((result) => result.ranking === 'relevance')
333-
? provider === 'jira'
334-
? ' More issues contained every word than fit on one page, so issues whose summary holds every word, then most of them, come first; refine the query, or page with a native JQL query.'
335-
: ' 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.'
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.'
336269
: '') +
337270
(pages.some((result) => result.ranking === 'unavailable')
338271
? ' 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: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -89,19 +89,14 @@ const escapeSearchPhrase = (value: string) =>
8989
JSON.stringify(value.replace(/[+\-&|!(){}[\]^"~*?:\\/]/g, '\\$&'))
9090

9191
/**
92-
* The clause for a plain query in Jira JQL, over all text fields or the summary alone. `any` ORs
93-
* every pair of the query's longest terms, so a match contains at least two of them in that field,
94-
* or either term of a two-term query. Jira stems text search, so a word and its plural share one
95-
* term.
92+
* The `text ~` clause for a plain query in Jira JQL. `any` ORs every pair of the query's longest
93+
* terms, so a match contains at least two of them somewhere in the issue, or either term of a
94+
* two-term query. Jira stems text search, so a word and its plural share one term.
9695
*/
97-
export function jiraTextClause(
98-
text: string,
99-
match: TextMatch = 'all',
100-
field: 'text' | 'summary' = 'text'
101-
): string {
96+
export function jiraTextClause(text: string, match: TextMatch = 'all'): string {
10297
const terms = match === 'any' ? distinctTerms(text, singularKey) : []
103-
if (terms.length < 2) return `${field} ~ ${escapeSearchPhrase(text)}`
104-
const clause = (term: string) => `${field} ~ ${escapeSearchPhrase(term)}`
98+
if (terms.length < 2) return `text ~ ${escapeSearchPhrase(text)}`
99+
const clause = (term: string) => `text ~ ${escapeSearchPhrase(term)}`
105100
const groups =
106101
terms.length === 2
107102
? terms.map(clause)
@@ -130,7 +125,7 @@ export function termCoverage(text: string, terms: readonly string[]): number {
130125
* Query and text words are compared by their singular key, so a word and its regular plural count
131126
* as one term; a term with punctuation inside it is matched as written.
132127
*/
133-
export function stemmedTerms(query: string): {
128+
function stemmedTerms(query: string): {
134129
count: number
135130
/** At least half the terms, the minimum a partial match must hold. */
136131
required: number

0 commit comments

Comments
 (0)