@cat-factory/app 0.162.2 → 0.163.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -118,6 +118,14 @@ const dynamicIntegrationCommands = computed<Command[]>(() => {
118
118
  keywords: t('layout.commandBar.keywords.taskImport'),
119
119
  run: () => ui.openTaskImport(null),
120
120
  })
121
+ list.push({
122
+ id: 'bug-hunt',
123
+ label: t('layout.commandBar.cmd.bugHunt'),
124
+ group: groupIntegrations,
125
+ icon: 'i-lucide-radar',
126
+ keywords: t('layout.commandBar.keywords.bugHunt'),
127
+ run: () => ui.openBugHunt(null),
128
+ })
121
129
  }
122
130
  }
123
131
  return list
@@ -241,6 +241,13 @@ const groups = computed<IntegrationGroup[]>(() => {
241
241
  description: t('layout.integrationsHub.items.taskImport.description'),
242
242
  onClick: () => go(() => ui.openTaskImport(null)),
243
243
  })
244
+ trackers.push({
245
+ key: 'task:bug-hunt',
246
+ icon: 'i-lucide-radar',
247
+ label: t('layout.integrationsHub.items.bugHunt.label'),
248
+ description: t('layout.integrationsHub.items.bugHunt.description'),
249
+ onClick: () => go(() => ui.openBugHunt(null)),
250
+ })
244
251
  }
245
252
  // Choosing the filing tracker / writeback is workspace CONFIG, not an integration, so it
246
253
  // sits as a quiet footer link under the sources rather than a competing row.
@@ -451,6 +451,19 @@ const showOriginalDescription = ref(false)
451
451
  : t('panels.inspector.connectTracker')
452
452
  }}
453
453
  </UButton>
454
+ <!-- Hunt this service's tracker board for a bug worth picking up. Scoped to the
455
+ selected container, so an adopted candidate lands here rather than wherever the
456
+ board's first frame happens to be. -->
457
+ <UButton
458
+ v-if="isContainer && tasks.anyOffered"
459
+ color="neutral"
460
+ variant="soft"
461
+ size="xs"
462
+ icon="i-lucide-radar"
463
+ @click="ui.openBugHunt(null, block!.id)"
464
+ >
465
+ {{ t('panels.inspector.huntBugs') }}
466
+ </UButton>
454
467
  <UButton
455
468
  v-if="isContainer && documents.available && documents.anyConnected"
456
469
  color="neutral"
@@ -0,0 +1,361 @@
1
+ <script setup lang="ts">
2
+ // Bug hunt: pick a connected tracker, pick one of its boards, and let the platform rank that
3
+ // board's open, UNASSIGNED bugs by impact against implementation complexity. Confirming a
4
+ // candidate adopts it as a bug task in the chosen container and starts the bug-fix pipeline.
5
+ //
6
+ // The interactive dual of the recurring bug-triage schedule: same reading and same pipeline,
7
+ // but a human picks the bug instead of the oldest match being claimed unattended.
8
+ //
9
+ // Two states this deliberately renders rather than hides: a board whose ranking was
10
+ // unavailable or failed still shows its candidates (flagged as unassessed, since the scan is
11
+ // useful on its own), and a scan that hit its cap says so — a silently shortened list reads
12
+ // exactly like an exhaustive one.
13
+ import type {
14
+ BugHuntAnalysisStatus,
15
+ BugHuntCandidate,
16
+ BugHuntConfidence,
17
+ TaskSourceKind,
18
+ } from '~/types/domain'
19
+ import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
20
+
21
+ const { t, d, n } = useI18n()
22
+ const ui = useUiStore()
23
+ const tasks = useTasksStore()
24
+ const board = useBoardStore()
25
+ const hunt = useBugHuntStore()
26
+ const toast = useToast()
27
+
28
+ const open = computed({
29
+ get: () => ui.bugHunt !== null,
30
+ set: (v: boolean) => {
31
+ if (!v) ui.closeBugHunt()
32
+ },
33
+ })
34
+ const back = useIntegrationBack(open)
35
+
36
+ const source = ref<TaskSourceKind | undefined>(undefined)
37
+ const boardId = ref('')
38
+ const issueType = ref('')
39
+ const labels = ref('')
40
+ const containerId = ref<string | undefined>(undefined)
41
+
42
+ // Containers an adopted bug can land in: every service frame and module on the board. Modules
43
+ // are labelled with their parent frame so the choice is unambiguous (same as the import modal).
44
+ const containerItems = computed(() =>
45
+ board.blocks
46
+ .filter((b) => b.level === 'frame' || b.level === 'module')
47
+ .map((b) => ({
48
+ label:
49
+ b.level === 'module'
50
+ ? `${board.getBlock(b.parentId ?? '')?.title ?? '?'} › ${b.title}`
51
+ : b.title,
52
+ value: b.id,
53
+ })),
54
+ )
55
+
56
+ const sourceItems = computed(() =>
57
+ tasks.offeredSources.map((s) => ({ label: s.label, value: s.source })),
58
+ )
59
+
60
+ const boardItems = computed(() =>
61
+ hunt.boards.map((b) => ({
62
+ label: b.key && b.key !== b.name ? `${b.name} (${b.key})` : b.name,
63
+ value: b.id,
64
+ })),
65
+ )
66
+
67
+ /**
68
+ * The tracker CANNOT enumerate boards, so the user types the scope in themselves. Keyed on the
69
+ * backend's reason code, not on "any error": an unreachable tracker or an expired token would
70
+ * otherwise present as a free-text field that simply moves the same failure to the next click.
71
+ */
72
+ const boardIsFreeText = computed(
73
+ () => !hunt.boardsLoading && hunt.boardsErrorReason === 'boards_unsupported',
74
+ )
75
+
76
+ /** A board read that failed for a reason the user has to fix — shown, never silently swallowed. */
77
+ const boardsFailure = computed(() =>
78
+ !hunt.boardsLoading && hunt.boardsError !== null && !boardIsFreeText.value
79
+ ? hunt.boardsError
80
+ : null,
81
+ )
82
+
83
+ /**
84
+ * A tracker date the SPA can actually format. A provider reports `createdAt` as an opaque
85
+ * string, so an unparseable one must render as nothing rather than as "Invalid Date".
86
+ */
87
+ function createdAtDate(createdAt: string): Date | null {
88
+ if (!createdAt) return null
89
+ const parsed = new Date(createdAt)
90
+ return Number.isNaN(parsed.getTime()) ? null : parsed
91
+ }
92
+
93
+ const canHunt = computed(() => !!source.value && boardId.value.trim().length > 0)
94
+
95
+ watch(open, (isOpen) => {
96
+ if (!isOpen) return
97
+ hunt.reset()
98
+ boardId.value = ''
99
+ issueType.value = ''
100
+ labels.value = ''
101
+ source.value = ui.bugHunt?.source ?? tasks.offeredSources[0]?.source ?? undefined
102
+ containerId.value = ui.bugHunt?.containerId ?? containerItems.value[0]?.value
103
+ if (source.value) hunt.loadBoards(source.value)
104
+ })
105
+
106
+ // Switching tracker invalidates both the board list and any ranking already on screen — the
107
+ // candidates belong to the previous tracker's board and would otherwise sit there looking current.
108
+ watch(source, (next) => {
109
+ boardId.value = ''
110
+ hunt.reset()
111
+ if (next) hunt.loadBoards(next)
112
+ })
113
+
114
+ async function runHunt() {
115
+ if (!source.value || !canHunt.value) return
116
+ const parsedLabels = labels.value
117
+ .split(',')
118
+ .map((l) => l.trim())
119
+ .filter((l) => l.length > 0)
120
+ const ok = await hunt.hunt(source.value, {
121
+ board: boardId.value.trim(),
122
+ ...(issueType.value.trim() ? { issueType: issueType.value.trim() } : {}),
123
+ ...(parsedLabels.length ? { labels: parsedLabels } : {}),
124
+ })
125
+ if (!ok) {
126
+ toast.add({
127
+ title: t('bugHunt.huntFailed'),
128
+ description: hunt.huntError ?? undefined,
129
+ icon: 'i-lucide-triangle-alert',
130
+ color: 'error',
131
+ })
132
+ }
133
+ }
134
+
135
+ async function adopt(candidate: BugHuntCandidate) {
136
+ if (!source.value || !containerId.value) return
137
+ try {
138
+ const blockId = await hunt.adopt(source.value, candidate.externalId, containerId.value)
139
+ if (!blockId) return // the user cancelled the personal-credential prompt
140
+ ui.closeBugHunt()
141
+ ui.select(blockId)
142
+ toast.add({
143
+ title: t('bugHunt.adopted', { id: candidate.externalId }),
144
+ description: t('bugHunt.adoptedRunning'),
145
+ icon: 'i-lucide-bug-play',
146
+ })
147
+ } catch (e) {
148
+ toast.add({
149
+ title: t('bugHunt.adoptFailed'),
150
+ description: e instanceof Error ? e.message : String(e),
151
+ icon: 'i-lucide-triangle-alert',
152
+ color: 'error',
153
+ })
154
+ }
155
+ }
156
+
157
+ /** Colour the score chip by how good the impact-per-effort ratio is. */
158
+ function scoreColor(score: number): 'success' | 'primary' | 'neutral' {
159
+ if (score >= 2) return 'success'
160
+ if (score >= 1) return 'primary'
161
+ return 'neutral'
162
+ }
163
+
164
+ /**
165
+ * Confidence labels, as an exhaustive Record over the closed union so a new member fails the
166
+ * typecheck rather than rendering a raw code (the enum→key drift guard).
167
+ */
168
+ const CONFIDENCE_KEYS: Record<BugHuntConfidence, string> = {
169
+ high: 'bugHunt.confidence.high',
170
+ medium: 'bugHunt.confidence.medium',
171
+ low: 'bugHunt.confidence.low',
172
+ }
173
+
174
+ /** The banner shown above the results, per analysis status — same exhaustive-Record guard. */
175
+ const STATUS_KEYS: Record<BugHuntAnalysisStatus, string> = {
176
+ ranked: 'bugHunt.status.ranked',
177
+ unavailable: 'bugHunt.status.unavailable',
178
+ failed: 'bugHunt.status.failed',
179
+ over_budget: 'bugHunt.status.over_budget',
180
+ empty: 'bugHunt.status.empty',
181
+ }
182
+ </script>
183
+
184
+ <template>
185
+ <UModal v-model:open="open" :title="t('bugHunt.title')" :ui="{ content: 'max-w-3xl' }">
186
+ <template #title>
187
+ <IntegrationBackTitle :title="t('bugHunt.title')" @back="back" />
188
+ </template>
189
+ <template #body>
190
+ <!-- No tracker offered (none connected/installed, or all disabled). -->
191
+ <div v-if="!tasks.anyOffered" class="space-y-3 text-center">
192
+ <UIcon name="i-lucide-plug" class="mx-auto h-8 w-8 text-slate-500" />
193
+ <p class="text-sm text-slate-400">{{ t('bugHunt.connectFirst') }}</p>
194
+ <div class="flex justify-center gap-2">
195
+ <UButton
196
+ v-for="s in tasks.sources"
197
+ :key="s.source"
198
+ color="primary"
199
+ variant="soft"
200
+ :icon="s.icon"
201
+ @click="ui.openTaskConnect(s.source)"
202
+ >
203
+ {{ t('bugHunt.connectSource', { label: s.label }) }}
204
+ </UButton>
205
+ </div>
206
+ </div>
207
+
208
+ <!-- No service frame yet → nowhere for an adopted bug to land. -->
209
+ <p v-else-if="!containerItems.length" class="text-center text-xs text-slate-500">
210
+ {{ t('bugHunt.needFrameFirst') }}
211
+ </p>
212
+
213
+ <div v-else class="space-y-4">
214
+ <p class="text-xs text-slate-400">{{ t('bugHunt.intro') }}</p>
215
+
216
+ <div class="grid gap-3 sm:grid-cols-2">
217
+ <UFormField :label="t('bugHunt.tracker')">
218
+ <USelect v-model="source" :items="sourceItems" class="w-full" />
219
+ </UFormField>
220
+
221
+ <UFormField :label="t('bugHunt.board')">
222
+ <!-- A tracker that can't enumerate its boards gets a free-text field rather than
223
+ an empty picker, so the hunt is still usable. -->
224
+ <UInput
225
+ v-if="boardIsFreeText"
226
+ v-model="boardId"
227
+ :placeholder="t('bugHunt.boardPlaceholder')"
228
+ class="w-full"
229
+ />
230
+ <USelect
231
+ v-else
232
+ v-model="boardId"
233
+ :items="boardItems"
234
+ :loading="hunt.boardsLoading"
235
+ :placeholder="t('bugHunt.pickBoard')"
236
+ class="w-full"
237
+ />
238
+ <!-- A board read that failed for a fixable reason (unreachable site, expired
239
+ token): named, so the user isn't left with an empty picker and no cause. -->
240
+ <p v-if="boardsFailure" class="mt-1 text-xs text-amber-400">
241
+ {{ t('bugHunt.boardsFailed', { reason: boardsFailure }) }}
242
+ </p>
243
+ </UFormField>
244
+
245
+ <UFormField :label="t('bugHunt.issueType')" :help="t('bugHunt.issueTypeHelp')">
246
+ <UInput v-model="issueType" placeholder="bug" class="w-full" />
247
+ </UFormField>
248
+
249
+ <UFormField :label="t('bugHunt.labels')" :help="t('bugHunt.labelsHelp')">
250
+ <UInput v-model="labels" placeholder="regression, checkout" class="w-full" />
251
+ </UFormField>
252
+ </div>
253
+
254
+ <UFormField :label="t('bugHunt.adoptInto')">
255
+ <USelect v-model="containerId" :items="containerItems" class="w-full" />
256
+ </UFormField>
257
+
258
+ <div class="flex items-center gap-2">
259
+ <UButton
260
+ color="primary"
261
+ icon="i-lucide-radar"
262
+ :loading="hunt.hunting"
263
+ :disabled="!canHunt"
264
+ @click="runHunt"
265
+ >
266
+ {{ t('bugHunt.run') }}
267
+ </UButton>
268
+ <span v-if="hunt.hunting" class="text-xs text-slate-400">
269
+ {{ t('bugHunt.running') }}
270
+ </span>
271
+ </div>
272
+
273
+ <!-- Results -->
274
+ <div v-if="hunt.hasResult" class="space-y-3 border-t border-slate-800 pt-3">
275
+ <p class="text-xs text-slate-400">
276
+ {{ t(STATUS_KEYS[hunt.result!.analysisStatus]) }}
277
+ <span v-if="hunt.result!.model" class="text-slate-500">
278
+ {{ t('bugHunt.viaModel', { model: hunt.result!.model }) }}
279
+ </span>
280
+ </p>
281
+ <p v-if="hunt.result!.truncated" class="text-xs text-amber-400">
282
+ {{ t('bugHunt.truncated', { count: hunt.result!.scanned }) }}
283
+ </p>
284
+
285
+ <div
286
+ v-for="candidate in hunt.candidates"
287
+ :key="candidate.externalId"
288
+ class="space-y-2 rounded-md border border-slate-800 p-3"
289
+ >
290
+ <div class="flex items-start justify-between gap-3">
291
+ <div class="min-w-0 space-y-1">
292
+ <div class="flex items-center gap-2">
293
+ <UBadge
294
+ v-if="candidate.analysis"
295
+ :color="scoreColor(candidate.analysis.score)"
296
+ variant="soft"
297
+ >
298
+ {{ n(candidate.analysis.score) }}
299
+ </UBadge>
300
+ <UBadge v-else color="neutral" variant="soft">
301
+ {{ t('bugHunt.notAssessed') }}
302
+ </UBadge>
303
+ <ULink
304
+ :to="candidate.url"
305
+ target="_blank"
306
+ rel="noopener noreferrer"
307
+ class="truncate text-sm font-medium text-slate-100"
308
+ >
309
+ {{ candidate.externalId }}: {{ candidate.title }}
310
+ </ULink>
311
+ </div>
312
+ <p v-if="candidate.analysis" class="text-xs text-slate-400">
313
+ {{
314
+ t('bugHunt.ratings', {
315
+ impact: candidate.analysis.impact,
316
+ complexity: candidate.analysis.complexity,
317
+ confidence: t(CONFIDENCE_KEYS[candidate.analysis.confidence]),
318
+ })
319
+ }}
320
+ </p>
321
+ <p v-if="candidate.analysis?.rationale" class="text-xs text-slate-300">
322
+ {{ candidate.analysis.rationale }}
323
+ </p>
324
+ <div class="flex flex-wrap items-center gap-2 text-[11px] text-slate-500">
325
+ <span v-if="candidate.priority">{{ candidate.priority }}</span>
326
+ <span v-for="label in candidate.labels" :key="label">{{ label }}</span>
327
+ <span v-if="createdAtDate(candidate.createdAt)">
328
+ {{ d(createdAtDate(candidate.createdAt)!, 'short') }}
329
+ </span>
330
+ <span>{{
331
+ t('bugHunt.comments', { count: candidate.commentCount }, candidate.commentCount)
332
+ }}</span>
333
+ </div>
334
+ </div>
335
+ <div class="flex shrink-0 flex-col items-end gap-1">
336
+ <UBadge v-if="candidate.analysis?.recommended" color="success" variant="subtle">
337
+ {{ t('bugHunt.recommended') }}
338
+ </UBadge>
339
+ <UButton
340
+ color="primary"
341
+ variant="soft"
342
+ icon="i-lucide-play"
343
+ size="xs"
344
+ :loading="hunt.adopting === candidate.externalId"
345
+ :disabled="hunt.adopting !== null"
346
+ @click="adopt(candidate)"
347
+ >
348
+ {{ t('bugHunt.adopt') }}
349
+ </UButton>
350
+ </div>
351
+ </div>
352
+ </div>
353
+
354
+ <p v-if="!hunt.candidates.length" class="text-center text-xs text-slate-500">
355
+ {{ t('bugHunt.noCandidates') }}
356
+ </p>
357
+ </div>
358
+ </div>
359
+ </template>
360
+ </UModal>
361
+ </template>
@@ -0,0 +1,40 @@
1
+ import {
2
+ adoptBugHuntCandidateContract,
3
+ listTrackerBoardsContract,
4
+ runBugHuntContract,
5
+ } from '@cat-factory/contracts'
6
+ import type { RunBugHuntInput, TaskSourceKind } from '~/types/domain'
7
+ import type { ApiContext } from './context'
8
+
9
+ /** Bug hunt: list a tracker's boards, rank a board's open unassigned bugs, adopt one. */
10
+ export function bugHuntApi({ send, sendWith, ws, pwHeaders }: ApiContext) {
11
+ return {
12
+ // The boards a hunt can run against (Jira projects / Linear teams / GitHub repos). Errors
13
+ // when the source can't enumerate them, which the modal turns into a free-text board field.
14
+ listTrackerBoards: (workspaceId: string, source: TaskSourceKind) =>
15
+ send(listTrackerBoardsContract, { pathPrefix: ws(workspaceId), pathParams: { source } }),
16
+
17
+ // Scan the board for open, unassigned bugs and rank them by impact vs complexity. A live
18
+ // external call plus a model call, so it can take a while — the modal shows progress.
19
+ runBugHunt: (workspaceId: string, source: TaskSourceKind, body: RunBugHuntInput) =>
20
+ send(runBugHuntContract, {
21
+ pathPrefix: ws(workspaceId),
22
+ pathParams: { source },
23
+ body,
24
+ }),
25
+
26
+ // Adopt the confirmed candidate as a bug task and start its run. Carries the personal
27
+ // password header when the pipeline resolves an individual-usage model, like every start.
28
+ adoptBugHuntCandidate: (
29
+ workspaceId: string,
30
+ source: TaskSourceKind,
31
+ body: { externalId: string; containerId: string; pipelineId?: string },
32
+ password?: string,
33
+ ) =>
34
+ sendWith(pwHeaders(password), adoptBugHuntCandidateContract, {
35
+ pathPrefix: ws(workspaceId),
36
+ pathParams: { source },
37
+ body,
38
+ }),
39
+ }
40
+ }
@@ -44,6 +44,7 @@ import { reviewsApi } from './api/reviews'
44
44
  import { slackApi } from './api/slack'
45
45
  import { specApi } from './api/spec'
46
46
  import { tasksApi } from './api/tasks'
47
+ import { bugHuntApi } from './api/bugHunt'
47
48
  import { testSecretsApi } from './api/testSecrets'
48
49
  import { userSecretsApi } from './api/userSecrets'
49
50
  import { userSettingsApi } from './api/userSettings'
@@ -119,6 +120,7 @@ export function useApi() {
119
120
  ...executionApi(ctx),
120
121
  ...documentsApi(ctx),
121
122
  ...tasksApi(ctx),
123
+ ...bugHuntApi(ctx),
122
124
  ...reviewsApi(ctx),
123
125
  ...followUpsApi(ctx),
124
126
  ...forkDecisionApi(ctx),
@@ -45,6 +45,7 @@ const TaskSourceConnectModal = defineAsyncComponent(
45
45
  () => import('~/components/tasks/TaskSourceConnectModal.vue'),
46
46
  )
47
47
  const TaskImportModal = defineAsyncComponent(() => import('~/components/tasks/TaskImportModal.vue'))
48
+ const BugHuntModal = defineAsyncComponent(() => import('~/components/tasks/BugHuntModal.vue'))
48
49
  const RecurringPipelineModal = defineAsyncComponent(
49
50
  () => import('~/components/board/RecurringPipelineModal.vue'),
50
51
  )
@@ -398,6 +399,7 @@ watch(
398
399
  first open (its own chunk) rather than bloating the initial bundle. -->
399
400
  <TaskSourceConnectModal v-if="ui.taskConnect" />
400
401
  <TaskImportModal v-if="ui.taskImport" />
402
+ <BugHuntModal v-if="ui.bugHunt" />
401
403
  <RecurringPipelineModal v-if="ui.addRecurringFrameId" />
402
404
  <ObservabilityPanel v-if="ui.observabilityInstanceId" />
403
405
  <OperatorDashboardPanel v-if="ui.operatorDashboardOpen" />
@@ -0,0 +1,103 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { useBugHuntStore } from '~/stores/bugHunt'
3
+ import { useWorkspaceStore } from '~/stores/workspace'
4
+ import { ApiError } from '~/composables/api/errors'
5
+ import type { TaskSourceKind, TrackerBoardsView } from '~/types/domain'
6
+
7
+ // What the board picker does with a FAILED board read. Only one failure means "this tracker
8
+ // cannot enumerate boards, so type one in"; every other failure has to stay visible as an error,
9
+ // or a tracker outage silently wears the same clothes as an unsupported provider.
10
+
11
+ function apiError(statusCode: number, code: string, details?: Record<string, unknown>): ApiError {
12
+ return new ApiError(statusCode, { error: { code, message: 'nope', details } })
13
+ }
14
+
15
+ /** The unsupported-source refusal `BugHuntService.listBoards` raises. */
16
+ const boardsUnsupported = () =>
17
+ Promise.reject(apiError(400, 'validation', { reason: 'boards_unsupported' }))
18
+
19
+ /**
20
+ * Stub `useApi` ONCE, behind a handler the test can swap. The store resolves `useApi()` at setup,
21
+ * so re-stubbing after `useBugHuntStore()` would leave it holding the first stub for ever.
22
+ */
23
+ function stubApi(): {
24
+ store: ReturnType<typeof useBugHuntStore>
25
+ serve: (fn: () => Promise<unknown>) => void
26
+ } {
27
+ let handler: () => Promise<unknown> = () => Promise.resolve({ source: 'jira', boards: [] })
28
+ vi.stubGlobal('useApi', () => ({
29
+ listTrackerBoards: (_ws: string, _source: TaskSourceKind) =>
30
+ handler() as Promise<TrackerBoardsView>,
31
+ }))
32
+ return {
33
+ store: useBugHuntStore(),
34
+ serve: (fn) => {
35
+ handler = fn
36
+ },
37
+ }
38
+ }
39
+
40
+ describe('bug hunt store — board listing failures', () => {
41
+ beforeEach(() => {
42
+ useWorkspaceStore().workspaceId = 'ws1'
43
+ })
44
+
45
+ it('keeps the backend reason so the picker can offer free text for an unsupported source', async () => {
46
+ const { store, serve } = stubApi()
47
+ serve(boardsUnsupported)
48
+
49
+ await store.loadBoards('jira')
50
+
51
+ expect(store.boards).toEqual([])
52
+ expect(store.boardsErrorReason).toBe('boards_unsupported')
53
+ expect(store.boardsError).toBeTruthy()
54
+ })
55
+
56
+ it('records NO reason for a failure that is simply an unreachable tracker', async () => {
57
+ const { store, serve } = stubApi()
58
+ serve(() => Promise.reject(apiError(502, 'upstream')))
59
+
60
+ await store.loadBoards('jira')
61
+
62
+ // The modal keys its free-text fallback on the reason, so a null one keeps this an error.
63
+ expect(store.boardsErrorReason).toBeNull()
64
+ expect(store.boardsError).toBeTruthy()
65
+ })
66
+
67
+ it('clears a previous failure when a later source lists its boards fine', async () => {
68
+ const { store, serve } = stubApi()
69
+ serve(boardsUnsupported)
70
+ await store.loadBoards('jira')
71
+
72
+ serve(() =>
73
+ Promise.resolve({ source: 'linear', boards: [{ id: 't1', name: 'Eng', key: 'ENG' }] }),
74
+ )
75
+ await store.loadBoards('linear')
76
+
77
+ expect(store.boardsError).toBeNull()
78
+ expect(store.boardsErrorReason).toBeNull()
79
+ expect(store.boards.map((b) => b.id)).toEqual(['t1'])
80
+ })
81
+
82
+ it('a source switch mid-flight never lands the older tracker failure on the newer one', async () => {
83
+ const { store, serve } = stubApi()
84
+ let rejectJira!: (e: unknown) => void
85
+ serve(
86
+ () =>
87
+ new Promise((_res, rej) => {
88
+ rejectJira = rej
89
+ }),
90
+ )
91
+ const inFlight = store.loadBoards('jira')
92
+
93
+ serve(() => Promise.resolve({ source: 'linear', boards: [] }))
94
+ await store.loadBoards('linear')
95
+ rejectJira(apiError(400, 'validation', { reason: 'boards_unsupported' }))
96
+ await inFlight
97
+
98
+ // The slower tracker's refusal must not flip the newer tracker's picker to free text.
99
+ expect(store.boardsSource).toBe('linear')
100
+ expect(store.boardsErrorReason).toBeNull()
101
+ expect(store.boardsError).toBeNull()
102
+ })
103
+ })