@cat-factory/app 0.293.1 → 0.295.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 +54 -0
- package/app/components/bootstrap/BootstrapModal.logic.ts +44 -0
- package/app/components/bootstrap/BootstrapModal.vue +155 -16
- 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/agentRuns.spec.ts +1 -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/bootstrap.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 +121 -10
- package/i18n/locales/en.json +120 -9
- package/i18n/locales/es.json +121 -10
- package/i18n/locales/fr.json +121 -10
- package/i18n/locales/he.json +121 -10
- package/i18n/locales/it.json +121 -10
- package/i18n/locales/ja.json +121 -10
- package/i18n/locales/pl.json +121 -10
- package/i18n/locales/tr.json +121 -10
- package/i18n/locales/uk.json +121 -10
- package/package.json +2 -2
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
TaskTypeFields,
|
|
23
23
|
} from '~/types/domain'
|
|
24
24
|
import { DOC_KINDS, DOC_KIND_FIELDS } from '~/types/domain'
|
|
25
|
+
import { BUG_FISHING_PHASES } from '@cat-factory/contracts'
|
|
25
26
|
import { resolveComponentRegistry } from '@modular-vue/core'
|
|
26
27
|
import { useReactiveSlots } from '@modular-vue/runtime'
|
|
27
28
|
import type { AppSlots, ResultViewContribution } from '~/modular/slots'
|
|
@@ -97,6 +98,11 @@ const TASK_TYPES = computed<{ value: TaskTypeChoice; label: string; icon: string
|
|
|
97
98
|
const all: { value: TaskTypeChoice; label: string; icon: string }[] = [
|
|
98
99
|
{ value: 'feature', label: t('board.addTask.types.feature'), icon: 'i-lucide-sparkles' },
|
|
99
100
|
{ value: 'bug', label: t('board.addTask.types.bug'), icon: 'i-lucide-bug' },
|
|
101
|
+
{
|
|
102
|
+
value: 'bug-fishing',
|
|
103
|
+
label: t('board.addTask.types.bugFishing'),
|
|
104
|
+
icon: 'i-lucide-fish',
|
|
105
|
+
},
|
|
100
106
|
{ value: 'document', label: t('board.addTask.types.document'), icon: 'i-lucide-file-text' },
|
|
101
107
|
{ value: 'spike', label: t('board.addTask.types.spike'), icon: 'i-lucide-flask-conical' },
|
|
102
108
|
{
|
|
@@ -127,6 +133,11 @@ const isRecurring = computed(() => taskType.value === 'recurring')
|
|
|
127
133
|
const severity = ref<'low' | 'medium' | 'high' | 'critical' | ''>('')
|
|
128
134
|
const stepsToReproduce = ref('')
|
|
129
135
|
const timeboxHours = ref<number | undefined>(undefined)
|
|
136
|
+
// Bug-fishing expedition: which ANGLES to fish (empty ⇒ every shipped angle, the intended
|
|
137
|
+
// default — an expedition exists to cover ground nobody thought to look at, so narrowing it is
|
|
138
|
+
// the deliberate act) plus an optional focus folded into every angle's prompt.
|
|
139
|
+
const fishingPhaseIds = ref<string[]>([])
|
|
140
|
+
const fishingFocus = ref('')
|
|
130
141
|
// Spike research criteria — folded into the spike agent's prompt (see the backend `spike` kind).
|
|
131
142
|
const spikeResearchQuestion = ref('')
|
|
132
143
|
const spikeSuccessCriteria = ref('')
|
|
@@ -284,6 +295,24 @@ function buildCustomTypeFields(): TaskTypeFields | undefined {
|
|
|
284
295
|
return Object.keys(bag).length ? { custom: bag } : undefined
|
|
285
296
|
}
|
|
286
297
|
|
|
298
|
+
/**
|
|
299
|
+
* The bug-fishing expedition's creation fields. Both NARROW a hunt that otherwise covers every
|
|
300
|
+
* angle, so both are omitted when they narrow nothing: an empty selection and "every angle" are
|
|
301
|
+
* the same run, and storing the full catalog would freeze today's angle list onto a task that
|
|
302
|
+
* runs next quarter.
|
|
303
|
+
*
|
|
304
|
+
* Its own function rather than another arm of {@link buildTypeFields}, whose per-type chain is at
|
|
305
|
+
* its complexity ceiling — a budget is a split trigger, not a number to raise.
|
|
306
|
+
*/
|
|
307
|
+
function buildBugFishingFields(): TaskTypeFields | undefined {
|
|
308
|
+
const f: TaskTypeFields = {}
|
|
309
|
+
if (fishingPhaseIds.value.length && fishingPhaseIds.value.length < BUG_FISHING_PHASES.length) {
|
|
310
|
+
f.fishingPhaseIds = [...fishingPhaseIds.value]
|
|
311
|
+
}
|
|
312
|
+
if (fishingFocus.value.trim()) f.fishingFocus = fishingFocus.value.trim()
|
|
313
|
+
return Object.keys(f).length ? f : undefined
|
|
314
|
+
}
|
|
315
|
+
|
|
287
316
|
function buildTypeFields(): TaskTypeFields | undefined {
|
|
288
317
|
if (taskType.value === 'bug') {
|
|
289
318
|
const f: TaskTypeFields = {}
|
|
@@ -291,6 +320,7 @@ function buildTypeFields(): TaskTypeFields | undefined {
|
|
|
291
320
|
if (stepsToReproduce.value.trim()) f.stepsToReproduce = stepsToReproduce.value.trim()
|
|
292
321
|
return Object.keys(f).length ? f : undefined
|
|
293
322
|
}
|
|
323
|
+
if (taskType.value === 'bug-fishing') return buildBugFishingFields()
|
|
294
324
|
if (taskType.value === 'spike') {
|
|
295
325
|
const f: TaskTypeFields = {}
|
|
296
326
|
// `v-model.number` on a cleared number input yields '' (not undefined), which would
|
|
@@ -420,6 +450,7 @@ const DEFAULT_PIPELINE_FOR_TYPE: Partial<Record<TaskTypeChoice, string>> = {
|
|
|
420
450
|
document: 'pl_document',
|
|
421
451
|
review: 'pl_review',
|
|
422
452
|
media: 'pl_media',
|
|
453
|
+
'bug-fishing': 'pl_bug_fishing',
|
|
423
454
|
}
|
|
424
455
|
/**
|
|
425
456
|
* The pipeline a task type opens with: a custom type's registered `defaultPipelineId`, else the
|
|
@@ -564,6 +595,8 @@ watch(open, (isOpen) => {
|
|
|
564
595
|
severity.value = ''
|
|
565
596
|
stepsToReproduce.value = ''
|
|
566
597
|
timeboxHours.value = undefined
|
|
598
|
+
fishingPhaseIds.value = []
|
|
599
|
+
fishingFocus.value = ''
|
|
567
600
|
spikeResearchQuestion.value = ''
|
|
568
601
|
spikeSuccessCriteria.value = ''
|
|
569
602
|
spikeOptionsToCompare.value = ''
|
|
@@ -1027,6 +1060,53 @@ function openReviewFrictionDialog(conflict: NonNullable<ReturnType<typeof parseC
|
|
|
1027
1060
|
</UFormField>
|
|
1028
1061
|
</div>
|
|
1029
1062
|
|
|
1063
|
+
<!-- Bug-fishing expedition. Both fields NARROW a hunt that otherwise covers every
|
|
1064
|
+
angle, which is why neither is required and why the angle list is rendered as
|
|
1065
|
+
opt-OUT checkboxes rather than an empty multi-select: leaving it alone has to
|
|
1066
|
+
mean "fish everything", not "fish nothing". -->
|
|
1067
|
+
<div v-else-if="taskType === 'bug-fishing'" class="space-y-3">
|
|
1068
|
+
<UFormField
|
|
1069
|
+
:label="t('board.addTask.bugFishingFields.angles.label')"
|
|
1070
|
+
:hint="t('board.addTask.optional')"
|
|
1071
|
+
:description="t('board.addTask.bugFishingFields.angles.hint')"
|
|
1072
|
+
>
|
|
1073
|
+
<div class="grid gap-1.5 sm:grid-cols-2">
|
|
1074
|
+
<label
|
|
1075
|
+
v-for="phase in BUG_FISHING_PHASES"
|
|
1076
|
+
:key="phase.id"
|
|
1077
|
+
class="flex items-start gap-2 rounded-md px-1.5 py-1 text-[12px] hover:bg-slate-800/40"
|
|
1078
|
+
>
|
|
1079
|
+
<input
|
|
1080
|
+
v-model="fishingPhaseIds"
|
|
1081
|
+
type="checkbox"
|
|
1082
|
+
:value="phase.id"
|
|
1083
|
+
class="mt-0.5 accent-sky-500"
|
|
1084
|
+
:data-testid="`add-task-fishing-angle-${phase.id}`"
|
|
1085
|
+
/>
|
|
1086
|
+
<span class="min-w-0">
|
|
1087
|
+
<span class="block text-slate-200">{{ phase.title }}</span>
|
|
1088
|
+
<span class="block text-[11px] text-slate-500">{{ phase.goal }}</span>
|
|
1089
|
+
</span>
|
|
1090
|
+
</label>
|
|
1091
|
+
</div>
|
|
1092
|
+
<p v-if="fishingPhaseIds.length === 0" class="mt-1.5 text-[11px] text-slate-500">
|
|
1093
|
+
{{ t('board.addTask.bugFishingFields.angles.allSelected') }}
|
|
1094
|
+
</p>
|
|
1095
|
+
</UFormField>
|
|
1096
|
+
<UFormField
|
|
1097
|
+
:label="t('board.addTask.bugFishingFields.focus.label')"
|
|
1098
|
+
:hint="t('board.addTask.optional')"
|
|
1099
|
+
>
|
|
1100
|
+
<UTextarea
|
|
1101
|
+
v-model="fishingFocus"
|
|
1102
|
+
:rows="2"
|
|
1103
|
+
autoresize
|
|
1104
|
+
:placeholder="t('board.addTask.bugFishingFields.focus.placeholder')"
|
|
1105
|
+
class="w-full"
|
|
1106
|
+
/>
|
|
1107
|
+
</UFormField>
|
|
1108
|
+
</div>
|
|
1109
|
+
|
|
1030
1110
|
<div v-else-if="taskType === 'spike'" class="space-y-3">
|
|
1031
1111
|
<UFormField :label="t('board.addTask.timebox')">
|
|
1032
1112
|
<UInput
|
|
@@ -139,17 +139,43 @@ const selectablePipelines = computed(() =>
|
|
|
139
139
|
)
|
|
140
140
|
const selectedPipeline = computed(() => pipelines.getPipeline(pipelineId.value))
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Whether a pipeline RUNS a step of this kind: present in its `agentKinds` AND not disabled.
|
|
144
|
+
*
|
|
145
|
+
* The three questions this modal asks about the picked pipeline (does it fish, does it file a
|
|
146
|
+
* ticket, does it pull from the tracker) are the same question about three kinds, and each of
|
|
147
|
+
* them has to honour the disabled half: a step somebody turned off imposes nothing, so demanding
|
|
148
|
+
* its configuration would block a schedule on a step that will not run.
|
|
149
|
+
*/
|
|
150
|
+
function hasEnabledStep(
|
|
151
|
+
pipeline: { agentKinds: string[]; enabled?: boolean[] } | null | undefined,
|
|
152
|
+
kind: string,
|
|
153
|
+
): boolean {
|
|
154
|
+
if (!pipeline) return false
|
|
155
|
+
return pipeline.agentKinds.some((k, i) => k === kind && pipeline.enabled?.[i] !== false)
|
|
156
|
+
}
|
|
157
|
+
|
|
142
158
|
// Infer the template from the picked pipeline so the backend seeds the right block
|
|
143
159
|
// description (and so we know to show the tracker config).
|
|
144
160
|
//
|
|
145
161
|
// Only the pipelines whose SHAPE is specific to one kind of recurring work can be inferred this
|
|
146
|
-
// way
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
162
|
+
// way. `dep-update` and `tech-debt` were both retired from the catalog (the first was the
|
|
163
|
+
// ordinary build tail under a recurring name, the second that tail behind an audit head), so
|
|
164
|
+
// those schedules now run an ordinary build rung — which is also what every generic schedule
|
|
165
|
+
// runs, so inferring a template from it would mislabel all of them. Both templates survive for an
|
|
166
|
+
// explicit API caller; see `scheduleTemplateSchema`.
|
|
167
|
+
//
|
|
168
|
+
// The expedition is read off its own step KIND rather than off `pl_bug_fishing`'s id, for the
|
|
169
|
+
// reason `filesTicket` below states: an id keys on the one preset that ships today and misses
|
|
170
|
+
// every pipeline a workspace composes around the same step, and the whole seed description is
|
|
171
|
+
// about what a `bug-fisher` pass does.
|
|
172
|
+
const isBugFishing = computed(() => hasEnabledStep(selectedPipeline.value, 'bug-fisher'))
|
|
151
173
|
const template = computed<ScheduleTemplate>(() =>
|
|
152
|
-
|
|
174
|
+
isBugFishing.value
|
|
175
|
+
? 'bug-fishing'
|
|
176
|
+
: pipelineId.value === 'pl_bug_triage'
|
|
177
|
+
? 'bug-triage'
|
|
178
|
+
: 'custom',
|
|
153
179
|
)
|
|
154
180
|
/**
|
|
155
181
|
* Whether the picked pipeline FILES a ticket (an enabled `tracker` step), so the schedule's first
|
|
@@ -159,24 +185,12 @@ const template = computed<ScheduleTemplate>(() =>
|
|
|
159
185
|
* `analysis` + `tracker` head. Keying on the id would have offered the tracker config to exactly
|
|
160
186
|
* the one pipeline that no longer exists, and to none of the pipelines that now do this work.
|
|
161
187
|
*/
|
|
162
|
-
const filesTicket = computed(() =>
|
|
163
|
-
const pipeline = selectedPipeline.value
|
|
164
|
-
if (!pipeline) return false
|
|
165
|
-
return pipeline.agentKinds.some(
|
|
166
|
-
(kind, i) => kind === 'tracker' && pipeline.enabled?.[i] !== false,
|
|
167
|
-
)
|
|
168
|
-
})
|
|
188
|
+
const filesTicket = computed(() => hasEnabledStep(selectedPipeline.value, 'tracker'))
|
|
169
189
|
|
|
170
190
|
// A pipeline whose ENABLED steps include `bug-intake` pulls its work from the tracker board, so
|
|
171
191
|
// the intake config is surfaced + required. Mirrors the backend `pipelineHasEnabledBugIntake`
|
|
172
192
|
// (a disabled step imposes nothing), so the modal doesn't demand config for a step that won't run.
|
|
173
|
-
const isBugIntake = computed(() =>
|
|
174
|
-
const pipeline = selectedPipeline.value
|
|
175
|
-
if (!pipeline) return false
|
|
176
|
-
return pipeline.agentKinds.some(
|
|
177
|
-
(kind, i) => kind === 'bug-intake' && pipeline.enabled?.[i] !== false,
|
|
178
|
-
)
|
|
179
|
-
})
|
|
193
|
+
const isBugIntake = computed(() => hasEnabledStep(selectedPipeline.value, 'bug-intake'))
|
|
180
194
|
/**
|
|
181
195
|
* Whether the intake section is shown, and in which DISPATCH mode — both DERIVED from the picked
|
|
182
196
|
* pipeline rather than chosen, because the two modes are not interchangeable:
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
defaultBootstrapDelivery,
|
|
4
|
+
serviceDirectoryLeaf,
|
|
5
|
+
serviceDirectoryParent,
|
|
6
|
+
} from '~/components/bootstrap/BootstrapModal.logic'
|
|
7
|
+
|
|
8
|
+
// The rule these two pin is what makes the field browsable AND typable at once: the tree hands
|
|
9
|
+
// back the folder it was standing in plus the leaf, so a name someone typed has to survive a
|
|
10
|
+
// trip through the tree. Reading the leaf off the service name instead would silently discard it.
|
|
11
|
+
|
|
12
|
+
describe('serviceDirectoryLeaf', () => {
|
|
13
|
+
it('is the typed path’s own last segment, not the service name', () => {
|
|
14
|
+
expect(serviceDirectoryLeaf('services/billing', 'payments')).toBe('billing')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('treats a bare name as the leaf (a directory at the repo root)', () => {
|
|
18
|
+
expect(serviceDirectoryLeaf('billing', 'payments')).toBe('billing')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('survives a trailing slash rather than reading as an empty leaf', () => {
|
|
22
|
+
expect(serviceDirectoryLeaf('services/billing/', 'payments')).toBe('billing')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('falls back to the service name while the field is still blank', () => {
|
|
26
|
+
expect(serviceDirectoryLeaf('', 'payments')).toBe('payments')
|
|
27
|
+
expect(serviceDirectoryLeaf(' ', ' payments ')).toBe('payments')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('is empty when neither is known, so the tree can say it has nothing to place', () => {
|
|
31
|
+
expect(serviceDirectoryLeaf('', '')).toBe('')
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('serviceDirectoryParent', () => {
|
|
36
|
+
it('is the folder the typed path sits in', () => {
|
|
37
|
+
expect(serviceDirectoryParent('packages/services/billing')).toBe('packages/services')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('is the repo root for a bare name', () => {
|
|
41
|
+
expect(serviceDirectoryParent('billing')).toBe('')
|
|
42
|
+
expect(serviceDirectoryParent('')).toBe('')
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('defaultBootstrapDelivery', () => {
|
|
47
|
+
it('reviews a monorepo and pushes a repository being created', () => {
|
|
48
|
+
// The form has to SHOW the default it is about to send, and the two targets want opposite
|
|
49
|
+
// ones, so a constant would render the wrong answer for one of them and ask the person to
|
|
50
|
+
// correct a choice they never made. Same rule the backend applies to a request naming none.
|
|
51
|
+
expect(defaultBootstrapDelivery(true)).toBe('pull_request')
|
|
52
|
+
expect(defaultBootstrapDelivery(false)).toBe('direct_push')
|
|
53
|
+
})
|
|
54
|
+
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { BootstrapDelivery } from '~/types/domain'
|
|
2
|
+
import { repoPathSegments } from '~/utils/repoPath'
|
|
3
|
+
|
|
4
|
+
// The pure half of the bootstrap launch form's monorepo service-directory field. That field
|
|
5
|
+
// holds one string but carries two decisions: what the new directory is CALLED and WHERE in the
|
|
6
|
+
// repo it sits. Browsing the repo tree answers only the second, so it rewrites the parent and
|
|
7
|
+
// keeps the leaf, which means both halves have to be readable off the typed value on their own.
|
|
8
|
+
// Extracted for the reason every `*.logic.ts` here is: a decision worth a test should not need a
|
|
9
|
+
// mounted component to reach.
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What the new directory is called: the last segment of the typed path.
|
|
13
|
+
*
|
|
14
|
+
* Falls back to the service name, which is what the field's own seeding watcher would have put
|
|
15
|
+
* there anyway, so opening the tree before typing a path still has a name to place. Empty only
|
|
16
|
+
* when NEITHER is known, and that answer is load-bearing: with nothing to place, the tree can
|
|
17
|
+
* decide nothing and says so rather than offering picks that compose a bare folder path.
|
|
18
|
+
*/
|
|
19
|
+
export function serviceDirectoryLeaf(directory: string, serviceName: string): string {
|
|
20
|
+
return repoPathSegments(directory).at(-1) ?? serviceName.trim()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The folder the typed path sits in, and so where the tree should OPEN: empty for a bare name,
|
|
25
|
+
* because a name with no parent is a directory at the repo root.
|
|
26
|
+
*/
|
|
27
|
+
export function serviceDirectoryParent(directory: string): string {
|
|
28
|
+
return repoPathSegments(directory).slice(0, -1).join('/')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The delivery a target takes when nobody has answered the question.
|
|
33
|
+
*
|
|
34
|
+
* The backend applies the same rule for a request that names none, and it is stated on both
|
|
35
|
+
* sides deliberately: the form has to SHOW the default it is about to send, and a control
|
|
36
|
+
* rendering the wrong one asks the person to correct something they never chose. The two targets
|
|
37
|
+
* want opposite answers, which is why it is a function of the target rather than a constant.
|
|
38
|
+
*
|
|
39
|
+
* Also what the form RESETS to after a launch: an explicit choice binds the run it was made for,
|
|
40
|
+
* never every later one, so the reset restores the default for whatever target is still selected.
|
|
41
|
+
*/
|
|
42
|
+
export function defaultBootstrapDelivery(intoMonorepo: boolean): BootstrapDelivery {
|
|
43
|
+
return intoMonorepo ? 'pull_request' : 'direct_push'
|
|
44
|
+
}
|
|
@@ -4,7 +4,18 @@
|
|
|
4
4
|
// adapt it (in a sandbox container) — either by cloning a chosen reference
|
|
5
5
|
// architecture, or from scratch following a freeform prompt. The modal pairs the
|
|
6
6
|
// launch form with the managed base list.
|
|
7
|
-
import type {
|
|
7
|
+
import type {
|
|
8
|
+
BootstrapDelivery,
|
|
9
|
+
BootstrapStatus,
|
|
10
|
+
FrameRepoType,
|
|
11
|
+
ReferenceArchitecture,
|
|
12
|
+
} from '~/types/domain'
|
|
13
|
+
import {
|
|
14
|
+
defaultBootstrapDelivery,
|
|
15
|
+
serviceDirectoryLeaf,
|
|
16
|
+
serviceDirectoryParent,
|
|
17
|
+
} from '~/components/bootstrap/BootstrapModal.logic'
|
|
18
|
+
import RepoTreeBrowser from '~/components/github/RepoTreeBrowser.vue'
|
|
8
19
|
import VcsConnectSurfaces from '~/components/vcs/VcsConnectSurfaces.vue'
|
|
9
20
|
import { appInstallationManageUrl, newRepoUrl, VCS_PROVIDER_LABELS } from '~/utils/vcs'
|
|
10
21
|
|
|
@@ -100,6 +111,42 @@ const targetItems = computed(() => [
|
|
|
100
111
|
])
|
|
101
112
|
const intoMonorepo = computed(() => target.value === 'monorepo')
|
|
102
113
|
|
|
114
|
+
// ---- how the work LANDS ----------------------------------------------------
|
|
115
|
+
// A third axis, orthogonal to both of the above: the same service, written the same way, either
|
|
116
|
+
// arrives as a pull request somebody reviews or straight on the default branch. The two targets
|
|
117
|
+
// want opposite defaults (a repository being created has nobody to review its first commit; a
|
|
118
|
+
// monorepo's default branch is the branch every other service builds from), which is exactly why
|
|
119
|
+
// this is a control and not a constant.
|
|
120
|
+
const delivery = ref<BootstrapDelivery>(defaultBootstrapDelivery(false))
|
|
121
|
+
// Whether the person has answered this question themselves. Until they have, switching target
|
|
122
|
+
// re-defaults; once they have, their answer stands, because re-defaulting over an explicit
|
|
123
|
+
// choice is how a run they asked to review lands unreviewed. Cleared after a launch, so the
|
|
124
|
+
// answer binds the run it was given for rather than every later one (see `launch`).
|
|
125
|
+
const deliveryTouched = ref(false)
|
|
126
|
+
watch(intoMonorepo, (into) => {
|
|
127
|
+
if (!deliveryTouched.value) delivery.value = defaultBootstrapDelivery(into)
|
|
128
|
+
})
|
|
129
|
+
function chooseDelivery(value: BootstrapDelivery) {
|
|
130
|
+
deliveryTouched.value = true
|
|
131
|
+
delivery.value = value
|
|
132
|
+
}
|
|
133
|
+
const deliveryItems = computed(() => [
|
|
134
|
+
{
|
|
135
|
+
label: t('bootstrap.delivery.pullRequest.label'),
|
|
136
|
+
value: 'pull_request' as const,
|
|
137
|
+
description: intoMonorepo.value
|
|
138
|
+
? t('bootstrap.delivery.pullRequest.descMonorepo')
|
|
139
|
+
: t('bootstrap.delivery.pullRequest.descNewRepo'),
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
label: t('bootstrap.delivery.directPush.label'),
|
|
143
|
+
value: 'direct_push' as const,
|
|
144
|
+
description: intoMonorepo.value
|
|
145
|
+
? t('bootstrap.delivery.directPush.descMonorepo')
|
|
146
|
+
: t('bootstrap.delivery.directPush.descNewRepo'),
|
|
147
|
+
},
|
|
148
|
+
])
|
|
149
|
+
|
|
103
150
|
/** The projected repo the new service lands in, by numeric id. */
|
|
104
151
|
const monorepoRepoId = ref<number | undefined>(undefined)
|
|
105
152
|
const monorepoDirectory = ref('')
|
|
@@ -108,20 +155,43 @@ const monorepoRepoItems = computed(() =>
|
|
|
108
155
|
github.repos.map((r) => ({ label: `${r.owner}/${r.name}`, value: r.githubId })),
|
|
109
156
|
)
|
|
110
157
|
|
|
111
|
-
//
|
|
112
|
-
// directory, so a value that could escape the checkout is refused here
|
|
158
|
+
// `repoPathSegments` is the backend's `normalizeServiceDirectory` reduction: the path becomes
|
|
159
|
+
// an agent's working directory, so a value that could escape the checkout is refused here
|
|
160
|
+
// rather than at the API.
|
|
161
|
+
const directorySegments = computed(() => repoPathSegments(monorepoDirectory.value))
|
|
113
162
|
const directoryError = computed<string | undefined>(() => {
|
|
114
|
-
|
|
115
|
-
if (!value) return
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
.filter((s) => s && s !== '.')
|
|
120
|
-
if (!segments.length) return t('bootstrap.monorepo.directory.error.empty')
|
|
121
|
-
if (segments.some((s) => s === '..')) return t('bootstrap.monorepo.directory.error.escapes')
|
|
163
|
+
if (!monorepoDirectory.value.trim()) return undefined
|
|
164
|
+
if (!directorySegments.value.length) return t('bootstrap.monorepo.directory.error.empty')
|
|
165
|
+
if (directorySegments.value.some((seg) => seg === '..')) {
|
|
166
|
+
return t('bootstrap.monorepo.directory.error.escapes')
|
|
167
|
+
}
|
|
122
168
|
return undefined
|
|
123
169
|
})
|
|
124
170
|
|
|
171
|
+
// ---- exploring the monorepo for the directory's home -----------------------
|
|
172
|
+
// The target must NOT exist, so nothing in the tree can BE it: the tree picks the enclosing
|
|
173
|
+
// folder and hands back that folder plus the leaf (see `BootstrapModal.logic`, which owns the
|
|
174
|
+
// two readings of the typed value).
|
|
175
|
+
const directoryLeaf = computed(() => serviceDirectoryLeaf(monorepoDirectory.value, repoName.value))
|
|
176
|
+
const browsingDirectory = ref(false)
|
|
177
|
+
// The folder the tree opens at, captured when the browser is OPENED rather than read live off
|
|
178
|
+
// the field: as a computed it would re-navigate the listing on every keystroke in the input.
|
|
179
|
+
const directoryBrowseStart = ref('')
|
|
180
|
+
|
|
181
|
+
function toggleDirectoryBrowse() {
|
|
182
|
+
if (!browsingDirectory.value) {
|
|
183
|
+
directoryBrowseStart.value = serviceDirectoryParent(monorepoDirectory.value)
|
|
184
|
+
}
|
|
185
|
+
browsingDirectory.value = !browsingDirectory.value
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The tree emits the composed path: the folder it was standing in plus the leaf it was given. */
|
|
189
|
+
function placeDirectory(path: string | undefined) {
|
|
190
|
+
if (!path) return
|
|
191
|
+
monorepoDirectory.value = path
|
|
192
|
+
browsingDirectory.value = false
|
|
193
|
+
}
|
|
194
|
+
|
|
125
195
|
// Landing in a monorepo needs no NEW repository, so the repo name is the SERVICE's name (and
|
|
126
196
|
// seeds the directory's leaf); the create-repo affordances below are for the other target.
|
|
127
197
|
watch([intoMonorepo, repoName], ([into, name]) => {
|
|
@@ -295,6 +365,7 @@ async function launch() {
|
|
|
295
365
|
private: isPrivate.value,
|
|
296
366
|
instructions: instructions.value.trim(),
|
|
297
367
|
type: selectedType.value,
|
|
368
|
+
delivery: delivery.value,
|
|
298
369
|
...(intoMonorepo.value && monorepoRepoId.value
|
|
299
370
|
? {
|
|
300
371
|
monorepo: {
|
|
@@ -332,8 +403,15 @@ async function launch() {
|
|
|
332
403
|
description.value = ''
|
|
333
404
|
instructions.value = ''
|
|
334
405
|
monorepoDirectory.value = ''
|
|
406
|
+
browsingDirectory.value = false
|
|
335
407
|
// Reset the repo role too, so a later bootstrap doesn't silently inherit this one's type.
|
|
336
408
|
selectedType.value = 'service'
|
|
409
|
+
// And the delivery, which has to reset the ANSWERED flag with it: leaving that set disarms
|
|
410
|
+
// the per-target default for good, so a "push directly" picked deliberately for one
|
|
411
|
+
// monorepo would go on governing the next bootstrap, into a different repository, without
|
|
412
|
+
// the person having been asked about that one. Back to the current target's own default.
|
|
413
|
+
deliveryTouched.value = false
|
|
414
|
+
delivery.value = defaultBootstrapDelivery(intoMonorepo.value)
|
|
337
415
|
// The provisional frame arrived (bootstrap() refreshed the board). Re-home it to
|
|
338
416
|
// free space so it never overlaps an existing service — the backend places it on a
|
|
339
417
|
// fixed diagonal stagger that can land on top of a large neighbour — then centre the
|
|
@@ -524,6 +602,17 @@ const statusLabel = computed<Record<BootstrapStatus, string>>(() => ({
|
|
|
524
602
|
<URadioGroup v-model="target" :items="targetItems" />
|
|
525
603
|
</UFormField>
|
|
526
604
|
|
|
605
|
+
<!-- Where the service goes and how it gets there are two questions, and the second
|
|
606
|
+
has no answer that is right for both targets. Its descriptions therefore change
|
|
607
|
+
with the target rather than the control being duplicated per target. -->
|
|
608
|
+
<UFormField :label="t('bootstrap.delivery.label')" required>
|
|
609
|
+
<URadioGroup
|
|
610
|
+
:model-value="delivery"
|
|
611
|
+
:items="deliveryItems"
|
|
612
|
+
@update:model-value="chooseDelivery($event as BootstrapDelivery)"
|
|
613
|
+
/>
|
|
614
|
+
</UFormField>
|
|
615
|
+
|
|
527
616
|
<!-- Landing in an existing monorepo: pick the repository and the subdirectory. The
|
|
528
617
|
run surveys the monorepo's conventions against the template's and PARKS for a
|
|
529
618
|
human adoption review before it writes anything. -->
|
|
@@ -550,11 +639,49 @@ const statusLabel = computed<Record<BootstrapStatus, string>>(() => ({
|
|
|
550
639
|
required
|
|
551
640
|
:error="directoryError"
|
|
552
641
|
>
|
|
553
|
-
<
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
642
|
+
<div class="space-y-2">
|
|
643
|
+
<div class="flex items-center gap-2">
|
|
644
|
+
<UInput
|
|
645
|
+
v-model="monorepoDirectory"
|
|
646
|
+
:placeholder="t('bootstrap.monorepo.directory.placeholder')"
|
|
647
|
+
class="flex-1"
|
|
648
|
+
/>
|
|
649
|
+
<UButton
|
|
650
|
+
v-if="monorepoRepoId !== undefined"
|
|
651
|
+
variant="soft"
|
|
652
|
+
color="neutral"
|
|
653
|
+
icon="i-lucide-folder-search"
|
|
654
|
+
:title="t('bootstrap.monorepo.directory.browse')"
|
|
655
|
+
:aria-label="t('bootstrap.monorepo.directory.browse')"
|
|
656
|
+
data-testid="bootstrap-directory-browse"
|
|
657
|
+
@click="toggleDirectoryBrowse()"
|
|
658
|
+
/>
|
|
659
|
+
</div>
|
|
660
|
+
|
|
661
|
+
<!-- The tree answers WHERE, never WHAT: with no name to place yet it could
|
|
662
|
+
decide nothing, so say that instead of listing a repo for nothing. -->
|
|
663
|
+
<div
|
|
664
|
+
v-if="browsingDirectory && monorepoRepoId !== undefined"
|
|
665
|
+
class="rounded-md border border-slate-800 bg-slate-900/40 p-2"
|
|
666
|
+
>
|
|
667
|
+
<p class="mb-2 text-xs text-slate-400">
|
|
668
|
+
{{
|
|
669
|
+
directoryLeaf
|
|
670
|
+
? t('bootstrap.monorepo.directory.browseHint')
|
|
671
|
+
: t('bootstrap.monorepo.directory.browseNeedsName')
|
|
672
|
+
}}
|
|
673
|
+
</p>
|
|
674
|
+
<RepoTreeBrowser
|
|
675
|
+
v-if="directoryLeaf"
|
|
676
|
+
:repo-github-id="monorepoRepoId"
|
|
677
|
+
mode="dir"
|
|
678
|
+
:new-dir-name="directoryLeaf"
|
|
679
|
+
:model-value="monorepoDirectory"
|
|
680
|
+
:start-path="directoryBrowseStart"
|
|
681
|
+
@update:model-value="placeDirectory"
|
|
682
|
+
/>
|
|
683
|
+
</div>
|
|
684
|
+
</div>
|
|
558
685
|
</UFormField>
|
|
559
686
|
</template>
|
|
560
687
|
|
|
@@ -733,6 +860,18 @@ const statusLabel = computed<Record<BootstrapStatus, string>>(() => ({
|
|
|
733
860
|
>
|
|
734
861
|
{{ t('bootstrap.recent.open') }}
|
|
735
862
|
</ULink>
|
|
863
|
+
<!-- The deliverable of a `pull_request` run, and the only thing it produced that
|
|
864
|
+
the user still has to act on. A monorepo run has no `repoUrl` at all, so
|
|
865
|
+
without this the run's whole output is unreachable from the list that
|
|
866
|
+
offered the choice. -->
|
|
867
|
+
<ULink
|
|
868
|
+
v-if="job.prUrl"
|
|
869
|
+
:to="job.prUrl"
|
|
870
|
+
target="_blank"
|
|
871
|
+
class="text-[11px] text-indigo-400 hover:underline"
|
|
872
|
+
>
|
|
873
|
+
{{ t('bootstrap.recent.openPr') }}
|
|
874
|
+
</ULink>
|
|
736
875
|
<UBadge :color="statusColor[job.status]" variant="subtle" size="sm">
|
|
737
876
|
{{ statusLabel[job.status] }}
|
|
738
877
|
</UBadge>
|