@cat-factory/app 0.162.1 → 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.
- package/app/components/layout/CommandBar.vue +8 -0
- package/app/components/layout/IntegrationsHub.vue +7 -0
- package/app/components/panels/InspectorPanel.vue +13 -0
- package/app/components/prReview/PrReviewWindow.vue +18 -5
- package/app/components/tasks/BugHuntModal.vue +361 -0
- package/app/composables/api/bugHunt.ts +40 -0
- package/app/composables/useApi.ts +2 -0
- package/app/pages/index.vue +2 -0
- package/app/stores/bugHunt.spec.ts +103 -0
- package/app/stores/bugHunt.ts +148 -0
- package/app/stores/ui/modals.ts +14 -0
- package/app/types/bugHunt.ts +21 -0
- package/app/types/domain.ts +1 -0
- package/i18n/locales/de.json +55 -4
- package/i18n/locales/en.json +58 -4
- package/i18n/locales/es.json +55 -4
- package/i18n/locales/fr.json +55 -4
- package/i18n/locales/he.json +55 -4
- package/i18n/locales/it.json +55 -4
- package/i18n/locales/ja.json +55 -4
- package/i18n/locales/pl.json +55 -4
- package/i18n/locales/tr.json +55 -4
- package/i18n/locales/uk.json +55 -4
- package/package.json +2 -2
|
@@ -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"
|
|
@@ -314,16 +314,26 @@ async function onDismiss(id: string): Promise<void> {
|
|
|
314
314
|
/>
|
|
315
315
|
</div>
|
|
316
316
|
|
|
317
|
-
<!-- The chunk(s) being actively reviewed right now, called out on their own.
|
|
317
|
+
<!-- The chunk(s) being actively reviewed right now, called out on their own. Kept
|
|
318
|
+
MOUNTED for the whole reviewing phase: the reviewer runs a bounded window of
|
|
319
|
+
slice subagents, so between two waves nothing is in flight for a moment, and
|
|
320
|
+
dropping the callout there made the window look like it had lost a list. -->
|
|
318
321
|
<div
|
|
319
|
-
v-if="activeChunks.length"
|
|
320
322
|
data-testid="pr-review-active-chunks"
|
|
321
|
-
class="mt-3 rounded-lg border
|
|
323
|
+
class="mt-3 rounded-lg border px-2.5 py-2"
|
|
324
|
+
:class="
|
|
325
|
+
activeChunks.length
|
|
326
|
+
? 'border-indigo-500/30 bg-indigo-500/5'
|
|
327
|
+
: 'border-slate-800 bg-slate-900/40'
|
|
328
|
+
"
|
|
322
329
|
>
|
|
323
|
-
<p
|
|
330
|
+
<p
|
|
331
|
+
class="mb-1 text-[10px] font-semibold uppercase tracking-wide"
|
|
332
|
+
:class="activeChunks.length ? 'text-indigo-300' : 'text-slate-500'"
|
|
333
|
+
>
|
|
324
334
|
{{ t('prReview.reviewing.activeHeading') }}
|
|
325
335
|
</p>
|
|
326
|
-
<ul class="space-y-1">
|
|
336
|
+
<ul v-if="activeChunks.length" class="space-y-1">
|
|
327
337
|
<li
|
|
328
338
|
v-for="(label, i) in activeChunks"
|
|
329
339
|
:key="i"
|
|
@@ -336,6 +346,9 @@ async function onDismiss(id: string): Promise<void> {
|
|
|
336
346
|
<span class="min-w-0">{{ label }}</span>
|
|
337
347
|
</li>
|
|
338
348
|
</ul>
|
|
349
|
+
<p v-else data-testid="pr-review-active-idle" class="text-[12px] text-slate-400">
|
|
350
|
+
{{ t('prReview.reviewing.activeIdle') }}
|
|
351
|
+
</p>
|
|
339
352
|
</div>
|
|
340
353
|
|
|
341
354
|
<!-- Every chunk with its explicit status (Reviewed / Reviewing… / Queued). -->
|
|
@@ -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),
|
package/app/pages/index.vue
CHANGED
|
@@ -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
|
+
})
|