@cat-factory/app 0.156.0 → 0.158.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.
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { Notification } from '~/types/domain'
3
+ import type { ReviewEffort } from '~/types/merge'
3
4
 
4
5
  // The board's notification inbox: a bell with an open-count badge that opens a
5
6
  // panel of human-actionable items (a PR awaiting a merge decision, a completed
@@ -13,6 +14,7 @@ const notifications = useNotificationsStore()
13
14
  const ui = useUiStore()
14
15
  const access = useWorkspaceAccess()
15
16
  const execution = useExecutionStore()
17
+ const trackRecords = useMergeTrackRecordsStore()
16
18
  const toast = useToast()
17
19
 
18
20
  const busy = ref<string | null>(null)
@@ -34,6 +36,10 @@ type Accent = 'warning' | 'primary' | 'error'
34
36
  const META: Record<Notification['type'], { icon: string; color: Accent }> = {
35
37
  merge_review: { icon: 'i-lucide-git-pull-request-arrow', color: 'warning' },
36
38
  pipeline_complete: { icon: 'i-lucide-circle-check', color: 'primary' },
39
+ // A PR merged directly on the provider left its merge track record untagged. Purely a nudge:
40
+ // "act" records the picked reviewer effort (nothing at all if none was picked) and dismissing
41
+ // it is always fine — the record stays valid untagged.
42
+ merge_tag_request: { icon: 'i-lucide-tag', color: 'primary' },
37
43
  ci_failed: { icon: 'i-lucide-triangle-alert', color: 'error' },
38
44
  test_failed: { icon: 'i-lucide-flask-conical', color: 'error' },
39
45
  // Clicking the title opens the review window for the task (see `reveal`); "act" just marks
@@ -89,6 +95,7 @@ const META: Record<Notification['type'], { icon: string; color: Accent }> = {
89
95
  const ACTION_KEYS: Record<Notification['type'], string> = {
90
96
  merge_review: 'layout.notifications.action.merge_review',
91
97
  pipeline_complete: 'layout.notifications.action.pipeline_complete',
98
+ merge_tag_request: 'layout.notifications.action.merge_tag_request',
92
99
  ci_failed: 'layout.notifications.action.ci_failed',
93
100
  test_failed: 'layout.notifications.action.test_failed',
94
101
  requirement_review: 'layout.notifications.action.requirement_review',
@@ -127,10 +134,57 @@ function accent(n: Notification): Accent {
127
134
  return isUrgent(n) ? 'error' : META[n.type].color
128
135
  }
129
136
 
137
+ /**
138
+ * Which cards collect a reviewer-effort tag: the two that MERGE a PR, plus the post-hoc nudge for
139
+ * one that was merged on the provider. Everything else has nothing to tag.
140
+ */
141
+ function collectsEffort(n: Notification): boolean {
142
+ return (
143
+ n.type === 'merge_review' || n.type === 'pipeline_complete' || n.type === 'merge_tag_request'
144
+ )
145
+ }
146
+
147
+ /**
148
+ * The human's picked effort per card, preselected from evidence rather than starting blank: if the
149
+ * run's `pr-reviewer` step actually recorded findings, review comments plausibly drove rework
150
+ * (`minor`); if it did not, the PR needed nothing (`none`). One tap either confirms that guess or
151
+ * corrects it. `undefined` means the card collects no tag at all.
152
+ */
153
+ const effortByCard = reactive<Record<string, ReviewEffort | null>>({})
154
+
155
+ /** Whether the run's PR review surfaced any findings — the default-preselection signal. */
156
+ function hadReviewFindings(n: Notification): boolean {
157
+ const instance = n.executionId ? execution.getInstance(n.executionId) : undefined
158
+ return (instance?.steps ?? []).some((s) => (s.prReview?.findings?.length ?? 0) > 0)
159
+ }
160
+
161
+ function effortFor(n: Notification): ReviewEffort | null {
162
+ if (!(n.id in effortByCard)) effortByCard[n.id] = hadReviewFindings(n) ? 'minor' : 'none'
163
+ return effortByCard[n.id] ?? null
164
+ }
165
+
166
+ /** The change class the engine recorded for the run, when classification resolved one. */
167
+ function changeClassOf(n: Notification) {
168
+ return n.payload?.changeClass
169
+ }
170
+
171
+ // Load the per-class rollups ONCE, the first time an effort-collecting card is in the inbox, so a
172
+ // merge card can show what that class has historically needed. Not part of the workspace snapshot
173
+ // (it's settings-screen/card context, not board state) and a single request for every class.
174
+ watch(
175
+ () => notifications.open.some(collectsEffort),
176
+ (needed) => {
177
+ if (needed && !trackRecords.loaded && !trackRecords.loading) void trackRecords.load()
178
+ },
179
+ { immediate: true },
180
+ )
181
+
130
182
  async function act(n: Notification) {
131
183
  busy.value = n.id
132
184
  try {
133
- await notifications.act(n.id)
185
+ // Only the merge cards carry a tag; passing `undefined` elsewhere keeps the historical
186
+ // no-body act, so a non-merge card's action is completely unchanged.
187
+ await notifications.act(n.id, collectsEffort(n) ? effortFor(n) : undefined)
134
188
  toast.add({
135
189
  title: t('layout.notifications.toast.acted'),
136
190
  color: 'success',
@@ -327,6 +381,14 @@ function revealDecision(n: Notification) {
327
381
  <UIcon name="i-lucide-external-link" class="h-3 w-3" />
328
382
  {{ t('layout.notifications.openPr') }}
329
383
  </a>
384
+ <MergeEffortChips
385
+ v-if="collectsEffort(n)"
386
+ :model-value="effortFor(n)"
387
+ :change-class="changeClassOf(n)"
388
+ :rollup="changeClassOf(n) ? trackRecords.byClass[changeClassOf(n)!] : null"
389
+ :disabled="busy === n.id || !access.canExecuteRuns.value"
390
+ @update:model-value="effortByCard[n.id] = $event"
391
+ />
330
392
  <div class="mt-2 flex items-center gap-1.5">
331
393
  <UButton
332
394
  data-testid="notification-act"
@@ -0,0 +1,122 @@
1
+ <script setup lang="ts">
2
+ // The reviewer-effort tag picker: ONE TAP, not a form. Shown on a merge-decision card (and the
3
+ // external-merge nudge) so the human who confirms a merge also records how much review the PR
4
+ // actually needed — the ground truth the auto-merge score thresholds are trying to approximate.
5
+ //
6
+ // Tagging is never mandatory: the parent can always act without a selection, and an untagged
7
+ // merge records a null tag. This component only chooses; the parent performs the action.
8
+ //
9
+ // See docs/initiatives/merge-track-record.md.
10
+ import { computed } from 'vue'
11
+ import { REVIEW_EFFORTS } from '@cat-factory/contracts'
12
+ import type { ChangeClass, MergeClassRollup, ReviewEffort } from '~/types/merge'
13
+
14
+ const props = defineProps<{
15
+ /** The currently picked effort, or null for "not chosen". */
16
+ modelValue: ReviewEffort | null
17
+ /** The run's change class, when classification resolved one — shown as context. */
18
+ changeClass?: ChangeClass
19
+ /** That class's accumulated track record, so the human sees what the class usually needs. */
20
+ rollup?: MergeClassRollup | null
21
+ disabled?: boolean
22
+ }>()
23
+
24
+ const emit = defineEmits<{ 'update:modelValue': [ReviewEffort | null] }>()
25
+
26
+ const { t, n } = useI18n()
27
+
28
+ // Per-effort label + icon. Exhaustive Records keyed off the union (a missing member fails the
29
+ // typecheck) with LITERAL catalog keys so the typed-message-keys check sees them; leaf keys
30
+ // mirror the enum value verbatim.
31
+ const EFFORT_LABEL_KEYS: Record<ReviewEffort, string> = {
32
+ none: 'merge.effort.none',
33
+ minor: 'merge.effort.minor',
34
+ major: 'merge.effort.major',
35
+ }
36
+ const EFFORT_ICONS: Record<ReviewEffort, string> = {
37
+ none: 'i-lucide-check',
38
+ minor: 'i-lucide-pencil-line',
39
+ major: 'i-lucide-hammer',
40
+ }
41
+
42
+ // Per-class label. Same exhaustive-Record discipline; `unknown` is included because a card can
43
+ // carry no class at all (classification unavailable) and the caller may still render context.
44
+ const CLASS_LABEL_KEYS: Record<ChangeClass, string> = {
45
+ docs: 'merge.changeClass.docs',
46
+ test: 'merge.changeClass.test',
47
+ dependency: 'merge.changeClass.dependency',
48
+ config: 'merge.changeClass.config',
49
+ source: 'merge.changeClass.source',
50
+ schema: 'merge.changeClass.schema',
51
+ unknown: 'merge.changeClass.unknown',
52
+ }
53
+
54
+ const chips = computed(() =>
55
+ REVIEW_EFFORTS.map((effort) => ({
56
+ effort,
57
+ label: t(EFFORT_LABEL_KEYS[effort]),
58
+ icon: EFFORT_ICONS[effort],
59
+ })),
60
+ )
61
+
62
+ const classLabel = computed(() =>
63
+ props.changeClass ? t(CLASS_LABEL_KEYS[props.changeClass]) : null,
64
+ )
65
+
66
+ /**
67
+ * The class's history line — how many of this class have landed and what share auto-merged. Null
68
+ * until the class has at least one landed record, so a fresh workspace shows nothing rather than
69
+ * a misleading "0%".
70
+ */
71
+ const history = computed(() => {
72
+ const r = props.rollup
73
+ if (!r || r.merged === 0) return null
74
+ // Percentages go through vue-i18n's named `percent` format, never a hand-rolled `* 100 + '%'`.
75
+ return t('merge.effort.classHistory', {
76
+ merged: r.merged,
77
+ autoShare: n(r.autoMerged / r.merged, 'percent'),
78
+ })
79
+ })
80
+
81
+ /** Toggle: tapping the picked chip clears the tag (back to untagged). */
82
+ function pick(effort: ReviewEffort) {
83
+ emit('update:modelValue', props.modelValue === effort ? null : effort)
84
+ }
85
+ </script>
86
+
87
+ <template>
88
+ <div data-testid="merge-effort-chips" class="mt-2">
89
+ <div class="flex items-center gap-1.5">
90
+ <span class="text-[10px] uppercase tracking-wide text-slate-500">
91
+ {{ t('merge.effort.prompt') }}
92
+ </span>
93
+ <UBadge
94
+ v-if="classLabel"
95
+ data-testid="merge-effort-class"
96
+ color="neutral"
97
+ variant="subtle"
98
+ size="sm"
99
+ >
100
+ {{ classLabel }}
101
+ </UBadge>
102
+ </div>
103
+ <div class="mt-1 flex flex-wrap items-center gap-1">
104
+ <UButton
105
+ v-for="chip in chips"
106
+ :key="chip.effort"
107
+ :data-testid="`merge-effort-${chip.effort}`"
108
+ :color="modelValue === chip.effort ? 'primary' : 'neutral'"
109
+ :variant="modelValue === chip.effort ? 'solid' : 'outline'"
110
+ size="xs"
111
+ :icon="chip.icon"
112
+ :disabled="disabled"
113
+ @click="pick(chip.effort)"
114
+ >
115
+ {{ chip.label }}
116
+ </UButton>
117
+ </div>
118
+ <p v-if="history" data-testid="merge-effort-history" class="mt-1 text-[10px] text-slate-500">
119
+ {{ history }}
120
+ </p>
121
+ </div>
122
+ </template>
@@ -7,7 +7,7 @@
7
7
  // banner — instead of the agent's raw JSON. Opened via the universal result-view host,
8
8
  // the same seam the requirements / tester windows use.
9
9
  import { computed } from 'vue'
10
- import type { MergeAxis, MergeDecision } from '@cat-factory/contracts'
10
+ import type { ChangeClass, MergeAxis, MergeDecision } from '@cat-factory/contracts'
11
11
  import StepRunMeta from '~/components/panels/StepRunMeta.vue'
12
12
  import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
13
13
  import MarkdownProse from '~/components/common/MarkdownProse.vue'
@@ -68,6 +68,8 @@ const REASON_KEYS: Record<MergeDecision['reason'], string> = {
68
68
  no_assessment: 'panels.mergerResult.reason.no_assessment',
69
69
  merge_failed: 'panels.mergerResult.reason.merge_failed',
70
70
  merge_partial: 'panels.mergerResult.reason.merge_partial',
71
+ class_auto_merge: 'panels.mergerResult.reason.class_auto_merge',
72
+ class_requires_review: 'panels.mergerResult.reason.class_requires_review',
71
73
  }
72
74
  const OUTCOME_KEYS: Record<MergeDecision['outcome'], string> = {
73
75
  auto_merged: 'panels.mergerResult.outcome.auto_merged',
@@ -81,6 +83,28 @@ const AXIS_KEYS: Record<MergeAxis, string> = {
81
83
 
82
84
  const outcomeText = computed(() => (decision.value ? t(OUTCOME_KEYS[decision.value.outcome]) : ''))
83
85
 
86
+ // Per-change-class label — exhaustive over the union so a new class fails typecheck here until it
87
+ // has a key. Only the classes a decision can actually carry are reachable (`unknown` never lands
88
+ // on a decision: the engine omits the field instead), but the map stays total by construction.
89
+ const CLASS_KEYS: Record<ChangeClass, string> = {
90
+ docs: 'merge.changeClass.docs',
91
+ test: 'merge.changeClass.test',
92
+ dependency: 'merge.changeClass.dependency',
93
+ config: 'merge.changeClass.config',
94
+ source: 'merge.changeClass.source',
95
+ schema: 'merge.changeClass.schema',
96
+ unknown: 'merge.changeClass.unknown',
97
+ }
98
+
99
+ /**
100
+ * The DETERMINISTIC change class the engine derived from the PR's changed files, when it resolved
101
+ * one. Shown beside the outcome so a reader sees WHAT KIND of change the decision was made about —
102
+ * which is what a `class_auto_merge` / `class_requires_review` reason is actually keyed on.
103
+ */
104
+ const changeClassLabel = computed(() =>
105
+ decision.value?.changeClass ? t(CLASS_KEYS[decision.value.changeClass]) : null,
106
+ )
107
+
84
108
  /** The three axes with their score + preset ceiling, for the bar rows. */
85
109
  const axes = computed(() => {
86
110
  const d = decision.value
@@ -150,12 +174,23 @@ const reasonText = computed(() => {
150
174
  :class="merged ? 'text-emerald-300' : 'text-amber-300'"
151
175
  />
152
176
  <div class="min-w-0">
153
- <p
154
- class="text-sm font-semibold"
155
- :class="merged ? 'text-emerald-200' : 'text-amber-200'"
156
- >
157
- {{ outcomeText }}
158
- </p>
177
+ <div class="flex flex-wrap items-center gap-2">
178
+ <p
179
+ class="text-sm font-semibold"
180
+ :class="merged ? 'text-emerald-200' : 'text-amber-200'"
181
+ >
182
+ {{ outcomeText }}
183
+ </p>
184
+ <UBadge
185
+ v-if="changeClassLabel"
186
+ data-testid="merger-change-class"
187
+ color="neutral"
188
+ variant="subtle"
189
+ size="sm"
190
+ >
191
+ {{ changeClassLabel }}
192
+ </UBadge>
193
+ </div>
159
194
  <p class="mt-0.5 text-[13px] leading-relaxed text-slate-300">{{ reasonText }}</p>
160
195
  </div>
161
196
  </div>
@@ -13,6 +13,8 @@ import EmptyState from '~/components/common/EmptyState.vue'
13
13
  import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
14
14
  import { useNowTick, stepDurationLabel } from '~/composables/useStepTimer'
15
15
  import type { PipelineStep } from '~/types/execution'
16
+ import type { ChangeClass, ReviewEffort } from '~/types/merge'
17
+ import MergeEffortChips from '~/components/merge/MergeEffortChips.vue'
16
18
 
17
19
  const props = defineProps<{ block: Block }>()
18
20
 
@@ -21,6 +23,7 @@ const agentRuns = useAgentRunsStore()
21
23
  const ui = useUiStore()
22
24
  const models = useModelsStore()
23
25
  const reviews = useReviewStage()
26
+ const trackRecords = useMergeTrackRecordsStore()
24
27
  const access = useWorkspaceAccess()
25
28
  const { t, te } = useI18n()
26
29
  const { confirm } = useConfirm()
@@ -203,6 +206,39 @@ async function resetRun() {
203
206
  }
204
207
  }
205
208
 
209
+ /**
210
+ * The reviewer-effort tag for this merge, preselected from evidence rather than starting blank: if
211
+ * the run's `pr-reviewer` step recorded findings, review comments plausibly drove rework
212
+ * (`minor`); if it did not, the PR needed nothing (`none`). One tap confirms or corrects it, and
213
+ * `null` merges untagged — tagging is a nudge, never a gate.
214
+ */
215
+ const mergeEffort = ref<ReviewEffort | null>(null)
216
+
217
+ watch(
218
+ () => props.block.status === 'pr_ready',
219
+ (awaiting) => {
220
+ if (!awaiting) return
221
+ // ONE request for every class, only once and only when a merge decision is actually pending.
222
+ if (!trackRecords.loaded && !trackRecords.loading) void trackRecords.load()
223
+ // Preselect from evidence rather than leaving it blank. Set HERE (not at setup) because the
224
+ // run instance may still be loading when this component first mounts.
225
+ mergeEffort.value ??= (instance.value?.steps ?? []).some(
226
+ (s) => (s.prReview?.findings?.length ?? 0) > 0,
227
+ )
228
+ ? 'minor'
229
+ : 'none'
230
+ },
231
+ { immediate: true },
232
+ )
233
+
234
+ /** The change class the engine recorded on the run's merger step, when one resolved. */
235
+ const mergeChangeClass = computed<ChangeClass | undefined>(() => {
236
+ const decision = instance.value?.steps.find((s) => s.agentKind === 'merger')?.custom as
237
+ | { changeClass?: ChangeClass }
238
+ | undefined
239
+ return decision?.changeClass
240
+ })
241
+
206
242
  // Merging a PR is consequential and effectively irreversible — confirm first. `execution.mergePr`
207
243
  // surfaces its own error toast, so no catch is needed here.
208
244
  async function mergePr() {
@@ -213,7 +249,7 @@ async function mergePr() {
213
249
  icon: 'i-lucide-git-merge',
214
250
  })
215
251
  if (!ok) return
216
- await execution.mergePr(props.block.id)
252
+ await execution.mergePr(props.block.id, mergeEffort.value)
217
253
  }
218
254
  </script>
219
255
 
@@ -548,17 +584,25 @@ async function mergePr() {
548
584
  :description="t('inspector.execution.empty.body')"
549
585
  />
550
586
 
551
- <!-- PR ready: merge -->
552
- <UButton
553
- v-if="block.status === 'pr_ready'"
554
- color="success"
555
- variant="solid"
556
- size="sm"
557
- icon="i-lucide-git-merge"
558
- block
559
- @click="mergePr"
560
- >
561
- {{ t('inspector.execution.mergePr') }}
562
- </UButton>
587
+ <!-- PR ready: record how much review it needed, then merge. -->
588
+ <template v-if="block.status === 'pr_ready'">
589
+ <MergeEffortChips
590
+ v-model="mergeEffort"
591
+ :change-class="mergeChangeClass"
592
+ :rollup="mergeChangeClass ? trackRecords.byClass[mergeChangeClass] : null"
593
+ />
594
+ <UButton
595
+ class="mt-2"
596
+ color="success"
597
+ variant="solid"
598
+ size="sm"
599
+ icon="i-lucide-git-merge"
600
+ block
601
+ data-testid="inspector-merge-pr"
602
+ @click="mergePr"
603
+ >
604
+ {{ t('inspector.execution.mergePr') }}
605
+ </UButton>
606
+ </template>
563
607
  </InspectorSection>
564
608
  </template>
@@ -183,6 +183,11 @@ function setCommentOnPrOpen(value: WritebackOverride | null) {
183
183
  function setResolveOnMerge(value: WritebackOverride | null) {
184
184
  board.updateBlock(props.block.id, { trackerResolveOnMerge: value })
185
185
  }
186
+ // Only consulted for runs started through the public API — a task started here keeps its in-app
187
+ // clarification window regardless (docs/initiatives/headless-clarification-loop.md).
188
+ function setQuestionsOnPark(value: WritebackOverride | null) {
189
+ board.updateBlock(props.block.id, { trackerQuestionsOnPark: value })
190
+ }
186
191
  function writebackMenu(set: (value: WritebackOverride | null) => void) {
187
192
  return [
188
193
  [
@@ -212,6 +217,9 @@ const commentOnPrOpenLabel = computed(() =>
212
217
  const resolveOnMergeLabel = computed(() =>
213
218
  writebackLabel(props.block.trackerResolveOnMerge, tracker.settings.writebackResolveOnMerge),
214
219
  )
220
+ const questionsOnParkLabel = computed(() =>
221
+ writebackLabel(props.block.trackerQuestionsOnPark, tracker.settings.writebackQuestionsOnPark),
222
+ )
215
223
 
216
224
  // ---- technical label (tri-state) -------------------------------------------
217
225
  // Whether this is a purely technical task (the implementer then treats the task
@@ -520,6 +528,21 @@ const technicalLabel = computed(() => {
520
528
  </UButton>
521
529
  </UDropdownMenu>
522
530
  </div>
531
+ <div class="flex items-center justify-between">
532
+ <span class="text-[11px] text-slate-400">{{
533
+ t('inspector.runSettings.questionsOnPark')
534
+ }}</span>
535
+ <UDropdownMenu :items="writebackMenu(setQuestionsOnPark)">
536
+ <UButton
537
+ size="xs"
538
+ variant="ghost"
539
+ color="neutral"
540
+ trailing-icon="i-lucide-chevron-down"
541
+ >
542
+ {{ questionsOnParkLabel }}
543
+ </UButton>
544
+ </UDropdownMenu>
545
+ </div>
523
546
  </div>
524
547
  <div class="mt-1 text-[11px] text-slate-500">
525
548
  {{ t('inspector.runSettings.writebackHint') }}
@@ -29,6 +29,7 @@ const jiraProjectKey = ref('')
29
29
  const linearTeamId = ref('')
30
30
  const commentOnPrOpen = ref(false)
31
31
  const resolveOnMerge = ref(false)
32
+ const questionsOnPark = ref(false)
32
33
  const saving = ref(false)
33
34
 
34
35
  function hydrate() {
@@ -37,6 +38,7 @@ function hydrate() {
37
38
  linearTeamId.value = tracker.settings.linearTeamId ?? ''
38
39
  commentOnPrOpen.value = tracker.settings.writebackCommentOnPrOpen
39
40
  resolveOnMerge.value = tracker.settings.writebackResolveOnMerge
41
+ questionsOnPark.value = tracker.settings.writebackQuestionsOnPark
40
42
  }
41
43
  onMounted(() => {
42
44
  hydrate()
@@ -111,6 +113,7 @@ async function save() {
111
113
  linearTeamId: trackerKind.value === 'linear' ? linearTeamId.value.trim() : null,
112
114
  writebackCommentOnPrOpen: commentOnPrOpen.value,
113
115
  writebackResolveOnMerge: resolveOnMerge.value,
116
+ writebackQuestionsOnPark: questionsOnPark.value,
114
117
  })
115
118
  toast.add({
116
119
  title: t('settings.issueTracker.toast.saved'),
@@ -591,6 +594,18 @@ const STATUS_UI: Record<
591
594
  </span>
592
595
  </span>
593
596
  </label>
597
+
598
+ <label class="flex items-start gap-3 rounded-lg border border-slate-700 bg-slate-800/40 p-3">
599
+ <USwitch v-model="questionsOnPark" />
600
+ <span class="text-sm">
601
+ <span class="block text-slate-200">
602
+ {{ t('settings.issueTracker.writeback.questionsOnPark.label') }}
603
+ </span>
604
+ <span class="block text-xs text-slate-500">
605
+ {{ t('settings.issueTracker.writeback.questionsOnPark.help') }}
606
+ </span>
607
+ </span>
608
+ </label>
594
609
  </section>
595
610
 
596
611
  <div class="flex justify-end">
@@ -0,0 +1,140 @@
1
+ <script setup lang="ts">
2
+ // Per-change-class auto-merge rules for one merge preset, shown NEXT TO each class's accumulated
3
+ // track record — the number that justifies widening a rule. That pairing is the whole point: an
4
+ // operator flips `docs` to "always auto-merge" because the history says humans found nothing to
5
+ // say about docs changes, not on a hunch.
6
+ //
7
+ // `unknown` is deliberately not listed: no rule may ever match it (an unclassifiable diff must
8
+ // fall back to the score ceilings), so offering one would be a lie. See
9
+ // docs/initiatives/merge-track-record.md.
10
+ import { computed } from 'vue'
11
+ import { autoMergeShare, frictionlessShare, RULEABLE_CHANGE_CLASSES } from '@cat-factory/contracts'
12
+ import type { MergeClassRule, MergeClassRules } from '~/types/merge'
13
+
14
+ const props = defineProps<{
15
+ /** The preset's current rules; an absent class means "use the score ceilings". */
16
+ modelValue: MergeClassRules
17
+ /** Whether the preset auto-merges at all — a rule can never override `autoMergeEnabled: false`. */
18
+ autoMergeEnabled: boolean
19
+ disabled?: boolean
20
+ }>()
21
+
22
+ const emit = defineEmits<{ 'update:modelValue': [MergeClassRules] }>()
23
+
24
+ const { t, n } = useI18n()
25
+ const trackRecords = useMergeTrackRecordsStore()
26
+
27
+ // Load the rollups once when the editor mounts. ONE request returns every class (a single SQL
28
+ // aggregate server-side), so this never fans out per class.
29
+ onMounted(() => {
30
+ if (!trackRecords.loaded && !trackRecords.loading) void trackRecords.load()
31
+ })
32
+
33
+ // Per-class + per-rule labels. Exhaustive Records keyed off the unions (a missing member fails the
34
+ // typecheck) with LITERAL catalog keys so the typed-message-keys check sees them.
35
+ const CLASS_LABEL_KEYS: Record<(typeof RULEABLE_CHANGE_CLASSES)[number], string> = {
36
+ docs: 'merge.changeClass.docs',
37
+ test: 'merge.changeClass.test',
38
+ dependency: 'merge.changeClass.dependency',
39
+ config: 'merge.changeClass.config',
40
+ source: 'merge.changeClass.source',
41
+ schema: 'merge.changeClass.schema',
42
+ }
43
+ const RULE_LABEL_KEYS: Record<MergeClassRule, string> = {
44
+ thresholds: 'settings.riskPolicy.classRules.rule.thresholds',
45
+ always: 'settings.riskPolicy.classRules.rule.always',
46
+ never: 'settings.riskPolicy.classRules.rule.never',
47
+ }
48
+
49
+ const RULE_OPTIONS = computed<{ value: MergeClassRule; label: string }[]>(() => [
50
+ { value: 'thresholds', label: t(RULE_LABEL_KEYS.thresholds) },
51
+ { value: 'always', label: t(RULE_LABEL_KEYS.always) },
52
+ { value: 'never', label: t(RULE_LABEL_KEYS.never) },
53
+ ])
54
+
55
+ /** One row: the class, its current rule, and the evidence behind it. */
56
+ const rows = computed(() =>
57
+ RULEABLE_CHANGE_CLASSES.map((changeClass) => {
58
+ const rollup = trackRecords.byClass[changeClass]
59
+ const auto = autoMergeShare(rollup)
60
+ const frictionless = frictionlessShare(rollup)
61
+ return {
62
+ changeClass,
63
+ label: t(CLASS_LABEL_KEYS[changeClass]),
64
+ rule: props.modelValue[changeClass] ?? ('thresholds' as MergeClassRule),
65
+ merged: rollup.merged,
66
+ // Null until something has landed / been tagged, so a fresh workspace shows "no data yet"
67
+ // rather than a misleading 0%.
68
+ autoShare: auto,
69
+ frictionlessShare: frictionless,
70
+ }
71
+ }),
72
+ )
73
+
74
+ /** Set (or clear) a class's rule. `thresholds` is stored as an OMISSION — it IS the default. */
75
+ function setRule(changeClass: (typeof RULEABLE_CHANGE_CLASSES)[number], rule: MergeClassRule) {
76
+ const next: MergeClassRules = { ...props.modelValue }
77
+ if (rule === 'thresholds') delete next[changeClass]
78
+ else next[changeClass] = rule
79
+ emit('update:modelValue', next)
80
+ }
81
+ </script>
82
+
83
+ <template>
84
+ <div data-testid="merge-class-rules" class="space-y-2">
85
+ <div>
86
+ <span class="block text-[10px] uppercase tracking-wide text-slate-500">
87
+ {{ t('settings.riskPolicy.classRules.heading') }}
88
+ </span>
89
+ <p class="mt-0.5 text-[11px] leading-snug text-slate-500">
90
+ {{ t('settings.riskPolicy.classRules.help') }}
91
+ </p>
92
+ <p v-if="!autoMergeEnabled" class="mt-1 text-[11px] leading-snug text-amber-400/90">
93
+ {{ t('settings.riskPolicy.classRules.autoMergeOffWarning') }}
94
+ </p>
95
+ </div>
96
+
97
+ <div
98
+ v-for="row in rows"
99
+ :key="row.changeClass"
100
+ class="flex flex-wrap items-center gap-2 rounded-md border border-slate-700/50 bg-slate-900/30 px-2 py-1.5"
101
+ :data-testid="`merge-class-row-${row.changeClass}`"
102
+ >
103
+ <span class="min-w-[7rem] text-xs text-slate-300">{{ row.label }}</span>
104
+ <USelect
105
+ :model-value="row.rule"
106
+ :items="RULE_OPTIONS"
107
+ value-key="value"
108
+ size="sm"
109
+ class="w-44"
110
+ :disabled="disabled"
111
+ :data-testid="`merge-class-rule-${row.changeClass}`"
112
+ @update:model-value="setRule(row.changeClass, $event as MergeClassRule)"
113
+ />
114
+ <span
115
+ class="text-[11px] text-slate-500"
116
+ :data-testid="`merge-class-record-${row.changeClass}`"
117
+ >
118
+ <template v-if="row.merged === 0">
119
+ {{ t('settings.riskPolicy.classRules.noData') }}
120
+ </template>
121
+ <template v-else>
122
+ {{
123
+ t('settings.riskPolicy.classRules.record', {
124
+ merged: row.merged,
125
+ autoShare: n(row.autoShare ?? 0, 'percent'),
126
+ })
127
+ }}
128
+ <template v-if="row.frictionlessShare !== null">
129
+ &middot;
130
+ {{
131
+ t('settings.riskPolicy.classRules.frictionless', {
132
+ share: n(row.frictionlessShare, 'percent'),
133
+ })
134
+ }}
135
+ </template>
136
+ </template>
137
+ </span>
138
+ </div>
139
+ </div>
140
+ </template>