mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 08:02:21 +00:00
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:
@@ -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) : []
|
||||
}
|
||||
+27
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
+5
-5
@@ -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 &&
|
||||
|
||||
+12
-9
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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) ? '*' : ''}"`
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user