diff --git a/src/renderer/src/components/jira-key-or-text-search.ts b/src/renderer/src/components/jira-key-or-text-search.ts new file mode 100644 index 00000000000..d91078f3b00 --- /dev/null +++ b/src/renderer/src/components/jira-key-or-text-search.ts @@ -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 +): Promise { + 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) : [] +} diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.jira-accessibility.test.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.jira-accessibility.test.tsx index b11b8b20ac1..54c2e0832c7 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.jira-accessibility.test.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.jira-accessibility.test.tsx @@ -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, diff --git a/src/renderer/src/components/new-workspace/smart-workspace-source-results.test.ts b/src/renderer/src/components/new-workspace/smart-workspace-source-results.test.ts index e78b6d5d76c..b2d2b8a1097 100644 --- a/src/renderer/src/components/new-workspace/smart-workspace-source-results.test.ts +++ b/src/renderer/src/components/new-workspace/smart-workspace-source-results.test.ts @@ -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() }) diff --git a/src/renderer/src/components/new-workspace/use-smart-workspace-name-field-controller.ts b/src/renderer/src/components/new-workspace/use-smart-workspace-name-field-controller.ts index c878f586bf7..220b8e27e69 100644 --- a/src/renderer/src/components/new-workspace/use-smart-workspace-name-field-controller.ts +++ b/src/renderer/src/components/new-workspace/use-smart-workspace-name-field-controller.ts @@ -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 && diff --git a/src/renderer/src/components/new-workspace/use-smart-workspace-secondary-searches.ts b/src/renderer/src/components/new-workspace/use-smart-workspace-secondary-searches.ts index 8d4ebdf12b7..053614f9b2f 100644 --- a/src/renderer/src/components/new-workspace/use-smart-workspace-secondary-searches.ts +++ b/src/renderer/src/components/new-workspace/use-smart-workspace-secondary-searches.ts @@ -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 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, diff --git a/src/renderer/src/components/task-page-jira-load-state.test.ts b/src/renderer/src/components/task-page-jira-load-state.test.ts index a5a151386c0..272fa81a8c8 100644 --- a/src/renderer/src/components/task-page-jira-load-state.test.ts +++ b/src/renderer/src/components/task-page-jira-load-state.test.ts @@ -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() + } + ) +}) diff --git a/src/renderer/src/components/task-page-jira-load-state.ts b/src/renderer/src/components/task-page-jira-load-state.ts index 0a542095ab0..47e63c8182a 100644 --- a/src/renderer/src/components/task-page-jira-load-state.ts +++ b/src/renderer/src/components/task-page-jira-load-state.ts @@ -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 } } } diff --git a/src/renderer/src/components/task-page-jira-search.test.ts b/src/renderer/src/components/task-page-jira-search.test.ts new file mode 100644 index 00000000000..5a01ee1040f --- /dev/null +++ b/src/renderer/src/components/task-page-jira-search.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/components/task-page-jira-search.ts b/src/renderer/src/components/task-page-jira-search.ts new file mode 100644 index 00000000000..d3f2b74691f --- /dev/null +++ b/src/renderer/src/components/task-page-jira-search.ts @@ -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 +): Promise { + 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`, `
`). + return { issues, jqlRejection: { reason, likelyTypo: trimmed.includes('=') } } + } +} diff --git a/src/renderer/src/components/task-page-jira-text-fallback-notice.test.tsx b/src/renderer/src/components/task-page-jira-text-fallback-notice.test.tsx new file mode 100644 index 00000000000..dcb05878bf4 --- /dev/null +++ b/src/renderer/src/components/task-page-jira-text-fallback-notice.test.tsx @@ -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() + 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() + expect(screen.getByText(REASON)).toBeTruthy() + }) + + it('omits Details when Jira gave no reason', () => { + render() + 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() + const region = screen.getByRole('status') + expect(region.textContent).toBe('') + + rerender() + expect(screen.getByRole('status')).toBe(region) + expect(region.textContent).toContain('Showing text matches') + + rerender() + expect(screen.getByRole('status')).toBe(region) + expect(region.textContent).toBe('') + }) +}) diff --git a/src/renderer/src/components/task-page-jira-text-fallback-notice.tsx b/src/renderer/src/components/task-page-jira-text-fallback-notice.tsx new file mode 100644 index 00000000000..f283c94f793 --- /dev/null +++ b/src/renderer/src/components/task-page-jira-text-fallback-notice.tsx @@ -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 ( +
+ {rejection === null ? null : } +
+ ) +} + +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 ( +
+ +
+ +

+ {translate( + 'auto.components.TaskPage.jiraTextMatchesNotice', + "Showing text matches. Jira couldn't run this search as JQL." + )} +

+ {reason ? ( + <> + + + + +

{reason}

+
+ + ) : null} +
+
+
+ ) +} diff --git a/src/renderer/src/components/task-page/jira/Content.tsx b/src/renderer/src/components/task-page/jira/Content.tsx index 498f12b5d1b..2a67a66dc12 100644 --- a/src/renderer/src/components/task-page/jira/Content.tsx +++ b/src/renderer/src/components/task-page/jira/Content.tsx @@ -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} + {jiraLoading && jiraIssues.length === 0 ? (
@@ -123,7 +128,10 @@ export function TaskPageJiraContent({

{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.' diff --git a/src/renderer/src/components/task-page/jira/Filters.tsx b/src/renderer/src/components/task-page/jira/Filters.tsx index eb2871b38ce..3470f372a45 100644 --- a/src/renderer/src/components/task-page/jira/Filters.tsx +++ b/src/renderer/src/components/task-page/jira/Filters.tsx @@ -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" /> diff --git a/src/renderer/src/components/use-task-page-jira-list-effects.test.ts b/src/renderer/src/components/use-task-page-jira-list-effects.test.ts index 9a1f6e4973e..ad583e444be 100644 --- a/src/renderer/src/components/use-task-page-jira-list-effects.test.ts +++ b/src/renderer/src/components/use-task-page-jira-list-effects.test.ts @@ -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 }) + ) + }) }) diff --git a/src/renderer/src/components/use-task-page-jira-list-effects.ts b/src/renderer/src/components/use-task-page-jira-list-effects.ts index cd0f9a067bf..6cdd282b051 100644 --- a/src/renderer/src/components/use-task-page-jira-list-effects.ts +++ b/src/renderer/src/components/use-task-page-jira-list-effects.ts @@ -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) { diff --git a/src/renderer/src/components/use-task-page-jira-list-state.ts b/src/renderer/src/components/use-task-page-jira-list-state.ts index a3bec834c72..7c471ffcfb4 100644 --- a/src/renderer/src/components/use-task-page-jira-list-state.ts +++ b/src/renderer/src/components/use-task-page-jira-list-state.ts @@ -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(null) const [jiraErrorDetailsOpen, setJiraErrorDetailsOpen] = useState(false) + const [jiraJqlRejection, setJiraJqlRejection] = useState(null) const [jiraSearchInput, setJiraSearchInput] = useState('') const [appliedJiraSearch, setAppliedJiraSearch] = useState('') const [activeJiraPreset, setActiveJiraPreset] = useState('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 diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 44bf92f7feb..2667cce34b2 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 2275b24e92c..26de12f6ffa 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index c06303538f2..3654f29a591 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -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", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 9a699b8472b..009b445d221 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 9e49e318191..149dc5a4314 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 5dc51d5433b..84c3806f337 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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", diff --git a/src/renderer/src/store/slices/jira-read-coordination.ts b/src/renderer/src/store/slices/jira-read-coordination.ts index 4756f4bf9b6..6d38ff652eb 100644 --- a/src/renderer/src/store/slices/jira-read-coordination.ts +++ b/src/renderer/src/store/slices/jira-read-coordination.ts @@ -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( 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) } diff --git a/src/renderer/src/store/slices/jira.test.ts b/src/renderer/src/store/slices/jira.test.ts index 48f46e2e7e3..8674ceb9126 100644 --- a/src/renderer/src/store/slices/jira.test.ts +++ b/src/renderer/src/store/slices/jira.test.ts @@ -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') diff --git a/src/shared/jira-search-input-jql.test.ts b/src/shared/jira-search-input-jql.test.ts new file mode 100644 index 00000000000..3ff4cf387a2 --- /dev/null +++ b/src/shared/jira-search-input-jql.test.ts @@ -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() + }) +}) diff --git a/src/shared/jira-search-input-jql.ts b/src/shared/jira-search-input-jql.ts new file mode 100644 index 00000000000..d9f0c543b22 --- /dev/null +++ b/src/shared/jira-search-input-jql.ts @@ -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`(?:

` 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 +} diff --git a/src/shared/new-workspace/smart-workspace-source-results.ts b/src/shared/new-workspace/smart-workspace-source-results.ts index 39bd8ae3b54..21d4dc849b1 100644 --- a/src/shared/new-workspace/smart-workspace-source-results.ts +++ b/src/shared/new-workspace/smart-workspace-source-results.ts @@ -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 {