fix(jira): search plain text when task search input isn't JQL (#22900)

* fix(jira): search plain text when task search input isn't JQL

The Tasks page sent every keystroke to Jira as JQL, so partial input like `s` failed with a 400.

Fixes #22265

* fix(jira): keep the JQL rejection reason behind Details

For plain-text searches the reason ("Field 'login' does not exist") reads as noise.

* fix(jira): announce the text-match notice to screen readers

* fix(jira): skip the wildcard when the last search word has punctuation

Jira doesn't split a wildcard term into words, so `login,*` or `c#*` matched nothing.

* fix(jira): keep the text-match live region mounted between searches

* fix(jira): keep hyphenated words like sign-in as plain text search

* fix(jira): retry a key-shaped search as text when no issue matches

Input like `utf-8`, `sha-256` or `covid-19` matches the issue-key shape, so
the search ran only `key = "UTF-8"` and showed an empty list with nothing to
explain why. An empty exact-key lookup is a wrong guess, not an answer, so
retry it as a text search — the same let-Jira-decide-then-fall-back rule the
JQL path already uses.

Splits the key and text builders apart so the caller can tell which branch it
took; `buildJiraTextSearchJql` keeps its combined behaviour for the
smart-workspace caller.

* fix(jira): let Jira's answer settle key, JQL and text guesses

Bare `in`/`is` sent everyday phrases down the JQL path, a key lookup Jira
answered with 400 never reached the text search, and a missing key like
PROJ-1401 was read as a lost connection. One key-or-text search now serves
the Tasks page and new-workspace, and one parser reads the status prefix.

* fix(jira): require IN to be its own word before a function name

`input (raw)` and `init()` read as `in` + a function call and took the JQL path.

---------

Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
Kelvin Amoaba
2026-10-03 10:28:24 -07:00
committed by GitHub
co-authored by Neil
parent 1d010ce2f8
commit ee84608bda
28 changed files with 820 additions and 120 deletions
@@ -0,0 +1,27 @@
import type { JiraIssue } from '../../../shared/jira-types'
import { buildJiraIssueKeyJql, buildJiraTextMatchJql } from '../../../shared/jira-search-input-jql'
import { getJiraBadRequestReason } from './task-page-jira-load-state'
/** Find the issue with this key, or failing that, issues whose text matches the input. */
export async function searchJiraIssuesByKeyOrText(
input: string,
search: (jql: string) => Promise<JiraIssue[]>
): Promise<JiraIssue[]> {
const keyJql = buildJiraIssueKeyJql(input)
if (keyJql) {
// Why: `utf-8` is key-shaped but meant as text. Jira answers a key it doesn't have with
// no issues (unknown project) or a 400 (unknown number), so neither is a final answer.
const issues = await search(keyJql).catch((error: unknown) => {
if (getJiraBadRequestReason(error) === null) {
throw error
}
return []
})
if (issues.length > 0) {
return issues
}
}
const textJql = buildJiraTextMatchJql(input)
// Why: the runtime RPC rejects empty JQL.
return textJql ? search(textJql) : []
}
@@ -422,6 +422,33 @@ describe('SmartWorkspaceNameField Jira accessibility', () => {
).not.toBeNull()
})
it('searches issue text when a key-shaped query matches no issue key', async () => {
Object.assign(jiraMock.state, { intent: false, loading: false })
Object.assign(jiraConnectionMock.status, { connected: true, selectedSiteId: 'site-a' })
jiraSearchMock.mockResolvedValueOnce([]).mockResolvedValueOnce([
{
id: 'jira-2',
key: 'ORCA-7',
title: 'Decode utf-8 names',
url: 'https://company.atlassian.net/browse/ORCA-7',
project: { id: 'project-1', key: 'ORCA', name: 'Orca' },
issueType: { id: 'type-1', name: 'Task' },
status: { id: 'status-1', name: 'Open', categoryKey: 'new', categoryName: 'To Do' },
labels: [],
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z'
}
])
renderField({ jiraSourceContext: true, value: 'utf-8' })
fireEvent.click(screen.getByRole('button', { name: 'Jira' }))
expect(await screen.findByRole('button', { name: /ORCA-7.*Decode utf-8 names/ })).not.toBeNull()
expect(jiraSearchMock).toHaveBeenCalledTimes(2)
expect(jiraSearchMock).toHaveBeenNthCalledWith(1, 'key = "UTF-8"', 12, expect.anything())
expect(jiraSearchMock).toHaveBeenNthCalledWith(2, 'text ~ "utf 8*"', 12, expect.anything())
})
it('labels duplicate-account choices with site and account', () => {
Object.assign(jiraMock.state, {
loading: false,
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
buildJiraIssueSearchJql,
getJiraIssueSearchQuery,
buildSmartWorkspaceSourceRows,
getBranchSearchRequest,
getSmartWorkspaceEmptyHint,
@@ -729,12 +729,13 @@ describe('Jira issue search', () => {
expect(isBlockingJiraUrlIntent('jira', 'ordinary workspace name')).toBe(false)
})
it('builds text and exact-key JQL without accepting oversized input', () => {
expect(buildJiraIssueSearchJql('test')).toBe('text ~ "test*"')
expect(buildJiraIssueSearchJql('orca-123')).toBe('key = "ORCA-123"')
expect(buildJiraIssueSearchJql('say "hello"')).toBe('text ~ "say \\"hello\\"*"')
it('accepts a Jira query only when it is short enough and has words to search', () => {
expect(getJiraIssueSearchQuery(' orca-123 ')).toBe('orca-123')
expect(getJiraIssueSearchQuery('say "hello"')).toBe('say "hello"')
expect(getJiraIssueSearchQuery('()')).toBeNull()
expect(getJiraIssueSearchQuery(' ')).toBeNull()
expect(
buildJiraIssueSearchJql('x'.repeat(SMART_WORKSPACE_SOURCE_QUERY_MAX_BYTES + 1))
getJiraIssueSearchQuery('x'.repeat(SMART_WORKSPACE_SOURCE_QUERY_MAX_BYTES + 1))
).toBeNull()
})
@@ -1,7 +1,7 @@
import { useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import {
buildJiraIssueSearchJql,
getJiraIssueSearchQuery,
isSmartWorkspaceSourceQueryWithinLimit
} from './smart-workspace-source-results'
import { parseBoundedSmartWorkspaceLinearIssueUrlIntent } from '../../../../shared/new-workspace/smart-workspace-linear-intent'
@@ -82,16 +82,16 @@ export function useSmartWorkspaceNameFieldController({
!textOnly &&
foundation.linearAvailable &&
(foundation.mode === 'smart' || foundation.mode === 'linear')
const jiraSearchJql =
const jiraSearchQuery =
foundation.mode === 'jira' && !foundation.jiraSource.intent && sourceQueryWithinLimit
? buildJiraIssueSearchJql(foundation.debouncedQuery)
? getJiraIssueSearchQuery(foundation.debouncedQuery)
: null
const shouldQueryJira =
!disabled &&
!textOnly &&
foundation.jiraSourceConnected &&
jiraSourceContext !== null &&
jiraSearchJql !== null
jiraSearchQuery !== null
useSmartWorkspaceGithubSearch({
foundation,
@@ -105,7 +105,7 @@ export function useSmartWorkspaceNameFieldController({
linearUrlIntent,
linearUrlIntentOwnsInput,
shouldQueryJira,
jiraSearchJql
jiraSearchQuery
})
const shouldQueryGitlab =
sourceQueryWithinLimit &&
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef } from 'react'
import { searchRuntimeRepoBaseRefDetails } from '@/runtime/runtime-repo-client'
import { lookupLinearIssueUrl } from '@/lib/linear-issue-url-lookup'
import { searchJiraIssuesByKeyOrText } from '@/components/jira-key-or-text-search'
import { linearWorkspaceScopeSignature } from '../../../../shared/linear/workspace-types'
import { getSmartWorkspaceLinearSearchQuery } from '../../../../shared/new-workspace/smart-workspace-linear-intent'
import type { parseBoundedSmartWorkspaceLinearIssueUrlIntent } from '../../../../shared/new-workspace/smart-workspace-linear-intent'
@@ -17,7 +18,7 @@ export function useSmartWorkspaceSecondarySearches({
linearUrlIntent,
linearUrlIntentOwnsInput,
shouldQueryJira,
jiraSearchJql
jiraSearchQuery
}: {
foundation: Foundation
shouldQueryLinear: boolean
@@ -25,7 +26,7 @@ export function useSmartWorkspaceSecondarySearches({
linearUrlIntent: ReturnType<typeof parseBoundedSmartWorkspaceLinearIssueUrlIntent>
linearUrlIntentOwnsInput: boolean
shouldQueryJira: boolean
jiraSearchJql: string | null
jiraSearchQuery: string | null
}): void {
const {
disabled,
@@ -213,7 +214,7 @@ export function useSmartWorkspaceSecondarySearches({
])
useEffect(() => {
if (!shouldQueryJira || !jiraSourceContext || !jiraSearchJql) {
if (!shouldQueryJira || !jiraSourceContext || !jiraSearchQuery) {
setJiraIssues([])
setJiraLoading(false)
return
@@ -224,11 +225,13 @@ export function useSmartWorkspaceSecondarySearches({
setJiraLoading(true)
const siteId =
jiraConnectionStatus?.selectedSiteId ?? jiraConnectionStatus?.activeSiteId ?? null
void searchJiraIssues(jiraSearchJql, RESULT_LIMIT, {
sourceContext: jiraSourceContext,
siteId,
signal: controller.signal
})
void searchJiraIssuesByKeyOrText(jiraSearchQuery, (jql) =>
searchJiraIssues(jql, RESULT_LIMIT, {
sourceContext: jiraSourceContext,
siteId,
signal: controller.signal
})
)
.then((issues) => {
if (!stale) {
setJiraIssues(issues)
@@ -251,7 +254,7 @@ export function useSmartWorkspaceSecondarySearches({
}, [
jiraConnectionStatus?.activeSiteId,
jiraConnectionStatus?.selectedSiteId,
jiraSearchJql,
jiraSearchQuery,
jiraSourceContext,
searchJiraIssues,
setJiraIssues,
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'
import { createTaskPageJiraLoadFailureState } from './task-page-jira-load-state'
import {
createTaskPageJiraLoadFailureState,
getJiraBadRequestReason
} from './task-page-jira-load-state'
describe('TaskPage Jira load state', () => {
it('explains Jira forbidden errors while clearing stale issues', () => {
@@ -24,6 +27,20 @@ describe('TaskPage Jira load state', () => {
})
})
it('reads the status through the local IPC prefix', () => {
expect(
createTaskPageJiraLoadFailureState(
new Error("Error invoking remote method 'jira:searchIssues': Error: Error 429: Slow down")
)
).toEqual({
issues: [],
error: {
title: 'Error 429: Jira rate-limited this issue search. Try again in a moment.',
details: 'Slow down'
}
})
})
it('explains malformed JQL errors', () => {
expect(createTaskPageJiraLoadFailureState(new Error('Malformed JQL'))).toEqual({
issues: [],
@@ -64,3 +81,23 @@ describe('TaskPage Jira load state', () => {
})
})
})
describe('getJiraBadRequestReason', () => {
it.each([
['Error 400: Error in the JQL Query: bad', 'Error in the JQL Query: bad'],
[
"Error invoking remote method 'jira:searchIssues': Error: Error 400: Error in the JQL Query: bad",
'Error in the JQL Query: bad'
],
['Error 400:', '']
])('reads Jira reason from %s', (message, reason) => {
expect(getJiraBadRequestReason(new Error(message))).toBe(reason)
})
it.each(['Error 401: Unauthorized', 'Error 4000: nope', 'Bad request', 'fetch failed'])(
'ignores other failures: %s',
(message) => {
expect(getJiraBadRequestReason(new Error(message))).toBeNull()
}
)
})
@@ -1,4 +1,5 @@
import type { JiraIssue } from '../../../shared/jira-types'
import { parseJiraStatusError } from '../../../shared/jira-status-error'
export type TaskPageJiraLoadError = {
title: string
@@ -14,11 +15,7 @@ function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'Failed to load Jira issues.'
}
function getErrorCode(message: string): number | null {
const explicit = /^Error\s+(\d{3})\b/i.exec(message)?.[1]
if (explicit) {
return Number(explicit)
}
function inferErrorCode(message: string): number | null {
if (/\bforbidden\b/i.test(message)) {
return 403
}
@@ -34,12 +31,6 @@ function getErrorCode(message: string): number | null {
return null
}
function getErrorDetails(message: string, code: number | null): string | null {
const normalized =
code === null ? message : message.replace(new RegExp(`^Error\\s+${code}:\\s*`, 'i'), '')
return normalized.trim() || null
}
function getIssueSearchErrorSummary(message: string, code: number | null): string {
if (code === 401) {
return 'Jira authentication failed. Reconnect Jira in Settings, then try again.'
@@ -62,15 +53,22 @@ function getIssueSearchErrorSummary(message: string, code: number | null): strin
return "Couldn't load Jira issues. Try again in a moment."
}
/** Jira's reason when it rejected the request as malformed (HTTP 400); null for other failures. */
export function getJiraBadRequestReason(error: unknown): string | null {
const status = parseJiraStatusError(getErrorMessage(error))
return status?.code === 400 ? status.details : null
}
export function createTaskPageJiraLoadFailureState(error: unknown): TaskPageJiraLoadFailureState {
const message = getErrorMessage(error)
const code = getErrorCode(message)
const status = parseJiraStatusError(message)
const code = status?.code ?? inferErrorCode(message)
const summary = getIssueSearchErrorSummary(message, code)
return {
issues: [],
error: {
title: code === null ? summary : `Error ${code}: ${summary}`,
details: getErrorDetails(message, code)
details: (status?.details ?? message).trim() || null
}
}
}
@@ -0,0 +1,182 @@
import { describe, expect, it, vi } from 'vitest'
import type { JiraIssue } from '../../../shared/jira-types'
import { searchTaskPageJiraIssues } from './task-page-jira-search'
const ISSUE: JiraIssue = {
id: '10001',
key: 'ABC-1',
title: 'Fix login',
url: 'https://jira.example.com/browse/ABC-1',
project: { id: '10000', key: 'ABC', name: 'ABC' },
issueType: { id: '1', name: 'Bug' },
status: { id: '1', name: 'Open', categoryKey: 'new', categoryName: 'To Do' },
labels: [],
createdAt: '2026-09-25T00:00:00.000Z',
updatedAt: '2026-09-25T00:00:00.000Z'
}
const SYNTAX_ERROR = "Error 400: Error in the JQL Query: Expecting operator but got 'slow'."
// Shape of the same failure when it crosses local Electron IPC.
const IPC_SYNTAX_ERROR = `Error invoking remote method 'jira:searchIssues': Error: ${SYNTAX_ERROR}`
describe('searchTaskPageJiraIssues', () => {
it('searches plain text without a JQL round trip', async () => {
const search = vi.fn().mockResolvedValue([ISSUE])
await expect(searchTaskPageJiraIssues(' s ', search)).resolves.toEqual({
issues: [ISSUE],
jqlRejection: null
})
expect(search.mock.calls).toEqual([['text ~ "s*"']])
})
it('looks up an issue key directly', async () => {
const search = vi.fn().mockResolvedValue([ISSUE])
await searchTaskPageJiraIssues('abc-1', search)
expect(search.mock.calls).toEqual([['key = "ABC-1"']])
})
it('retries a key-shaped search as text when no issue has that key', async () => {
const search = vi.fn().mockResolvedValueOnce([]).mockResolvedValueOnce([ISSUE])
await expect(searchTaskPageJiraIssues('utf-8', search)).resolves.toEqual({
issues: [ISSUE],
jqlRejection: null
})
expect(search.mock.calls).toEqual([['key = "UTF-8"'], ['text ~ "utf 8*"']])
})
it('retries a key-shaped search as text when Jira says the key does not exist', async () => {
const search = vi
.fn()
.mockRejectedValueOnce(
new Error("Error 400: An issue with key 'ABC-999' does not exist for field 'key'.")
)
.mockResolvedValueOnce([ISSUE])
await expect(searchTaskPageJiraIssues('abc-999', search)).resolves.toEqual({
issues: [ISSUE],
jqlRejection: null
})
expect(search.mock.calls).toEqual([['key = "ABC-999"'], ['text ~ "abc 999*"']])
})
it('does not mask a key lookup that failed for another reason', async () => {
const error = new Error('Error 403: Forbidden')
const search = vi.fn().mockRejectedValue(error)
await expect(searchTaskPageJiraIssues('abc-999', search)).rejects.toBe(error)
expect(search).toHaveBeenCalledTimes(1)
})
it('searches everyday phrases with `in` and `is` as text in one request', async () => {
const search = vi.fn().mockResolvedValue([ISSUE])
await searchTaskPageJiraIssues('crash in terminal', search)
await searchTaskPageJiraIssues('login is slow', search)
expect(search.mock.calls).toEqual([
['text ~ "crash in terminal*"'],
['text ~ "login is slow*"']
])
})
it('does not retry a key lookup that found the issue', async () => {
const search = vi.fn().mockResolvedValue([ISSUE])
await searchTaskPageJiraIssues('sha-256', search)
expect(search.mock.calls).toEqual([['key = "SHA-256"']])
})
it('runs input Jira accepts as JQL unchanged', async () => {
const search = vi.fn().mockResolvedValue([ISSUE])
await expect(searchTaskPageJiraIssues('status WAS Done', search)).resolves.toEqual({
issues: [ISSUE],
jqlRejection: null
})
expect(search.mock.calls).toEqual([['status WAS Done']])
})
it.each([SYNTAX_ERROR, IPC_SYNTAX_ERROR])(
'falls back to text when Jira rejects the input as JQL: %s',
async (message) => {
const search = vi
.fn()
.mockRejectedValueOnce(new Error(message))
.mockResolvedValueOnce([ISSUE])
await expect(searchTaskPageJiraIssues('login was slow', search)).resolves.toEqual({
issues: [ISSUE],
jqlRejection: {
reason: "Error in the JQL Query: Expecting operator but got 'slow'.",
likelyTypo: false
}
})
expect(search.mock.calls).toEqual([['login was slow'], ['text ~ "login was slow*"']])
}
)
it('marks a rejected `=` query as a likely JQL typo', async () => {
const search = vi
.fn()
.mockRejectedValueOnce(
new Error("Error 400: The value 'NOPE' does not exist for the field 'project'.")
)
.mockResolvedValueOnce([])
await expect(searchTaskPageJiraIssues('project = NOPE', search)).resolves.toEqual({
issues: [],
jqlRejection: {
reason: "The value 'NOPE' does not exist for the field 'project'.",
likelyTypo: true
}
})
})
it.each([
'Error 401: Unauthorized',
'Error 403: Forbidden',
'Error 429: Too Many Requests',
'Error 503: Service Unavailable',
'fetch failed'
])('does not mask non-query failures: %s', async (message) => {
const error = new Error(message)
const search = vi.fn().mockRejectedValue(error)
await expect(searchTaskPageJiraIssues('status = Done', search)).rejects.toBe(error)
expect(search).toHaveBeenCalledTimes(1)
})
it.each(['fetch failed', 'Error 429: Too Many Requests', 'Error 503: Service Unavailable'])(
'surfaces the text retry failure instead of blaming the query: %s',
async (message) => {
const retryError = new Error(message)
const search = vi
.fn()
.mockRejectedValueOnce(new Error(SYNTAX_ERROR))
.mockRejectedValueOnce(retryError)
await expect(searchTaskPageJiraIssues('login was slow', search)).rejects.toBe(retryError)
}
)
it('keeps the JQL error when Jira also rejects the text retry', async () => {
const jqlError = new Error(SYNTAX_ERROR)
const search = vi
.fn()
.mockRejectedValueOnce(jqlError)
.mockRejectedValueOnce(new Error("Error 400: Unable to parse the text 'x' for field 'text'."))
await expect(searchTaskPageJiraIssues('login was slow', search)).rejects.toBe(jqlError)
})
it('sends partially typed punctuation as plain text', async () => {
const search = vi.fn().mockResolvedValue([ISSUE])
await searchTaskPageJiraIssues('fix (login', search)
await searchTaskPageJiraIssues('say "hi', search)
expect(search.mock.calls).toEqual([['text ~ "fix login*"'], ['text ~ "say hi*"']])
})
it('skips the request when only punctuation was typed', async () => {
const search = vi.fn()
await expect(searchTaskPageJiraIssues('(', search)).resolves.toEqual({
issues: [],
jqlRejection: null
})
expect(search).not.toHaveBeenCalled()
})
it('reports the JQL error when no words remain to retry as text', async () => {
const jqlError = new Error('Error 400: Error in the JQL Query: bad')
const search = vi.fn().mockRejectedValueOnce(jqlError)
await expect(searchTaskPageJiraIssues('~ !', search)).rejects.toBe(jqlError)
expect(search).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,44 @@
import type { JiraIssue } from '../../../shared/jira-types'
import { buildJiraTextMatchJql, mayBeJql } from '../../../shared/jira-search-input-jql'
import { searchJiraIssuesByKeyOrText } from './jira-key-or-text-search'
import { getJiraBadRequestReason } from './task-page-jira-load-state'
export type TaskPageJiraJqlRejection = {
/** Jira's reason; '' when it gave none. */
reason: string
/** The input was written as JQL, so the reason matters more than the text matches. */
likelyTypo: boolean
}
export type TaskPageJiraSearchResult = {
issues: JiraIssue[]
/** Set when Jira rejected the input as JQL and text matches are shown instead. */
jqlRejection: TaskPageJiraJqlRejection | null
}
export async function searchTaskPageJiraIssues(
query: string,
search: (jql: string) => Promise<JiraIssue[]>
): Promise<TaskPageJiraSearchResult> {
const trimmed = query.trim()
if (!mayBeJql(trimmed)) {
return { issues: await searchJiraIssuesByKeyOrText(trimmed, search), jqlRejection: null }
}
try {
return { issues: await search(trimmed), jqlRejection: null }
} catch (jqlError) {
// Why: only a 400 means Jira couldn't use the query; auth, rate-limit and outages must surface.
const reason = getJiraBadRequestReason(jqlError)
// Input with a JQL operator is never key-shaped, so the retry is a plain text search.
const textJql = buildJiraTextMatchJql(trimmed)
if (reason === null || !textJql) {
throw jqlError
}
const issues = await search(textJql).catch((textError: unknown) => {
// Why: if Jira rejects the text too, the JQL reason is the useful one.
throw getJiraBadRequestReason(textError) === null ? textError : jqlError
})
// Why: `=` almost never appears in a plain search (`~`, `<`, `>` do: `~/.zshrc`, `<br>`).
return { issues, jqlRejection: { reason, likelyTypo: trimmed.includes('=') } }
}
}
@@ -0,0 +1,46 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { TaskPageJiraTextFallbackNotice } from './task-page-jira-text-fallback-notice'
afterEach(cleanup)
const REASON = "Field 'login' does not exist or you do not have permission to view it."
const PROSE = { reason: REASON, likelyTypo: false }
describe('TaskPageJiraTextFallbackNotice', () => {
it("keeps Jira's reason behind Details", () => {
render(<TaskPageJiraTextFallbackNotice rejection={PROSE} />)
expect(screen.getByText(/Showing text matches/)).toBeTruthy()
expect(screen.getByRole('status').textContent).toContain('Showing text matches')
expect(screen.queryByText(REASON)).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Details' }))
expect(screen.getByText(REASON)).toBeTruthy()
})
it("shows Jira's reason straight away for a likely JQL typo", () => {
render(<TaskPageJiraTextFallbackNotice rejection={{ reason: REASON, likelyTypo: true }} />)
expect(screen.getByText(REASON)).toBeTruthy()
})
it('omits Details when Jira gave no reason', () => {
render(<TaskPageJiraTextFallbackNotice rejection={{ reason: '', likelyTypo: true }} />)
expect(screen.getByText(/Showing text matches/)).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Details' })).toBeNull()
})
it('updates one mounted live region instead of inserting a new one', () => {
const { rerender } = render(<TaskPageJiraTextFallbackNotice rejection={null} />)
const region = screen.getByRole('status')
expect(region.textContent).toBe('')
rerender(<TaskPageJiraTextFallbackNotice rejection={PROSE} />)
expect(screen.getByRole('status')).toBe(region)
expect(region.textContent).toContain('Showing text matches')
rerender(<TaskPageJiraTextFallbackNotice rejection={null} />)
expect(screen.getByRole('status')).toBe(region)
expect(region.textContent).toBe('')
})
})
@@ -0,0 +1,53 @@
import { useState } from 'react'
import { ChevronDown, ChevronRight, Info } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { Button } from '@/components/ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import type { TaskPageJiraJqlRejection } from './task-page-jira-search'
/** `rejection` is null when no fallback happened. */
export function TaskPageJiraTextFallbackNotice({
rejection
}: {
rejection: TaskPageJiraJqlRejection | null
}): React.JSX.Element {
// Why: screen readers announce changes inside an existing live region, not a freshly mounted one.
return (
<div role="status">
{rejection === null ? null : <FallbackNoticeBody key={rejection.reason} {...rejection} />}
</div>
)
}
function FallbackNoticeBody({ reason, likelyTypo }: TaskPageJiraJqlRejection): React.JSX.Element {
// Why: for plain-text searches Jira's JQL reason is noise; for a JQL typo it is the answer.
const [open, setOpen] = useState(likelyTypo)
return (
<div className="flex items-start gap-2 border-b border-border/50 bg-muted/35 px-4 py-2 text-xs text-muted-foreground">
<Info className="mt-0.5 size-3.5 flex-none" />
<div className="min-w-0 flex-1">
<Collapsible open={open} onOpenChange={setOpen}>
<p className="leading-5">
{translate(
'auto.components.TaskPage.jiraTextMatchesNotice',
"Showing text matches. Jira couldn't run this search as JQL."
)}
</p>
{reason ? (
<>
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="xs" className="-ml-1 mt-0.5">
{open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
{translate('auto.components.TaskPage.40eaf2c27c', 'Details')}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<p className="mt-1 break-words">{reason}</p>
</CollapsibleContent>
</>
) : null}
</Collapsible>
</div>
</div>
)
}
@@ -5,6 +5,7 @@ import { translate } from '@/i18n/i18n'
import { Button } from '@/components/ui/button'
import { TaskPageJiraSortControls } from '../../task-page-jira-sort-controls'
import { TaskPageJiraErrorBanner } from '../../task-page-linear-jira-list-model'
import { TaskPageJiraTextFallbackNotice } from '../../task-page-jira-text-fallback-notice'
import { TaskPageJiraIssueList } from '@/components/task-page-jira-issue-list'
import { formatRelativeTime } from '../../task-page-source-context'
import { getJiraStatusTone } from '@/components/task-page-jira-status-tone'
@@ -31,6 +32,7 @@ export function TaskPageJiraContent({
jiraError,
jiraErrorDetailsOpen,
setJiraErrorDetailsOpen,
jiraJqlRejection,
jiraSearchInput,
jiraOrderBy,
jiraOrderDirection,
@@ -102,6 +104,9 @@ export function TaskPageJiraContent({
onOpenChange={setJiraErrorDetailsOpen}
/>
) : null}
<TaskPageJiraTextFallbackNotice
rejection={jiraStatus.credentialError ? null : jiraJqlRejection}
/>
{jiraLoading && jiraIssues.length === 0 ? (
<div className="divide-y divide-border/50">
@@ -123,7 +128,10 @@ export function TaskPageJiraContent({
</p>
<p className="mt-2 text-sm text-muted-foreground">
{jiraSearchInput
? translate('auto.components.TaskPage.f51e254d35', 'Try a different JQL query.')
? translate(
'auto.components.TaskPage.f51e254d35',
'Try different search terms or JQL.'
)
: translate(
'auto.components.TaskPage.94d900518d',
'No issues match the selected preset.'
@@ -155,7 +155,7 @@ export function TaskPageJiraFilters({
}}
placeholder={translate(
'auto.components.TaskPage.99c2755218',
'Jira JQL, e.g. project = ABC AND statusCategory != Done'
'Search issues or enter JQL, e.g. project = ABC'
)}
className="h-8 rounded-md border-border/50 bg-background pl-8 pr-8 text-xs"
/>
@@ -28,6 +28,7 @@ function createModel(): TaskPageLinearCollectionEffectsModel {
setJiraLoading: vi.fn(),
setJiraError: vi.fn(),
setJiraErrorDetailsOpen: vi.fn(),
setJiraJqlRejection: vi.fn(),
jiraSearchInput: '',
appliedJiraSearch: '',
setAppliedJiraSearch: vi.fn(),
@@ -80,4 +81,70 @@ describe('useTaskPageJiraListEffects refresh wiring', () => {
force: false
})
})
it('passes force through the text-search wrapper on refresh', async () => {
const model = createModel()
model.appliedJiraSearch = 'fix login'
const { rerender } = renderHook(() => useTaskPageJiraListEffects(model))
await waitFor(() => expect(searchJiraIssues).toHaveBeenCalledTimes(1))
await act(async () => {
model.jiraRefreshNonce = 1
rerender()
await Promise.resolve()
})
await waitFor(() => expect(searchJiraIssues).toHaveBeenCalledTimes(2))
expect(searchJiraIssues).toHaveBeenNthCalledWith(2, 'text ~ "fix login*"', 50, {
sourceContext: null,
force: true
})
})
it('shows the JQL rejection with its text matches and clears it for the next search', async () => {
const model = createModel()
model.appliedJiraSearch = 'project = NOPE'
searchJiraIssues
.mockRejectedValueOnce(new Error("Error 400: The value 'NOPE' does not exist."))
.mockResolvedValueOnce([])
const { rerender } = renderHook(() => useTaskPageJiraListEffects(model))
const rejection = { reason: "The value 'NOPE' does not exist.", likelyTypo: true }
await waitFor(() => expect(model.setJiraJqlRejection).toHaveBeenLastCalledWith(rejection))
// The next search never settles, so only the reset at its start can clear the notice.
searchJiraIssues.mockReturnValueOnce(new Promise(() => {}))
await act(async () => {
model.appliedJiraSearch = 'fix login'
rerender()
await Promise.resolve()
})
expect(model.setJiraJqlRejection).toHaveBeenLastCalledWith(null)
})
it('drops the rejection of a search that was replaced before it finished', async () => {
const model = createModel()
model.appliedJiraSearch = 'project = NOPE'
let rejectJql: (error: Error) => void = () => {}
searchJiraIssues
.mockReturnValueOnce(new Promise((_, reject) => (rejectJql = reject)))
.mockResolvedValue([])
const { rerender } = renderHook(() => useTaskPageJiraListEffects(model))
await waitFor(() => expect(searchJiraIssues).toHaveBeenCalledTimes(1))
await act(async () => {
model.appliedJiraSearch = 'fix login'
rerender()
await Promise.resolve()
})
await act(async () => {
rejectJql(new Error("Error 400: The value 'NOPE' does not exist."))
await Promise.resolve()
})
await waitFor(() => expect(searchJiraIssues).toHaveBeenCalledTimes(3))
await act(async () => {
await Promise.resolve()
})
expect(model.setJiraJqlRejection).not.toHaveBeenCalledWith(
expect.objectContaining({ likelyTypo: true })
)
})
})
@@ -6,6 +6,7 @@ import {
loadTaskPageJiraProjectStatusOrder
} from '@/components/task-page-jira-status-order'
import { createTaskPageJiraLoadFailureState } from '@/components/task-page-jira-load-state'
import { searchTaskPageJiraIssues } from '@/components/task-page-jira-search'
import { JIRA_ITEM_LIMIT, TASK_SEARCH_DEBOUNCE_MS } from './task-page-source-context'
export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffectsModel) {
const {
@@ -28,6 +29,7 @@ export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffect
setJiraLoading,
setJiraError,
setJiraErrorDetailsOpen,
setJiraJqlRejection,
jiraSearchInput,
appliedJiraSearch,
setAppliedJiraSearch,
@@ -73,24 +75,28 @@ export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffect
let cancelled = false
setJiraLoading(true)
setJiraError(null)
setJiraJqlRejection(null)
setJiraErrorDetailsOpen(false)
const trimmed = appliedJiraSearch.trim()
const request =
trimmed.length > 0
? searchJiraIssues(trimmed, JIRA_ITEM_LIMIT, {
sourceContext: jiraTaskSourceContext,
force
})
? searchTaskPageJiraIssues(trimmed, (jql) =>
searchJiraIssues(jql, JIRA_ITEM_LIMIT, {
sourceContext: jiraTaskSourceContext,
force
})
)
: listJiraIssues(activeJiraPreset, JIRA_ITEM_LIMIT, {
sourceContext: jiraTaskSourceContext,
force
})
}).then((issues) => ({ issues, jqlRejection: null }))
void request
.then((issues) => {
.then(({ issues, jqlRejection }) => {
if (cancelled) {
return
}
setJiraIssues(issues)
setJiraJqlRejection(jqlRejection)
setJiraLoading(false)
const projectScope = getSingleJiraProjectScope(issues)
if (!projectScope) {
@@ -2,6 +2,7 @@ import type { TaskPageLinearViewStateModel } from './use-task-page-linear-view-s
import { useState, useMemo, useEffect, useCallback } from 'react'
import type { JiraIssue, JiraProjectStatusOrder, JiraPriority } from '../../../shared/jira-types'
import type { TaskPageJiraLoadError } from '@/components/task-page-jira-load-state'
import type { TaskPageJiraJqlRejection } from '@/components/task-page-jira-search'
import type { JiraPresetId } from '@/components/task-page-localized-options'
import type {
JiraIssueSortColumn,
@@ -16,6 +17,7 @@ export function useTaskPageJiraListState(model: TaskPageLinearViewStateModel) {
const [jiraLoading, setJiraLoading] = useState(false)
const [jiraError, setJiraError] = useState<TaskPageJiraLoadError | null>(null)
const [jiraErrorDetailsOpen, setJiraErrorDetailsOpen] = useState(false)
const [jiraJqlRejection, setJiraJqlRejection] = useState<TaskPageJiraJqlRejection | null>(null)
const [jiraSearchInput, setJiraSearchInput] = useState('')
const [appliedJiraSearch, setAppliedJiraSearch] = useState('')
const [activeJiraPreset, setActiveJiraPreset] = useState<JiraPresetId>('assigned')
@@ -82,60 +84,35 @@ export function useTaskPageJiraListState(model: TaskPageLinearViewStateModel) {
},
[jiraOrderBy]
)
const nextModel = model as typeof model & {
jiraIssues: typeof jiraIssues
setJiraIssues: typeof setJiraIssues
jiraLoading: typeof jiraLoading
setJiraLoading: typeof setJiraLoading
jiraError: typeof jiraError
setJiraError: typeof setJiraError
jiraErrorDetailsOpen: typeof jiraErrorDetailsOpen
setJiraErrorDetailsOpen: typeof setJiraErrorDetailsOpen
jiraSearchInput: typeof jiraSearchInput
setJiraSearchInput: typeof setJiraSearchInput
appliedJiraSearch: typeof appliedJiraSearch
setAppliedJiraSearch: typeof setAppliedJiraSearch
activeJiraPreset: typeof activeJiraPreset
setActiveJiraPreset: typeof setActiveJiraPreset
jiraRefreshNonce: typeof jiraRefreshNonce
setJiraRefreshNonce: typeof setJiraRefreshNonce
jiraProjectStatusOrder: typeof jiraProjectStatusOrder
setJiraProjectStatusOrder: typeof setJiraProjectStatusOrder
jiraOrderBy: typeof jiraOrderBy
setJiraOrderBy: typeof setJiraOrderBy
jiraOrderDirection: typeof jiraOrderDirection
setJiraOrderDirection: typeof setJiraOrderDirection
jiraPrioritiesBySite: typeof jiraPrioritiesBySite
setJiraPrioritiesBySite: typeof setJiraPrioritiesBySite
jiraPrioritySiteIdsKey: typeof jiraPrioritySiteIdsKey
handleJiraSort: typeof handleJiraSort
}
nextModel.jiraIssues = jiraIssues
nextModel.setJiraIssues = setJiraIssues
nextModel.jiraLoading = jiraLoading
nextModel.setJiraLoading = setJiraLoading
nextModel.jiraError = jiraError
nextModel.setJiraError = setJiraError
nextModel.jiraErrorDetailsOpen = jiraErrorDetailsOpen
nextModel.setJiraErrorDetailsOpen = setJiraErrorDetailsOpen
nextModel.jiraSearchInput = jiraSearchInput
nextModel.setJiraSearchInput = setJiraSearchInput
nextModel.appliedJiraSearch = appliedJiraSearch
nextModel.setAppliedJiraSearch = setAppliedJiraSearch
nextModel.activeJiraPreset = activeJiraPreset
nextModel.setActiveJiraPreset = setActiveJiraPreset
nextModel.jiraRefreshNonce = jiraRefreshNonce
nextModel.setJiraRefreshNonce = setJiraRefreshNonce
nextModel.jiraProjectStatusOrder = jiraProjectStatusOrder
nextModel.setJiraProjectStatusOrder = setJiraProjectStatusOrder
nextModel.jiraOrderBy = jiraOrderBy
nextModel.setJiraOrderBy = setJiraOrderBy
nextModel.jiraOrderDirection = jiraOrderDirection
nextModel.setJiraOrderDirection = setJiraOrderDirection
nextModel.jiraPrioritiesBySite = jiraPrioritiesBySite
nextModel.setJiraPrioritiesBySite = setJiraPrioritiesBySite
nextModel.jiraPrioritySiteIdsKey = jiraPrioritySiteIdsKey
nextModel.handleJiraSort = handleJiraSort
return nextModel
return Object.assign(model, {
jiraIssues,
setJiraIssues,
jiraLoading,
setJiraLoading,
jiraError,
setJiraError,
jiraErrorDetailsOpen,
setJiraErrorDetailsOpen,
jiraJqlRejection,
setJiraJqlRejection,
jiraSearchInput,
setJiraSearchInput,
appliedJiraSearch,
setAppliedJiraSearch,
activeJiraPreset,
setActiveJiraPreset,
jiraRefreshNonce,
setJiraRefreshNonce,
jiraProjectStatusOrder,
setJiraProjectStatusOrder,
jiraOrderBy,
setJiraOrderBy,
jiraOrderDirection,
setJiraOrderDirection,
jiraPrioritiesBySite,
setJiraPrioritiesBySite,
jiraPrioritySiteIdsKey,
handleJiraSort
})
}
export type TaskPageJiraListStateModel = ReturnType<typeof useTaskPageJiraListState>
+3 -2
View File
@@ -2187,7 +2187,8 @@
"c679af7ad9": "Refresh My Todos",
"dfd72673e7": "Failed to save project selection.",
"b797bdd7c3": "Clear search",
"99c2755218": "Jira JQL, e.g. project = ABC AND statusCategory != Done",
"jiraTextMatchesNotice": "Showing text matches. Jira couldn't run this search as JQL.",
"99c2755218": "Search issues or enter JQL, e.g. project = ABC",
"2ff9fd71fd": "Refresh Jira issues",
"0b65d3fb2c": "Search Linear projects...",
"eec0c5c079": "Search Linear issues...",
@@ -2306,7 +2307,7 @@
"6edf402e11": "overview",
"9ae151b26b": "linear",
"94d900518d": "No issues match the selected preset.",
"f51e254d35": "Try a different JQL query.",
"f51e254d35": "Try different search terms or JQL.",
"4645a7814f": "jira",
"e224d76876": "MR",
"bbec4717ee": "mr",
+3 -2
View File
@@ -1777,7 +1777,8 @@
"c679af7ad9": "Actualizar My Todos",
"dfd72673e7": "No se pudo guardar la selección del proyecto.",
"b797bdd7c3": "Borrar búsqueda",
"99c2755218": "Jira JQL, p. ej. project = ABC AND statusCategory != Done",
"jiraTextMatchesNotice": "Mostrando coincidencias de texto. Jira no pudo ejecutar esta búsqueda como JQL.",
"99c2755218": "Busca issues o escribe JQL, p. ej. project = ABC",
"2ff9fd71fd": "Actualizar issues de Jira",
"0b65d3fb2c": "Buscar proyectos de Linear...",
"eec0c5c079": "Buscar issues de Linear...",
@@ -1896,7 +1897,7 @@
"6edf402e11": "descripción general",
"9ae151b26b": "lineal",
"94d900518d": "Ningún issue coincide con el preset seleccionado.",
"f51e254d35": "Prueba con otra consulta JQL.",
"f51e254d35": "Prueba con otros términos de búsqueda o JQL.",
"4645a7814f": "jira",
"e224d76876": "MR",
"bbec4717ee": "mr",
+3 -2
View File
@@ -2104,7 +2104,8 @@
"c679af7ad9": "Actualiser mes todos",
"dfd72673e7": "Échec de l'enregistrement de la sélection de projets.",
"b797bdd7c3": "Effacer la recherche",
"99c2755218": "JQL Jira, ex. project = ABC AND statusCategory != Done",
"jiraTextMatchesNotice": "Résultats textuels affichés. Jira n'a pas pu exécuter cette recherche en JQL.",
"99c2755218": "Recherchez des issues ou saisissez du JQL, ex. project = ABC",
"2ff9fd71fd": "Actualiser les tickets Jira",
"0b65d3fb2c": "Rechercher des projets Linear...",
"eec0c5c079": "Rechercher des tickets Linear...",
@@ -2223,7 +2224,7 @@
"6edf402e11": "vue d'ensemble",
"9ae151b26b": "linear",
"94d900518d": "Aucune issue ne correspond au préréglage sélectionné.",
"f51e254d35": "Essayez une requête JQL différente.",
"f51e254d35": "Essayez d'autres termes de recherche ou du JQL.",
"4645a7814f": "jira",
"e224d76876": "MR",
"bbec4717ee": "mr",
+3 -2
View File
@@ -2002,7 +2002,8 @@
"c679af7ad9": "Todo を更新する",
"dfd72673e7": "プロジェクトの選択を保存できませんでした。",
"b797bdd7c3": "検索をクリア",
"99c2755218": "Jira JQL、例:プロジェクト = ABC AND statusCategory != 完了",
"jiraTextMatchesNotice": "テキスト一致を表示しています。Jira はこの検索を JQL として実行できませんでした。",
"99c2755218": "Issue を検索するか JQL を入力、例: project = ABC",
"2ff9fd71fd": "Jira Issue を更新する",
"0b65d3fb2c": "Linear プロジェクトを検索…",
"eec0c5c079": "Linear Issue を検索…",
@@ -2121,7 +2122,7 @@
"6edf402e11": "概要",
"9ae151b26b": "Linear",
"94d900518d": "選択したプリセットに一致する Issue はありません。",
"f51e254d35": "別の JQL クエリを試す。",
"f51e254d35": "別の検索語句または JQL をお試しください。",
"4645a7814f": "Jira",
"e224d76876": "MR",
"bbec4717ee": "MR",
+3 -2
View File
@@ -2002,7 +2002,8 @@
"c679af7ad9": "내 할 일 새로 고침",
"dfd72673e7": "프로젝트 선택을 저장하지 못했습니다.",
"b797bdd7c3": "검색 지우기",
"99c2755218": "Jira JQL, 예: project = ABC AND statusCategory != Done",
"jiraTextMatchesNotice": "텍스트 일치 결과를 표시합니다. Jira에서 이 검색을 JQL로 실행할 수 없습니다.",
"99c2755218": "이슈 검색 또는 JQL 입력, 예: project = ABC",
"2ff9fd71fd": "Jira 이슈 새로 고침",
"0b65d3fb2c": "Linear 프로젝트 검색...",
"eec0c5c079": "Linear 이슈 검색...",
@@ -2121,7 +2122,7 @@
"6edf402e11": "개요",
"9ae151b26b": "Linear",
"94d900518d": "선택한 프리셋과 일치하는 이슈가 없습니다.",
"f51e254d35": "다른 JQL 쿼리를 시도해 보세요.",
"f51e254d35": "다른 검색어나 JQL을 사용해 보세요.",
"4645a7814f": "Jira",
"e224d76876": "MR",
"bbec4717ee": "MR",
+3 -2
View File
@@ -2002,7 +2002,8 @@
"c679af7ad9": "刷新我的待办事项",
"dfd72673e7": "无法保存项目选择。",
"b797bdd7c3": "清除搜索",
"99c2755218": "Jira JQL,例如 project = ABC AND statusCategory != Done",
"jiraTextMatchesNotice": "正在显示文本匹配结果。Jira 无法将此搜索作为 JQL 运行。",
"99c2755218": "搜索议题或输入 JQL,例如 project = ABC",
"2ff9fd71fd": "刷新 Jira 议题",
"0b65d3fb2c": "搜索 Linear 项目...",
"eec0c5c079": "搜索 Linear 议题...",
@@ -2121,7 +2122,7 @@
"6edf402e11": "概述",
"9ae151b26b": "Linear",
"94d900518d": "没有与所选预设匹配的议题。",
"f51e254d35": "尝试不同的 JQL 查询。",
"f51e254d35": "请尝试其他搜索词或 JQL。",
"4645a7814f": "Jira",
"e224d76876": "MR",
"bbec4717ee": "mr",
@@ -12,6 +12,7 @@ import {
type TaskSourceContext
} from '../../../../shared/task-source-context'
import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context'
import { parseJiraStatusError } from '../../../../shared/jira-status-error'
const CACHE_TTL = 60_000
const MAX_CACHE_ENTRIES = 500
@@ -70,7 +71,13 @@ export function evictStaleJiraCacheEntries<T>(
export function looksLikeJiraAuthError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
// Jira 403 commonly means endpoint/project access is denied while the token remains valid.
// Why: Jira echoes the query in a 400 (`key 'PROJ-1401' does not exist`), so wording is only
// a fallback for errors that carry no status.
const status = parseJiraStatusError(message)
if (status) {
// Jira 403 commonly means endpoint/project access is denied while the token remains valid.
return status.code === 401
}
return /authenticat|unauthorized|401/i.test(message)
}
@@ -685,6 +685,20 @@ describe('createJiraSlice credential errors', () => {
expect(store.getState().jiraStatus).toEqual(focusedStatus)
})
it('does not read a rejected query that mentions 401 as a lost connection', async () => {
const store = createTestStore()
const source = jiraSourceContext('remote-runtime')
const error = new Error("Error 400: An issue with key 'ALP-1401' does not exist.")
jiraSearchIssues.mockRejectedValueOnce(error)
await expect(
store
.getState()
.searchJiraIssues('key = "ALP-1401"', 12, { sourceContext: source, siteId: 'site-1' })
).rejects.toBe(error)
expect(store.getState().jiraConnectionRevisions).toEqual({})
})
it('does not borrow the global site when a workspace source has no selected site', async () => {
const store = createTestStore()
const source = jiraSourceContext('remote-runtime')
+144
View File
@@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest'
import { buildJiraIssueKeyJql, buildJiraTextMatchJql, mayBeJql } from './jira-search-input-jql'
describe('mayBeJql', () => {
it.each([
'project = ABC AND statusCategory != Done',
'summary ~ login',
'summary !~ login',
'created >= -7d',
'created < -1w',
'status in (Open, "In Progress")',
'priority NOT IN (Low)',
'assignee is EMPTY',
'fixVersion IS NOT null',
'status WAS Done',
'status WAS NOT IN (Done)',
'assignee CHANGED',
'ORDER BY updated DESC',
'"Custom field" = "value"',
'cf[12345] >= 10',
'issueFunction in linkedIssuesOf("project = ABC")',
'sprint in openSprints ()',
'issue in "linkedIssues"("ABC-1")',
'status IN(Done)',
'status in\n(Open)',
'labels is not empty',
'NOT (status = Done OR assignee IS EMPTY)',
// Prose that fits the grammar still reaches Jira; its answer decides.
'value is null',
'crash in terminal (macOS)',
'login was slow'
])('sends input that could parse as JQL to Jira: %s', (input) => {
expect(mayBeJql(input)).toBe(true)
})
it.each([
's',
'test',
'fix login bug',
'ABC-12',
'within isolation',
'order the pizza',
'sign-in page',
'built-in was-',
// IN needs a list or function and IS needs EMPTY or NULL, so Jira rejects all of these.
'sign in',
'log in page',
'crash in terminal',
'login is slow',
'this is broken',
'status is not open',
// A word that only starts with `in` is not the operator.
'input (raw)',
'install (macos)',
'init()',
' '
])('skips JQL for input with no operator: %s', (input) => {
expect(mayBeJql(input)).toBe(false)
})
})
describe('buildJiraTextMatchJql', () => {
it.each([
['s', 'text ~ "s*"'],
[' Fix Login ', 'text ~ "fix login*"'],
// The key lookup is the caller's choice, so key shape is ignored here.
['abc-12', 'text ~ "abc 12*"'],
['utf-8', 'text ~ "utf 8*"'],
["don't break", 'text ~ "don\'t break*"']
])('%s -> %s', (input, expected) => {
expect(buildJiraTextMatchJql(input)).toBe(expected)
})
// Each of these returned HTTP 400 or zero results from Jira before being neutralized.
it.each([
['fix (login', 'text ~ "fix login*"'],
['fix login)', 'text ~ "fix login*"'],
['say "hi', 'text ~ "say hi*"'],
['say "hi" \\ bye', 'text ~ "say hi bye*"'],
['foo [bar', 'text ~ "foo bar*"'],
['{x', 'text ~ "x*"'],
['^boost', 'text ~ "boost*"'],
['login -', 'text ~ "login*"'],
['fix &&', 'text ~ "fix*"'],
['foo ||', 'text ~ "foo*"'],
['a:b', 'text ~ "a b*"'],
['C++ build', 'text ~ "c build*"'],
['what?', 'text ~ "what*"']
])('treats search syntax in %s as plain text', (input, expected) => {
expect(buildJiraTextMatchJql(input)).toBe(expected)
})
// Jira skips word-splitting for a wildcard term, so each of these matched nothing with a trailing *.
it.each([
['fix login.', 'text ~ "fix login."'],
['login,', 'text ~ "login,"'],
['C#', 'text ~ "c#"'],
['100%', 'text ~ "100%"'],
['$5', 'text ~ "$5"'],
['a;b', 'text ~ "a;b"'],
['foo=bar', 'text ~ "foo=bar"'],
['node.js', 'text ~ "node.js"']
])('drops the wildcard when the last word has punctuation: %s', (input, expected) => {
expect(buildJiraTextMatchJql(input)).toBe(expected)
})
it.each([
['café', 'text ~ "café*"'],
["don't", 'text ~ "don\'t*"'],
['login. fix', 'text ~ "login. fix*"']
])('keeps the wildcard on a plain last word: %s', (input, expected) => {
expect(buildJiraTextMatchJql(input)).toBe(expected)
})
it.each([
['OR x', 'text ~ "or x*"'],
['AND x', 'text ~ "and x*"'],
['fix NOT login', 'text ~ "fix not login*"']
])('keeps boolean words as words: %s', (input, expected) => {
expect(buildJiraTextMatchJql(input)).toBe(expected)
})
it.each(['', ' ', '(', '"', '()[]{}', '&& ||'])(
'returns nothing searchable for %j',
(input) => {
expect(buildJiraTextMatchJql(input)).toBe('')
}
)
})
describe('buildJiraIssueKeyJql', () => {
it.each([
['abc-12', 'key = "ABC-12"'],
[' ABC-12 ', 'key = "ABC-12"'],
// Key-shaped text a user may well mean literally; the caller retries these as text.
['utf-8', 'key = "UTF-8"']
])('%s -> %s', (input, expected) => {
expect(buildJiraIssueKeyJql(input)).toBe(expected)
})
it.each(['fix login', 'ABC-', '-12', 'ABC-12x', '', ' '])('is not a key: %j', (input) => {
expect(buildJiraIssueKeyJql(input)).toBeNull()
})
})
+48
View File
@@ -0,0 +1,48 @@
import { JIRA_ISSUE_KEY_PATTERN } from './jira-issue-url'
// Why: JQL's operator set is closed (plugins add functions, not operators), so input with none
// of these and no leading ORDER BY cannot parse as JQL. Hyphens excluded so `sign-in` stays text.
const JQL_OPERATOR_PATTERN = new RegExp(
[
'[=~<>]',
String.raw`(?<![\w-])(?:was|changed)(?![\w-])`,
// IN takes only a list or a function call, so `crash in terminal` cannot parse. The space
// before a bare function name keeps `input (raw)` from reading as `in` + `put(`.
String.raw`(?<![\w-])in(?:\s+[a-z_][\w.]*|\s*"[^"]+"|\s*'[^']+')?\s*\(`,
// IS takes only EMPTY or NULL, so `login is slow` cannot parse.
String.raw`(?<![\w-])is\s+(?:not\s+)?(?:empty|null)(?![\w-])`,
String.raw`^order\s+by\b`
].join('|'),
'i'
)
// Lucene text-search syntax. Jira's index drops these characters, so spaces keep matches intact.
const TEXT_SEARCH_SYNTAX_PATTERN = /[+\-&|!(){}[\]^"~*?:\\/]/g
// Why: Jira skips word-splitting for a wildcard term, so `login,*` or `c#*` match nothing.
const WILDCARD_SAFE_WORD_PATTERN = /^[\p{L}\p{N}']+$/u
export function mayBeJql(input: string): boolean {
return JQL_OPERATOR_PATTERN.test(input.trim())
}
/** Exact-issue-key JQL when the whole input is key-shaped; null otherwise. */
export function buildJiraIssueKeyJql(input: string): string | null {
const trimmed = input.trim()
return JIRA_ISSUE_KEY_PATTERN.test(trimmed) ? `key = "${trimmed.toUpperCase()}"` : null
}
/** Search issue text, ignoring key shape. Empty when no searchable words remain. */
export function buildJiraTextMatchJql(input: string): string {
// Why: uppercase AND/OR/NOT are Lucene operators; text search ignores case anyway.
const words = input
.replace(TEXT_SEARCH_SYNTAX_PATTERN, ' ')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase()
if (!words) {
return ''
}
const lastWord = words.slice(words.lastIndexOf(' ') + 1)
return `text ~ "${words}${WILDCARD_SAFE_WORD_PATTERN.test(lastWord) ? '*' : ''}"`
}
+8
View File
@@ -0,0 +1,8 @@
// Not anchored: local IPC prefixes "Error invoking remote method '…': Error: ".
const STATUS_ERROR_PATTERN = /\bError (\d{3}):\s*([\s\S]*)$/
/** Reads the `Error <status>: <details>` shape main gives a failed Jira request; null without it. */
export function parseJiraStatusError(message: string): { code: number; details: string } | null {
const match = STATUS_ERROR_PATTERN.exec(message)
return match ? { code: Number(match[1]), details: match[2].trim() } : null
}
@@ -4,7 +4,8 @@ import type { JiraIssue } from '../jira-types'
import type { LinearIssue } from '../linear/issue-types'
import type { LinearCollectionResult } from '../linear/workspace-types'
import type { BaseRefSearchResult } from '../repo-types'
import { JIRA_ISSUE_KEY_PATTERN, parseJiraIssueUrl } from '../jira-issue-url'
import { parseJiraIssueUrl } from '../jira-issue-url'
import { buildJiraTextMatchJql } from '../jira-search-input-jql'
import type { GitHubIssueOrPRLink } from '../github/links'
import {
buildSmartWorkspaceUrlSourceRows,
@@ -44,16 +45,12 @@ export function getSmartWorkspaceEmptyHint(mode: SmartNameMode): string {
return EMPTY_HINT_BY_MODE[mode]
}
export function buildJiraIssueSearchJql(query: string): string | null {
/** The trimmed query when it is short enough and has words to search for; null otherwise. */
export function getJiraIssueSearchQuery(query: string): string | null {
const trimmed = query.trim()
if (!trimmed || !isSmartWorkspaceSourceQueryWithinLimit(trimmed)) {
return null
}
if (JIRA_ISSUE_KEY_PATTERN.test(trimmed)) {
return `key = "${trimmed.toUpperCase()}"`
}
const escaped = trimmed.replaceAll('\\', '\\\\').replaceAll('"', '\\"')
return `text ~ "${escaped}*"`
return isSmartWorkspaceSourceQueryWithinLimit(trimmed) && buildJiraTextMatchJql(trimmed)
? trimmed
: null
}
export function isBlockingJiraUrlIntent(mode: SmartNameMode, value: string): boolean {