@cat-factory/app 0.139.0 → 0.141.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.
@@ -7,12 +7,13 @@
7
7
  // via `v-model`. The component owns its own navigation/loading state so callers
8
8
  // just bind a repo id + mode; it self-loads on mount and when those change.
9
9
  //
10
- // `dir` mode additionally supports `multiple`: instead of the single `v-model`
10
+ // Both modes additionally support `multiple`: instead of the single `v-model`
11
11
  // path, the caller passes the current `selectedPaths` (a cart) + `addedPaths`
12
- // (directories already on the board, shown disabled) and handles the `toggle`
13
- // event to add/remove a directory. This lets one browse session accumulate
14
- // several services from ANY parent folder (the monorepo add flow) navigating
15
- // away never drops earlier picks.
12
+ // (paths already chosen elsewhere, shown disabled) and handles the `toggle`
13
+ // event to add/remove a path. In `dir` mode this accumulates service directories
14
+ // from ANY parent folder (the monorepo add flow); in `file` mode it accumulates
15
+ // context-document files from anywhere in the tree — navigating away never drops
16
+ // earlier picks.
16
17
  import type { RepoTreeEntry } from '~/types/domain'
17
18
 
18
19
  const props = withDefaults(
@@ -23,11 +24,11 @@ const props = withDefaults(
23
24
  modelValue?: string
24
25
  /** Directory to open at (e.g. a monorepo service's subdirectory). */
25
26
  startPath?: string
26
- /** `dir` mode: accumulate a set of picks (via `selectedPaths`/`toggle`) instead of one. */
27
+ /** Accumulate a set of picks (via `selectedPaths`/`toggle`) instead of one. Either mode. */
27
28
  multiple?: boolean
28
- /** `dir` + `multiple`: the current cart of picked directories (repo-root-relative). */
29
+ /** `multiple`: the current cart of picked paths (repo-root-relative). */
29
30
  selectedPaths?: string[]
30
- /** `dir` + `multiple`: directories already on the board — listed but not selectable. */
31
+ /** `multiple`: paths already chosen elsewhere — listed but not selectable. */
31
32
  addedPaths?: string[]
32
33
  }>(),
33
34
  { mode: 'dir', startPath: '', multiple: false, selectedPaths: () => [], addedPaths: () => [] },
@@ -182,14 +183,22 @@ watch(
182
183
  <button
183
184
  type="button"
184
185
  class="flex items-center gap-2 truncate text-sm hover:text-primary-400"
185
- :class="modelValue === entry.path ? 'text-primary-400' : 'text-slate-300'"
186
+ :class="isPicked(entry.path) ? 'text-primary-400' : 'text-slate-300'"
187
+ :disabled="isAdded(entry.path)"
186
188
  @click="pick(entry.path)"
187
189
  >
188
190
  <UIcon name="i-lucide-file" class="h-4 w-4 shrink-0 text-slate-400" />
189
191
  <span class="truncate">{{ entry.name }}</span>
190
192
  </button>
193
+ <span
194
+ v-if="isAdded(entry.path)"
195
+ class="flex shrink-0 items-center gap-1 text-xs text-slate-500"
196
+ >
197
+ <UIcon name="i-lucide-check" class="h-3.5 w-3.5" />
198
+ {{ t('github.repoTree.added') }}
199
+ </span>
191
200
  <UIcon
192
- v-if="modelValue === entry.path"
201
+ v-else-if="isPicked(entry.path)"
193
202
  name="i-lucide-check"
194
203
  class="h-4 w-4 shrink-0 text-primary-400"
195
204
  />
@@ -1,21 +1,29 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue'
3
3
  import { useLocalStorage } from '@vueuse/core'
4
- import type { AgentKind } from '~/types/domain'
4
+ import { purposeAllowsAgentCategory } from '@cat-factory/contracts'
5
+ import type { AgentKind, PipelinePurpose } from '~/types/domain'
5
6
  import { AGENT_CATEGORIES, OBSERVABILITY_GATE_ARCHETYPE } from '~/utils/catalog'
6
7
 
7
8
  const { t } = useI18n()
8
9
  const agents = useAgentsStore()
9
10
  const releaseHealth = useReleaseHealthStore()
10
11
  defineEmits<{ (e: 'add', kind: AgentKind): void }>()
12
+ // The purpose of the pipeline being built. When set to a non-`build` classifier, the
13
+ // Implementation (`build`) and Testing (`test`) categories are hidden — such a pipeline writes
14
+ // no product code and runs no tests (see `purposeAllowsAgentCategory`). `null`/`build` shows all.
15
+ const props = defineProps<{ purpose?: PipelinePurpose | null }>()
11
16
 
12
17
  // The post-release-health gate is only meaningful — and only accepted by the backend —
13
18
  // with an observability integration connected, so it appears in the palette ONLY then.
14
- const palette = computed(() =>
15
- releaseHealth.connection.connected
19
+ const palette = computed(() => {
20
+ const all = releaseHealth.connection.connected
16
21
  ? [...agents.archetypes, OBSERVABILITY_GATE_ARCHETYPE]
17
- : agents.archetypes,
18
- )
22
+ : agents.archetypes
23
+ // Hide the categories the pipeline's purpose doesn't build from (an uncategorized custom kind
24
+ // has no category to gate, so it always shows).
25
+ return all.filter((a) => !a.category || purposeAllowsAgentCategory(props.purpose, a.category))
26
+ })
19
27
 
20
28
  // Group the palette into the ordered catalog categories, plus a trailing "Custom" bucket
21
29
  // for runtime-added agents that carry no category. Empty groups are dropped.
@@ -133,29 +133,16 @@ function setModelPreset(id: string) {
133
133
  const selectedPipeline = computed(() =>
134
134
  props.block.pipelineId ? pipelines.getPipeline(props.block.pipelineId) : undefined,
135
135
  )
136
- // Hide UI-testing pipelines when this task's frame has no UI to exercise, and `'recurring'`-only
137
- // pipelines (the task's manual Run control can't start one) — they'd be refused at run start
138
- // (see utils/pipeline + the backend gate).
136
+ // Hide UI-testing pipelines when this task's frame has no UI to exercise, `'recurring'`-only
137
+ // pipelines (the task's manual Run control can't start one), and for a `document` task every
138
+ // non-document pipeline (it authors a doc, so a build/test pipeline makes no sense). All would be
139
+ // refused / wrong at run start (see utils/pipeline + the backend gate + the purpose classifier).
139
140
  const taskFrame = computed(() => board.serviceOf(props.block))
140
141
  const selectablePipelines = computed(() =>
141
142
  pipelines.pipelines.filter((p) =>
142
- pipelineAllowedForManualStart(p, taskFrame.value, board.blocks),
143
+ pipelineAllowedForManualStart(p, taskFrame.value, board.blocks, props.block.taskType),
143
144
  ),
144
145
  )
145
- const pipelineMenu = computed(() => [
146
- [
147
- {
148
- label: t('inspector.runSettings.noDefault'),
149
- icon: 'i-lucide-rotate-ccw',
150
- onSelect: () => setPipeline(''),
151
- },
152
- ...selectablePipelines.value.map((p) => ({
153
- label: p.name,
154
- icon: 'i-lucide-workflow',
155
- onSelect: () => setPipeline(p.id),
156
- })),
157
- ],
158
- ])
159
146
  function setPipeline(id: string) {
160
147
  board.updateBlock(props.block.id, { pipelineId: id })
161
148
  }
@@ -271,15 +258,22 @@ const technicalLabel = computed(() => {
271
258
  <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
272
259
  {{ t('inspector.runSettings.pipeline') }}
273
260
  </span>
274
- <UDropdownMenu :items="pipelineMenu">
275
- <UButton
276
- size="xs"
277
- variant="ghost"
278
- color="neutral"
279
- icon="i-lucide-workflow"
280
- trailing-icon="i-lucide-chevron-down"
281
- />
282
- </UDropdownMenu>
261
+ <PipelinePicker
262
+ :model-value="block.pipelineId ?? ''"
263
+ :options="selectablePipelines"
264
+ :none-label="t('inspector.runSettings.noDefault')"
265
+ @update:model-value="setPipeline"
266
+ >
267
+ <template #trigger>
268
+ <UButton
269
+ size="xs"
270
+ variant="ghost"
271
+ color="neutral"
272
+ icon="i-lucide-workflow"
273
+ trailing-icon="i-lucide-chevron-down"
274
+ />
275
+ </template>
276
+ </PipelinePicker>
283
277
  </div>
284
278
  <div v-if="selectedPipeline" class="flex items-center gap-1">
285
279
  <UBadge
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, ref, watch } from 'vue'
3
- import type { AgentKind, Pipeline } from '~/types/domain'
3
+ import { purposeAllowsAgentCategory } from '@cat-factory/contracts'
4
+ import type { AgentKind, Pipeline, PipelinePurpose } from '~/types/domain'
4
5
  import AgentPalette from '~/components/palettes/AgentPalette.vue'
5
6
  import AgentKindIcon from '~/components/pipeline/AgentKindIcon.vue'
6
7
  import {
@@ -22,6 +23,18 @@ const CONSENSUS_STRATEGIES = computed<{ value: ConsensusStrategy; label: string
22
23
  { value: 'ranked-voting', label: t('pipeline.builder.strategyOption.ranked-voting') },
23
24
  ])
24
25
 
26
+ // The use-case classifier options for the pipeline (its `purpose`). Static literal `t()` keys, one
27
+ // per `PIPELINE_PURPOSES` member, so the typed-message-keys check sees them (a runtime-built key
28
+ // wouldn't be checkable). A non-`build` purpose hides the Implementation/Testing agent kinds in
29
+ // the palette below (see `AgentPalette`), and drives which task pickers offer the saved pipeline.
30
+ const PURPOSE_OPTIONS = computed<{ value: PipelinePurpose; label: string }[]>(() => [
31
+ { value: 'build', label: t('pipeline.builder.purposeOption.build') },
32
+ { value: 'document', label: t('pipeline.builder.purposeOption.document') },
33
+ { value: 'review', label: t('pipeline.builder.purposeOption.review') },
34
+ { value: 'research', label: t('pipeline.builder.purposeOption.research') },
35
+ { value: 'planning', label: t('pipeline.builder.purposeOption.planning') },
36
+ ])
37
+
25
38
  /** Add a blank participant to the draft step's consensus config. */
26
39
  function addParticipant(i: number) {
27
40
  const cfg = pipelines.draftConsensus[i]
@@ -56,6 +69,19 @@ const skillStepNeedsPick = computed(() =>
56
69
  ),
57
70
  )
58
71
 
72
+ // Steps whose agent category the chosen purpose forbids (a non-`build` purpose writes no code and
73
+ // runs no tests, so the Implementation/Testing categories are disallowed — see
74
+ // `purposeAllowsAgentCategory`). The palette hides those kinds, so this is only reachable by
75
+ // switching an existing draft to a non-`build` purpose AFTER such steps were added. The backend has
76
+ // no kind→category map to gate on, so the builder is the enforcement point: save is blocked until
77
+ // the offending steps are removed (or the purpose set back to Build).
78
+ const stepsDisallowedByPurpose = computed(() =>
79
+ pipelines.draft.filter((kind) => {
80
+ const category = agentKindMeta(kind).category
81
+ return !!category && !purposeAllowsAgentCategory(pipelines.draftPurpose, category)
82
+ }),
83
+ )
84
+
59
85
  // A step's picked skill id is no longer in the account catalog (the source dir was renamed or
60
86
  // unlinked). The step will fail cleanly at dispatch; flag it so the user re-picks.
61
87
  function skillMissing(index: number): boolean {
@@ -282,7 +308,7 @@ async function clone(p: Pipeline) {
282
308
  </UButton>
283
309
  </div>
284
310
  <div class="flex-1 pe-1 lg:min-h-0 lg:overflow-y-auto">
285
- <AgentPalette @add="add" />
311
+ <AgentPalette :purpose="pipelines.draftPurpose" @add="add" />
286
312
  </div>
287
313
  </div>
288
314
 
@@ -310,6 +336,34 @@ async function clone(p: Pipeline) {
310
336
  class="mb-2"
311
337
  />
312
338
 
339
+ <!-- Purpose: the pipeline's use-case classifier. Drives which task pickers offer it (a
340
+ document task offers only `document` pipelines) and narrows the palette below (a
341
+ non-build purpose hides the Implementation/Testing kinds). -->
342
+ <div class="mb-2 flex items-center gap-2">
343
+ <label class="shrink-0 text-[11px] font-medium text-slate-400">
344
+ {{ t('pipeline.builder.purposeLabel') }}
345
+ </label>
346
+ <USelect
347
+ :model-value="pipelines.draftPurpose ?? undefined"
348
+ :items="PURPOSE_OPTIONS"
349
+ value-key="value"
350
+ size="sm"
351
+ class="min-w-40"
352
+ :placeholder="t('pipeline.builder.purposePlaceholder')"
353
+ @update:model-value="pipelines.draftPurpose = $event"
354
+ />
355
+ </div>
356
+
357
+ <!-- Description: the prose summary shown next to the step list in the pipeline pickers. -->
358
+ <UTextarea
359
+ v-model="pipelines.draftDescription"
360
+ :placeholder="t('pipeline.builder.descriptionPlaceholder')"
361
+ :rows="2"
362
+ autoresize
363
+ size="sm"
364
+ class="mb-2 w-full"
365
+ />
366
+
313
367
  <!-- Labels: organize the pipeline in the library (filter/search). -->
314
368
  <div class="mb-3 flex flex-wrap items-center gap-1.5">
315
369
  <UBadge
@@ -350,6 +404,14 @@ async function clone(p: Pipeline) {
350
404
  {{ t('pipeline.builder.skillNeedsPick') }}
351
405
  </p>
352
406
 
407
+ <p
408
+ v-if="stepsDisallowedByPurpose.length"
409
+ class="mb-2 flex items-center gap-1.5 rounded-md border border-amber-800/50 bg-amber-950/30 px-2 py-1 text-[11px] text-amber-300"
410
+ >
411
+ <UIcon name="i-lucide-alert-triangle" class="h-3.5 w-3.5 shrink-0" />
412
+ {{ t('pipeline.builder.purposeStepsConflict') }}
413
+ </p>
414
+
353
415
  <div
354
416
  v-if="pipelines.draft.length === 0"
355
417
  class="flex flex-1 items-center justify-center rounded-lg border border-dashed border-slate-700 p-4 text-center text-xs text-slate-500"
@@ -1005,7 +1067,7 @@ async function clone(p: Pipeline) {
1005
1067
  color="primary"
1006
1068
  icon="i-lucide-save"
1007
1069
  size="sm"
1008
- :disabled="pipelines.draft.length === 0"
1070
+ :disabled="pipelines.draft.length === 0 || stepsDisallowedByPurpose.length > 0"
1009
1071
  @click="save"
1010
1072
  >
1011
1073
  {{ pipelines.editingId ? t('pipeline.builder.update') : t('pipeline.builder.save') }}
@@ -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
+ })