fix(jira): surface issue-search failures instead of a misleading empty list (#5958)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Orca <help@stably.ai>
Co-authored-by: brennanb2025 <brennankbenson@gmail.com>
This commit is contained in:
TJ Baker 2026-06-22 20:27:38 -07:00 committed by GitHub
parent 59a9b44e2f
commit ac3ab72b52
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 371 additions and 27 deletions

View File

@ -18,10 +18,10 @@ vi.mock('./client', () => ({
jiraRequest: (...args: unknown[]) => jiraRequestMock(...args)
}))
function makeEntry(): JiraClientForSite {
function makeEntry(id = 'site-1'): JiraClientForSite {
return {
site: {
id: 'site-1',
id,
siteUrl: 'https://example.atlassian.net',
email: 'ada@example.com',
displayName: 'Example Jira',
@ -60,6 +60,71 @@ describe('Jira issue operations', () => {
).rejects.toThrow(error.message)
})
it('rejects single-site search failures so the UI can surface them', async () => {
getClientsMock.mockReturnValue([makeEntry('site-1')])
jiraRequestMock.mockRejectedValueOnce(new Error('Forbidden'))
const { searchIssues } = await import('./issues')
await expect(searchIssues('project = ALP', 20, 'site-1')).rejects.toThrow('Forbidden')
})
it('includes Jira status codes in surfaced single-site search failures', async () => {
const error = Object.assign(new Error('Forbidden'), { status: 403 })
getClientsMock.mockReturnValue([makeEntry('site-1')])
jiraRequestMock.mockRejectedValueOnce(error)
const { searchIssues } = await import('./issues')
await expect(searchIssues('project = ALP', 20, 'site-1')).rejects.toThrow(
'Error 403: Forbidden'
)
})
it('keeps healthy sites when one site fails under an "all" search', async () => {
getClientsMock.mockReturnValue([makeEntry('site-1'), makeEntry('site-2')])
jiraRequestMock.mockRejectedValueOnce(new Error('Forbidden')).mockResolvedValueOnce({
issues: [{ id: '1', key: 'BRV-1', fields: { summary: 'Healthy' } }]
})
const { searchIssues } = await import('./issues')
await expect(searchIssues('project = ALP', 20, 'all')).resolves.toMatchObject([
{ key: 'BRV-1', title: 'Healthy' }
])
})
it('keeps healthy sites when the saved selection fans out without an explicit site', async () => {
getClientsMock.mockReturnValue([makeEntry('site-1'), makeEntry('site-2')])
jiraRequestMock.mockRejectedValueOnce(new Error('Forbidden')).mockResolvedValueOnce({
issues: [{ id: '1', key: 'BRV-1', fields: { summary: 'Healthy' } }]
})
const { searchIssues } = await import('./issues')
await expect(searchIssues('project = ALP', 20)).resolves.toMatchObject([
{ key: 'BRV-1', title: 'Healthy' }
])
})
it('surfaces an error when every site fails under an "all" search', async () => {
getClientsMock.mockReturnValue([makeEntry('site-1'), makeEntry('site-2')])
jiraRequestMock
.mockRejectedValueOnce(new Error('Forbidden'))
.mockRejectedValueOnce(new Error('Service Unavailable'))
const { searchIssues } = await import('./issues')
await expect(searchIssues('project = ALP', 20, 'all')).rejects.toThrow('Forbidden')
})
it('prefers operational failures when every "all" search site fails', async () => {
const authError = new Error('Unauthorized')
const operationalError = new Error('Service Unavailable')
getClientsMock.mockReturnValue([makeEntry('site-1'), makeEntry('site-2')])
isAuthErrorMock.mockImplementation((error) => error === authError)
jiraRequestMock.mockRejectedValueOnce(authError).mockRejectedValueOnce(operationalError)
const { searchIssues } = await import('./issues')
await expect(searchIssues('project = ALP', 20, 'all')).rejects.toThrow('Service Unavailable')
expect(clearTokenMock).toHaveBeenCalledWith('site-1')
})
it('paginates Jira project search results before sorting them', async () => {
jiraRequestMock
.mockResolvedValueOnce({

View File

@ -68,8 +68,38 @@ function clampLimit(limit: number | undefined, fallback = 30): number {
return Math.min(Math.max(1, Number.isFinite(limit) ? Number(limit) : fallback), 100)
}
function shouldThrowAuthError(selection: JiraSiteSelection | null | undefined): boolean {
return selection !== 'all'
type JiraIssueSearchFailure = {
error: unknown
auth: boolean
}
function getErrorStatus(error: unknown): number | null {
if (!error || typeof error !== 'object' || !('status' in error)) {
return null
}
const status = (error as { status?: unknown }).status
return typeof status === 'number' && Number.isFinite(status) ? status : null
}
function toIssueSearchFailureError(error: unknown): unknown {
const status = getErrorStatus(error)
if (
status === null ||
!(error instanceof Error) ||
error.message.startsWith(`Error ${status}:`)
) {
return error
}
return new Error(`Error ${status}: ${error.message}`)
}
function shouldSurfaceSiteFailure(
selection: JiraSiteSelection | null | undefined,
entryCount: number
): boolean {
// getClients can resolve an omitted selection to the persisted 'all' choice;
// multi-entry reads need the same resilient fan-out policy as explicit 'all'.
return selection !== 'all' && entryCount <= 1
}
function asRecord(value: unknown): JiraRecord {
@ -345,26 +375,37 @@ export async function searchIssues(
return []
}
const safeLimit = clampLimit(limit)
const failures: (JiraIssueSearchFailure | undefined)[] = Array.from({ length: entries.length })
const surfaceSiteFailure = shouldSurfaceSiteFailure(siteId, entries.length)
const results = await Promise.all(
entries.map(async (entry) => {
entries.map(async (entry, index) => {
await acquire()
try {
return await searchIssuesForClient(entry, jql.trim(), safeLimit)
} catch (error) {
if (isAuthError(error)) {
const authFailure = isAuthError(error)
if (authFailure) {
clearToken(entry.site.id)
if (shouldThrowAuthError(siteId)) {
throw error
}
} else {
console.warn('[jira] searchIssues failed:', error)
}
return []
if (surfaceSiteFailure) {
throw toIssueSearchFailureError(error)
}
console.warn('[jira] searchIssues failed:', error)
failures[index] = { error: toIssueSearchFailureError(error), auth: authFailure }
return [] as JiraIssue[]
} finally {
release()
}
})
)
// 'all' fan-out: only surface an error when every connected site failed, so a
// partial success (or a genuinely empty result) is not reported as an error.
const recordedFailures = failures.filter(
(failure): failure is JiraIssueSearchFailure => failure !== undefined
)
if (recordedFailures.length === entries.length) {
throw (recordedFailures.find((failure) => !failure.auth) ?? recordedFailures[0]).error
}
return entries.length === 1
? results.flat().slice(0, safeLimit)
: sortAndLimitIssues(results.flat(), safeLimit)
@ -388,7 +429,7 @@ export async function getIssue(
} catch (error) {
if (isAuthError(error)) {
clearToken(entry.site.id)
if (shouldThrowAuthError(siteId)) {
if (shouldSurfaceSiteFailure(siteId, entries.length)) {
throw error
}
} else {
@ -590,7 +631,7 @@ export async function listProjects(siteId?: JiraSiteSelection | null): Promise<J
} catch (error) {
if (isAuthError(error)) {
clearToken(entry.site.id)
if (shouldThrowAuthError(siteId)) {
if (shouldSurfaceSiteFailure(siteId, entries.length)) {
throw error
}
} else {

View File

@ -85,6 +85,7 @@ import {
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import TaskProjectSourceCombobox from '@/components/task-project-source-combobox'
import { LinearApiKeyDialog } from '@/components/linear-api-key-dialog'
import { LinearScopeSelector } from '@/components/linear-scope-selector'
@ -209,6 +210,10 @@ import {
resolveTaskPageGitHubStatusStateDraft,
updateTaskPageGitHubStatusLocalState
} from '@/components/task-page-github-status-state'
import {
createTaskPageJiraLoadFailureState,
type TaskPageJiraLoadError
} from '@/components/task-page-jira-load-state'
import { deriveTaskPagePRCheckSummary } from '@/components/task-page-pr-check-summary'
import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state'
import { buildJiraCreateTextAdf } from '@/components/jira-create-adf'
@ -815,6 +820,51 @@ function groupLinearIssues(
return [...sections.values()]
}
function TaskPageJiraErrorBanner({
error,
open,
onOpenChange
}: {
error: TaskPageJiraLoadError
open: boolean
onOpenChange: (open: boolean) => void
}): React.JSX.Element {
return (
<Collapsible
open={open}
onOpenChange={onOpenChange}
className="border-b border-border bg-destructive/10 px-4 py-3 text-sm text-destructive"
>
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 size-4 flex-none" />
<div className="min-w-0 flex-1">
<div className="font-medium leading-5">{error.title}</div>
{error.details ? (
<>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="xs"
className="-ml-1 mt-1 h-6 px-1.5 text-destructive hover:bg-destructive/10 hover:text-destructive"
>
{open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
{translate('auto.components.TaskPage.40eaf2c27c', 'Details')}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-1 rounded-md border border-destructive/20 bg-background/80 px-2 py-1.5 font-mono text-xs text-foreground">
{error.details}
</div>
</CollapsibleContent>
</>
) : null}
</div>
</div>
</Collapsible>
)
}
function getLinearIssueGridTemplate(visibleProperties: ReadonlySet<LinearDisplayProperty>): string {
const columns = ['96px', 'minmax(240px,1.55fr)']
if (visibleProperties.has('labels')) {
@ -4121,7 +4171,8 @@ export default function TaskPage(): React.JSX.Element {
// Jira tab state
const [jiraIssues, setJiraIssues] = useState<JiraIssue[]>([])
const [jiraLoading, setJiraLoading] = useState(false)
const [jiraError, setJiraError] = useState<string | null>(null)
const [jiraError, setJiraError] = useState<TaskPageJiraLoadError | null>(null)
const [jiraErrorDetailsOpen, setJiraErrorDetailsOpen] = useState(false)
const [jiraSearchInput, setJiraSearchInput] = useState('')
const [appliedJiraSearch, setAppliedJiraSearch] = useState('')
const [activeJiraPreset, setActiveJiraPreset] = useState<JiraPresetId>('assigned')
@ -7231,6 +7282,7 @@ export default function TaskPage(): React.JSX.Element {
let cancelled = false
setJiraLoading(true)
setJiraError(null)
setJiraErrorDetailsOpen(false)
const trimmed = appliedJiraSearch.trim()
const request =
@ -7252,7 +7304,9 @@ export default function TaskPage(): React.JSX.Element {
if (cancelled) {
return
}
setJiraError(err instanceof Error ? err.message : 'Failed to load Jira issues.')
const failureState = createTaskPageJiraLoadFailureState(err)
setJiraIssues(failureState.issues)
setJiraError(failureState.error)
setJiraLoading(false)
})
@ -9387,11 +9441,18 @@ export default function TaskPage(): React.JSX.Element {
className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek"
style={{ scrollbarGutter: 'stable' }}
>
{(jiraStatus.credentialError ?? jiraError) ? (
{jiraStatus.credentialError ? (
<div className="border-b border-border px-4 py-4 text-sm text-destructive">
{jiraStatus.credentialError ?? jiraError}
{jiraStatus.credentialError}
</div>
) : null}
{!jiraStatus.credentialError && jiraError ? (
<TaskPageJiraErrorBanner
error={jiraError}
open={jiraErrorDetailsOpen}
onOpenChange={setJiraErrorDetailsOpen}
/>
) : null}
{jiraLoading && jiraIssues.length === 0 ? (
<div className="divide-y divide-border/50">

View File

@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest'
import { createTaskPageJiraLoadFailureState } from './task-page-jira-load-state'
describe('TaskPage Jira load state', () => {
it('explains Jira forbidden errors while clearing stale issues', () => {
expect(createTaskPageJiraLoadFailureState(new Error('Forbidden'))).toEqual({
issues: [],
error: {
title:
'Error 403: Jira denied access to this issue search. Check project permissions or try a different JQL query.',
details: 'Forbidden'
}
})
})
it('keeps raw provider detail separate from the Jira status summary', () => {
expect(createTaskPageJiraLoadFailureState(new Error('Error 403: XSRF check failed'))).toEqual({
issues: [],
error: {
title:
'Error 403: Jira denied access to this issue search. Check project permissions or try a different JQL query.',
details: 'XSRF check failed'
}
})
})
it('explains malformed JQL errors', () => {
expect(createTaskPageJiraLoadFailureState(new Error('Malformed JQL'))).toEqual({
issues: [],
error: {
title: "Jira couldn't run this JQL query. Check the syntax and try again.",
details: 'Malformed JQL'
}
})
})
it('explains network errors', () => {
expect(createTaskPageJiraLoadFailureState(new Error('Network request failed'))).toEqual({
issues: [],
error: {
title: "Couldn't reach Jira. Check your connection and try again.",
details: 'Network request failed'
}
})
})
it('explains Jira server errors', () => {
expect(createTaskPageJiraLoadFailureState(new Error('Service Unavailable'))).toEqual({
issues: [],
error: {
title: 'Error 503: Jira had a server error while loading issues. Try again in a moment.',
details: 'Service Unavailable'
}
})
})
it('uses the generic load error for non-Error rejections', () => {
expect(createTaskPageJiraLoadFailureState('failed')).toEqual({
issues: [],
error: {
title: "Couldn't load Jira issues. Try again in a moment.",
details: 'Failed to load Jira issues.'
}
})
})
})

View File

@ -0,0 +1,76 @@
import type { JiraIssue } from '../../../shared/types'
export type TaskPageJiraLoadError = {
title: string
details: string | null
}
export type TaskPageJiraLoadFailureState = {
issues: JiraIssue[]
error: TaskPageJiraLoadError
}
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)
}
if (/\bforbidden\b/i.test(message)) {
return 403
}
if (/\bunauthorized\b|\bunauthenticated\b/i.test(message)) {
return 401
}
if (/\btoo many requests\b|\brate limit\b/i.test(message)) {
return 429
}
if (/\bservice unavailable\b/i.test(message)) {
return 503
}
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.'
}
if (code === 403) {
return 'Jira denied access to this issue search. Check project permissions or try a different JQL query.'
}
if (code === 429) {
return 'Jira rate-limited this issue search. Try again in a moment.'
}
if (code !== null && code >= 500) {
return 'Jira had a server error while loading issues. Try again in a moment.'
}
if (/\bjql\b|\bsyntax\b/i.test(message)) {
return "Jira couldn't run this JQL query. Check the syntax and try again."
}
if (/\bnetwork\b|\bfetch failed\b|\btimed? ?out\b|\beconn/i.test(message)) {
return "Couldn't reach Jira. Check your connection and try again."
}
return "Couldn't load Jira issues. Try again in a moment."
}
export function createTaskPageJiraLoadFailureState(error: unknown): TaskPageJiraLoadFailureState {
const message = getErrorMessage(error)
const code = getErrorCode(message)
const summary = getIssueSearchErrorSummary(message, code)
return {
issues: [],
error: {
title: code === null ? summary : `Error ${code}: ${summary}`,
details: getErrorDetails(message, code)
}
}
}

View File

@ -1601,7 +1601,8 @@
"ff90d0abc7": "Start workspace from {{value0}}",
"fe28c9821f": "view",
"8d1e17a3ef": "Open {{value0}} in GitHub",
"4ac8ff2275": "Open {{value0}} in Jira"
"4ac8ff2275": "Open {{value0}} in Jira",
"40eaf2c27c": "Details"
},
"Terminal": {
"73768427cf": "Close",

View File

@ -1601,7 +1601,8 @@
"ff90d0abc7": "Iniciar espacio de trabajo desde {{value0}}",
"fe28c9821f": "vista",
"8d1e17a3ef": "Abra {{value0}} en GitHub",
"4ac8ff2275": "Abrir {{value0}} en Jira"
"4ac8ff2275": "Abrir {{value0}} en Jira",
"40eaf2c27c": "Details"
},
"Terminal": {
"73768427cf": "Cerca",

View File

@ -1601,7 +1601,8 @@
"ff90d0abc7": "{{value0}} からワークスペースを開始",
"fe28c9821f": "view",
"8d1e17a3ef": "{{value0}} を GitHub で開く",
"4ac8ff2275": "{{value0}} を Jira で開く"
"4ac8ff2275": "{{value0}} を Jira で開く",
"40eaf2c27c": "Details"
},
"Terminal": {
"73768427cf": "閉じる",

View File

@ -1601,7 +1601,8 @@
"ff90d0abc7": "{{value0}}에서 워크스페이스 시작",
"fe28c9821f": "보기",
"8d1e17a3ef": "GitHub에서 {{value0}} 열기",
"4ac8ff2275": "Jira에서 {{value0}} 열기"
"4ac8ff2275": "Jira에서 {{value0}} 열기",
"40eaf2c27c": "Details"
},
"Terminal": {
"73768427cf": "닫기",

View File

@ -1601,7 +1601,8 @@
"ff90d0abc7": "从 {{value0}} 开始工作区",
"fe28c9821f": "视图",
"8d1e17a3ef": "在 GitHub 中打开 {{value0}}",
"4ac8ff2275": "在 Jira 中打开 {{value0}}"
"4ac8ff2275": "在 Jira 中打开 {{value0}}",
"40eaf2c27c": "Details"
},
"Terminal": {
"73768427cf": "关闭",

View File

@ -391,14 +391,30 @@ describe('createJiraSlice credential errors', () => {
})
})
it('keeps Jira connected when an issue read hits endpoint-level forbidden access', async () => {
it('surfaces endpoint-level forbidden errors without disconnecting Jira', async () => {
const store = createTestStore()
store.setState({
jiraStatus: { connected: true, viewer: null, selectedSiteId: 'site-1' }
})
jiraListIssues.mockRejectedValueOnce(new Error('Forbidden'))
await expect(store.getState().listJiraIssues('assigned', 30)).resolves.toEqual([])
// A non-auth failure must reject so the Tasks panel can show a real error
// instead of a misleading empty list, while keeping the session connected.
await expect(store.getState().listJiraIssues('assigned', 30)).rejects.toThrow('Forbidden')
expect(store.getState().jiraStatus.connected).toBe(true)
})
it('surfaces endpoint-level search errors without disconnecting Jira', async () => {
const store = createTestStore()
store.setState({
jiraStatus: { connected: true, viewer: null, selectedSiteId: 'site-1' }
})
jiraSearchIssues.mockRejectedValueOnce(new Error('Malformed JQL'))
await expect(store.getState().searchJiraIssues('project =', 30)).rejects.toThrow(
'Malformed JQL'
)
expect(store.getState().jiraStatus.connected).toBe(true)
})

View File

@ -496,7 +496,14 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set,
) {
set({ jiraStatus: { connected: false, viewer: null } })
}
return []
// Credential/auth failures are surfaced through connection state, so they
// keep the empty-list contract. Other failures (forbidden, bad JQL,
// network, 5xx) reject so the Tasks panel can show a real error instead
// of a misleading "No issues found".
if (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) {
return []
}
throw error
})
.finally(() => {
if (inflightSearchRequests.get(cacheKey) === entry) {
@ -583,7 +590,14 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set,
) {
set({ jiraStatus: { connected: false, viewer: null } })
}
return []
// Credential/auth failures are surfaced through connection state, so they
// keep the empty-list contract. Other failures (forbidden, bad JQL,
// network, 5xx) reject so the Tasks panel can show a real error instead
// of a misleading "No issues found".
if (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) {
return []
}
throw error
})
.finally(() => {
if (inflightListRequests.get(cacheKey) === entry) {