@cat-factory/app 0.164.0 → 0.166.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.
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { RiskPolicy } from '~/types/merge'
3
+ import { resolveRiskPolicyPicker } from '~/components/riskPolicy/RiskPolicyPicker.logic'
4
+
5
+ // The resolver reads ids only, so the fixture carries just enough to be identifiable.
6
+ const policy = (id: string, name: string) => ({ id, name }) as unknown as RiskPolicy
7
+
8
+ const balanced = policy('mp_balanced', 'Balanced')
9
+ const strict = policy('mp_strict', 'Strict')
10
+ const options = [balanced, strict]
11
+
12
+ describe('resolveRiskPolicyPicker', () => {
13
+ it('previews the active row over the selection', () => {
14
+ const state = resolveRiskPolicyPicker({
15
+ options,
16
+ defaultPolicy: balanced,
17
+ modelValue: 'mp_balanced',
18
+ activeId: 'mp_strict',
19
+ })
20
+ expect(state).toEqual({ policy: strict, viaWorkspaceDefault: false })
21
+ })
22
+
23
+ it('falls back to the selection when no row is active', () => {
24
+ const state = resolveRiskPolicyPicker({
25
+ options,
26
+ defaultPolicy: balanced,
27
+ modelValue: 'mp_strict',
28
+ activeId: undefined,
29
+ })
30
+ expect(state).toEqual({ policy: strict, viaWorkspaceDefault: false })
31
+ })
32
+
33
+ it('previews the workspace default for the "pick nothing" row, captioned', () => {
34
+ const state = resolveRiskPolicyPicker({
35
+ options,
36
+ defaultPolicy: balanced,
37
+ modelValue: 'mp_strict',
38
+ activeId: '',
39
+ })
40
+ expect(state).toEqual({ policy: balanced, viaWorkspaceDefault: true })
41
+ })
42
+
43
+ it('previews the workspace default when nothing is selected at all', () => {
44
+ const state = resolveRiskPolicyPicker({
45
+ options,
46
+ defaultPolicy: balanced,
47
+ modelValue: '',
48
+ activeId: undefined,
49
+ })
50
+ expect(state).toEqual({ policy: balanced, viaWorkspaceDefault: true })
51
+ })
52
+
53
+ it('resolves a DELETED policy to the workspace default, as the run engine does', () => {
54
+ // A task can hold a riskPolicyId whose policy was since removed from the library. The
55
+ // store's resolve() hands that task the default, so the pane must not claim there is no
56
+ // policy — that would describe the run wrongly on the one surface explaining it.
57
+ const state = resolveRiskPolicyPicker({
58
+ options,
59
+ defaultPolicy: balanced,
60
+ modelValue: 'mp_deleted',
61
+ activeId: undefined,
62
+ })
63
+ expect(state).toEqual({ policy: balanced, viaWorkspaceDefault: true })
64
+ })
65
+
66
+ it('reports nothing to preview only when the workspace has no default at all', () => {
67
+ const state = resolveRiskPolicyPicker({
68
+ options: [],
69
+ defaultPolicy: null,
70
+ modelValue: '',
71
+ activeId: '',
72
+ })
73
+ expect(state).toEqual({ policy: null, viaWorkspaceDefault: false })
74
+ })
75
+ })
@@ -0,0 +1,47 @@
1
+ // Pure resolution behind <RiskPolicyPicker>'s detail pane.
2
+ //
3
+ // Kept out of the SFC because the interesting case is not the happy path: a task can hold a
4
+ // `riskPolicyId` naming a policy that has since been DELETED from the workspace library, and
5
+ // what the pane shows then has to agree with what the run engine would actually do. The
6
+ // riskPolicies store's `resolve()` falls back to the workspace default for both an empty id
7
+ // and a dangling one; this mirrors that, so the picker can never tell the user "no risk policy
8
+ // configured" about a task the default is quietly governing.
9
+ import type { RiskPolicy } from '~/types/merge'
10
+
11
+ export interface RiskPolicyPickerState {
12
+ /** The policy the detail pane renders, or `null` when the workspace has none at all. */
13
+ policy: RiskPolicy | null
14
+ /**
15
+ * True when `policy` is the workspace default standing in for a row that names no live
16
+ * policy of its own — the pane captions it, so the user knows why they are looking at a
17
+ * policy they did not point at.
18
+ */
19
+ viaWorkspaceDefault: boolean
20
+ }
21
+
22
+ export interface RiskPolicyPickerInput {
23
+ /** The policies offered (the workspace's library). */
24
+ options: readonly RiskPolicy[]
25
+ /** The workspace default, resolved by the caller. `null` when the workspace has none. */
26
+ defaultPolicy: RiskPolicy | null
27
+ /** The selected policy id, or `''` for the "workspace default" row. */
28
+ modelValue: string
29
+ /**
30
+ * The row the pointer or keyboard focus is on: an id, `''` for the "workspace default"
31
+ * row, or `undefined` when neither is on a row (fall back to the selection).
32
+ */
33
+ activeId: string | undefined
34
+ }
35
+
36
+ /**
37
+ * Resolve what the detail pane shows: the active row's policy, else the selected one, else
38
+ * the workspace default. An id that resolves to nothing — empty (the explicit "workspace
39
+ * default" row) or dangling (a deleted policy) — takes the default, since that is the policy
40
+ * the task is governed by either way.
41
+ */
42
+ export function resolveRiskPolicyPicker(input: RiskPolicyPickerInput): RiskPolicyPickerState {
43
+ const id = input.activeId ?? input.modelValue
44
+ const named = id ? input.options.find((p) => p.id === id) : undefined
45
+ if (named) return { policy: named, viaWorkspaceDefault: false }
46
+ return { policy: input.defaultPolicy, viaWorkspaceDefault: !!input.defaultPolicy }
47
+ }
@@ -0,0 +1,159 @@
1
+ <script setup lang="ts">
2
+ // The rich risk-policy picker used wherever a task's merge policy is chosen (add-task modal,
3
+ // inspector run settings). A master-detail popover mirroring <PipelinePicker>: the left column
4
+ // lists the policies by NAME ONLY (plus a "workspace default" row), and hovering a row reveals
5
+ // that policy's ceilings and what they mean in the right column. Cramming the thresholds into
6
+ // the option line itself made every row an unreadable run of abbreviated percentages; the
7
+ // detail pane is where they belong. The trigger is customizable via the `#trigger` slot (the
8
+ // inspector uses a bare icon button; the modal a full-width labelled one).
9
+ //
10
+ // The detail pane follows FOCUS as well as the pointer. Moving the numbers off the option
11
+ // line would otherwise put them out of reach of anyone driving the list from the keyboard,
12
+ // who never fires a `mouseenter` — the thresholds are the whole point of the pane, so they
13
+ // cannot be pointer-only.
14
+ import { computed, ref } from 'vue'
15
+ import type { RiskPolicy } from '~/types/merge'
16
+ import RiskPolicyPreview from '~/components/riskPolicy/RiskPolicyPreview.vue'
17
+ import { resolveRiskPolicyPicker } from '~/components/riskPolicy/RiskPolicyPicker.logic'
18
+
19
+ const props = withDefaults(
20
+ defineProps<{
21
+ /** Selected policy id, or '' for the "workspace default" option. */
22
+ modelValue: string
23
+ /** The policies offered (the workspace's library). */
24
+ options: RiskPolicy[]
25
+ /**
26
+ * The workspace default a task picking nothing is governed by, resolved by the consumer
27
+ * (which already reads it off the store to build `noneLabel`) so the two can't disagree.
28
+ */
29
+ defaultPolicy: RiskPolicy | null
30
+ /** Label for the "workspace default" row (the consumer names the resolved default). */
31
+ noneLabel: string
32
+ /** Extra classes for the default trigger button (e.g. full-width in the modal). */
33
+ triggerClass?: string
34
+ }>(),
35
+ { triggerClass: '' },
36
+ )
37
+
38
+ const emit = defineEmits<{ 'update:modelValue': [string] }>()
39
+ const { t } = useI18n()
40
+
41
+ const open = ref(false)
42
+ // The row the pointer or the keyboard is currently on, driving the right-column preview.
43
+ // `undefined` ⇒ fall back to the selected policy; the sentinel '' means the "workspace
44
+ // default" row is active.
45
+ const activeId = ref<string | undefined>(undefined)
46
+
47
+ const selected = computed(() => props.options.find((p) => p.id === props.modelValue))
48
+ const triggerLabel = computed(() => selected.value?.name ?? props.noneLabel)
49
+
50
+ const preview = computed(() =>
51
+ resolveRiskPolicyPicker({
52
+ options: props.options,
53
+ defaultPolicy: props.defaultPolicy,
54
+ modelValue: props.modelValue,
55
+ activeId: activeId.value,
56
+ }),
57
+ )
58
+
59
+ /**
60
+ * Tabbing BETWEEN two rows fires `focusout` on the one being left, so only focus leaving the
61
+ * panel altogether may drop the preview back to the selection.
62
+ */
63
+ function onPanelFocusOut(event: FocusEvent) {
64
+ const panel = event.currentTarget
65
+ const next = event.relatedTarget
66
+ if (panel instanceof Node && next instanceof Node && panel.contains(next)) return
67
+ activeId.value = undefined
68
+ }
69
+
70
+ function choose(id: string) {
71
+ emit('update:modelValue', id)
72
+ open.value = false
73
+ }
74
+ </script>
75
+
76
+ <template>
77
+ <UPopover v-model:open="open" :content="{ align: 'start' }">
78
+ <slot name="trigger" :label="triggerLabel">
79
+ <UButton
80
+ color="neutral"
81
+ variant="subtle"
82
+ size="sm"
83
+ icon="i-lucide-git-merge"
84
+ trailing-icon="i-lucide-chevron-down"
85
+ :class="triggerClass"
86
+ data-testid="risk-policy-picker-trigger"
87
+ >
88
+ {{ triggerLabel }}
89
+ </UButton>
90
+ </slot>
91
+
92
+ <template #content>
93
+ <div
94
+ class="flex max-h-[24rem] w-[min(40rem,94vw)]"
95
+ data-testid="risk-policy-picker-panel"
96
+ @mouseleave="activeId = undefined"
97
+ @focusout="onPanelFocusOut"
98
+ >
99
+ <!-- left: selectable options, NAME ONLY -->
100
+ <ul class="w-1/2 shrink-0 overflow-y-auto border-e border-slate-800 p-1">
101
+ <li>
102
+ <button
103
+ type="button"
104
+ class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-start text-sm hover:bg-slate-800/60"
105
+ :class="modelValue ? 'text-slate-300' : 'text-slate-100'"
106
+ data-testid="risk-policy-option-none"
107
+ @mouseenter="activeId = ''"
108
+ @focus="activeId = ''"
109
+ @click="choose('')"
110
+ >
111
+ <UIcon name="i-lucide-rotate-ccw" class="h-4 w-4 shrink-0 text-slate-400" />
112
+ <span class="flex-1 truncate">{{ noneLabel }}</span>
113
+ <UIcon
114
+ v-if="!modelValue"
115
+ name="i-lucide-check"
116
+ class="h-4 w-4 shrink-0 text-primary-400"
117
+ />
118
+ </button>
119
+ </li>
120
+ <li v-for="p in options" :key="p.id">
121
+ <button
122
+ type="button"
123
+ class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-start text-sm hover:bg-slate-800/60"
124
+ :class="modelValue === p.id ? 'text-slate-100' : 'text-slate-300'"
125
+ :data-testid="`risk-policy-option-${p.id}`"
126
+ @mouseenter="activeId = p.id"
127
+ @focus="activeId = p.id"
128
+ @click="choose(p.id)"
129
+ >
130
+ <UIcon name="i-lucide-git-merge" class="h-4 w-4 shrink-0 text-slate-400" />
131
+ <span class="flex-1 truncate">{{ p.name }}</span>
132
+ <UIcon
133
+ v-if="modelValue === p.id"
134
+ name="i-lucide-check"
135
+ class="h-4 w-4 shrink-0 text-primary-400"
136
+ />
137
+ </button>
138
+ </li>
139
+ </ul>
140
+
141
+ <!-- right: what the active (or selected) policy actually does -->
142
+ <div class="w-1/2 overflow-y-auto p-3">
143
+ <template v-if="preview.policy">
144
+ <p
145
+ v-if="preview.viaWorkspaceDefault"
146
+ class="mb-2 text-[11px] leading-snug text-slate-500"
147
+ >
148
+ {{ t('riskPolicy.picker.workspaceDefaultCaption') }}
149
+ </p>
150
+ <RiskPolicyPreview :policy="preview.policy" />
151
+ </template>
152
+ <div v-else class="text-[12px] leading-snug text-slate-500">
153
+ {{ t('riskPolicy.picker.noneHint') }}
154
+ </div>
155
+ </div>
156
+ </div>
157
+ </template>
158
+ </UPopover>
159
+ </template>
@@ -0,0 +1,83 @@
1
+ <script setup lang="ts">
2
+ // A compact, read-only summary of a risk policy: the three auto-merge ceilings grouped
3
+ // together, plus prose saying what clearing them actually DOES (the pull request merges
4
+ // without waiting for a human review). Shared by the risk-policy picker's hover preview so
5
+ // "what this policy means" is explained the same way everywhere — the <PipelinePreview>
6
+ // counterpart for merge policy.
7
+ //
8
+ // A policy with auto-merge off has no thresholds to explain (the master switch wins before
9
+ // any score is compared), so it says so instead of listing ceilings that never apply.
10
+ import { computed } from 'vue'
11
+ import type { RiskPolicy } from '~/types/merge'
12
+ import { riskPolicyCeilings, type RiskPolicyAxis } from '~/utils/riskPolicy'
13
+
14
+ const props = defineProps<{ policy: RiskPolicy }>()
15
+ const { t, n } = useI18n()
16
+
17
+ // Exhaustive over the axis union with LITERAL keys, so both i18n drift guards apply: the
18
+ // typed-key check catches a renamed key, and the Record catches a new axis.
19
+ const AXIS_LABEL: Record<RiskPolicyAxis, () => string> = {
20
+ risk: () => t('riskPolicy.preview.axis.risk'),
21
+ impact: () => t('riskPolicy.preview.axis.impact'),
22
+ complexity: () => t('riskPolicy.preview.axis.complexity'),
23
+ }
24
+
25
+ const ceilings = computed(() =>
26
+ riskPolicyCeilings(props.policy).map((c) => ({ ...c, label: AXIS_LABEL[c.axis]() })),
27
+ )
28
+ </script>
29
+
30
+ <template>
31
+ <div class="space-y-3" data-testid="risk-policy-preview">
32
+ <div class="flex items-center gap-1.5">
33
+ <span class="text-sm font-semibold text-slate-100">{{ policy.name }}</span>
34
+ <UBadge v-if="policy.isDefault" size="sm" variant="soft" color="neutral">
35
+ {{ t('riskPolicy.preview.defaultBadge') }}
36
+ </UBadge>
37
+ </div>
38
+
39
+ <!-- the three ceilings, grouped under one heading that names what they are -->
40
+ <div v-if="policy.autoMergeEnabled">
41
+ <div class="mb-1 flex items-center gap-1 text-[10px] uppercase tracking-wide text-slate-500">
42
+ <UIcon name="i-lucide-git-merge" class="h-3 w-3" />
43
+ {{ t('riskPolicy.preview.autoMergeHeading') }}
44
+ </div>
45
+ <dl class="space-y-1">
46
+ <div
47
+ v-for="c in ceilings"
48
+ :key="c.axis"
49
+ class="flex items-baseline justify-between gap-3 rounded bg-slate-800/70 px-1.5 py-0.5 text-[12px]"
50
+ >
51
+ <dt class="text-slate-300">{{ c.label }}</dt>
52
+ <dd class="tabular-nums text-slate-100">
53
+ {{ t('riskPolicy.preview.ceiling', { value: n(c.max, { key: 'percent' }) }) }}
54
+ </dd>
55
+ </div>
56
+ </dl>
57
+ <p class="mt-1.5 text-[12px] leading-snug text-slate-400">
58
+ {{ t('riskPolicy.preview.autoMergeExplainer') }}
59
+ </p>
60
+ </div>
61
+ <div v-else>
62
+ <div class="mb-1 flex items-center gap-1 text-[10px] uppercase tracking-wide text-slate-500">
63
+ <UIcon name="i-lucide-user-check" class="h-3 w-3" />
64
+ {{ t('riskPolicy.preview.manualHeading') }}
65
+ </div>
66
+ <p class="text-[12px] leading-snug text-slate-400">
67
+ {{ t('riskPolicy.preview.manualExplainer') }}
68
+ </p>
69
+ </div>
70
+
71
+ <div>
72
+ <div class="mb-1 flex items-center gap-1 text-[10px] uppercase tracking-wide text-slate-500">
73
+ <UIcon name="i-lucide-wrench" class="h-3 w-3" />
74
+ {{ t('riskPolicy.preview.ciHeading') }}
75
+ </div>
76
+ <p class="text-[12px] leading-snug text-slate-400">
77
+ {{
78
+ t('riskPolicy.preview.ciAttempts', { count: policy.ciMaxAttempts }, policy.ciMaxAttempts)
79
+ }}
80
+ </p>
81
+ </div>
82
+ </div>
83
+ </template>
@@ -7,9 +7,40 @@
7
7
  import { computed, reactive, ref, watch } from 'vue'
8
8
  import type { MergeClassRules, RiskPolicy, RequirementConcernLevel } from '~/types/merge'
9
9
  import type { StepGating } from '@cat-factory/contracts'
10
+ import {
11
+ RISK_POLICY_AXES,
12
+ RISK_POLICY_CEILING_FIELD,
13
+ type RiskPolicyAxis,
14
+ } from '~/utils/riskPolicy'
10
15
 
11
16
  const { t } = useI18n()
12
17
 
18
+ // Both axis groups below iterate the SHARED presentation order rather than hard-coding one,
19
+ // so this editor can't drift from the order the picker's preview and the inspector's summary
20
+ // line use (it used to read complexity-first while they read risk-first).
21
+ const CEILING_LABEL_KEYS: Record<RiskPolicyAxis, string> = {
22
+ risk: 'settings.riskPolicy.field.maxRisk',
23
+ impact: 'settings.riskPolicy.field.maxImpact',
24
+ complexity: 'settings.riskPolicy.field.maxComplexity',
25
+ }
26
+
27
+ // The fork-decision group is the same three axes read as FLOORS (how big an estimate has to
28
+ // be before the coder stops to propose implementations), so it shares the order but not the
29
+ // fields.
30
+ const FORK_FLOOR_FIELD: Record<
31
+ RiskPolicyAxis,
32
+ 'forkMinRisk' | 'forkMinImpact' | 'forkMinComplexity'
33
+ > = {
34
+ risk: 'forkMinRisk',
35
+ impact: 'forkMinImpact',
36
+ complexity: 'forkMinComplexity',
37
+ }
38
+ const FORK_FLOOR_LABEL_KEYS: Record<RiskPolicyAxis, string> = {
39
+ risk: 'settings.riskPolicy.forkDecision.minRisk',
40
+ impact: 'settings.riskPolicy.forkDecision.minImpact',
41
+ complexity: 'settings.riskPolicy.forkDecision.minComplexity',
42
+ }
43
+
13
44
  // Per-concern-level label. An exhaustive Record keyed off the union (a missing member fails
14
45
  // the typecheck); each value is a LITERAL catalog key so the typed-message-keys check sees
15
46
  // it. Leaf keys mirror the enum value verbatim.
@@ -276,36 +307,12 @@ async function create() {
276
307
  </div>
277
308
 
278
309
  <div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
279
- <label class="block">
280
- <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
281
- {{ t('settings.riskPolicy.field.maxComplexity') }}
282
- </span>
283
- <UInput
284
- v-model.number="drafts[p.id]!.maxComplexity"
285
- type="number"
286
- :min="0"
287
- :max="100"
288
- size="sm"
289
- />
290
- </label>
291
- <label class="block">
310
+ <label v-for="axis in RISK_POLICY_AXES" :key="axis" class="block">
292
311
  <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
293
- {{ t('settings.riskPolicy.field.maxRisk') }}
312
+ {{ t(CEILING_LABEL_KEYS[axis]) }}
294
313
  </span>
295
314
  <UInput
296
- v-model.number="drafts[p.id]!.maxRisk"
297
- type="number"
298
- :min="0"
299
- :max="100"
300
- size="sm"
301
- />
302
- </label>
303
- <label class="block">
304
- <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
305
- {{ t('settings.riskPolicy.field.maxImpact') }}
306
- </span>
307
- <UInput
308
- v-model.number="drafts[p.id]!.maxImpact"
315
+ v-model.number="drafts[p.id]![RISK_POLICY_CEILING_FIELD[axis]]"
309
316
  type="number"
310
317
  :min="0"
311
318
  :max="100"
@@ -369,36 +376,12 @@ async function create() {
369
376
  :description="t('settings.riskPolicy.forkDecision.hint')"
370
377
  />
371
378
  <div v-if="drafts[p.id]!.forkEnabled" class="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-4">
372
- <label class="block">
373
- <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
374
- {{ t('settings.riskPolicy.forkDecision.minComplexity') }}
375
- </span>
376
- <UInput
377
- v-model.number="drafts[p.id]!.forkMinComplexity"
378
- type="number"
379
- size="sm"
380
- :min="0"
381
- :max="100"
382
- />
383
- </label>
384
- <label class="block">
385
- <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
386
- {{ t('settings.riskPolicy.forkDecision.minRisk') }}
387
- </span>
388
- <UInput
389
- v-model.number="drafts[p.id]!.forkMinRisk"
390
- type="number"
391
- size="sm"
392
- :min="0"
393
- :max="100"
394
- />
395
- </label>
396
- <label class="block">
379
+ <label v-for="axis in RISK_POLICY_AXES" :key="axis" class="block">
397
380
  <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
398
- {{ t('settings.riskPolicy.forkDecision.minImpact') }}
381
+ {{ t(FORK_FLOOR_LABEL_KEYS[axis]) }}
399
382
  </span>
400
383
  <UInput
401
- v-model.number="drafts[p.id]!.forkMinImpact"
384
+ v-model.number="drafts[p.id]![FORK_FLOOR_FIELD[axis]]"
402
385
  type="number"
403
386
  size="sm"
404
387
  :min="0"
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { RiskPolicy } from '~/types/merge'
3
+ import { RISK_POLICY_AXES, RISK_POLICY_CEILING_FIELD, riskPolicyCeilings } from '~/utils/riskPolicy'
4
+
5
+ const policy = (over: Partial<RiskPolicy> = {}): RiskPolicy =>
6
+ ({
7
+ id: 'mp_balanced',
8
+ name: 'Balanced',
9
+ maxComplexity: 0.6,
10
+ maxRisk: 0.4,
11
+ maxImpact: 0.5,
12
+ ciMaxAttempts: 10,
13
+ maxRequirementIterations: 6,
14
+ maxRequirementConcernAllowed: 'none',
15
+ maxTesterQualityIterations: 3,
16
+ releaseWatchWindowMinutes: 30,
17
+ releaseMaxAttempts: 1,
18
+ humanReviewGraceMinutes: 10,
19
+ judgeMinScore: 0.7,
20
+ judgeMaxBounces: 2,
21
+ autoMergeEnabled: true,
22
+ classRules: {},
23
+ isDefault: true,
24
+ version: 1,
25
+ createdAt: 0,
26
+ updatedAt: 0,
27
+ ...over,
28
+ }) as RiskPolicy
29
+
30
+ describe('riskPolicyCeilings', () => {
31
+ it('groups the three axes in presentation order', () => {
32
+ expect(riskPolicyCeilings(policy()).map((c) => c.axis)).toEqual([
33
+ 'risk',
34
+ 'impact',
35
+ 'complexity',
36
+ ])
37
+ })
38
+
39
+ it('carries each axis ceiling as the stored 0..1 ratio', () => {
40
+ expect(riskPolicyCeilings(policy())).toEqual([
41
+ { axis: 'risk', max: 0.4 },
42
+ { axis: 'impact', max: 0.5 },
43
+ { axis: 'complexity', max: 0.6 },
44
+ ])
45
+ })
46
+
47
+ it('follows the shared axis order, which the settings editor iterates too', () => {
48
+ // The point of the exported order: the picker preview, the inspector summary and the
49
+ // settings editor all read it, so none of them can drift into its own sequence.
50
+ expect(riskPolicyCeilings(policy()).map((c) => c.axis)).toEqual([...RISK_POLICY_AXES])
51
+ expect(Object.keys(RISK_POLICY_CEILING_FIELD).sort()).toEqual([...RISK_POLICY_AXES].sort())
52
+ })
53
+ })
@@ -1,21 +1,42 @@
1
1
  import type { RiskPolicy } from '~/types/merge'
2
2
 
3
3
  /**
4
- * A compact one-line summary of a merge preset's auto-merge ceilings + CI-fix budget,
5
- * suitable for a dropdown option label so the user sees each preset's actual thresholds
6
- * (not just its name) while choosing one. Percentages are the stored 0..1 ratios
7
- * rendered as whole percents.
4
+ * The three axes a `merger` agent scores a pull request on. Presentation order is
5
+ * risk impact complexity: what the PR could break first, then how far it reaches, then
6
+ * how hard it was to write.
7
+ *
8
+ * This array IS the order — every surface that shows the three axes iterates it rather than
9
+ * hard-coding a sequence, so the picker's preview, the inspector's summary line and the
10
+ * settings editor cannot drift into three different orders (which is exactly what they had).
8
11
  */
9
- export function riskPolicySummary(p: RiskPolicy): string {
10
- // Auto-merge disabled: the thresholds don't apply, every PR goes to human review.
11
- if (!p.autoMergeEnabled) return `manual review only · ${p.ciMaxAttempts} CI fixes`
12
- const pct = (n: number) => `${Math.round(n * 100)}%`
13
- return `cx ≤${pct(p.maxComplexity)} · risk ≤${pct(p.maxRisk)} · impact ≤${pct(
14
- p.maxImpact,
15
- )} · ${p.ciMaxAttempts} CI fixes`
12
+ export const RISK_POLICY_AXES = ['risk', 'impact', 'complexity'] as const
13
+
14
+ export type RiskPolicyAxis = (typeof RISK_POLICY_AXES)[number]
15
+
16
+ /**
17
+ * Which `RiskPolicy` field carries each axis's auto-merge ceiling. Exhaustive over the axis
18
+ * union, so adding an axis fails the typecheck here rather than silently rendering two.
19
+ */
20
+ export const RISK_POLICY_CEILING_FIELD: Record<
21
+ RiskPolicyAxis,
22
+ 'maxRisk' | 'maxImpact' | 'maxComplexity'
23
+ > = {
24
+ risk: 'maxRisk',
25
+ impact: 'maxImpact',
26
+ complexity: 'maxComplexity',
16
27
  }
17
28
 
18
- /** The preset name followed by its thresholds, for a single-line dropdown option. */
19
- export function riskPolicyOptionLabel(p: RiskPolicy): string {
20
- return `${p.name} — ${riskPolicySummary(p)}`
29
+ /** One axis of a policy, with the ceiling a score must stay at or below to auto-merge. */
30
+ export interface RiskPolicyCeiling {
31
+ axis: RiskPolicyAxis
32
+ /** The stored 0..1 ratio. Render it through the `percent` number format, never raw. */
33
+ max: number
34
+ }
35
+
36
+ /**
37
+ * A policy's auto-merge ceilings, one entry per axis in presentation order, so every
38
+ * surface that explains a policy groups the three axes the same way.
39
+ */
40
+ export function riskPolicyCeilings(p: RiskPolicy): RiskPolicyCeiling[] {
41
+ return RISK_POLICY_AXES.map((axis) => ({ axis, max: p[RISK_POLICY_CEILING_FIELD[axis]] }))
21
42
  }
@@ -1318,8 +1318,8 @@
1318
1318
  "unassigned": "Nicht zugewiesen",
1319
1319
  "workspaceDefault": "Workspace-Standard",
1320
1320
  "workspaceDefaultParen": "(Workspace-Standard)",
1321
- "defaultPresetThresholds": "Standard ({name}): {thresholds}",
1322
- "defaultPreset": "Standard ({name})",
1321
+ "defaultRiskPolicy": "Standard ({name})",
1322
+ "defaultModelPreset": "Standard ({name})",
1323
1323
  "noDefault": "Kein Standard",
1324
1324
  "inheritWorkspace": "Vom Workspace erben",
1325
1325
  "on": "An",
@@ -1335,7 +1335,8 @@
1335
1335
  "pipelineEmpty": "Kein Standard. Wähle eine Pipeline, wenn du diese Aufgabe ausführst.",
1336
1336
  "pipelineHint": "Die Pipeline, die der Run-Button standardmäßig startet: die Abfolge der Agent-Schritte (zum Beispiel Spec, Code, Test, Merge), die für diese Aufgabe ausgeführt werden.",
1337
1337
  "mergePolicy": "Risikorichtlinie",
1338
- "riskPolicyDetail": "{name}: automatisch mergen, wenn Komplexität ≤ {complexity}, Risiko ≤ {risk}, Wirkung ≤ {impact}; bis zu {attempts} CI-Fix-Versuche.",
1338
+ "riskPolicyDetail": "{name}: mergt ohne menschliche Prüfung, wenn Risiko ≤ {risk}, Wirkung ≤ {impact} und Komplexität ≤ {complexity}; bis zu {attempts} CI-Fix-Versuche.",
1339
+ "riskPolicyManual": "{name}: mergt nie von selbst, jeder Pull Request wartet also auf eine menschliche Prüfung; bis zu {attempts} CI-Fix-Versuche.",
1339
1340
  "riskPolicyEmpty": "Keine Risikorichtlinie konfiguriert. Der Merger löst für jeden PR eine Prüfbenachrichtigung aus.",
1340
1341
  "mergePolicyHint": "Entscheidet, wann ein fertiger Pull Request automatisch mergt, statt auf eine menschliche Prüfung zu warten, und wie viele CI-Fix-Versuche ein Lauf erhält.",
1341
1342
  "modelPreset": "Modell-Preset",
@@ -2304,7 +2305,7 @@
2304
2305
  "chooseAtRunTime": "Zur Laufzeit auswählen",
2305
2306
  "mergePolicy": "Risikorichtlinie",
2306
2307
  "workspaceDefault": "Workspace-Standard",
2307
- "defaultPreset": "Standard ({name}) — {thresholds}",
2308
+ "defaultPreset": "Standard ({name})",
2308
2309
  "defaultModelPreset": "Standard ({name})",
2309
2310
  "modelPreset": "Modell-Preset",
2310
2311
  "bestPractices": "Best Practices",
@@ -3592,6 +3593,11 @@
3592
3593
  "recommendationRequested": "Empfehlung angefordert — prüfen Sie den Vorschlag unten.",
3593
3594
  "recommendedDefault": "Empfohlene Vorgabe: behalten, bearbeiten oder die Feststellung verwerfen.",
3594
3595
  "needsYourInput": "Benötigt Ihre Eingabe",
3596
+ "group": {
3597
+ "action": "Benötigt Ihre Reaktion",
3598
+ "waiting": "Warten auf Vorschläge",
3599
+ "settled": "Erledigt"
3600
+ },
3595
3601
  "mode": {
3596
3602
  "answer": "Beantworten",
3597
3603
  "dismiss": "Verwerfen",
@@ -5131,6 +5137,25 @@
5131
5137
  "toast": {
5132
5138
  "reseedFailed": "Risikorichtlinie konnte nicht erneut geseedet werden"
5133
5139
  }
5140
+ },
5141
+ "picker": {
5142
+ "workspaceDefaultCaption": "Gilt, weil diese Aufgabe keine eigene Richtlinie wählt.",
5143
+ "noneHint": "Keine Risikorichtlinie konfiguriert. Jeder Pull Request wartet auf eine menschliche Prüfung."
5144
+ },
5145
+ "preview": {
5146
+ "defaultBadge": "Standard",
5147
+ "autoMergeHeading": "Schwellen für automatisches Mergen",
5148
+ "axis": {
5149
+ "risk": "Risiko",
5150
+ "impact": "Wirkung",
5151
+ "complexity": "Komplexität"
5152
+ },
5153
+ "ceiling": "höchstens {value}",
5154
+ "autoMergeExplainer": "Sobald die CI grün ist, bewertet der Merger-Agent den Pull Request auf diesen drei Achsen. Bleibt jede Bewertung auf oder unter ihrer Obergrenze, mergt der Pull Request von selbst und überspringt die menschliche Prüfung; alles darüber geht an eine Person.",
5155
+ "manualHeading": "Nur manuelle Prüfung",
5156
+ "manualExplainer": "Diese Richtlinie mergt nie von selbst, jeder Pull Request wartet also unabhängig von den Bewertungen auf eine menschliche Prüfung.",
5157
+ "ciHeading": "CI-Fixes",
5158
+ "ciAttempts": "Der Lauf darf {count} Mal versuchen, eine rote CI grün zu bekommen, bevor er aufgibt. | Der Lauf darf {count} Mal versuchen, eine rote CI grün zu bekommen, bevor er aufgibt."
5134
5159
  }
5135
5160
  },
5136
5161
  "modelPreset": {