@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.
- package/app/components/board/AddTaskModal.vue +12 -36
- package/app/components/panels/inspector/TaskRunSettings.vue +40 -32
- package/app/components/requirements/RequirementsReviewWindow.logic.spec.ts +145 -0
- package/app/components/requirements/RequirementsReviewWindow.logic.ts +105 -0
- package/app/components/requirements/RequirementsReviewWindow.vue +303 -215
- package/app/components/riskPolicy/RiskPolicyPicker.logic.spec.ts +75 -0
- package/app/components/riskPolicy/RiskPolicyPicker.logic.ts +47 -0
- package/app/components/riskPolicy/RiskPolicyPicker.vue +159 -0
- package/app/components/riskPolicy/RiskPolicyPreview.vue +83 -0
- package/app/components/settings/RiskPolicyPanel.vue +37 -54
- package/app/utils/riskPolicy.spec.ts +53 -0
- package/app/utils/riskPolicy.ts +35 -14
- package/i18n/locales/de.json +29 -4
- package/i18n/locales/en.json +29 -4
- package/i18n/locales/es.json +29 -4
- package/i18n/locales/fr.json +29 -4
- package/i18n/locales/he.json +29 -4
- package/i18n/locales/it.json +29 -4
- package/i18n/locales/ja.json +29 -4
- package/i18n/locales/pl.json +29 -4
- package/i18n/locales/tr.json +29 -4
- package/i18n/locales/uk.json +29 -4
- package/package.json +1 -1
|
@@ -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(
|
|
312
|
+
{{ t(CEILING_LABEL_KEYS[axis]) }}
|
|
294
313
|
</span>
|
|
295
314
|
<UInput
|
|
296
|
-
v-model.number="drafts[p.id]
|
|
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(
|
|
381
|
+
{{ t(FORK_FLOOR_LABEL_KEYS[axis]) }}
|
|
399
382
|
</span>
|
|
400
383
|
<UInput
|
|
401
|
-
v-model.number="drafts[p.id]
|
|
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
|
+
})
|
package/app/utils/riskPolicy.ts
CHANGED
|
@@ -1,21 +1,42 @@
|
|
|
1
1
|
import type { RiskPolicy } from '~/types/merge'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
/**
|
|
19
|
-
export
|
|
20
|
-
|
|
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
|
}
|
package/i18n/locales/de.json
CHANGED
|
@@ -1318,8 +1318,8 @@
|
|
|
1318
1318
|
"unassigned": "Nicht zugewiesen",
|
|
1319
1319
|
"workspaceDefault": "Workspace-Standard",
|
|
1320
1320
|
"workspaceDefaultParen": "(Workspace-Standard)",
|
|
1321
|
-
"
|
|
1322
|
-
"
|
|
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}:
|
|
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})
|
|
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": {
|