@cat-factory/app 0.162.2 → 0.164.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/README.md +4 -1
- package/app/components/board/RecurringPipelineModal.vue +9 -22
- package/app/components/focus/BlockFocusView.vue +25 -19
- 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/pipeline/PipelinePicker.vue +38 -16
- package/app/components/pipeline/PipelinePreview.vue +65 -23
- package/app/components/tasks/BugHuntModal.vue +361 -0
- package/app/components/tasks/ContextIssuePicker.logic.spec.ts +89 -0
- package/app/components/tasks/ContextIssuePicker.logic.ts +74 -0
- package/app/components/tasks/ContextIssuePicker.vue +71 -21
- package/app/components/tasks/TaskImportModal.vue +2 -4
- 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/app/utils/pipeline.spec.ts +41 -1
- package/app/utils/pipeline.ts +9 -0
- package/i18n/locales/de.json +61 -5
- package/i18n/locales/en.json +64 -5
- package/i18n/locales/es.json +61 -5
- package/i18n/locales/fr.json +61 -5
- package/i18n/locales/he.json +61 -5
- package/i18n/locales/it.json +61 -5
- package/i18n/locales/ja.json +61 -5
- package/i18n/locales/pl.json +61 -5
- package/i18n/locales/tr.json +61 -5
- package/i18n/locales/uk.json +61 -5
- package/package.json +2 -2
|
@@ -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,89 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { buildSourceChoices, reconcileSource } from './ContextIssuePicker.logic'
|
|
3
|
+
import type { TaskSourceState } from '~/types/domain'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The pure source-selection behind `<ContextIssuePicker>`. Pins what the always-visible
|
|
7
|
+
* selector promises: the tracker in use is named even when it is the only one, a tracker
|
|
8
|
+
* the workspace hasn't got yet is offered as something to ADD (worded for its actual
|
|
9
|
+
* state), and the selection stays valid as the offered set changes underneath it.
|
|
10
|
+
*/
|
|
11
|
+
const state = (source: string, { available = true, enabled = true } = {}): TaskSourceState =>
|
|
12
|
+
({
|
|
13
|
+
source,
|
|
14
|
+
label: source.toUpperCase(),
|
|
15
|
+
icon: `i-lucide-${source}`,
|
|
16
|
+
available,
|
|
17
|
+
enabled,
|
|
18
|
+
credentialFields: [],
|
|
19
|
+
refLabel: '',
|
|
20
|
+
refPlaceholder: '',
|
|
21
|
+
}) as unknown as TaskSourceState
|
|
22
|
+
|
|
23
|
+
describe('buildSourceChoices', () => {
|
|
24
|
+
it('lists a single offered tracker, marked active (the selector is never hidden)', () => {
|
|
25
|
+
const groups = buildSourceChoices([state('github')], 'github')
|
|
26
|
+
expect(groups).toEqual([
|
|
27
|
+
[
|
|
28
|
+
{
|
|
29
|
+
action: 'select',
|
|
30
|
+
source: 'github',
|
|
31
|
+
label: 'GITHUB',
|
|
32
|
+
icon: 'i-lucide-github',
|
|
33
|
+
active: true,
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
])
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('marks only the selected tracker active', () => {
|
|
40
|
+
const [offered] = buildSourceChoices([state('github'), state('jira')], 'jira')
|
|
41
|
+
expect(offered!.map((c) => [c.source, 'active' in c && c.active])).toEqual([
|
|
42
|
+
['github', false],
|
|
43
|
+
['jira', true],
|
|
44
|
+
])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('offers an unavailable tracker as `connect` and an available-but-off one as `enable`', () => {
|
|
48
|
+
const [, addable] = buildSourceChoices(
|
|
49
|
+
[state('github'), state('jira', { available: false }), state('linear', { enabled: false })],
|
|
50
|
+
'github',
|
|
51
|
+
)
|
|
52
|
+
expect(addable).toEqual([
|
|
53
|
+
{ action: 'connect', source: 'jira', label: 'JIRA', icon: 'i-lucide-jira' },
|
|
54
|
+
{ action: 'enable', source: 'linear', label: 'LINEAR', icon: 'i-lucide-linear' },
|
|
55
|
+
])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('drops empty groups so the menu renders no stray separator', () => {
|
|
59
|
+
expect(buildSourceChoices([state('github')], 'github')).toHaveLength(1)
|
|
60
|
+
expect(buildSourceChoices([state('jira', { available: false })], undefined)).toHaveLength(1)
|
|
61
|
+
expect(buildSourceChoices([], undefined)).toEqual([])
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('reconcileSource', () => {
|
|
66
|
+
it('selects the tracker the user just went off to connect, once it is offered', () => {
|
|
67
|
+
expect(reconcileSource(['github', 'jira'], 'github', 'jira')).toBe('jira')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('keeps the current selection while the connect is still pending', () => {
|
|
71
|
+
expect(reconcileSource(['github'], 'github', 'jira')).toBe('github')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('keeps a still-offered selection', () => {
|
|
75
|
+
expect(reconcileSource(['github', 'jira'], 'jira', null)).toBe('jira')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('falls back to the first offered tracker when the selection stopped being offered', () => {
|
|
79
|
+
expect(reconcileSource(['github'], 'jira', null)).toBe('github')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('selects the first offered tracker when nothing is selected yet', () => {
|
|
83
|
+
expect(reconcileSource(['github'], undefined, null)).toBe('github')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('resolves to nothing when the workspace offers no tracker at all', () => {
|
|
87
|
+
expect(reconcileSource([], 'jira', 'jira')).toBeUndefined()
|
|
88
|
+
})
|
|
89
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { TaskSourceKind, TaskSourceState } from '~/types/domain'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure source-selection logic for `<ContextIssuePicker>`: which tracker the picker is
|
|
5
|
+
* searching, and what the source menu offers. Kept out of the component so both the
|
|
6
|
+
* template's `computed`s and the unit spec call the same code.
|
|
7
|
+
*
|
|
8
|
+
* The menu is deliberately two-tier — pick an already-offered tracker, or go and add
|
|
9
|
+
* one — because "attach a context issue" is where a user first discovers a tracker is
|
|
10
|
+
* missing, and sending them to the Integrations hub loses their in-progress task.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** One row of the picker's source menu. */
|
|
14
|
+
export type SourceChoice =
|
|
15
|
+
/** An offered tracker the picker can search right now. */
|
|
16
|
+
| { action: 'select'; source: TaskSourceKind; label: string; icon: string; active: boolean }
|
|
17
|
+
/**
|
|
18
|
+
* A configured tracker that is not offered yet, so it can be added from here. `connect`
|
|
19
|
+
* has no credential/App behind it; `enable` is connected but toggled off for the
|
|
20
|
+
* workspace. Both open the same connect modal, which serves either case — only the
|
|
21
|
+
* wording differs, so the user isn't told to "connect" something already connected.
|
|
22
|
+
*/
|
|
23
|
+
| { action: 'connect' | 'enable'; source: TaskSourceKind; label: string; icon: string }
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The picker's source menu, as non-empty groups (the offered trackers, then the ones the
|
|
27
|
+
* user could add). Empty groups are dropped so the menu never renders a stray separator.
|
|
28
|
+
*/
|
|
29
|
+
export function buildSourceChoices(
|
|
30
|
+
sources: TaskSourceState[],
|
|
31
|
+
selected: TaskSourceKind | undefined,
|
|
32
|
+
): SourceChoice[][] {
|
|
33
|
+
const offered: SourceChoice[] = []
|
|
34
|
+
const addable: SourceChoice[] = []
|
|
35
|
+
for (const s of sources) {
|
|
36
|
+
if (s.available && s.enabled) {
|
|
37
|
+
offered.push({
|
|
38
|
+
action: 'select',
|
|
39
|
+
source: s.source,
|
|
40
|
+
label: s.label,
|
|
41
|
+
icon: s.icon,
|
|
42
|
+
active: s.source === selected,
|
|
43
|
+
})
|
|
44
|
+
} else {
|
|
45
|
+
addable.push({
|
|
46
|
+
action: s.available ? 'enable' : 'connect',
|
|
47
|
+
source: s.source,
|
|
48
|
+
label: s.label,
|
|
49
|
+
icon: s.icon,
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return [offered, addable].filter((group) => group.length > 0)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The source the picker should hold once the offered set changes — after a connect, a
|
|
58
|
+
* disconnect, or the per-workspace toggle flipping elsewhere.
|
|
59
|
+
*
|
|
60
|
+
* `awaiting` is the tracker the user just left to connect: the moment it becomes offered
|
|
61
|
+
* it wins, so they land back on the source they went to add rather than on whatever was
|
|
62
|
+
* selected before. Otherwise a still-offered selection is kept, and a selection that
|
|
63
|
+
* stopped being offered falls back to the first one (searching a tracker the workspace no
|
|
64
|
+
* longer offers only yields errors).
|
|
65
|
+
*/
|
|
66
|
+
export function reconcileSource(
|
|
67
|
+
offered: TaskSourceKind[],
|
|
68
|
+
selected: TaskSourceKind | undefined,
|
|
69
|
+
awaiting: TaskSourceKind | null,
|
|
70
|
+
): TaskSourceKind | undefined {
|
|
71
|
+
if (awaiting && offered.includes(awaiting)) return awaiting
|
|
72
|
+
if (selected && offered.includes(selected)) return selected
|
|
73
|
+
return offered[0]
|
|
74
|
+
}
|
|
@@ -6,8 +6,15 @@
|
|
|
6
6
|
// It only *stages* a choice: the caller collects PendingContext items and links
|
|
7
7
|
// them once the block exists (see useContextLinking). A search hit / pasted ref
|
|
8
8
|
// carries `needsImport: true` so it's fetched + persisted before linking.
|
|
9
|
+
//
|
|
10
|
+
// The tracker being searched is ALWAYS on screen (even when the workspace offers
|
|
11
|
+
// exactly one), and its menu doubles as the "add a tracker" affordance: attaching a
|
|
12
|
+
// context issue is where a missing integration is discovered, and the connect modal
|
|
13
|
+
// opens over the caller's form rather than navigating away from it.
|
|
14
|
+
import type { DropdownMenuItem } from '@nuxt/ui'
|
|
9
15
|
import type { SourceTask, TaskSearchResult, TaskSourceKind } from '~/types/domain'
|
|
10
16
|
import EmptyState from '~/components/common/EmptyState.vue'
|
|
17
|
+
import { buildSourceChoices, reconcileSource } from '~/components/tasks/ContextIssuePicker.logic'
|
|
11
18
|
|
|
12
19
|
const props = defineProps<{
|
|
13
20
|
/** contextKeys already staged by the caller, so they're filtered out / not re-offered. */
|
|
@@ -23,13 +30,6 @@ const props = defineProps<{
|
|
|
23
30
|
* `v-model:source`); omitted, the picker manages it internally (the add-task case).
|
|
24
31
|
*/
|
|
25
32
|
source?: TaskSourceKind
|
|
26
|
-
/**
|
|
27
|
-
* Always render the source selector, even with a single offered tracker — so the
|
|
28
|
-
* user can see *which* tracker is being searched (the "create task from issue"
|
|
29
|
-
* surface, where the source is otherwise invisible). Off by default: the inline
|
|
30
|
-
* add-task picker stays compact and only shows a selector when there's a choice.
|
|
31
|
-
*/
|
|
32
|
-
alwaysShowSource?: boolean
|
|
33
33
|
}>()
|
|
34
34
|
const emit = defineEmits<{
|
|
35
35
|
pick: [item: PendingContext]
|
|
@@ -38,12 +38,14 @@ const emit = defineEmits<{
|
|
|
38
38
|
|
|
39
39
|
const { t } = useI18n()
|
|
40
40
|
const tasks = useTasksStore()
|
|
41
|
+
const ui = useUiStore()
|
|
41
42
|
|
|
42
43
|
const chosen = computed(() => new Set(props.chosenKeys ?? []))
|
|
43
44
|
|
|
44
45
|
// Source: default to the first offered tracker. Controlled when the parent passes
|
|
45
|
-
// `source` (write-through to `update:source`), else internal.
|
|
46
|
-
//
|
|
46
|
+
// `source` (write-through to `update:source`), else internal. The selector is always
|
|
47
|
+
// rendered, single tracker or not — which tracker is being searched decides what a
|
|
48
|
+
// pasted key resolves to, so it must never be invisible.
|
|
47
49
|
const internalSource = ref<TaskSourceKind | undefined>(tasks.offeredSources[0]?.source)
|
|
48
50
|
const source = computed<TaskSourceKind | undefined>({
|
|
49
51
|
get: () => props.source ?? internalSource.value,
|
|
@@ -52,13 +54,49 @@ const source = computed<TaskSourceKind | undefined>({
|
|
|
52
54
|
if (v) emit('update:source', v)
|
|
53
55
|
},
|
|
54
56
|
})
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
+
const descriptor = computed(() => (source.value ? tasks.descriptorFor(source.value) : undefined))
|
|
58
|
+
|
|
59
|
+
// The tracker the user left to connect, so it becomes the selection the moment it turns
|
|
60
|
+
// up offered (the connect modal re-probes on success). Also the reconcile trigger for a
|
|
61
|
+
// source that stops being offered — disconnected, or toggled off in settings.
|
|
62
|
+
const awaitingConnect = ref<TaskSourceKind | null>(null)
|
|
63
|
+
function addSource(s: TaskSourceKind) {
|
|
64
|
+
awaitingConnect.value = s
|
|
65
|
+
ui.openTaskConnect(s)
|
|
66
|
+
}
|
|
67
|
+
watch(
|
|
68
|
+
() => tasks.offeredSources.map((s) => s.source),
|
|
69
|
+
(offered) => {
|
|
70
|
+
const next = reconcileSource(offered, source.value, awaitingConnect.value)
|
|
71
|
+
if (next && next === awaitingConnect.value) awaitingConnect.value = null
|
|
72
|
+
if (next !== source.value && next) source.value = next
|
|
73
|
+
},
|
|
57
74
|
)
|
|
58
|
-
|
|
59
|
-
|
|
75
|
+
|
|
76
|
+
// Two-tier menu: pick an offered tracker, or add one that isn't offered yet.
|
|
77
|
+
const sourceMenu = computed<DropdownMenuItem[][]>(() =>
|
|
78
|
+
buildSourceChoices(tasks.sources, source.value).map((group) =>
|
|
79
|
+
group.map((choice) =>
|
|
80
|
+
choice.action === 'select'
|
|
81
|
+
? {
|
|
82
|
+
label: choice.label,
|
|
83
|
+
icon: choice.icon,
|
|
84
|
+
trailingIcon: choice.active ? 'i-lucide-check' : undefined,
|
|
85
|
+
onSelect: () => {
|
|
86
|
+
source.value = choice.source
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
: {
|
|
90
|
+
label:
|
|
91
|
+
choice.action === 'enable'
|
|
92
|
+
? t('tasks.picker.enableSource', { label: choice.label })
|
|
93
|
+
: t('tasks.picker.connectSource', { label: choice.label }),
|
|
94
|
+
icon: 'i-lucide-plug',
|
|
95
|
+
onSelect: () => addSource(choice.source),
|
|
96
|
+
},
|
|
97
|
+
),
|
|
98
|
+
),
|
|
60
99
|
)
|
|
61
|
-
const descriptor = computed(() => (source.value ? tasks.descriptorFor(source.value) : undefined))
|
|
62
100
|
const searchable = computed(() => descriptor.value?.searchable ?? false)
|
|
63
101
|
|
|
64
102
|
const query = ref('')
|
|
@@ -222,13 +260,25 @@ onMounted(() => {
|
|
|
222
260
|
|
|
223
261
|
<template>
|
|
224
262
|
<div class="space-y-2 rounded-lg border border-slate-800 bg-slate-900/40 p-2">
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
263
|
+
<!-- Which tracker is being searched, always visible, plus the trackers the user
|
|
264
|
+
could add from here (each opens the connect modal over the caller's form). -->
|
|
265
|
+
<div class="flex items-center gap-1.5">
|
|
266
|
+
<span class="shrink-0 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
267
|
+
{{ t('tasks.picker.sourceLabel') }}
|
|
268
|
+
</span>
|
|
269
|
+
<UDropdownMenu :items="sourceMenu" :content="{ side: 'bottom', align: 'start' }">
|
|
270
|
+
<UButton
|
|
271
|
+
color="neutral"
|
|
272
|
+
variant="soft"
|
|
273
|
+
size="xs"
|
|
274
|
+
:icon="icon"
|
|
275
|
+
trailing-icon="i-lucide-chevron-down"
|
|
276
|
+
class="max-w-full"
|
|
277
|
+
>
|
|
278
|
+
<span class="truncate">{{ descriptor?.label ?? t('tasks.picker.noSource') }}</span>
|
|
279
|
+
</UButton>
|
|
280
|
+
</UDropdownMenu>
|
|
281
|
+
</div>
|
|
232
282
|
|
|
233
283
|
<UInput
|
|
234
284
|
v-model="query"
|
|
@@ -27,7 +27,7 @@ const back = useIntegrationBack(open)
|
|
|
27
27
|
|
|
28
28
|
// The tracker being browsed. Owned here (not the picker) so the epic action and the
|
|
29
29
|
// ref-input placeholder share the same selected source; passed to the picker via
|
|
30
|
-
// `v-model:source
|
|
30
|
+
// `v-model:source`, whose selector always shows (and can add) the tracker in use.
|
|
31
31
|
const source = ref<TaskSourceKind | undefined>(undefined)
|
|
32
32
|
const ref_ = ref('')
|
|
33
33
|
const importing = ref(false)
|
|
@@ -162,13 +162,11 @@ async function doSpawnEpic() {
|
|
|
162
162
|
uses for context issues: search by title, pick an already-imported one,
|
|
163
163
|
or paste a URL/key — choosing one opens the prefilled add-task form. The
|
|
164
164
|
search is scoped to the chosen container's repo (so a GitHub search stays
|
|
165
|
-
in that service's repo and a pasted URL / bare number resolves there)
|
|
166
|
-
the source selector is always shown so it's clear which tracker is in use. -->
|
|
165
|
+
in that service's repo and a pasted URL / bare number resolves there). -->
|
|
167
166
|
<UFormField :label="t('tasks.import.searchIssues')">
|
|
168
167
|
<ContextIssuePicker
|
|
169
168
|
v-model:source="source"
|
|
170
169
|
:scope-block-id="containerId"
|
|
171
|
-
always-show-source
|
|
172
170
|
@pick="createFromPick"
|
|
173
171
|
/>
|
|
174
172
|
</UFormField>
|