@cat-factory/app 0.139.0 → 0.140.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 +12 -39
- package/app/components/documents/ContextDocumentPicker.vue +89 -70
- package/app/components/documents/RepoContextDocPicker.vue +277 -0
- package/app/components/github/RepoTreeBrowser.vue +19 -10
- package/app/components/panels/inspector/TaskRunSettings.vue +16 -23
- package/app/components/pipeline/PipelineBuilder.vue +10 -0
- package/app/components/pipeline/PipelinePicker.vue +120 -0
- package/app/components/pipeline/PipelinePreview.vue +49 -0
- package/app/composables/api/github.ts +8 -0
- package/app/composables/useContextLinking.spec.ts +138 -0
- package/app/composables/useContextLinking.ts +120 -8
- package/app/composables/useCopyToClipboard.spec.ts +27 -0
- package/app/composables/useCopyToClipboard.ts +17 -1
- package/app/stores/github.ts +24 -0
- package/app/stores/pipelines.ts +9 -0
- package/app/utils/pipeline.ts +25 -1
- package/i18n/locales/de.json +20 -0
- package/i18n/locales/en.json +20 -0
- package/i18n/locales/es.json +20 -0
- package/i18n/locales/fr.json +20 -0
- package/i18n/locales/he.json +20 -0
- package/i18n/locales/it.json +20 -0
- package/i18n/locales/ja.json +20 -0
- package/i18n/locales/pl.json +20 -0
- package/i18n/locales/tr.json +20 -0
- package/i18n/locales/uk.json +20 -0
- package/package.json +2 -2
|
@@ -310,6 +310,16 @@ async function clone(p: Pipeline) {
|
|
|
310
310
|
class="mb-2"
|
|
311
311
|
/>
|
|
312
312
|
|
|
313
|
+
<!-- Description: the prose summary shown next to the step list in the pipeline pickers. -->
|
|
314
|
+
<UTextarea
|
|
315
|
+
v-model="pipelines.draftDescription"
|
|
316
|
+
:placeholder="t('pipeline.builder.descriptionPlaceholder')"
|
|
317
|
+
:rows="2"
|
|
318
|
+
autoresize
|
|
319
|
+
size="sm"
|
|
320
|
+
class="mb-2 w-full"
|
|
321
|
+
/>
|
|
322
|
+
|
|
313
323
|
<!-- Labels: organize the pipeline in the library (filter/search). -->
|
|
314
324
|
<div class="mb-3 flex flex-wrap items-center gap-1.5">
|
|
315
325
|
<UBadge
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The rich pipeline picker used wherever a pipeline is chosen (add-task modal, inspector run
|
|
3
|
+
// settings). A master–detail popover: the left column lists the selectable pipelines (plus a
|
|
4
|
+
// "none / choose at run time" row), and hovering a row reveals that pipeline's full preview — its
|
|
5
|
+
// description + the ordered agent steps — in the right column, so a user sees exactly what a
|
|
6
|
+
// pipeline does before picking it. The trigger is customizable via the `#trigger` slot (the
|
|
7
|
+
// inspector uses a bare icon button; the modal a full-width labelled one).
|
|
8
|
+
import { computed, ref } from 'vue'
|
|
9
|
+
import type { Pipeline } from '~/types/domain'
|
|
10
|
+
|
|
11
|
+
const props = withDefaults(
|
|
12
|
+
defineProps<{
|
|
13
|
+
/** Selected pipeline id, or '' for the "none" option. */
|
|
14
|
+
modelValue: string
|
|
15
|
+
/** The pipelines offered (already filtered for the surface, e.g. manual-start allowed). */
|
|
16
|
+
options: Pipeline[]
|
|
17
|
+
/** Label for the "none" row (e.g. "Choose at run time" / "No default"). */
|
|
18
|
+
noneLabel: string
|
|
19
|
+
/** Extra classes for the default trigger button (e.g. full-width in the modal). */
|
|
20
|
+
triggerClass?: string
|
|
21
|
+
}>(),
|
|
22
|
+
{ triggerClass: '' },
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
const emit = defineEmits<{ 'update:modelValue': [string] }>()
|
|
26
|
+
const { t } = useI18n()
|
|
27
|
+
|
|
28
|
+
const open = ref(false)
|
|
29
|
+
// The row currently hovered, driving the right-column preview. `undefined` ⇒ fall back to the
|
|
30
|
+
// selected pipeline; the sentinel '' means the "none" row is hovered (show the none hint).
|
|
31
|
+
const hoverId = ref<string | undefined>(undefined)
|
|
32
|
+
|
|
33
|
+
const selected = computed(() => props.options.find((p) => p.id === props.modelValue))
|
|
34
|
+
const triggerLabel = computed(() => selected.value?.name ?? props.noneLabel)
|
|
35
|
+
|
|
36
|
+
/** The pipeline the right pane previews: the hovered row, else the current selection. */
|
|
37
|
+
const previewPipeline = computed<Pipeline | null>(() => {
|
|
38
|
+
const id = hoverId.value ?? props.modelValue
|
|
39
|
+
return id ? (props.options.find((p) => p.id === id) ?? null) : null
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
function choose(id: string) {
|
|
43
|
+
emit('update:modelValue', id)
|
|
44
|
+
open.value = false
|
|
45
|
+
}
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<template>
|
|
49
|
+
<UPopover v-model:open="open" :content="{ align: 'start' }">
|
|
50
|
+
<slot name="trigger" :label="triggerLabel">
|
|
51
|
+
<UButton
|
|
52
|
+
color="neutral"
|
|
53
|
+
variant="subtle"
|
|
54
|
+
size="sm"
|
|
55
|
+
icon="i-lucide-workflow"
|
|
56
|
+
trailing-icon="i-lucide-chevron-down"
|
|
57
|
+
:class="triggerClass"
|
|
58
|
+
data-testid="pipeline-picker-trigger"
|
|
59
|
+
>
|
|
60
|
+
{{ triggerLabel }}
|
|
61
|
+
</UButton>
|
|
62
|
+
</slot>
|
|
63
|
+
|
|
64
|
+
<template #content>
|
|
65
|
+
<div
|
|
66
|
+
class="flex max-h-[24rem] w-[min(44rem,94vw)]"
|
|
67
|
+
data-testid="pipeline-picker-panel"
|
|
68
|
+
@mouseleave="hoverId = undefined"
|
|
69
|
+
>
|
|
70
|
+
<!-- left: selectable options -->
|
|
71
|
+
<ul class="w-1/2 shrink-0 overflow-y-auto border-e border-slate-800 p-1">
|
|
72
|
+
<li>
|
|
73
|
+
<button
|
|
74
|
+
type="button"
|
|
75
|
+
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-start text-sm hover:bg-slate-800/60"
|
|
76
|
+
:class="modelValue ? 'text-slate-300' : 'text-slate-100'"
|
|
77
|
+
data-testid="pipeline-option-none"
|
|
78
|
+
@mouseenter="hoverId = ''"
|
|
79
|
+
@click="choose('')"
|
|
80
|
+
>
|
|
81
|
+
<UIcon name="i-lucide-rotate-ccw" class="h-4 w-4 shrink-0 text-slate-400" />
|
|
82
|
+
<span class="flex-1 truncate">{{ noneLabel }}</span>
|
|
83
|
+
<UIcon
|
|
84
|
+
v-if="!modelValue"
|
|
85
|
+
name="i-lucide-check"
|
|
86
|
+
class="h-4 w-4 shrink-0 text-primary-400"
|
|
87
|
+
/>
|
|
88
|
+
</button>
|
|
89
|
+
</li>
|
|
90
|
+
<li v-for="p in options" :key="p.id">
|
|
91
|
+
<button
|
|
92
|
+
type="button"
|
|
93
|
+
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-start text-sm hover:bg-slate-800/60"
|
|
94
|
+
:class="modelValue === p.id ? 'text-slate-100' : 'text-slate-300'"
|
|
95
|
+
:data-testid="`pipeline-option-${p.id}`"
|
|
96
|
+
@mouseenter="hoverId = p.id"
|
|
97
|
+
@click="choose(p.id)"
|
|
98
|
+
>
|
|
99
|
+
<UIcon name="i-lucide-workflow" class="h-4 w-4 shrink-0 text-slate-400" />
|
|
100
|
+
<span class="flex-1 truncate">{{ p.name }}</span>
|
|
101
|
+
<UIcon
|
|
102
|
+
v-if="modelValue === p.id"
|
|
103
|
+
name="i-lucide-check"
|
|
104
|
+
class="h-4 w-4 shrink-0 text-primary-400"
|
|
105
|
+
/>
|
|
106
|
+
</button>
|
|
107
|
+
</li>
|
|
108
|
+
</ul>
|
|
109
|
+
|
|
110
|
+
<!-- right: preview of the hovered (or selected) pipeline -->
|
|
111
|
+
<div class="w-1/2 overflow-y-auto p-3">
|
|
112
|
+
<PipelinePreview v-if="previewPipeline" :pipeline="previewPipeline" />
|
|
113
|
+
<div v-else class="text-[12px] leading-snug text-slate-500">
|
|
114
|
+
{{ t('pipeline.picker.noneHint') }}
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
</template>
|
|
119
|
+
</UPopover>
|
|
120
|
+
</template>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// A compact, read-only summary of a pipeline: its name, prose description (when authored), and the
|
|
3
|
+
// ordered list of enabled agent steps rendered as icon+label chips (a human-gated step is flagged).
|
|
4
|
+
// Shared by the pipeline pickers' hover preview so "what a pipeline consists of" is explained the
|
|
5
|
+
// same way everywhere. Resolves each step's display metadata through the single `agentKindMeta`
|
|
6
|
+
// path (via <AgentKindIcon>), so a system/custom kind can never blow up the renderer.
|
|
7
|
+
import { computed } from 'vue'
|
|
8
|
+
import type { Pipeline } from '~/types/domain'
|
|
9
|
+
import { pipelineDisplaySteps } from '~/utils/pipeline'
|
|
10
|
+
|
|
11
|
+
const props = defineProps<{ pipeline: Pipeline }>()
|
|
12
|
+
const { t } = useI18n()
|
|
13
|
+
|
|
14
|
+
const steps = computed(() => pipelineDisplaySteps(props.pipeline))
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<template>
|
|
18
|
+
<div class="space-y-2" data-testid="pipeline-preview">
|
|
19
|
+
<div class="text-sm font-semibold text-slate-100">{{ pipeline.name }}</div>
|
|
20
|
+
<p
|
|
21
|
+
v-if="pipeline.description"
|
|
22
|
+
class="text-[12px] leading-snug text-slate-400"
|
|
23
|
+
data-testid="pipeline-preview-description"
|
|
24
|
+
>
|
|
25
|
+
{{ pipeline.description }}
|
|
26
|
+
</p>
|
|
27
|
+
<div>
|
|
28
|
+
<div class="mb-1 flex items-center gap-1 text-[10px] uppercase tracking-wide text-slate-500">
|
|
29
|
+
<UIcon name="i-lucide-workflow" class="h-3 w-3" />
|
|
30
|
+
{{ t('pipeline.preview.stepCount', { count: steps.length }, steps.length) }}
|
|
31
|
+
</div>
|
|
32
|
+
<ol class="flex flex-wrap items-center gap-1">
|
|
33
|
+
<li
|
|
34
|
+
v-for="(s, i) in steps"
|
|
35
|
+
:key="i"
|
|
36
|
+
class="inline-flex items-center gap-1 rounded bg-slate-800/70 px-1.5 py-0.5"
|
|
37
|
+
>
|
|
38
|
+
<AgentKindIcon :kind="s.kind" show-label icon-class="h-3.5 w-3.5" />
|
|
39
|
+
<UIcon
|
|
40
|
+
v-if="s.gated"
|
|
41
|
+
name="i-lucide-shield-check"
|
|
42
|
+
class="h-3 w-3 text-amber-400"
|
|
43
|
+
:title="t('pipeline.preview.gated')"
|
|
44
|
+
/>
|
|
45
|
+
</li>
|
|
46
|
+
</ol>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
</template>
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
listGitHubIssuesContract,
|
|
14
14
|
listGitHubPullsContract,
|
|
15
15
|
listGitHubReposContract,
|
|
16
|
+
listGitHubRepoFilesContract,
|
|
16
17
|
listGitHubRepoTreeContract,
|
|
17
18
|
mergeGitHubPullRequestContract,
|
|
18
19
|
openGitHubPullRequestContract,
|
|
@@ -92,6 +93,13 @@ export function githubApi({ send, ws }: ApiContext) {
|
|
|
92
93
|
queryParams: { path },
|
|
93
94
|
}),
|
|
94
95
|
|
|
96
|
+
// List every file in a repo (whole tree, one recursive read) for file-path search.
|
|
97
|
+
listGitHubRepoFiles: (workspaceId: string, repoGithubId: number) =>
|
|
98
|
+
send(listGitHubRepoFilesContract, {
|
|
99
|
+
pathPrefix: ws(workspaceId),
|
|
100
|
+
pathParams: { repoGithubId: String(repoGithubId) },
|
|
101
|
+
}),
|
|
102
|
+
|
|
95
103
|
listGitHubBranches: (workspaceId: string, repoGithubId: number) =>
|
|
96
104
|
send(listGitHubBranchesContract, {
|
|
97
105
|
pathPrefix: ws(workspaceId),
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
buildLinkFailureReport,
|
|
4
|
+
contextKey,
|
|
5
|
+
type LinkFailure,
|
|
6
|
+
type PendingContext,
|
|
7
|
+
useContextLinking,
|
|
8
|
+
} from '~/composables/useContextLinking'
|
|
9
|
+
|
|
10
|
+
// The context-linking path used to swallow every attachment failure into a bare count.
|
|
11
|
+
// `buildLinkFailureReport` is the pure diagnostic dump the "Copy details" toast action
|
|
12
|
+
// puts on the clipboard, so it must carry the item coordinates, the HTTP status + backend
|
|
13
|
+
// code, and the server's message — the exact context a bug report needs.
|
|
14
|
+
|
|
15
|
+
function item(overrides: Partial<PendingContext> = {}): PendingContext {
|
|
16
|
+
return {
|
|
17
|
+
kind: 'document',
|
|
18
|
+
source: 'github',
|
|
19
|
+
externalId: 'acme/repo:docs/x.md',
|
|
20
|
+
title: 'x.md',
|
|
21
|
+
needsImport: true,
|
|
22
|
+
...overrides,
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('buildLinkFailureReport', () => {
|
|
27
|
+
it('captures every failure with its coordinates, status, code, and message', () => {
|
|
28
|
+
const failures: LinkFailure[] = [
|
|
29
|
+
{
|
|
30
|
+
item: item(),
|
|
31
|
+
message: 'GitHub denied access to "docs/x.md" in acme/repo (HTTP 403).',
|
|
32
|
+
status: 403,
|
|
33
|
+
code: 'conflict',
|
|
34
|
+
},
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
const report = buildLinkFailureReport(failures, {
|
|
38
|
+
workspaceId: 'ws_1',
|
|
39
|
+
blockId: 'blk_1',
|
|
40
|
+
when: '2026-07-19T00:00:00.000Z',
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
expect(report).toContain('Context link failures: 1')
|
|
44
|
+
expect(report).toContain('workspace: ws_1')
|
|
45
|
+
expect(report).toContain('block: blk_1')
|
|
46
|
+
expect(report).toContain('document/github: acme/repo:docs/x.md')
|
|
47
|
+
expect(report).toContain('status: 403')
|
|
48
|
+
expect(report).toContain('code: conflict')
|
|
49
|
+
expect(report).toContain('error: GitHub denied access')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('omits absent optional fields (no status/code/context)', () => {
|
|
53
|
+
const report = buildLinkFailureReport([{ item: item(), message: 'network error' }])
|
|
54
|
+
expect(report).toContain('Context link failures: 1')
|
|
55
|
+
expect(report).not.toContain('status:')
|
|
56
|
+
expect(report).not.toContain('code:')
|
|
57
|
+
expect(report).not.toContain('workspace:')
|
|
58
|
+
expect(report).toContain('error: network error')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('dumps the backend details bag, distinguishing the upstream status from the HTTP status', () => {
|
|
62
|
+
const report = buildLinkFailureReport([
|
|
63
|
+
{
|
|
64
|
+
item: item(),
|
|
65
|
+
message: 'GitHub denied access to "docs/x.md" in acme/repo (HTTP 403).',
|
|
66
|
+
status: 409,
|
|
67
|
+
code: 'conflict',
|
|
68
|
+
details: { owner: 'acme', repo: 'repo', path: 'docs/x.md', status: 403 },
|
|
69
|
+
},
|
|
70
|
+
])
|
|
71
|
+
// The mapped HTTP status and the upstream GitHub status are both present, unambiguously.
|
|
72
|
+
expect(report).toContain('status: 409')
|
|
73
|
+
expect(report).toContain('details.status: 403')
|
|
74
|
+
expect(report).toContain('details.owner: acme')
|
|
75
|
+
expect(report).toContain('details.path: docs/x.md')
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('contextKey', () => {
|
|
80
|
+
it('is stable and distinguishes kind/source/externalId', () => {
|
|
81
|
+
expect(contextKey(item())).toBe('document:github:acme/repo:docs/x.md')
|
|
82
|
+
expect(contextKey(item({ kind: 'task', source: 'github' }))).toBe(
|
|
83
|
+
'task:github:acme/repo:docs/x.md',
|
|
84
|
+
)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('presentLinkFailures', () => {
|
|
89
|
+
// Stub the Nuxt auto-imports `useContextLinking` pulls in, so the toast-orchestration
|
|
90
|
+
// side of the composable can be exercised without a full Nuxt runtime.
|
|
91
|
+
function stub() {
|
|
92
|
+
const add = vi.fn()
|
|
93
|
+
// `copyAction` echoes the text it would copy so we can assert the report content.
|
|
94
|
+
const copyAction = vi.fn((text: string) => ({ label: 'copy', text, onClick: () => {} }))
|
|
95
|
+
vi.stubGlobal('useDocumentsStore', () => ({}))
|
|
96
|
+
vi.stubGlobal('useTasksStore', () => ({}))
|
|
97
|
+
vi.stubGlobal('useWorkspaceStore', () => ({ workspaceId: 'ws_1' }))
|
|
98
|
+
vi.stubGlobal('useToast', () => ({ add }))
|
|
99
|
+
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
|
100
|
+
vi.stubGlobal('useCopyToClipboard', () => ({ copyAction }))
|
|
101
|
+
return { add, copyAction }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
afterEach(() => vi.unstubAllGlobals())
|
|
105
|
+
|
|
106
|
+
it('is a no-op when nothing failed', () => {
|
|
107
|
+
const { add } = stub()
|
|
108
|
+
useContextLinking().presentLinkFailures([])
|
|
109
|
+
expect(add).not.toHaveBeenCalled()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('raises one sticky, actionable toast whose Copy action carries the full report', () => {
|
|
113
|
+
const { add, copyAction } = stub()
|
|
114
|
+
const failures: LinkFailure[] = [
|
|
115
|
+
{
|
|
116
|
+
item: item(),
|
|
117
|
+
message: 'GitHub denied access to "docs/x.md" in acme/repo (HTTP 403).',
|
|
118
|
+
status: 409,
|
|
119
|
+
code: 'conflict',
|
|
120
|
+
details: { owner: 'acme', repo: 'repo', path: 'docs/x.md', status: 403 },
|
|
121
|
+
},
|
|
122
|
+
]
|
|
123
|
+
useContextLinking().presentLinkFailures(failures, 'blk_1')
|
|
124
|
+
|
|
125
|
+
expect(add).toHaveBeenCalledTimes(1)
|
|
126
|
+
const toast = add.mock.calls[0]![0]
|
|
127
|
+
// Sticky so the cause stays readable, titled by the count key, and per-item reason shown.
|
|
128
|
+
expect(toast.title).toBe('board.addTask.linkFailed')
|
|
129
|
+
expect(toast.duration).toBe(0)
|
|
130
|
+
expect(toast.description).toContain('GitHub denied access')
|
|
131
|
+
expect(toast.actions).toHaveLength(1)
|
|
132
|
+
// The Copy action's payload is the full diagnostic report (block + upstream status).
|
|
133
|
+
const report = copyAction.mock.calls[0]![0]
|
|
134
|
+
expect(report).toContain('block: blk_1')
|
|
135
|
+
expect(report).toContain('details.status: 403')
|
|
136
|
+
expect(toast.actions[0]).toMatchObject({ text: report })
|
|
137
|
+
})
|
|
138
|
+
})
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DocumentSourceKind, TaskSourceKind } from '~/types/domain'
|
|
2
|
+
import { apiErrorEnvelope, apiErrorStatus } from './api/errors'
|
|
2
3
|
|
|
3
4
|
// Shared model + orchestration for attaching external context (imported docs and
|
|
4
5
|
// tracker issues) to a board block. A "pending" item is something the user has
|
|
@@ -30,22 +31,96 @@ export interface PendingContext {
|
|
|
30
31
|
needsImport: boolean
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
/**
|
|
35
|
+
* A single pending attachment that failed to import or link, captured with its
|
|
36
|
+
* actual cause instead of swallowed. The message is the server's own explanation
|
|
37
|
+
* (e.g. "GitHub denied access to …" / "… was not found on the default branch"),
|
|
38
|
+
* the status is the HTTP code, and the code is the backend error code
|
|
39
|
+
* (`conflict` / `validation` / …) — enough to both display a specific reason and
|
|
40
|
+
* assemble a copy-pasteable diagnostic report.
|
|
41
|
+
*/
|
|
42
|
+
export interface LinkFailure {
|
|
43
|
+
item: PendingContext
|
|
44
|
+
/** The server's message (or a network-fault message) explaining why it failed. */
|
|
45
|
+
message: string
|
|
46
|
+
/** HTTP status of the failed request, when the error carried one. */
|
|
47
|
+
status?: number
|
|
48
|
+
/** Backend error code (`conflict` / `validation` / …), when present. */
|
|
49
|
+
code?: string
|
|
50
|
+
/**
|
|
51
|
+
* The backend error envelope's `details` bag, when present — for a GitHub doc read
|
|
52
|
+
* this carries the repo coordinates + the UPSTREAM GitHub status (e.g. `status: 403`),
|
|
53
|
+
* which differs from the HTTP `status` above (the mapped response code, e.g. 409). Kept
|
|
54
|
+
* so the diagnostic report shows the real GitHub status, not just the mapped one.
|
|
55
|
+
*/
|
|
56
|
+
details?: Record<string, unknown>
|
|
57
|
+
}
|
|
58
|
+
|
|
33
59
|
/** Stable key for a pending item, used for dedupe + selection toggles. */
|
|
34
60
|
export function contextKey(c: Pick<PendingContext, 'kind' | 'source' | 'externalId'>): string {
|
|
35
61
|
return `${c.kind}:${c.source}:${c.externalId}`
|
|
36
62
|
}
|
|
37
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Render a batch of {@link LinkFailure}s into a single plain-text diagnostic block
|
|
66
|
+
* for the clipboard — the exact context a bug report needs (the item coordinates,
|
|
67
|
+
* the HTTP status + backend code, the backend `details` bag, and the server's message)
|
|
68
|
+
* so the user does not have to retype any of it. Deliberately English/technical (a log
|
|
69
|
+
* dump, not UI prose), mirroring how format/code examples stay out of the i18n catalog.
|
|
70
|
+
*/
|
|
71
|
+
export function buildLinkFailureReport(
|
|
72
|
+
failures: LinkFailure[],
|
|
73
|
+
context: { workspaceId?: string | null; blockId?: string; when?: string } = {},
|
|
74
|
+
): string {
|
|
75
|
+
const lines: string[] = []
|
|
76
|
+
lines.push(`Context link failures: ${failures.length}`)
|
|
77
|
+
if (context.when) lines.push(`when: ${context.when}`)
|
|
78
|
+
if (context.workspaceId) lines.push(`workspace: ${context.workspaceId}`)
|
|
79
|
+
if (context.blockId) lines.push(`block: ${context.blockId}`)
|
|
80
|
+
for (const f of failures) {
|
|
81
|
+
lines.push('')
|
|
82
|
+
lines.push(`- ${f.item.kind}/${f.item.source}: ${f.item.externalId}`)
|
|
83
|
+
lines.push(` title: ${f.item.title}`)
|
|
84
|
+
if (f.status !== undefined) lines.push(` status: ${f.status}`)
|
|
85
|
+
if (f.code) lines.push(` code: ${f.code}`)
|
|
86
|
+
// Dump the backend `details` (repo coordinates + upstream status), each key namespaced
|
|
87
|
+
// so its `status` reads clearly as the GitHub status, distinct from the HTTP `status`.
|
|
88
|
+
for (const [key, value] of Object.entries(f.details ?? {})) {
|
|
89
|
+
lines.push(` details.${key}: ${formatDetailValue(value)}`)
|
|
90
|
+
}
|
|
91
|
+
lines.push(` error: ${f.message}`)
|
|
92
|
+
}
|
|
93
|
+
return lines.join('\n')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** One-line rendering of a `details` value for the diagnostic dump (objects → JSON). */
|
|
97
|
+
function formatDetailValue(value: unknown): string {
|
|
98
|
+
if (value === null || value === undefined) return String(value)
|
|
99
|
+
if (typeof value === 'object') {
|
|
100
|
+
try {
|
|
101
|
+
return JSON.stringify(value)
|
|
102
|
+
} catch {
|
|
103
|
+
return String(value)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return String(value)
|
|
107
|
+
}
|
|
108
|
+
|
|
38
109
|
export function useContextLinking() {
|
|
39
110
|
const documents = useDocumentsStore()
|
|
40
111
|
const tasks = useTasksStore()
|
|
112
|
+
const workspace = useWorkspaceStore()
|
|
113
|
+
const toast = useToast()
|
|
114
|
+
const { t } = useI18n()
|
|
115
|
+
const { copyAction } = useCopyToClipboard()
|
|
41
116
|
|
|
42
117
|
/**
|
|
43
118
|
* Import (when needed) then link every pending item to `blockId`. Each failure
|
|
44
|
-
* is
|
|
45
|
-
* the rest; returns
|
|
119
|
+
* is captured with its actual cause rather than aborting the batch, so one bad
|
|
120
|
+
* attachment doesn't sink the rest; returns the failures (empty ⇒ all linked).
|
|
46
121
|
*/
|
|
47
|
-
async function linkPending(blockId: string, items: PendingContext[]): Promise<
|
|
48
|
-
|
|
122
|
+
async function linkPending(blockId: string, items: PendingContext[]): Promise<LinkFailure[]> {
|
|
123
|
+
const failures: LinkFailure[] = []
|
|
49
124
|
for (const item of items) {
|
|
50
125
|
try {
|
|
51
126
|
if (item.kind === 'document') {
|
|
@@ -61,12 +136,49 @@ export function useContextLinking() {
|
|
|
61
136
|
: item.externalId
|
|
62
137
|
await tasks.linkToBlock(blockId, source, externalId)
|
|
63
138
|
}
|
|
64
|
-
} catch {
|
|
65
|
-
|
|
139
|
+
} catch (e) {
|
|
140
|
+
// Never swallow the cause: capture the server's own message + status/code/details
|
|
141
|
+
// so the toast can name the specific reason and the copy affordance can carry the
|
|
142
|
+
// full context (incl. the upstream GitHub status the backend puts on `details`).
|
|
143
|
+
const envelope = apiErrorEnvelope(e)
|
|
144
|
+
failures.push({
|
|
145
|
+
item,
|
|
146
|
+
message: e instanceof Error ? e.message : String(e),
|
|
147
|
+
status: apiErrorStatus(e),
|
|
148
|
+
code: envelope?.code,
|
|
149
|
+
details:
|
|
150
|
+
envelope?.details && typeof envelope.details === 'object'
|
|
151
|
+
? (envelope.details as Record<string, unknown>)
|
|
152
|
+
: undefined,
|
|
153
|
+
})
|
|
66
154
|
}
|
|
67
155
|
}
|
|
68
|
-
return
|
|
156
|
+
return failures
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Surface link failures as a single actionable toast: the specific per-item
|
|
161
|
+
* reasons as the body, and a "Copy details" action that puts the full diagnostic
|
|
162
|
+
* report ({@link buildLinkFailureReport}) on the clipboard. Sticky (`duration: 0`)
|
|
163
|
+
* so the cause stays readable long enough to act on. No-op when nothing failed.
|
|
164
|
+
*/
|
|
165
|
+
function presentLinkFailures(failures: LinkFailure[], blockId?: string): void {
|
|
166
|
+
if (failures.length === 0) return
|
|
167
|
+
const description = failures.map((f) => `${f.item.title}: ${f.message}`).join('\n')
|
|
168
|
+
const report = buildLinkFailureReport(failures, {
|
|
169
|
+
workspaceId: workspace.workspaceId,
|
|
170
|
+
blockId,
|
|
171
|
+
when: new Date().toISOString(),
|
|
172
|
+
})
|
|
173
|
+
toast.add({
|
|
174
|
+
title: t('board.addTask.linkFailed', { count: failures.length }, failures.length),
|
|
175
|
+
description,
|
|
176
|
+
icon: 'i-lucide-triangle-alert',
|
|
177
|
+
color: 'warning',
|
|
178
|
+
duration: 0,
|
|
179
|
+
actions: [copyAction(report)],
|
|
180
|
+
})
|
|
69
181
|
}
|
|
70
182
|
|
|
71
|
-
return { linkPending }
|
|
183
|
+
return { linkPending, presentLinkFailures }
|
|
72
184
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
// `useCopyToClipboard` wraps VueUse's clipboard; mock it so `copyAction` can be exercised
|
|
4
|
+
// without a real clipboard. The global `useToast`/`useI18n` stubs come from test/setup.ts
|
|
5
|
+
// (`t` echoes the key), so the default label resolves to its i18n key.
|
|
6
|
+
const { writeClipboard } = vi.hoisted(() => ({ writeClipboard: vi.fn(async () => {}) }))
|
|
7
|
+
vi.mock('@vueuse/core', () => ({
|
|
8
|
+
useClipboard: () => ({ copy: writeClipboard, isSupported: { value: true } }),
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
import { useCopyToClipboard } from '~/composables/useCopyToClipboard'
|
|
12
|
+
|
|
13
|
+
describe('copyAction', () => {
|
|
14
|
+
it('builds a Copy-details toast action that copies the given text', async () => {
|
|
15
|
+
const action = useCopyToClipboard().copyAction('diagnostic report')
|
|
16
|
+
expect(action.label).toBe('common.copyDetails')
|
|
17
|
+
expect(action.icon).toBe('i-lucide-clipboard')
|
|
18
|
+
|
|
19
|
+
action.onClick()
|
|
20
|
+
await Promise.resolve()
|
|
21
|
+
expect(writeClipboard).toHaveBeenCalledWith('diagnostic report')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('honours a custom label', () => {
|
|
25
|
+
expect(useCopyToClipboard().copyAction('x', 'Custom label').label).toBe('Custom label')
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -25,5 +25,21 @@ export function useCopyToClipboard() {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
/**
|
|
29
|
+
* A ready-made toast action that copies `text` (through {@link copy}, so it shows the
|
|
30
|
+
* same "Copied" / "Copy failed" feedback). Drop it into a toast's `actions` so any
|
|
31
|
+
* error/warning toast can offer a one-click "Copy details" — the message + context the
|
|
32
|
+
* user would otherwise have to retype into a bug report.
|
|
33
|
+
*/
|
|
34
|
+
function copyAction(text: string, label?: string) {
|
|
35
|
+
return {
|
|
36
|
+
label: label ?? t('common.copyDetails'),
|
|
37
|
+
icon: 'i-lucide-clipboard',
|
|
38
|
+
onClick: () => {
|
|
39
|
+
void copy(text)
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { copy, copyAction, isSupported }
|
|
29
45
|
}
|
package/app/stores/github.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
GitHubRepo,
|
|
12
12
|
MergePullRequestInput,
|
|
13
13
|
OpenPullRequestInput,
|
|
14
|
+
RepoTreeEntry,
|
|
14
15
|
ResyncRequest,
|
|
15
16
|
} from '~/types/domain'
|
|
16
17
|
import { useSingleFlightProbe } from '~/composables/useSingleFlightProbe'
|
|
@@ -215,6 +216,26 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
215
216
|
return api.listGitHubRepoTree(workspace.requireId(), repoGithubId, path)
|
|
216
217
|
}
|
|
217
218
|
|
|
219
|
+
/** Full file listing per repo (recursive tree), cached by GitHub numeric id. */
|
|
220
|
+
const repoFiles = ref<Record<number, RepoTreeEntry[]>>({})
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Load (and cache) EVERY file in a repo — the whole tree in one recursive read — so a
|
|
224
|
+
* picker can search files by path entirely client-side (no per-keystroke server call).
|
|
225
|
+
* Cached by repo id so re-opening the picker for the same repo is instant; force a
|
|
226
|
+
* refetch with `{ reload: true }`. Mirrors the branches cache.
|
|
227
|
+
*/
|
|
228
|
+
async function loadRepoFiles(
|
|
229
|
+
repoGithubId: number,
|
|
230
|
+
opts: { reload?: boolean } = {},
|
|
231
|
+
): Promise<RepoTreeEntry[]> {
|
|
232
|
+
const cached = repoFiles.value[repoGithubId]
|
|
233
|
+
if (cached && !opts.reload) return cached
|
|
234
|
+
const files = await api.listGitHubRepoFiles(workspace.requireId(), repoGithubId)
|
|
235
|
+
repoFiles.value = { ...repoFiles.value, [repoGithubId]: files }
|
|
236
|
+
return files
|
|
237
|
+
}
|
|
238
|
+
|
|
218
239
|
/** The URL a workspace owner visits to install the App against this workspace. */
|
|
219
240
|
function getInstallUrl(): Promise<string> {
|
|
220
241
|
return api.getGitHubInstallUrl(workspace.requireId()).then((r) => r.url)
|
|
@@ -313,6 +334,7 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
313
334
|
pulls.value = []
|
|
314
335
|
issues.value = []
|
|
315
336
|
branches.value = {}
|
|
337
|
+
repoFiles.value = {}
|
|
316
338
|
}
|
|
317
339
|
|
|
318
340
|
return {
|
|
@@ -347,6 +369,8 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
347
369
|
searchAvailableRepos,
|
|
348
370
|
setLinkedRepos,
|
|
349
371
|
loadRepoTree,
|
|
372
|
+
repoFiles,
|
|
373
|
+
loadRepoFiles,
|
|
350
374
|
loadBranches,
|
|
351
375
|
getInstallUrl,
|
|
352
376
|
loadInstallations,
|
package/app/stores/pipelines.ts
CHANGED
|
@@ -82,6 +82,8 @@ export const usePipelinesStore = defineStore('pipelines', () => {
|
|
|
82
82
|
/** Organizational labels for the pipeline being assembled/edited. */
|
|
83
83
|
const draftLabels = ref<string[]>([])
|
|
84
84
|
const draftName = ref('New pipeline')
|
|
85
|
+
/** Prose description for the pipeline being assembled/edited (shown in the pickers). */
|
|
86
|
+
const draftDescription = ref('')
|
|
85
87
|
/** The id of the pipeline being edited, or null when assembling a brand-new one. */
|
|
86
88
|
const editingId = ref<string | null>(null)
|
|
87
89
|
|
|
@@ -322,6 +324,7 @@ export const usePipelinesStore = defineStore('pipelines', () => {
|
|
|
322
324
|
draftStepOptions.value = []
|
|
323
325
|
draftLabels.value = []
|
|
324
326
|
draftName.value = 'New pipeline'
|
|
327
|
+
draftDescription.value = ''
|
|
325
328
|
editingId.value = null
|
|
326
329
|
}
|
|
327
330
|
|
|
@@ -340,6 +343,7 @@ export const usePipelinesStore = defineStore('pipelines', () => {
|
|
|
340
343
|
draftStepOptions.value = pipeline.agentKinds.map((_, i) => pipeline.stepOptions?.[i] ?? null)
|
|
341
344
|
draftLabels.value = [...(pipeline.labels ?? [])]
|
|
342
345
|
draftName.value = pipeline.name
|
|
346
|
+
draftDescription.value = pipeline.description ?? ''
|
|
343
347
|
editingId.value = pipeline.id
|
|
344
348
|
}
|
|
345
349
|
|
|
@@ -347,6 +351,10 @@ export const usePipelinesStore = defineStore('pipelines', () => {
|
|
|
347
351
|
function draftPayload() {
|
|
348
352
|
return {
|
|
349
353
|
name: draftName.value.trim() || 'Untitled pipeline',
|
|
354
|
+
// ALWAYS send description (like stepOptions) so an update can CLEAR it — an omitted field
|
|
355
|
+
// reads as "keep existing", so toggling the last of the text away must send the empty string.
|
|
356
|
+
// The backend trims + drops a blank one, so this is a no-op on create / an empty description.
|
|
357
|
+
description: draftDescription.value.trim(),
|
|
350
358
|
agentKinds: [...draft.value],
|
|
351
359
|
// Only send gates when at least one step is gated.
|
|
352
360
|
...(draftGates.value.some(Boolean) ? { gates: [...draftGates.value] } : {}),
|
|
@@ -452,6 +460,7 @@ export const usePipelinesStore = defineStore('pipelines', () => {
|
|
|
452
460
|
draftStepOptions,
|
|
453
461
|
draftLabels,
|
|
454
462
|
draftName,
|
|
463
|
+
draftDescription,
|
|
455
464
|
editingId,
|
|
456
465
|
units,
|
|
457
466
|
hydrate,
|