@cat-factory/app 0.20.0 → 0.22.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/BoardCanvas.vue +21 -3
- package/app/components/board/DependencyConnectOverlay.vue +25 -0
- package/app/components/board/TaskDependencyEdges.vue +45 -0
- package/app/components/board/nodes/EpicNode.vue +51 -0
- package/app/components/board/nodes/TaskCard.vue +14 -0
- package/app/components/humanTest/HumanTestWindow.vue +327 -0
- package/app/components/layout/NotificationsInbox.vue +19 -0
- package/app/components/panels/InspectorPanel.vue +5 -0
- package/app/components/panels/StepResultViewHost.vue +3 -0
- package/app/components/panels/inspector/EpicChildren.vue +83 -0
- package/app/components/panels/inspector/TaskRunSettings.vue +25 -0
- package/app/components/slack/SlackPanel.vue +2 -0
- package/app/components/tasks/TaskImportModal.vue +59 -0
- package/app/composables/api/board.ts +10 -0
- package/app/composables/api/humanTest.ts +37 -0
- package/app/composables/api/tasks.ts +12 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/useBlockQueries.ts +16 -0
- package/app/composables/useDependencyConnect.ts +61 -0
- package/app/stores/board.ts +71 -8
- package/app/stores/humanTest.ts +70 -0
- package/app/stores/tasks.ts +25 -0
- package/app/types/domain.ts +18 -1
- package/app/types/execution.ts +53 -0
- package/app/types/notifications.ts +1 -0
- package/app/utils/catalog.spec.ts +1 -0
- package/app/utils/catalog.ts +12 -0
- package/package.json +1 -1
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import type { Block } from '~/types/domain'
|
|
4
|
+
import { STATUS_META } from '~/utils/catalog'
|
|
5
|
+
|
|
6
|
+
// The epic inspector body: the full tree of member tasks (which may live under different
|
|
7
|
+
// services/modules), grouped service → module → task. Each task row selects it. Membership
|
|
8
|
+
// is the task's `epicId`; the epic is non-structural, so this reads across the whole board.
|
|
9
|
+
const props = defineProps<{ block: Block }>()
|
|
10
|
+
|
|
11
|
+
const board = useBoardStore()
|
|
12
|
+
const ui = useUiStore()
|
|
13
|
+
|
|
14
|
+
const members = computed(() => board.epicMembers(props.block.id))
|
|
15
|
+
const done = computed(() => members.value.filter((m) => m.status === 'done').length)
|
|
16
|
+
|
|
17
|
+
/** Member tasks grouped by their owning service, then by module (or "direct"). */
|
|
18
|
+
const groups = computed(() => {
|
|
19
|
+
const byService = new Map<
|
|
20
|
+
string,
|
|
21
|
+
{ service: Block | undefined; modules: Map<string, { module: Block | undefined; tasks: Block[] }> }
|
|
22
|
+
>()
|
|
23
|
+
for (const task of members.value) {
|
|
24
|
+
const service = board.serviceOf(task)
|
|
25
|
+
const serviceKey = service?.id ?? '—'
|
|
26
|
+
if (!byService.has(serviceKey)) byService.set(serviceKey, { service, modules: new Map() })
|
|
27
|
+
const group = byService.get(serviceKey)!
|
|
28
|
+
// The task's structural container: a module when its parent is a module, else "direct".
|
|
29
|
+
const parent = task.parentId ? board.getBlock(task.parentId) : undefined
|
|
30
|
+
const moduleKey = parent && parent.level === 'module' ? parent.id : '—'
|
|
31
|
+
if (!group.modules.has(moduleKey)) {
|
|
32
|
+
group.modules.set(moduleKey, { module: parent?.level === 'module' ? parent : undefined, tasks: [] })
|
|
33
|
+
}
|
|
34
|
+
group.modules.get(moduleKey)!.tasks.push(task)
|
|
35
|
+
}
|
|
36
|
+
return [...byService.values()]
|
|
37
|
+
})
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<template>
|
|
41
|
+
<div>
|
|
42
|
+
<div class="mb-1 flex items-center justify-between">
|
|
43
|
+
<span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
44
|
+
Epic tasks
|
|
45
|
+
</span>
|
|
46
|
+
<span class="text-[11px] text-slate-500">{{ done }}/{{ members.length }} done</span>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<div v-if="members.length === 0" class="text-[11px] text-slate-500">
|
|
50
|
+
No tasks belong to this epic yet. Import an epic's children, or set a task's epic.
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<div v-else class="space-y-2">
|
|
54
|
+
<div v-for="(group, gi) in groups" :key="gi" class="rounded-md border border-slate-700/60 p-2">
|
|
55
|
+
<div class="mb-1 flex items-center gap-1 text-[11px] font-medium text-slate-300">
|
|
56
|
+
<UIcon name="i-lucide-box" class="h-3 w-3 text-slate-500" />
|
|
57
|
+
{{ group.service?.title ?? 'Unassigned' }}
|
|
58
|
+
</div>
|
|
59
|
+
<div v-for="(mod, mi) in [...group.modules.values()]" :key="mi" class="pl-1">
|
|
60
|
+
<div v-if="mod.module" class="text-[10px] uppercase tracking-wide text-slate-500">
|
|
61
|
+
{{ mod.module.title }}
|
|
62
|
+
</div>
|
|
63
|
+
<button
|
|
64
|
+
v-for="task in mod.tasks"
|
|
65
|
+
:key="task.id"
|
|
66
|
+
type="button"
|
|
67
|
+
class="flex w-full items-center gap-1.5 rounded px-1 py-0.5 text-left text-xs text-slate-200 hover:bg-slate-800"
|
|
68
|
+
@click="ui.select(task.id)"
|
|
69
|
+
>
|
|
70
|
+
<span
|
|
71
|
+
class="h-2 w-2 shrink-0 rounded-full"
|
|
72
|
+
:style="{ backgroundColor: STATUS_META[task.status].color }"
|
|
73
|
+
/>
|
|
74
|
+
<span class="truncate">{{ task.title }}</span>
|
|
75
|
+
<span class="ml-auto shrink-0 text-[10px] text-slate-500">
|
|
76
|
+
{{ STATUS_META[task.status].label }}
|
|
77
|
+
</span>
|
|
78
|
+
</button>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
</template>
|
|
@@ -45,6 +45,13 @@ function setResponsible(userId: string) {
|
|
|
45
45
|
board.updateBlock(props.block.id, { responsibleProductUserId: userId })
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
// ---- auto-start dependents -------------------------------------------------
|
|
49
|
+
// Preceding-task toggle: when this task merges, the engine auto-starts the tasks that
|
|
50
|
+
// depend on it (skipping any on an individual-usage model, which can't unlock unattended).
|
|
51
|
+
function setAutoStartDependents(value: boolean) {
|
|
52
|
+
board.updateBlock(props.block.id, { autoStartDependents: value })
|
|
53
|
+
}
|
|
54
|
+
|
|
48
55
|
// ---- merge policy preset ---------------------------------------------------
|
|
49
56
|
// Which merge threshold preset governs this task's auto-merge decision + CI-fixer
|
|
50
57
|
// budget. None selected → the workspace default preset. (The old confidence-based
|
|
@@ -365,5 +372,23 @@ const resolveOnMergeLabel = computed(() =>
|
|
|
365
372
|
Unassigned — set a product owner to notify them when requirement review flags this task.
|
|
366
373
|
</div>
|
|
367
374
|
</div>
|
|
375
|
+
|
|
376
|
+
<!-- auto-start dependents: when this task merges, start the tasks that depend on it -->
|
|
377
|
+
<div>
|
|
378
|
+
<div class="flex items-center justify-between gap-2">
|
|
379
|
+
<span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
380
|
+
Auto-start dependents
|
|
381
|
+
</span>
|
|
382
|
+
<USwitch
|
|
383
|
+
size="sm"
|
|
384
|
+
:model-value="block.autoStartDependents ?? false"
|
|
385
|
+
@update:model-value="setAutoStartDependents"
|
|
386
|
+
/>
|
|
387
|
+
</div>
|
|
388
|
+
<div class="mt-1 text-[11px] text-slate-500">
|
|
389
|
+
When this task merges, automatically start the tasks that depend on it (once their
|
|
390
|
+
other dependencies are also done).
|
|
391
|
+
</div>
|
|
392
|
+
</div>
|
|
368
393
|
</div>
|
|
369
394
|
</template>
|
|
@@ -25,6 +25,7 @@ const ROUTABLE: { type: NotificationType; label: string }[] = [
|
|
|
25
25
|
{ type: 'requirement_review', label: 'Requirement review' },
|
|
26
26
|
{ type: 'clarity_review', label: 'Clarity review' },
|
|
27
27
|
{ type: 'release_regression', label: 'Release regression' },
|
|
28
|
+
{ type: 'human_test_ready', label: 'Ready for human testing' },
|
|
28
29
|
]
|
|
29
30
|
|
|
30
31
|
/** Notification-role options for a mapped member (drives who gets @-mentioned). */
|
|
@@ -41,6 +42,7 @@ const routes = reactive<Record<NotificationType, SlackRoute>>({
|
|
|
41
42
|
release_regression: { enabled: false, channel: '' },
|
|
42
43
|
// In-app only (not in ROUTABLE), but the map is exhaustive over the type.
|
|
43
44
|
decision_required: { enabled: false, channel: '' },
|
|
45
|
+
human_test_ready: { enabled: false, channel: '' },
|
|
44
46
|
})
|
|
45
47
|
const mentionsEnabled = ref(false)
|
|
46
48
|
const mapping = ref<SlackMemberMappingEntry[]>([])
|
|
@@ -162,6 +162,35 @@ async function doImport() {
|
|
|
162
162
|
importing.value = false
|
|
163
163
|
}
|
|
164
164
|
}
|
|
165
|
+
|
|
166
|
+
// Spawn the referenced issue as an EPIC: an epic node + a task per child issue (into the
|
|
167
|
+
// chosen container), with dependency edges seeded from the issues' blocked-by/depends-on
|
|
168
|
+
// links. Needs a container for the child tasks.
|
|
169
|
+
async function doSpawnEpic() {
|
|
170
|
+
const value = ref_.value.trim()
|
|
171
|
+
if (!value || !source.value || !containerId.value) return
|
|
172
|
+
importing.value = true
|
|
173
|
+
try {
|
|
174
|
+
const { epic, tasks: spawned } = await tasks.spawnEpic(source.value, value, containerId.value)
|
|
175
|
+
ref_.value = ''
|
|
176
|
+
ui.closeTaskImport()
|
|
177
|
+
ui.select(epic.id)
|
|
178
|
+
toast.add({
|
|
179
|
+
title: `Spawned epic "${epic.title}"`,
|
|
180
|
+
description: `${spawned.length} child task(s) created`,
|
|
181
|
+
icon: 'i-lucide-layers',
|
|
182
|
+
})
|
|
183
|
+
} catch (e) {
|
|
184
|
+
toast.add({
|
|
185
|
+
title: 'Could not spawn epic',
|
|
186
|
+
description: e instanceof Error ? e.message : String(e),
|
|
187
|
+
icon: 'i-lucide-triangle-alert',
|
|
188
|
+
color: 'error',
|
|
189
|
+
})
|
|
190
|
+
} finally {
|
|
191
|
+
importing.value = false
|
|
192
|
+
}
|
|
193
|
+
}
|
|
165
194
|
</script>
|
|
166
195
|
|
|
167
196
|
<template>
|
|
@@ -209,8 +238,38 @@ async function doImport() {
|
|
|
209
238
|
>
|
|
210
239
|
Import
|
|
211
240
|
</UButton>
|
|
241
|
+
<UButton
|
|
242
|
+
color="primary"
|
|
243
|
+
variant="soft"
|
|
244
|
+
icon="i-lucide-layers"
|
|
245
|
+
:loading="importing"
|
|
246
|
+
:disabled="!ref_.trim() || !containerId"
|
|
247
|
+
:title="
|
|
248
|
+
containerId
|
|
249
|
+
? 'Spawn this epic + its children as a linked task group'
|
|
250
|
+
: 'Pick a container for the child tasks first'
|
|
251
|
+
"
|
|
252
|
+
@click="doSpawnEpic"
|
|
253
|
+
>
|
|
254
|
+
As epic
|
|
255
|
+
</UButton>
|
|
212
256
|
</div>
|
|
213
257
|
|
|
258
|
+
<!-- Container for epic children when spawning from a pasted ref (the shared
|
|
259
|
+
"Create tasks in" selector below covers the search-results case). -->
|
|
260
|
+
<UFormField
|
|
261
|
+
v-if="containerItems.length && !freshHits.length && !sourceTasks.length"
|
|
262
|
+
label="Epic children container"
|
|
263
|
+
class="w-72"
|
|
264
|
+
>
|
|
265
|
+
<USelect
|
|
266
|
+
v-model="containerId"
|
|
267
|
+
:items="containerItems"
|
|
268
|
+
placeholder="Pick a frame or module"
|
|
269
|
+
class="w-full"
|
|
270
|
+
/>
|
|
271
|
+
</UFormField>
|
|
272
|
+
|
|
214
273
|
<!-- Browse: search the tracker by title so an issue can be turned into a
|
|
215
274
|
task without knowing its key. -->
|
|
216
275
|
<UFormField v-if="searchable" label="Search issues">
|
|
@@ -51,6 +51,16 @@ export function boardApi({ http, ws }: ApiContext) {
|
|
|
51
51
|
body: { name: string; position?: Position },
|
|
52
52
|
) => http<Block>(`${ws(workspaceId)}/blocks/${blockId}/modules`, { method: 'POST', body }),
|
|
53
53
|
|
|
54
|
+
// Create an epic grouping node (optionally placed under a service/module).
|
|
55
|
+
addEpic: (
|
|
56
|
+
workspaceId: string,
|
|
57
|
+
body: { title: string; description?: string; position: Position; parentId?: string },
|
|
58
|
+
) => http<Block>(`${ws(workspaceId)}/epics`, { method: 'POST', body }),
|
|
59
|
+
|
|
60
|
+
// Assign a task to an epic, or detach it (epicId: null).
|
|
61
|
+
assignToEpic: (workspaceId: string, blockId: string, body: { epicId: string | null }) =>
|
|
62
|
+
http<Block>(`${ws(workspaceId)}/blocks/${blockId}/epic`, { method: 'POST', body }),
|
|
63
|
+
|
|
54
64
|
updateBlock: (workspaceId: string, blockId: string, body: Partial<Block>) =>
|
|
55
65
|
http<Block>(`${ws(workspaceId)}/blocks/${blockId}`, { method: 'PATCH', body }),
|
|
56
66
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ExecutionInstance } from '~/types/domain'
|
|
2
|
+
import type { ApiContext } from './context'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The human-testing gate's run-driving actions. Each acts on the block's parked `human-test`
|
|
6
|
+
* step and returns the updated execution instance (the gate state rides on its current step,
|
|
7
|
+
* and also arrives live via the execution stream).
|
|
8
|
+
*/
|
|
9
|
+
export function humanTestApi({ http, ws }: ApiContext) {
|
|
10
|
+
const base = (workspaceId: string, blockId: string) =>
|
|
11
|
+
`${ws(workspaceId)}/blocks/${encodeURIComponent(blockId)}/human-test`
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
// Confirm the change works: tear the env down and advance the pipeline.
|
|
15
|
+
confirmHumanTest: (workspaceId: string, blockId: string) =>
|
|
16
|
+
http<ExecutionInstance>(`${base(workspaceId, blockId)}/confirm`, { method: 'POST' }),
|
|
17
|
+
|
|
18
|
+
// Submit findings and request a fix (dispatches the Tester's fixer, then rebuilds the env).
|
|
19
|
+
requestHumanTestFix: (workspaceId: string, blockId: string, findings: string) =>
|
|
20
|
+
http<ExecutionInstance>(`${base(workspaceId, blockId)}/request-fix`, {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
body: { findings },
|
|
23
|
+
}),
|
|
24
|
+
|
|
25
|
+
// Pull latest main into the PR branch + redeploy (conflict → conflict-resolver).
|
|
26
|
+
pullMainHumanTest: (workspaceId: string, blockId: string) =>
|
|
27
|
+
http<ExecutionInstance>(`${base(workspaceId, blockId)}/pull-main`, { method: 'POST' }),
|
|
28
|
+
|
|
29
|
+
// Rebuild the ephemeral environment on demand.
|
|
30
|
+
recreateHumanTestEnv: (workspaceId: string, blockId: string) =>
|
|
31
|
+
http<ExecutionInstance>(`${base(workspaceId, blockId)}/recreate-env`, { method: 'POST' }),
|
|
32
|
+
|
|
33
|
+
// Destroy the ephemeral environment on demand (the run stays parked).
|
|
34
|
+
destroyHumanTestEnv: (workspaceId: string, blockId: string) =>
|
|
35
|
+
http<ExecutionInstance>(`${base(workspaceId, blockId)}/destroy-env`, { method: 'POST' }),
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -82,6 +82,18 @@ export function tasksApi({ http, ws }: ApiContext) {
|
|
|
82
82
|
body,
|
|
83
83
|
}),
|
|
84
84
|
|
|
85
|
+
// Spawn an epic + its children as an epic node + child tasks, with dependency edges
|
|
86
|
+
// seeded from the issues' blocked-by/depends-on links.
|
|
87
|
+
spawnEpic: (
|
|
88
|
+
workspaceId: string,
|
|
89
|
+
source: TaskSourceKind,
|
|
90
|
+
body: { ref: string; containerId: string; position?: { x: number; y: number } },
|
|
91
|
+
) =>
|
|
92
|
+
http<{ epic: Block; tasks: Block[] }>(
|
|
93
|
+
`${ws(workspaceId)}/task-sources/${source}/epics/spawn`,
|
|
94
|
+
{ method: 'POST', body },
|
|
95
|
+
),
|
|
96
|
+
|
|
85
97
|
// ---- issue-tracker selection (workspace-level) ------------------------
|
|
86
98
|
getTrackerSettings: (workspaceId: string) =>
|
|
87
99
|
http<TrackerSettings>(`${ws(workspaceId)}/tracker-settings`),
|
|
@@ -8,6 +8,7 @@ import { documentsApi } from './api/documents'
|
|
|
8
8
|
import { executionApi } from './api/execution'
|
|
9
9
|
import { fragmentsApi } from './api/fragments'
|
|
10
10
|
import { githubApi } from './api/github'
|
|
11
|
+
import { humanTestApi } from './api/humanTest'
|
|
11
12
|
import { modelsApi } from './api/models'
|
|
12
13
|
import { notificationsApi } from './api/notifications'
|
|
13
14
|
import { presetsApi } from './api/presets'
|
|
@@ -80,6 +81,7 @@ export function useApi() {
|
|
|
80
81
|
...documentsApi(ctx),
|
|
81
82
|
...tasksApi(ctx),
|
|
82
83
|
...reviewsApi(ctx),
|
|
84
|
+
...humanTestApi(ctx),
|
|
83
85
|
...specApi(ctx),
|
|
84
86
|
...notificationsApi(ctx),
|
|
85
87
|
...presetsApi(ctx),
|
|
@@ -56,6 +56,19 @@ export function useBlockQueries(blocks: Ref<Block[]>) {
|
|
|
56
56
|
/** All tasks across every service (used for the dependency picker). */
|
|
57
57
|
const allTasks = computed(() => blocks.value.filter((b) => b.level === 'task'))
|
|
58
58
|
|
|
59
|
+
/** Epic grouping nodes (non-structural; group tasks via their `epicId`). */
|
|
60
|
+
const epics = computed(() => blocks.value.filter((b) => b.level === 'epic'))
|
|
61
|
+
|
|
62
|
+
/** The tasks that belong to an epic (anywhere on the board) via their `epicId`. */
|
|
63
|
+
function epicMembers(epicId: string): Block[] {
|
|
64
|
+
return blocks.value.filter((b) => b.epicId === epicId)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The epic a task belongs to, if any. */
|
|
68
|
+
function epicOf(task: Block): Block | undefined {
|
|
69
|
+
return task.epicId ? getBlock(task.epicId) : undefined
|
|
70
|
+
}
|
|
71
|
+
|
|
59
72
|
/** A task's dependencies that are not yet merged (i.e. block it from running). */
|
|
60
73
|
function unmetDeps(taskId: string) {
|
|
61
74
|
const t = getBlock(taskId)
|
|
@@ -139,6 +152,9 @@ export function useBlockQueries(blocks: Ref<Block[]>) {
|
|
|
139
152
|
getBlock,
|
|
140
153
|
frames,
|
|
141
154
|
allTasks,
|
|
155
|
+
epics,
|
|
156
|
+
epicMembers,
|
|
157
|
+
epicOf,
|
|
142
158
|
childrenOf,
|
|
143
159
|
tasksOf,
|
|
144
160
|
modulesOf,
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Drag-to-connect for board dependencies. A small connector handle on a task card calls
|
|
5
|
+
* {@link start} on pointerdown; we then track the pointer (a live preview line drawn by
|
|
6
|
+
* `DependencyConnectOverlay`) and, on release over another task card, create the edge
|
|
7
|
+
* "dropped-on dependsOn dragged-from" — i.e. you drag from the prerequisite onto the task
|
|
8
|
+
* that should wait for it. Module-level state so the handle and the overlay share one drag.
|
|
9
|
+
*
|
|
10
|
+
* Coordinates are client (screen) space, so the overlay is a fixed full-viewport SVG and
|
|
11
|
+
* the gesture follows pan/zoom for free (the cards move under the cursor, the line tracks
|
|
12
|
+
* the cursor). Target resolution uses `elementFromPoint` → nearest `[data-block-id]`.
|
|
13
|
+
*/
|
|
14
|
+
export interface ConnectState {
|
|
15
|
+
sourceId: string
|
|
16
|
+
x1: number
|
|
17
|
+
y1: number
|
|
18
|
+
x2: number
|
|
19
|
+
y2: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const connecting = ref<ConnectState | null>(null)
|
|
23
|
+
|
|
24
|
+
export function useDependencyConnect() {
|
|
25
|
+
const board = useBoardStore()
|
|
26
|
+
|
|
27
|
+
function onMove(ev: PointerEvent) {
|
|
28
|
+
if (!connecting.value) return
|
|
29
|
+
connecting.value.x2 = ev.clientX
|
|
30
|
+
connecting.value.y2 = ev.clientY
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function onUp(ev: PointerEvent) {
|
|
34
|
+
const state = connecting.value
|
|
35
|
+
window.removeEventListener('pointermove', onMove)
|
|
36
|
+
window.removeEventListener('pointerup', onUp)
|
|
37
|
+
connecting.value = null
|
|
38
|
+
if (!state) return
|
|
39
|
+
// Resolve the task under the cursor on release.
|
|
40
|
+
const el = document.elementFromPoint(ev.clientX, ev.clientY)
|
|
41
|
+
const card = el?.closest('[data-block-id]') as HTMLElement | null
|
|
42
|
+
const targetId = card?.getAttribute('data-block-id') ?? null
|
|
43
|
+
if (!targetId || targetId === state.sourceId) return
|
|
44
|
+
const target = board.getBlock(targetId)
|
|
45
|
+
const source = board.getBlock(state.sourceId)
|
|
46
|
+
if (!target || target.level !== 'task' || !source || source.level !== 'task') return
|
|
47
|
+
// Dropped-on task depends on the dragged-from (prerequisite) task.
|
|
48
|
+
await board.toggleDependency(targetId, state.sourceId)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Begin a connect drag from `sourceId`'s handle. */
|
|
52
|
+
function start(sourceId: string, ev: PointerEvent) {
|
|
53
|
+
ev.preventDefault()
|
|
54
|
+
ev.stopPropagation()
|
|
55
|
+
connecting.value = { sourceId, x1: ev.clientX, y1: ev.clientY, x2: ev.clientX, y2: ev.clientY }
|
|
56
|
+
window.addEventListener('pointermove', onMove)
|
|
57
|
+
window.addEventListener('pointerup', onUp)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { connecting, start }
|
|
61
|
+
}
|
package/app/stores/board.ts
CHANGED
|
@@ -15,8 +15,8 @@ import { useBlockQueries } from '~/composables/useBlockQueries'
|
|
|
15
15
|
interface RemovalSnapshot {
|
|
16
16
|
/** The removed block + all its descendants, in their original order. */
|
|
17
17
|
removed: Block[]
|
|
18
|
-
/** Survivors whose `dependsOn` lost an edge to a removed block (originals to restore). */
|
|
19
|
-
edges: { id: string; dependsOn: string[] }[]
|
|
18
|
+
/** Survivors whose `dependsOn`/`epicId` lost an edge to a removed block (originals to restore). */
|
|
19
|
+
edges: { id: string; dependsOn: string[]; epicId: string | null }[]
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export const useBoardStore = defineStore('board', () => {
|
|
@@ -97,6 +97,44 @@ export const useBoardStore = defineStore('board', () => {
|
|
|
97
97
|
return block
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Add an epic grouping node. Epics are non-structural: they group tasks via the tasks'
|
|
102
|
+
* `epicId`, so this just drops a new `epic`-level block on the board.
|
|
103
|
+
*/
|
|
104
|
+
async function addEpic(
|
|
105
|
+
title: string,
|
|
106
|
+
position: { x: number; y: number },
|
|
107
|
+
options?: { description?: string; parentId?: string },
|
|
108
|
+
): Promise<Block> {
|
|
109
|
+
const block = await api.addEpic(useWorkspaceStore().requireId(), {
|
|
110
|
+
title,
|
|
111
|
+
position,
|
|
112
|
+
...(options?.description ? { description: options.description } : {}),
|
|
113
|
+
...(options?.parentId ? { parentId: options.parentId } : {}),
|
|
114
|
+
})
|
|
115
|
+
upsert(block)
|
|
116
|
+
return block
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Assign a task to an epic, or detach it (epicId: null). */
|
|
120
|
+
async function assignToEpic(taskId: string, epicId: string | null) {
|
|
121
|
+
const t = getBlock(taskId)
|
|
122
|
+
if (!t) return
|
|
123
|
+
const prev = t.epicId ?? null
|
|
124
|
+
t.epicId = epicId // optimistic
|
|
125
|
+
try {
|
|
126
|
+
upsert(await api.assignToEpic(useWorkspaceStore().requireId(), taskId, { epicId }))
|
|
127
|
+
} catch (e) {
|
|
128
|
+
t.epicId = prev
|
|
129
|
+
toast.add({
|
|
130
|
+
title: 'Could not change epic',
|
|
131
|
+
description: e instanceof Error ? e.message : String(e),
|
|
132
|
+
icon: 'i-lucide-triangle-alert',
|
|
133
|
+
color: 'error',
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
100
138
|
/** Add a module (sub-frame) inside a service. */
|
|
101
139
|
async function addModule(
|
|
102
140
|
serviceId: string,
|
|
@@ -171,15 +209,22 @@ export const useBoardStore = defineStore('board', () => {
|
|
|
171
209
|
}
|
|
172
210
|
}
|
|
173
211
|
const removed = blocks.value.filter((b) => doomed.has(b.id))
|
|
174
|
-
// Survivors that pointed at a doomed block
|
|
212
|
+
// Survivors that pointed at a doomed block (dependency edge or epic membership) lose
|
|
213
|
+
// that link — snapshot the originals so a failed delete restores them faithfully.
|
|
175
214
|
const edges = blocks.value
|
|
176
|
-
.filter(
|
|
177
|
-
|
|
215
|
+
.filter(
|
|
216
|
+
(b) =>
|
|
217
|
+
!doomed.has(b.id) &&
|
|
218
|
+
(b.dependsOn.some((d) => doomed.has(d)) || (b.epicId != null && doomed.has(b.epicId))),
|
|
219
|
+
)
|
|
220
|
+
.map((b) => ({ id: b.id, dependsOn: [...b.dependsOn], epicId: b.epicId ?? null }))
|
|
178
221
|
blocks.value = blocks.value.filter((b) => !doomed.has(b.id))
|
|
179
222
|
for (const b of blocks.value) {
|
|
180
223
|
if (b.dependsOn.some((d) => doomed.has(d))) {
|
|
181
224
|
b.dependsOn = b.dependsOn.filter((d) => !doomed.has(d))
|
|
182
225
|
}
|
|
226
|
+
// A member of a deleted epic loses its membership (the task itself survives).
|
|
227
|
+
if (b.epicId != null && doomed.has(b.epicId)) b.epicId = null
|
|
183
228
|
}
|
|
184
229
|
return { removed, edges }
|
|
185
230
|
}
|
|
@@ -189,7 +234,10 @@ export const useBoardStore = defineStore('board', () => {
|
|
|
189
234
|
for (const b of snap.removed) if (!getBlock(b.id)) blocks.value.push(b)
|
|
190
235
|
for (const e of snap.edges) {
|
|
191
236
|
const b = getBlock(e.id)
|
|
192
|
-
if (b)
|
|
237
|
+
if (b) {
|
|
238
|
+
b.dependsOn = e.dependsOn
|
|
239
|
+
b.epicId = e.epicId
|
|
240
|
+
}
|
|
193
241
|
}
|
|
194
242
|
}
|
|
195
243
|
|
|
@@ -252,10 +300,23 @@ export const useBoardStore = defineStore('board', () => {
|
|
|
252
300
|
upsert(await api.updateBlock(useWorkspaceStore().requireId(), id, patch))
|
|
253
301
|
}
|
|
254
302
|
|
|
255
|
-
/**
|
|
303
|
+
/**
|
|
304
|
+
* Toggle a dependency edge target -> source (target dependsOn source). The backend
|
|
305
|
+
* rejects an edge that would close a cycle (422) — surface that as a toast rather than
|
|
306
|
+
* letting it throw unhandled out of a board gesture.
|
|
307
|
+
*/
|
|
256
308
|
async function toggleDependency(targetId: string, sourceId: string) {
|
|
257
309
|
if (targetId === sourceId || !getBlock(targetId)) return
|
|
258
|
-
|
|
310
|
+
try {
|
|
311
|
+
upsert(await api.toggleDependency(useWorkspaceStore().requireId(), targetId, { sourceId }))
|
|
312
|
+
} catch (e) {
|
|
313
|
+
toast.add({
|
|
314
|
+
title: 'Could not link tasks',
|
|
315
|
+
description: e instanceof Error ? e.message : String(e),
|
|
316
|
+
icon: 'i-lucide-triangle-alert',
|
|
317
|
+
color: 'error',
|
|
318
|
+
})
|
|
319
|
+
}
|
|
259
320
|
}
|
|
260
321
|
|
|
261
322
|
/** Remove a dependency edge target -> source if it exists. */
|
|
@@ -275,6 +336,8 @@ export const useBoardStore = defineStore('board', () => {
|
|
|
275
336
|
addServiceFromRepo,
|
|
276
337
|
addTask,
|
|
277
338
|
addModule,
|
|
339
|
+
addEpic,
|
|
340
|
+
assignToEpic,
|
|
278
341
|
reparentBlock,
|
|
279
342
|
detach,
|
|
280
343
|
reattach,
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import { useExecutionStore } from '~/stores/execution'
|
|
4
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Human-testing gate actions. The gate's live state rides on its execution step
|
|
8
|
+
* (`step.humanTest`) and arrives via the execution stream, so this store holds NO gate
|
|
9
|
+
* state — it only drives the actions (confirm / request a fix / pull main / recreate /
|
|
10
|
+
* destroy) and patches the execution store from each response. A per-block `busy` flag lets
|
|
11
|
+
* the window disable its controls while an action is in flight. Per-workspace; nothing
|
|
12
|
+
* persisted client-side.
|
|
13
|
+
*/
|
|
14
|
+
export const useHumanTestStore = defineStore('humanTest', () => {
|
|
15
|
+
const api = useApi()
|
|
16
|
+
const ws = useWorkspaceStore()
|
|
17
|
+
const execution = useExecutionStore()
|
|
18
|
+
|
|
19
|
+
/** Block ids with an action currently in flight (the window disables its buttons). */
|
|
20
|
+
const busy = ref<Set<string>>(new Set())
|
|
21
|
+
|
|
22
|
+
function isBusy(blockId: string): boolean {
|
|
23
|
+
return busy.value.has(blockId)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function run(blockId: string, action: () => Promise<unknown>): Promise<void> {
|
|
27
|
+
const next = new Set(busy.value)
|
|
28
|
+
next.add(blockId)
|
|
29
|
+
busy.value = next
|
|
30
|
+
try {
|
|
31
|
+
const instance = await action()
|
|
32
|
+
// The action returns the updated run; patch the store so the window reflects it
|
|
33
|
+
// immediately (the stream also pushes it, but this avoids a flash of stale state).
|
|
34
|
+
if (instance && typeof instance === 'object' && 'steps' in instance) {
|
|
35
|
+
execution.upsert(instance as Parameters<typeof execution.upsert>[0])
|
|
36
|
+
}
|
|
37
|
+
} finally {
|
|
38
|
+
const after = new Set(busy.value)
|
|
39
|
+
after.delete(blockId)
|
|
40
|
+
busy.value = after
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Confirm the change works: tear the env down and advance the pipeline. */
|
|
45
|
+
function confirm(blockId: string): Promise<void> {
|
|
46
|
+
return run(blockId, () => api.confirmHumanTest(ws.requireId(), blockId))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Submit findings and request a fix. */
|
|
50
|
+
function requestFix(blockId: string, findings: string): Promise<void> {
|
|
51
|
+
return run(blockId, () => api.requestHumanTestFix(ws.requireId(), blockId, findings))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Pull latest main into the branch + redeploy. */
|
|
55
|
+
function pullMain(blockId: string): Promise<void> {
|
|
56
|
+
return run(blockId, () => api.pullMainHumanTest(ws.requireId(), blockId))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Rebuild the ephemeral environment. */
|
|
60
|
+
function recreateEnv(blockId: string): Promise<void> {
|
|
61
|
+
return run(blockId, () => api.recreateHumanTestEnv(ws.requireId(), blockId))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Destroy the ephemeral environment (the run stays parked). */
|
|
65
|
+
function destroyEnv(blockId: string): Promise<void> {
|
|
66
|
+
return run(blockId, () => api.destroyHumanTestEnv(ws.requireId(), blockId))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { isBusy, confirm, requestFix, pullMain, recreateEnv, destroyEnv }
|
|
70
|
+
})
|
package/app/stores/tasks.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
TaskSourceState,
|
|
10
10
|
} from '~/types/domain'
|
|
11
11
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
12
|
+
import { useBoardStore } from '~/stores/board'
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Task-source integration state: the trackers the backend offers (and their
|
|
@@ -210,6 +211,29 @@ export const useTasksStore = defineStore('tasks', () => {
|
|
|
210
211
|
return result
|
|
211
212
|
}
|
|
212
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Spawn an epic and its children onto the board: an epic node + a task per child issue
|
|
216
|
+
* (joined to the epic), with dependency edges seeded from the issues' links. Upserts the
|
|
217
|
+
* created blocks so the board reflects them immediately (the stream also re-broadcasts).
|
|
218
|
+
*/
|
|
219
|
+
async function spawnEpic(
|
|
220
|
+
source: TaskSourceKind,
|
|
221
|
+
ref: string,
|
|
222
|
+
containerId: string,
|
|
223
|
+
position?: { x: number; y: number },
|
|
224
|
+
) {
|
|
225
|
+
const board = useBoardStore()
|
|
226
|
+
const result = await api.spawnEpic(workspace.requireId(), source, {
|
|
227
|
+
ref,
|
|
228
|
+
containerId,
|
|
229
|
+
...(position ? { position } : {}),
|
|
230
|
+
})
|
|
231
|
+
board.upsert(result.epic)
|
|
232
|
+
for (const t of result.tasks) board.upsert(t)
|
|
233
|
+
await loadTasks().catch(() => {})
|
|
234
|
+
return result
|
|
235
|
+
}
|
|
236
|
+
|
|
213
237
|
return {
|
|
214
238
|
available,
|
|
215
239
|
probeError,
|
|
@@ -237,5 +261,6 @@ export const useTasksStore = defineStore('tasks', () => {
|
|
|
237
261
|
search,
|
|
238
262
|
linkToBlock,
|
|
239
263
|
createTaskFromIssue,
|
|
264
|
+
spawnEpic,
|
|
240
265
|
}
|
|
241
266
|
})
|