@cat-factory/app 0.293.1 → 0.294.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/board/AddTaskModal.vue +80 -0
- package/app/components/board/RecurringPipelineModal.vue +34 -20
- package/app/components/bootstrap/BootstrapModal.logic.spec.ts +43 -0
- package/app/components/bootstrap/BootstrapModal.logic.ts +28 -0
- package/app/components/bootstrap/BootstrapModal.vue +82 -15
- package/app/components/bugFishing/BugFishingWindow.vue +524 -0
- package/app/components/focus/BlockFocusView.vue +2 -0
- package/app/components/github/RepoTreeBrowser.vue +89 -6
- package/app/components/layout/NotificationsInbox.vue +15 -0
- package/app/components/panels/ResultWindowDrafts.logic.spec.ts +25 -3
- package/app/components/panels/ResultWindowShell.logic.spec.ts +4 -0
- package/app/components/settings/WorkspaceSettingsPanel.vue +46 -1
- package/app/components/slack/SlackPanel.vue +1 -0
- package/app/composables/api/bugFishing.ts +52 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/usePipelineErrorToast.ts +21 -0
- package/app/modular/result-views.ts +6 -0
- package/app/stores/bugFishing.ts +143 -0
- package/app/stores/ui/resultViews.ts +14 -7
- package/app/stores/ui/runStepOpeners.ts +29 -1
- package/app/stores/workspaceSettings.ts +1 -0
- package/app/types/execution.ts +9 -0
- package/app/utils/catalog.spec.ts +1 -0
- package/app/utils/catalog.ts +25 -0
- package/app/utils/repoPath.spec.ts +49 -0
- package/app/utils/repoPath.ts +28 -0
- package/i18n/locales/de.json +103 -6
- package/i18n/locales/en.json +102 -5
- package/i18n/locales/es.json +103 -6
- package/i18n/locales/fr.json +103 -6
- package/i18n/locales/he.json +103 -6
- package/i18n/locales/it.json +103 -6
- package/i18n/locales/ja.json +103 -6
- package/i18n/locales/pl.json +103 -6
- package/i18n/locales/tr.json +103 -6
- package/i18n/locales/uk.json +103 -6
- package/package.json +2 -2
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Bug-fishing expedition window — the dedicated surface for the read-only `bug-fisher`'s
|
|
3
|
+
// multi-angle catch, opened via the universal result-view host. It reads the live expedition
|
|
4
|
+
// state straight off the run's `bug-fisher` step (`step.bugFishing`, kept fresh by the
|
|
5
|
+
// execution stream) and lets a human triage what each angle caught.
|
|
6
|
+
//
|
|
7
|
+
// The one thing that makes this window different from the PR-review window it is otherwise
|
|
8
|
+
// modelled on: triage is available WHILE the expedition is still fishing. Each angle is its own
|
|
9
|
+
// container dispatch, so a completed phase's findings are final the moment they land, and
|
|
10
|
+
// marking one spawns its bug-fix task immediately rather than at the end. The phase rail on the
|
|
11
|
+
// left is what makes that legible: it shows which angles have landed, which is being fished now,
|
|
12
|
+
// and which are still queued.
|
|
13
|
+
//
|
|
14
|
+
// Marking is per-finding rather than a multi-select-then-resolve, because each mark is a
|
|
15
|
+
// side effect (a task is created and its run started) rather than a selection to be applied
|
|
16
|
+
// later. The pipeline override applies to the marks made after it is changed, and the window
|
|
17
|
+
// says which pipeline it will use before anything is created.
|
|
18
|
+
import { computed, ref } from 'vue'
|
|
19
|
+
import { useResultView } from '~/composables/useResultView'
|
|
20
|
+
import { useExecutionStore } from '~/stores/execution'
|
|
21
|
+
import { useBoardStore } from '~/stores/board'
|
|
22
|
+
import { usePipelinesStore } from '~/stores/pipelines'
|
|
23
|
+
import { useUiStore } from '~/stores/ui'
|
|
24
|
+
import { pipelineAllowedForTaskType } from '~/utils/pipeline'
|
|
25
|
+
import { useBugFishingStore } from '~/stores/bugFishing'
|
|
26
|
+
import { bugFishingSpawnIsClaimable } from '@cat-factory/contracts'
|
|
27
|
+
import type { BugFishingFinding, BugFishingSeverity, BugFishingStepState } from '~/types/execution'
|
|
28
|
+
import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
|
|
29
|
+
import StepRunMeta from '~/components/panels/StepRunMeta.vue'
|
|
30
|
+
import MarkdownProse from '~/components/common/MarkdownProse.vue'
|
|
31
|
+
|
|
32
|
+
const execution = useExecutionStore()
|
|
33
|
+
const board = useBoardStore()
|
|
34
|
+
const pipelines = usePipelinesStore()
|
|
35
|
+
const bugFishing = useBugFishingStore()
|
|
36
|
+
const ui = useUiStore()
|
|
37
|
+
const access = useWorkspaceAccess()
|
|
38
|
+
|
|
39
|
+
const { t } = useI18n()
|
|
40
|
+
|
|
41
|
+
const { open, blockId, instanceId, stepIndex, close } = useResultView('bug-fishing', {
|
|
42
|
+
onOpen: ({ instanceId }) => {
|
|
43
|
+
if (instanceId) void bugFishing.load(instanceId)
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
|
|
48
|
+
const instance = computed(() =>
|
|
49
|
+
instanceId.value === null ? null : (execution.getInstance(instanceId.value) ?? null),
|
|
50
|
+
)
|
|
51
|
+
const step = computed(() => {
|
|
52
|
+
if (instance.value === null || stepIndex.value === null) return null
|
|
53
|
+
return instance.value.steps[stepIndex.value] ?? null
|
|
54
|
+
})
|
|
55
|
+
const state = computed<BugFishingStepState | null>(() => step.value?.bugFishing ?? null)
|
|
56
|
+
const status = computed(() => state.value?.status ?? null)
|
|
57
|
+
const awaiting = computed(() => status.value === 'awaiting_triage')
|
|
58
|
+
const fishing = computed(() => status.value === 'fishing')
|
|
59
|
+
|
|
60
|
+
const phases = computed(() => state.value?.phases ?? [])
|
|
61
|
+
const findings = computed(() => state.value?.findings ?? [])
|
|
62
|
+
|
|
63
|
+
/** Severity order, most severe first — the one place the window's ranking is stated. */
|
|
64
|
+
const SEVERITY_ORDER: BugFishingSeverity[] = ['critical', 'high', 'medium', 'low']
|
|
65
|
+
const severityRank = (s: BugFishingSeverity) => {
|
|
66
|
+
const i = SEVERITY_ORDER.indexOf(s)
|
|
67
|
+
return i === -1 ? SEVERITY_ORDER.length : i
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Which phase's findings the reader is looking at. `null` is "everything caught so far", which
|
|
72
|
+
* is the default because an expedition's value is the whole catch — the per-phase filter exists
|
|
73
|
+
* for someone working through one angle at a time, not as the primary reading.
|
|
74
|
+
*/
|
|
75
|
+
const selectedPhaseId = ref<string | null>(null)
|
|
76
|
+
|
|
77
|
+
/** Whether findings whose decision has been made are shown. Off by default: what is left to
|
|
78
|
+
* decide is the working list, and a triaged finding that stays in it reads as untriaged. */
|
|
79
|
+
const showTriaged = ref(false)
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Whether a finding is still OPEN — nothing is being done about it, so it belongs in the working
|
|
83
|
+
* list and the Fix button applies to it.
|
|
84
|
+
*
|
|
85
|
+
* Never `!f.spawn`: a spawn record whose status is `failed` is a mark the platform did not carry
|
|
86
|
+
* out, and reading the record's mere PRESENCE as "being fixed" is how a finding nobody is working
|
|
87
|
+
* on would drop out of the list of things left to decide. The rule is the engine's own
|
|
88
|
+
* (`@cat-factory/contracts`), so this window cannot come to offer a mark the engine refuses.
|
|
89
|
+
*/
|
|
90
|
+
function isOpen(f: BugFishingFinding): boolean {
|
|
91
|
+
return !f.dismissed && bugFishingSpawnIsClaimable(f.spawn, Date.now())
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const visibleFindings = computed<BugFishingFinding[]>(() => {
|
|
95
|
+
const byPhase = selectedPhaseId.value
|
|
96
|
+
? findings.value.filter((f) => f.phaseId === selectedPhaseId.value)
|
|
97
|
+
: findings.value
|
|
98
|
+
const triaged = showTriaged.value ? byPhase : byPhase.filter(isOpen)
|
|
99
|
+
return [...triaged].sort((a, b) => severityRank(a.severity) - severityRank(b.severity))
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
/** Findings with no decision yet, across every phase — what the header counts. */
|
|
103
|
+
const untriagedCount = computed(() => findings.value.filter(isOpen).length)
|
|
104
|
+
/** Findings whose fix task EXISTS. A claim still in flight is not one, and a failed one is not. */
|
|
105
|
+
const spawnedCount = computed(
|
|
106
|
+
() => findings.value.filter((f) => f.spawn?.status === 'spawned').length,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
/** The phase the rail has selected, when one is. */
|
|
110
|
+
const selectedPhase = computed(() =>
|
|
111
|
+
selectedPhaseId.value ? (phases.value.find((p) => p.id === selectedPhaseId.value) ?? null) : null,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
/** How many angles have settled (completed or failed) — what the still-fishing banner counts. */
|
|
115
|
+
const settledPhaseCount = computed(
|
|
116
|
+
() => phases.value.filter((p) => p.status === 'completed' || p.status === 'failed').length,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
/** How many findings each phase contributed, for the rail's per-angle count. */
|
|
120
|
+
function phaseFindingCount(phaseId: string): number {
|
|
121
|
+
return findings.value.filter((f) => f.phaseId === phaseId).length
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The pipeline spawned fix tasks will run: the board's configured default, overridden here for
|
|
126
|
+
* the marks made while the override is set. Stated in the window BEFORE anything is created,
|
|
127
|
+
* because the pipeline is the whole shape of the work a mark causes.
|
|
128
|
+
*/
|
|
129
|
+
/** '' means "use the board's default", the same spelling the workspace settings field uses. */
|
|
130
|
+
const pipelineOverride = ref('')
|
|
131
|
+
const defaultPipelineId = computed(() => state.value?.defaultFixPipelineId ?? null)
|
|
132
|
+
/** What a mark made right now would run on — the override if one is set, else the board default. */
|
|
133
|
+
const effectivePipelineName = computed(() => {
|
|
134
|
+
const id = pipelineOverride.value || defaultPipelineId.value
|
|
135
|
+
if (!id) return null
|
|
136
|
+
return pipelines.getPipeline(id)?.name ?? id
|
|
137
|
+
})
|
|
138
|
+
/**
|
|
139
|
+
* The pipelines a spawned fix task may run: the ones this board offers a `bug` task, since that is
|
|
140
|
+
* exactly what a spawned fix IS. Narrowed by the same predicate the create form uses, so the two
|
|
141
|
+
* screens cannot disagree about what a bug task may run. The leading row carries '' so "the
|
|
142
|
+
* board's default" is a value someone can pick back to rather than only a starting state.
|
|
143
|
+
*/
|
|
144
|
+
const pipelineOptions = computed(() => [
|
|
145
|
+
{ value: '', label: t('bugFishing.fixPipeline.boardDefault') },
|
|
146
|
+
...pipelines.pipelines
|
|
147
|
+
.filter((p) => pipelineAllowedForTaskType(p, 'bug'))
|
|
148
|
+
.map((p) => ({ label: p.name, value: p.id })),
|
|
149
|
+
])
|
|
150
|
+
|
|
151
|
+
/** Marking a finding STARTS a run, so it takes the run-execution permission, not board write. */
|
|
152
|
+
const canAct = computed(() => access.canExecuteRuns.value)
|
|
153
|
+
|
|
154
|
+
async function mark(finding: BugFishingFinding): Promise<void> {
|
|
155
|
+
if (!instanceId.value) return
|
|
156
|
+
await bugFishing.address(instanceId.value, [finding.id], pipelineOverride.value || undefined)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function dismiss(finding: BugFishingFinding): Promise<void> {
|
|
160
|
+
if (!instanceId.value) return
|
|
161
|
+
await bugFishing.dismiss(instanceId.value, finding.id)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function finish(): Promise<void> {
|
|
165
|
+
if (!instanceId.value) return
|
|
166
|
+
await bugFishing.resolve(instanceId.value)
|
|
167
|
+
close()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Open the task a marked finding spawned, so the reader can follow the work they caused. */
|
|
171
|
+
function openSpawnedTask(taskId: string): void {
|
|
172
|
+
close()
|
|
173
|
+
ui.select(taskId)
|
|
174
|
+
ui.focus(taskId)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const SEVERITY_CLASS: Record<BugFishingSeverity, string> = {
|
|
178
|
+
critical: 'bg-red-500/15 text-red-300 border-red-500/30',
|
|
179
|
+
high: 'bg-orange-500/15 text-orange-300 border-orange-500/30',
|
|
180
|
+
medium: 'bg-amber-500/15 text-amber-200 border-amber-500/30',
|
|
181
|
+
low: 'bg-slate-500/15 text-slate-300 border-slate-600/40',
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const PHASE_ICON: Record<string, string> = {
|
|
185
|
+
pending: 'i-lucide-circle-dashed',
|
|
186
|
+
fishing: 'i-lucide-loader-circle',
|
|
187
|
+
completed: 'i-lucide-check',
|
|
188
|
+
failed: 'i-lucide-triangle-alert',
|
|
189
|
+
}
|
|
190
|
+
</script>
|
|
191
|
+
|
|
192
|
+
<template>
|
|
193
|
+
<ResultWindowShell
|
|
194
|
+
:open="open"
|
|
195
|
+
icon="i-lucide-fish"
|
|
196
|
+
icon-class="bg-sky-500/15 text-sky-300"
|
|
197
|
+
:title="block ? t('bugFishing.titleWithBlock', { title: block.title }) : t('bugFishing.title')"
|
|
198
|
+
:subtitle="t('bugFishing.subtitle')"
|
|
199
|
+
width="full"
|
|
200
|
+
testid="bug-fishing-window"
|
|
201
|
+
:step="{ instanceId, stepIndex }"
|
|
202
|
+
@close="close"
|
|
203
|
+
>
|
|
204
|
+
<div class="flex min-h-0 flex-1">
|
|
205
|
+
<!-- The phase rail: which angles have landed, which is being fished, which are queued.
|
|
206
|
+
It is what makes mid-expedition triage legible — a reader has to be able to tell a
|
|
207
|
+
phase that found nothing from one that has not run yet. -->
|
|
208
|
+
<aside
|
|
209
|
+
data-testid="bug-fishing-phases"
|
|
210
|
+
class="w-60 shrink-0 overflow-y-auto border-r border-slate-800 px-3 py-4"
|
|
211
|
+
>
|
|
212
|
+
<p class="mb-2 px-1 text-[10px] font-semibold uppercase tracking-wide text-slate-500">
|
|
213
|
+
{{ t('bugFishing.phases.heading') }}
|
|
214
|
+
</p>
|
|
215
|
+
<button
|
|
216
|
+
type="button"
|
|
217
|
+
class="mb-1 w-full rounded-md px-2 py-1.5 text-left text-[12px]"
|
|
218
|
+
:class="
|
|
219
|
+
selectedPhaseId === null
|
|
220
|
+
? 'bg-slate-800 text-slate-100'
|
|
221
|
+
: 'text-slate-400 hover:bg-slate-800/60'
|
|
222
|
+
"
|
|
223
|
+
@click="selectedPhaseId = null"
|
|
224
|
+
>
|
|
225
|
+
{{ t('bugFishing.phases.all', { count: findings.length }) }}
|
|
226
|
+
</button>
|
|
227
|
+
<ul class="space-y-0.5">
|
|
228
|
+
<li v-for="phase in phases" :key="phase.id">
|
|
229
|
+
<button
|
|
230
|
+
type="button"
|
|
231
|
+
class="flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left"
|
|
232
|
+
:class="
|
|
233
|
+
selectedPhaseId === phase.id
|
|
234
|
+
? 'bg-slate-800 text-slate-100'
|
|
235
|
+
: 'text-slate-400 hover:bg-slate-800/60'
|
|
236
|
+
"
|
|
237
|
+
:data-testid="`bug-fishing-phase-${phase.id}`"
|
|
238
|
+
@click="selectedPhaseId = phase.id"
|
|
239
|
+
>
|
|
240
|
+
<UIcon
|
|
241
|
+
:name="PHASE_ICON[phase.status] ?? 'i-lucide-circle-dashed'"
|
|
242
|
+
class="mt-0.5 h-3.5 w-3.5 shrink-0"
|
|
243
|
+
:class="{
|
|
244
|
+
'animate-spin text-sky-300': phase.status === 'fishing',
|
|
245
|
+
'text-emerald-400': phase.status === 'completed',
|
|
246
|
+
'text-amber-400': phase.status === 'failed',
|
|
247
|
+
'text-slate-600': phase.status === 'pending',
|
|
248
|
+
}"
|
|
249
|
+
/>
|
|
250
|
+
<span class="min-w-0 flex-1">
|
|
251
|
+
<span class="block truncate text-[12px]">{{ phase.title }}</span>
|
|
252
|
+
<span class="block text-[10px] text-slate-500">
|
|
253
|
+
{{
|
|
254
|
+
phase.status === 'completed' || phase.status === 'failed'
|
|
255
|
+
? t('bugFishing.phases.found', { count: phaseFindingCount(phase.id) })
|
|
256
|
+
: t(`bugFishing.phases.status.${phase.status}`)
|
|
257
|
+
}}
|
|
258
|
+
</span>
|
|
259
|
+
</span>
|
|
260
|
+
</button>
|
|
261
|
+
</li>
|
|
262
|
+
</ul>
|
|
263
|
+
</aside>
|
|
264
|
+
|
|
265
|
+
<div class="min-w-0 flex-1 overflow-y-auto px-5 py-4">
|
|
266
|
+
<!-- The selected phase's own account of what it covered. Rendered for a FAILED phase
|
|
267
|
+
too, carrying its reason: a phase that reported nothing because it crashed and one
|
|
268
|
+
that reported nothing because it found nothing are different facts. -->
|
|
269
|
+
<div
|
|
270
|
+
v-if="selectedPhase"
|
|
271
|
+
class="mb-4 rounded-lg border border-slate-800 bg-slate-900/40 px-3 py-2.5"
|
|
272
|
+
>
|
|
273
|
+
<p class="text-[11px] text-slate-400">{{ selectedPhase.goal }}</p>
|
|
274
|
+
<p
|
|
275
|
+
v-if="selectedPhase.status === 'failed'"
|
|
276
|
+
data-testid="bug-fishing-phase-failed"
|
|
277
|
+
class="mt-2 text-[12px] text-amber-300"
|
|
278
|
+
>
|
|
279
|
+
{{ t('bugFishing.phases.failedNote', { reason: selectedPhase.failureReason ?? '' }) }}
|
|
280
|
+
</p>
|
|
281
|
+
<MarkdownProse
|
|
282
|
+
v-else-if="selectedPhase.summary"
|
|
283
|
+
:text="selectedPhase.summary"
|
|
284
|
+
class="mt-2 max-w-3xl text-[12px]"
|
|
285
|
+
/>
|
|
286
|
+
</div>
|
|
287
|
+
|
|
288
|
+
<!-- The still-fishing banner. Deliberately shown ABOVE the findings rather than in place
|
|
289
|
+
of them: everything already caught is actionable now, which is the whole design. -->
|
|
290
|
+
<div
|
|
291
|
+
v-if="fishing"
|
|
292
|
+
data-testid="bug-fishing-in-progress"
|
|
293
|
+
class="mb-4 flex items-center gap-2 rounded-lg border border-sky-500/25 bg-sky-500/5 px-3 py-2 text-[12px] text-sky-200"
|
|
294
|
+
>
|
|
295
|
+
<UIcon name="i-lucide-loader-circle" class="h-4 w-4 shrink-0 animate-spin" />
|
|
296
|
+
<span>
|
|
297
|
+
{{
|
|
298
|
+
t('bugFishing.stillFishing', {
|
|
299
|
+
done: settledPhaseCount,
|
|
300
|
+
total: phases.length,
|
|
301
|
+
})
|
|
302
|
+
}}
|
|
303
|
+
</span>
|
|
304
|
+
</div>
|
|
305
|
+
|
|
306
|
+
<!-- What the marks will run. Stated before anything is created, because the pipeline is
|
|
307
|
+
the shape of the work a mark causes. -->
|
|
308
|
+
<div
|
|
309
|
+
v-if="canAct"
|
|
310
|
+
data-testid="bug-fishing-pipeline"
|
|
311
|
+
class="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-slate-800 bg-slate-900/40 px-3 py-2"
|
|
312
|
+
>
|
|
313
|
+
<span class="text-[11px] text-slate-400">{{ t('bugFishing.fixPipeline.label') }}</span>
|
|
314
|
+
<USelectMenu
|
|
315
|
+
v-model="pipelineOverride"
|
|
316
|
+
:items="pipelineOptions"
|
|
317
|
+
value-key="value"
|
|
318
|
+
size="xs"
|
|
319
|
+
class="min-w-52"
|
|
320
|
+
/>
|
|
321
|
+
<span class="text-[11px] text-slate-500">
|
|
322
|
+
{{ t('bugFishing.fixPipeline.hint', { pipeline: effectivePipelineName ?? '—' }) }}
|
|
323
|
+
</span>
|
|
324
|
+
</div>
|
|
325
|
+
|
|
326
|
+
<div class="mb-2 flex items-center justify-between">
|
|
327
|
+
<p class="text-[11px] text-slate-400">
|
|
328
|
+
{{ t('bugFishing.counts', { untriaged: untriagedCount, spawned: spawnedCount }) }}
|
|
329
|
+
</p>
|
|
330
|
+
<label class="flex items-center gap-1.5 text-[11px] text-slate-400">
|
|
331
|
+
<input v-model="showTriaged" type="checkbox" class="accent-sky-500" />
|
|
332
|
+
{{ t('bugFishing.showTriaged') }}
|
|
333
|
+
</label>
|
|
334
|
+
</div>
|
|
335
|
+
|
|
336
|
+
<p v-if="bugFishing.error" class="mb-3 text-[12px] text-red-300">
|
|
337
|
+
{{ bugFishing.error }}
|
|
338
|
+
</p>
|
|
339
|
+
|
|
340
|
+
<!-- An expedition that caught nothing is a real answer, not an empty state. The copy
|
|
341
|
+
says which of the two it is, because "nothing found" and "nothing left to triage"
|
|
342
|
+
are different things to be told. -->
|
|
343
|
+
<div
|
|
344
|
+
v-if="visibleFindings.length === 0"
|
|
345
|
+
data-testid="bug-fishing-empty"
|
|
346
|
+
class="rounded-lg border border-slate-800 bg-slate-900/40 px-4 py-8 text-center text-[12px] text-slate-400"
|
|
347
|
+
>
|
|
348
|
+
{{
|
|
349
|
+
findings.length === 0
|
|
350
|
+
? t('bugFishing.empty.nothingCaught')
|
|
351
|
+
: t('bugFishing.empty.allTriaged')
|
|
352
|
+
}}
|
|
353
|
+
</div>
|
|
354
|
+
|
|
355
|
+
<ul v-else class="space-y-2">
|
|
356
|
+
<li
|
|
357
|
+
v-for="finding in visibleFindings"
|
|
358
|
+
:key="finding.id"
|
|
359
|
+
:data-testid="`bug-fishing-finding-${finding.id}`"
|
|
360
|
+
class="rounded-lg border border-slate-800 bg-slate-900/40 px-3 py-2.5"
|
|
361
|
+
:class="{ 'opacity-60': finding.dismissed }"
|
|
362
|
+
>
|
|
363
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
364
|
+
<span
|
|
365
|
+
class="rounded border px-1.5 py-0.5 text-[10px] font-semibold uppercase"
|
|
366
|
+
:class="SEVERITY_CLASS[finding.severity]"
|
|
367
|
+
>
|
|
368
|
+
{{ t(`bugFishing.severity.${finding.severity}`) }}
|
|
369
|
+
</span>
|
|
370
|
+
<span class="rounded bg-slate-800 px-1.5 py-0.5 text-[10px] text-slate-300">
|
|
371
|
+
{{ t(`bugFishing.kind.${finding.kind}`) }}
|
|
372
|
+
</span>
|
|
373
|
+
<span class="text-[10px] text-slate-500">
|
|
374
|
+
{{ t(`bugFishing.confidence.${finding.confidence}`) }}
|
|
375
|
+
</span>
|
|
376
|
+
<span
|
|
377
|
+
class="min-w-0 flex-1 text-[13px] text-slate-100"
|
|
378
|
+
:class="{ 'line-through': finding.dismissed }"
|
|
379
|
+
>
|
|
380
|
+
{{ finding.title }}
|
|
381
|
+
</span>
|
|
382
|
+
</div>
|
|
383
|
+
|
|
384
|
+
<p v-if="finding.path" class="mt-1 font-mono text-[11px] text-slate-500">
|
|
385
|
+
{{ finding.path }}<span v-if="finding.line">:{{ finding.line }}</span>
|
|
386
|
+
</p>
|
|
387
|
+
|
|
388
|
+
<MarkdownProse :text="finding.detail" class="mt-2 max-w-3xl text-[12px]" />
|
|
389
|
+
|
|
390
|
+
<div v-if="finding.failureScenario" class="mt-2 text-[12px] text-slate-300">
|
|
391
|
+
<span class="text-[10px] font-semibold uppercase tracking-wide text-slate-500">
|
|
392
|
+
{{ t('bugFishing.finding.failureScenario') }}
|
|
393
|
+
</span>
|
|
394
|
+
<MarkdownProse :text="finding.failureScenario" class="mt-0.5 max-w-3xl" />
|
|
395
|
+
</div>
|
|
396
|
+
|
|
397
|
+
<!-- Evidence is rendered apart from the detail for the reason the contract keeps them
|
|
398
|
+
apart: a finding that cannot point at the code it describes is speculating, and
|
|
399
|
+
that should be visible without reading the prose for it. -->
|
|
400
|
+
<details v-if="finding.evidence" class="mt-2">
|
|
401
|
+
<summary class="cursor-pointer text-[11px] text-slate-400 hover:text-slate-200">
|
|
402
|
+
{{ t('bugFishing.finding.evidence') }}
|
|
403
|
+
</summary>
|
|
404
|
+
<MarkdownProse :text="finding.evidence" class="mt-1 max-w-3xl text-[12px]" />
|
|
405
|
+
</details>
|
|
406
|
+
|
|
407
|
+
<details v-if="finding.suggestedFix" class="mt-1">
|
|
408
|
+
<summary class="cursor-pointer text-[11px] text-slate-400 hover:text-slate-200">
|
|
409
|
+
{{ t('bugFishing.finding.suggestedFix') }}
|
|
410
|
+
</summary>
|
|
411
|
+
<MarkdownProse :text="finding.suggestedFix" class="mt-1 max-w-3xl text-[12px]" />
|
|
412
|
+
</details>
|
|
413
|
+
|
|
414
|
+
<!-- Already marked: say what was created and let the reader follow it. The three
|
|
415
|
+
spawn states are rendered apart because they are three different facts — a task
|
|
416
|
+
that exists, one being made, and a mark that did not land — and only the last
|
|
417
|
+
one is something the reader has to do again. -->
|
|
418
|
+
<div
|
|
419
|
+
v-if="finding.spawn?.status === 'spawned'"
|
|
420
|
+
data-testid="bug-fishing-finding-spawned"
|
|
421
|
+
class="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-emerald-300"
|
|
422
|
+
>
|
|
423
|
+
<UIcon name="i-lucide-check-circle-2" class="h-3.5 w-3.5" />
|
|
424
|
+
<span>
|
|
425
|
+
{{
|
|
426
|
+
t('bugFishing.finding.spawned', {
|
|
427
|
+
pipeline:
|
|
428
|
+
pipelines.getPipeline(finding.spawn.pipelineId)?.name ??
|
|
429
|
+
finding.spawn.pipelineId,
|
|
430
|
+
})
|
|
431
|
+
}}
|
|
432
|
+
</span>
|
|
433
|
+
<button
|
|
434
|
+
type="button"
|
|
435
|
+
class="underline hover:text-emerald-200"
|
|
436
|
+
@click="openSpawnedTask(finding.spawn.taskId)"
|
|
437
|
+
>
|
|
438
|
+
{{ t('bugFishing.finding.openTask') }}
|
|
439
|
+
</button>
|
|
440
|
+
</div>
|
|
441
|
+
|
|
442
|
+
<!-- A claim held by a marking still in flight. No task to link yet, and no Fix
|
|
443
|
+
button: pressing it again is exactly the double-spawn the claim prevents. -->
|
|
444
|
+
<div
|
|
445
|
+
v-else-if="finding.spawn?.status === 'pending'"
|
|
446
|
+
data-testid="bug-fishing-finding-spawning"
|
|
447
|
+
class="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-slate-400"
|
|
448
|
+
>
|
|
449
|
+
<UIcon name="i-lucide-loader-circle" class="h-3.5 w-3.5 animate-spin" />
|
|
450
|
+
<span>{{ t('bugFishing.finding.spawning') }}</span>
|
|
451
|
+
</div>
|
|
452
|
+
|
|
453
|
+
<!-- A mark that did not land. Says so and carries the cause, because the finding is
|
|
454
|
+
markable again and the reader is the one who has to decide to try. -->
|
|
455
|
+
<p
|
|
456
|
+
v-else-if="finding.spawn?.status === 'failed'"
|
|
457
|
+
data-testid="bug-fishing-finding-spawn-failed"
|
|
458
|
+
class="mt-2 max-w-3xl text-[11px] text-amber-300"
|
|
459
|
+
>
|
|
460
|
+
{{
|
|
461
|
+
t('bugFishing.finding.spawnFailed', {
|
|
462
|
+
reason: finding.spawn.failureReason ?? '',
|
|
463
|
+
})
|
|
464
|
+
}}
|
|
465
|
+
</p>
|
|
466
|
+
|
|
467
|
+
<div v-if="isOpen(finding) && canAct" class="mt-2 flex items-center gap-2">
|
|
468
|
+
<UButton
|
|
469
|
+
size="xs"
|
|
470
|
+
color="primary"
|
|
471
|
+
icon="i-lucide-wrench"
|
|
472
|
+
:loading="bugFishing.spawning.has(finding.id)"
|
|
473
|
+
:disabled="finding.dismissed || bugFishing.spawning.has(finding.id)"
|
|
474
|
+
:data-testid="`bug-fishing-fix-${finding.id}`"
|
|
475
|
+
@click="mark(finding)"
|
|
476
|
+
>
|
|
477
|
+
{{ t('bugFishing.finding.fixThis') }}
|
|
478
|
+
</UButton>
|
|
479
|
+
<UButton
|
|
480
|
+
v-if="!finding.dismissed"
|
|
481
|
+
size="xs"
|
|
482
|
+
color="neutral"
|
|
483
|
+
variant="ghost"
|
|
484
|
+
:disabled="bugFishing.spawning.has(finding.id)"
|
|
485
|
+
@click="dismiss(finding)"
|
|
486
|
+
>
|
|
487
|
+
{{ t('bugFishing.finding.dismiss') }}
|
|
488
|
+
</UButton>
|
|
489
|
+
</div>
|
|
490
|
+
</li>
|
|
491
|
+
</ul>
|
|
492
|
+
|
|
493
|
+
<StepRunMeta v-if="step" :step="step" class="mt-5" />
|
|
494
|
+
</div>
|
|
495
|
+
</div>
|
|
496
|
+
|
|
497
|
+
<!-- Footer. Rendered only once every angle has settled: while the expedition is still
|
|
498
|
+
fishing there is nothing to finish, and offering it would read as a way to stop the hunt
|
|
499
|
+
(it is not — the run advances past the step and the remaining angles never run). -->
|
|
500
|
+
<footer
|
|
501
|
+
v-if="awaiting"
|
|
502
|
+
class="flex items-center justify-between gap-3 border-t border-slate-800 px-5 py-3"
|
|
503
|
+
>
|
|
504
|
+
<p class="text-[11px] text-slate-500">
|
|
505
|
+
{{
|
|
506
|
+
untriagedCount > 0
|
|
507
|
+
? t('bugFishing.footer.parkedWithUntriaged', { count: untriagedCount })
|
|
508
|
+
: t('bugFishing.footer.parked')
|
|
509
|
+
}}
|
|
510
|
+
</p>
|
|
511
|
+
<UButton
|
|
512
|
+
color="primary"
|
|
513
|
+
icon="i-lucide-check"
|
|
514
|
+
:loading="bugFishing.resolving"
|
|
515
|
+
:disabled="!canAct"
|
|
516
|
+
:title="canAct ? undefined : t('access.noRunExecute')"
|
|
517
|
+
data-testid="bug-fishing-finish"
|
|
518
|
+
@click="finish"
|
|
519
|
+
>
|
|
520
|
+
{{ t('bugFishing.footer.finish') }}
|
|
521
|
+
</UButton>
|
|
522
|
+
</footer>
|
|
523
|
+
</ResultWindowShell>
|
|
524
|
+
</template>
|
|
@@ -98,6 +98,8 @@ function openApprovalFor(approvalId: string) {
|
|
|
98
98
|
class="absolute inset-0 z-30 flex flex-col bg-slate-950/95 backdrop-blur"
|
|
99
99
|
role="dialog"
|
|
100
100
|
aria-modal="true"
|
|
101
|
+
data-testid="block-focus-view"
|
|
102
|
+
:data-focus-block="block.id"
|
|
101
103
|
>
|
|
102
104
|
<!-- header / breadcrumb -->
|
|
103
105
|
<header class="flex items-center gap-3 border-b border-slate-800 px-6 py-4">
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
// from ANY parent folder (the monorepo add flow); in `file` mode it accumulates
|
|
15
15
|
// context-document files from anywhere in the tree — navigating away never drops
|
|
16
16
|
// earlier picks.
|
|
17
|
+
//
|
|
18
|
+
// `dir` mode also PLACES a directory that does not exist yet (`newDirName`: the bootstrap
|
|
19
|
+
// service-directory field). Nothing in the tree can be the target then, so the pick is the
|
|
20
|
+
// folder the caller is standing in and the emitted value is that folder plus the new name.
|
|
17
21
|
import type { RepoTreeEntry } from '~/types/domain'
|
|
18
22
|
|
|
19
23
|
const props = withDefaults(
|
|
@@ -30,8 +34,23 @@ const props = withDefaults(
|
|
|
30
34
|
selectedPaths?: string[]
|
|
31
35
|
/** `multiple`: paths already chosen elsewhere — listed but not selectable. */
|
|
32
36
|
addedPaths?: string[]
|
|
37
|
+
/**
|
|
38
|
+
* `dir`, single-select: the name of a directory that DOES NOT EXIST YET, which this
|
|
39
|
+
* browser is choosing a home for. Picking a folder then emits `<folder>/<newDirName>`
|
|
40
|
+
* (the repo root included, so the footer is offered there too), and a listing that
|
|
41
|
+
* already holds the name says so and offers nothing: "the target must not exist" is the
|
|
42
|
+
* one half of the API's refusal the listing in front of the user can answer first.
|
|
43
|
+
*/
|
|
44
|
+
newDirName?: string
|
|
33
45
|
}>(),
|
|
34
|
-
{
|
|
46
|
+
{
|
|
47
|
+
mode: 'dir',
|
|
48
|
+
startPath: '',
|
|
49
|
+
multiple: false,
|
|
50
|
+
selectedPaths: () => [],
|
|
51
|
+
addedPaths: () => [],
|
|
52
|
+
newDirName: '',
|
|
53
|
+
},
|
|
35
54
|
)
|
|
36
55
|
const emit = defineEmits<{
|
|
37
56
|
'update:modelValue': [string | undefined]
|
|
@@ -49,11 +68,38 @@ const loading = ref(false)
|
|
|
49
68
|
|
|
50
69
|
const selectedSet = computed(() => new Set(props.selectedPaths.map(normalizeRepoPath)))
|
|
51
70
|
const addedSet = computed(() => new Set(props.addedPaths.map(normalizeRepoPath)))
|
|
71
|
+
// Placing a new directory is single-target by construction: a cart of not-yet-existing
|
|
72
|
+
// siblings has no caller, and the pick copy below reads as one target.
|
|
73
|
+
const placingNewDir = computed(() => props.mode === 'dir' && !props.multiple && !!props.newDirName)
|
|
74
|
+
|
|
75
|
+
/** What a pick on `folder` yields: the new directory's path, or the folder itself. */
|
|
76
|
+
function pickedPathFor(folder: string): string {
|
|
77
|
+
return placingNewDir.value ? joinRepoPath(folder, props.newDirName) : folder
|
|
78
|
+
}
|
|
79
|
+
|
|
52
80
|
function isAdded(path: string): boolean {
|
|
53
81
|
return props.multiple && addedSet.value.has(normalizeRepoPath(path))
|
|
54
82
|
}
|
|
55
83
|
function isPicked(path: string): boolean {
|
|
56
|
-
|
|
84
|
+
if (props.multiple) return selectedSet.value.has(normalizeRepoPath(path))
|
|
85
|
+
return normalizeRepoPath(props.modelValue ?? '') === normalizeRepoPath(pickedPathFor(path))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The CURRENT listing already holds the name, so the new directory cannot go here. Only the
|
|
89
|
+
// folder the browser has actually LISTED can be judged: answering for a child would mean
|
|
90
|
+
// listing every one of them, so navigating in IS how you ask about a child. `loading` is part
|
|
91
|
+
// of that reading, because `browseTo` moves `currentPath` before its fetch settles and the
|
|
92
|
+
// entries still in hand are the folder the user has already left.
|
|
93
|
+
const nameTakenHere = computed(
|
|
94
|
+
() =>
|
|
95
|
+
placingNewDir.value &&
|
|
96
|
+
!loading.value &&
|
|
97
|
+
treeEntries.value.some((e) => e.name === props.newDirName),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
/** The listed entry that IS the clash, flagged in place so the reason sits where the eye is. */
|
|
101
|
+
function clashes(entry: RepoTreeEntry): boolean {
|
|
102
|
+
return placingNewDir.value && entry.name === props.newDirName
|
|
57
103
|
}
|
|
58
104
|
|
|
59
105
|
const dirEntries = computed(() => treeEntries.value.filter((e) => e.type === 'dir'))
|
|
@@ -106,7 +152,7 @@ function pick(path: string) {
|
|
|
106
152
|
if (addedSet.value.has(normalizeRepoPath(path))) return
|
|
107
153
|
emit('toggle', path)
|
|
108
154
|
} else {
|
|
109
|
-
emit('update:modelValue', path)
|
|
155
|
+
emit('update:modelValue', pickedPathFor(path))
|
|
110
156
|
}
|
|
111
157
|
}
|
|
112
158
|
|
|
@@ -169,14 +215,21 @@ watch(
|
|
|
169
215
|
<span class="truncate">{{ entry.name }}</span>
|
|
170
216
|
</button>
|
|
171
217
|
<span
|
|
172
|
-
v-if="
|
|
218
|
+
v-if="clashes(entry)"
|
|
219
|
+
class="flex shrink-0 items-center gap-1 text-xs text-amber-400"
|
|
220
|
+
>
|
|
221
|
+
<UIcon name="i-lucide-circle-alert" class="h-3.5 w-3.5" />
|
|
222
|
+
{{ t('github.repoTree.exists') }}
|
|
223
|
+
</span>
|
|
224
|
+
<span
|
|
225
|
+
v-else-if="mode === 'dir' && isAdded(entry.path)"
|
|
173
226
|
class="flex shrink-0 items-center gap-1 text-xs text-slate-500"
|
|
174
227
|
>
|
|
175
228
|
<UIcon name="i-lucide-check" class="h-3.5 w-3.5" />
|
|
176
229
|
{{ t('github.repoTree.added') }}
|
|
177
230
|
</span>
|
|
178
231
|
<UButton
|
|
179
|
-
v-else-if="mode === 'dir'"
|
|
232
|
+
v-else-if="mode === 'dir' && !placingNewDir"
|
|
180
233
|
size="xs"
|
|
181
234
|
variant="soft"
|
|
182
235
|
:color="isPicked(entry.path) ? 'primary' : 'neutral'"
|
|
@@ -257,9 +310,39 @@ watch(
|
|
|
257
310
|
</ul>
|
|
258
311
|
</div>
|
|
259
312
|
|
|
313
|
+
<!-- placing a new directory: the pick is a LOCATION, so the repo root is a valid answer
|
|
314
|
+
(unlike a plain dir pick, where the root means "the whole repo") and the target path
|
|
315
|
+
is spelled out beside the button rather than left to be inferred from the crumbs -->
|
|
316
|
+
<div v-if="placingNewDir" class="mt-2 flex items-center justify-between gap-2">
|
|
317
|
+
<p
|
|
318
|
+
class="min-w-0 truncate text-xs"
|
|
319
|
+
:class="nameTakenHere ? 'text-amber-400' : 'text-slate-400'"
|
|
320
|
+
>
|
|
321
|
+
<template v-if="nameTakenHere">
|
|
322
|
+
{{ t('github.repoTree.nameTaken', { name: newDirName }) }}
|
|
323
|
+
</template>
|
|
324
|
+
<template v-else>
|
|
325
|
+
{{ t('github.repoTree.newDirTarget') }}
|
|
326
|
+
<code class="text-slate-200">{{ pickedPathFor(currentPath) }}</code>
|
|
327
|
+
</template>
|
|
328
|
+
</p>
|
|
329
|
+
<UButton
|
|
330
|
+
size="xs"
|
|
331
|
+
variant="soft"
|
|
332
|
+
:color="isPicked(currentPath) ? 'primary' : 'neutral'"
|
|
333
|
+
:disabled="loading || nameTakenHere"
|
|
334
|
+
data-testid="repo-tree-create-here"
|
|
335
|
+
@click="pick(currentPath)"
|
|
336
|
+
>
|
|
337
|
+
{{
|
|
338
|
+
isPicked(currentPath) ? t('github.repoTree.selected') : t('github.repoTree.createHere')
|
|
339
|
+
}}
|
|
340
|
+
</UButton>
|
|
341
|
+
</div>
|
|
342
|
+
|
|
260
343
|
<!-- dir mode: pin the current folder without descending into a child -->
|
|
261
344
|
<div
|
|
262
|
-
v-if="mode === 'dir' && currentPath && !isAdded(currentPath)"
|
|
345
|
+
v-else-if="mode === 'dir' && currentPath && !isAdded(currentPath)"
|
|
263
346
|
class="mt-2 flex justify-end"
|
|
264
347
|
>
|
|
265
348
|
<UButton
|