@cat-factory/app 0.165.0 → 0.167.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.vue +5 -0
- 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 +25 -4
- package/i18n/locales/en.json +25 -4
- package/i18n/locales/es.json +25 -4
- package/i18n/locales/fr.json +25 -4
- package/i18n/locales/he.json +25 -4
- package/i18n/locales/it.json +25 -4
- package/i18n/locales/ja.json +25 -4
- package/i18n/locales/pl.json +25 -4
- package/i18n/locales/tr.json +25 -4
- package/i18n/locales/uk.json +25 -4
- package/package.json +1 -1
|
@@ -27,7 +27,7 @@ import type { AppSlots, ResultViewContribution } from '~/modular/slots'
|
|
|
27
27
|
import ContextDocumentPicker from '~/components/documents/ContextDocumentPicker.vue'
|
|
28
28
|
import ContextIssuePicker from '~/components/tasks/ContextIssuePicker.vue'
|
|
29
29
|
import FragmentSelector from '~/components/fragments/FragmentSelector.vue'
|
|
30
|
-
import
|
|
30
|
+
import RiskPolicyPicker from '~/components/riskPolicy/RiskPolicyPicker.vue'
|
|
31
31
|
import { parseConflict } from '~/composables/usePipelineErrorToast'
|
|
32
32
|
import { pipelineAllowedForManualStart } from '~/utils/pipeline'
|
|
33
33
|
|
|
@@ -334,33 +334,13 @@ const riskPolicyId = ref('')
|
|
|
334
334
|
const modelPresetId = ref('')
|
|
335
335
|
const pipelineId = ref('')
|
|
336
336
|
|
|
337
|
+
// The "pick nothing" row names the default policy it resolves to; the picker's detail pane
|
|
338
|
+
// explains what that policy does, so the row itself stays a bare name.
|
|
337
339
|
const defaultPresetLabel = computed(() =>
|
|
338
340
|
riskPolicies.defaultPreset
|
|
339
|
-
? t('board.addTask.defaultPreset', {
|
|
340
|
-
name: riskPolicies.defaultPreset.name,
|
|
341
|
-
thresholds: riskPolicySummary(riskPolicies.defaultPreset),
|
|
342
|
-
})
|
|
341
|
+
? t('board.addTask.defaultPreset', { name: riskPolicies.defaultPreset.name })
|
|
343
342
|
: t('board.addTask.workspaceDefault'),
|
|
344
343
|
)
|
|
345
|
-
const presetMenu = computed(() => [
|
|
346
|
-
[
|
|
347
|
-
{
|
|
348
|
-
label: defaultPresetLabel.value,
|
|
349
|
-
icon: 'i-lucide-rotate-ccw',
|
|
350
|
-
onSelect: () => (riskPolicyId.value = ''),
|
|
351
|
-
},
|
|
352
|
-
...riskPolicies.presets.map((p) => ({
|
|
353
|
-
label: riskPolicyOptionLabel(p),
|
|
354
|
-
icon: 'i-lucide-git-merge',
|
|
355
|
-
onSelect: () => (riskPolicyId.value = p.id),
|
|
356
|
-
})),
|
|
357
|
-
],
|
|
358
|
-
])
|
|
359
|
-
const selectedPresetLabel = computed(() => {
|
|
360
|
-
if (!riskPolicyId.value) return defaultPresetLabel.value
|
|
361
|
-
const picked = riskPolicies.presets.find((p) => p.id === riskPolicyId.value)
|
|
362
|
-
return picked ? riskPolicyOptionLabel(picked) : t('board.addTask.workspaceDefault')
|
|
363
|
-
})
|
|
364
344
|
|
|
365
345
|
// Model preset: which model each agent runs on. Empty = workspace default preset.
|
|
366
346
|
const defaultModelPresetLabel = computed(() =>
|
|
@@ -1146,18 +1126,14 @@ function openReviewFrictionDialog(conflict: NonNullable<ReturnType<typeof parseC
|
|
|
1146
1126
|
|
|
1147
1127
|
<!-- A review task merges nothing, so its risk (merge) policy is meaningless — omit it. -->
|
|
1148
1128
|
<UFormField v-if="!isReview" :label="t('board.addTask.mergePolicy')">
|
|
1149
|
-
<
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
>
|
|
1158
|
-
{{ selectedPresetLabel }}
|
|
1159
|
-
</UButton>
|
|
1160
|
-
</UDropdownMenu>
|
|
1129
|
+
<RiskPolicyPicker
|
|
1130
|
+
:model-value="riskPolicyId"
|
|
1131
|
+
:options="riskPolicies.presets"
|
|
1132
|
+
:default-policy="riskPolicies.defaultPreset"
|
|
1133
|
+
:none-label="defaultPresetLabel"
|
|
1134
|
+
trigger-class="w-full justify-between"
|
|
1135
|
+
@update:model-value="riskPolicyId = $event"
|
|
1136
|
+
/>
|
|
1161
1137
|
</UFormField>
|
|
1162
1138
|
|
|
1163
1139
|
<UFormField :label="t('board.addTask.modelPreset')">
|
|
@@ -3,9 +3,9 @@ import { computed, onMounted } from 'vue'
|
|
|
3
3
|
import { connectionNeighborIds } from '@cat-factory/contracts'
|
|
4
4
|
import type { Block } from '~/types/domain'
|
|
5
5
|
import type { WritebackOverride } from '~/types/tracker'
|
|
6
|
-
import { riskPolicyOptionLabel, riskPolicySummary } from '~/utils/riskPolicy'
|
|
7
6
|
import { pipelineAllowedForManualStart } from '~/utils/pipeline'
|
|
8
7
|
import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
|
|
8
|
+
import RiskPolicyPicker from '~/components/riskPolicy/RiskPolicyPicker.vue'
|
|
9
9
|
import TaskAprioriBranches from '~/components/panels/inspector/TaskAprioriBranches.vue'
|
|
10
10
|
|
|
11
11
|
const props = defineProps<{ block: Block }>()
|
|
@@ -67,25 +67,13 @@ function setAutoStartDependents(value: boolean) {
|
|
|
67
67
|
// budget. None selected → the workspace default preset. (The old confidence-based
|
|
68
68
|
// auto-merge threshold is gone; the `merger` step gates on this policy instead.)
|
|
69
69
|
const selectedPreset = computed(() => riskPolicies.resolve(props.block.riskPolicyId))
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
})
|
|
78
|
-
: t('inspector.runSettings.workspaceDefault'),
|
|
79
|
-
icon: 'i-lucide-rotate-ccw',
|
|
80
|
-
onSelect: () => setPreset(''),
|
|
81
|
-
},
|
|
82
|
-
...riskPolicies.presets.map((p) => ({
|
|
83
|
-
label: riskPolicyOptionLabel(p),
|
|
84
|
-
icon: 'i-lucide-git-merge',
|
|
85
|
-
onSelect: () => setPreset(p.id),
|
|
86
|
-
})),
|
|
87
|
-
],
|
|
88
|
-
])
|
|
70
|
+
// The "pick nothing" row names the default policy it resolves to; the picker's detail pane
|
|
71
|
+
// explains what that policy does, so the row itself stays a bare name.
|
|
72
|
+
const defaultPresetLabel = computed(() =>
|
|
73
|
+
riskPolicies.defaultPreset
|
|
74
|
+
? t('inspector.runSettings.defaultRiskPolicy', { name: riskPolicies.defaultPreset.name })
|
|
75
|
+
: t('inspector.runSettings.workspaceDefault'),
|
|
76
|
+
)
|
|
89
77
|
function setPreset(id: string) {
|
|
90
78
|
board.updateBlock(props.block.id, { riskPolicyId: id })
|
|
91
79
|
}
|
|
@@ -111,7 +99,7 @@ const modelPresetMenu = computed(() => [
|
|
|
111
99
|
[
|
|
112
100
|
{
|
|
113
101
|
label: modelPresets.defaultPreset
|
|
114
|
-
? t('inspector.runSettings.
|
|
102
|
+
? t('inspector.runSettings.defaultModelPreset', { name: modelPresets.defaultPreset.name })
|
|
115
103
|
: t('inspector.runSettings.workspaceDefault'),
|
|
116
104
|
icon: 'i-lucide-rotate-ccw',
|
|
117
105
|
onSelect: () => setModelPreset(''),
|
|
@@ -308,24 +296,44 @@ const technicalLabel = computed(() => {
|
|
|
308
296
|
<span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
309
297
|
{{ t('inspector.runSettings.mergePolicy') }}
|
|
310
298
|
</span>
|
|
311
|
-
<
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
299
|
+
<RiskPolicyPicker
|
|
300
|
+
:model-value="block.riskPolicyId ?? ''"
|
|
301
|
+
:options="riskPolicies.presets"
|
|
302
|
+
:default-policy="riskPolicies.defaultPreset"
|
|
303
|
+
:none-label="defaultPresetLabel"
|
|
304
|
+
@update:model-value="setPreset"
|
|
305
|
+
>
|
|
306
|
+
<template #trigger>
|
|
307
|
+
<UButton
|
|
308
|
+
size="xs"
|
|
309
|
+
variant="ghost"
|
|
310
|
+
color="neutral"
|
|
311
|
+
icon="i-lucide-git-merge"
|
|
312
|
+
trailing-icon="i-lucide-chevron-down"
|
|
313
|
+
/>
|
|
314
|
+
</template>
|
|
315
|
+
</RiskPolicyPicker>
|
|
320
316
|
</div>
|
|
321
317
|
<div v-if="selectedPreset" class="text-[11px] text-slate-400">
|
|
322
|
-
<i18n-t
|
|
318
|
+
<i18n-t
|
|
319
|
+
v-if="selectedPreset.autoMergeEnabled"
|
|
320
|
+
keypath="inspector.runSettings.riskPolicyDetail"
|
|
321
|
+
tag="span"
|
|
322
|
+
scope="global"
|
|
323
|
+
>
|
|
323
324
|
<template #name>
|
|
324
325
|
<span class="text-slate-300">{{ selectedPreset.name }}</span>
|
|
325
326
|
</template>
|
|
326
|
-
<template #complexity>{{ n(selectedPreset.maxComplexity, { key: 'percent' }) }}</template>
|
|
327
327
|
<template #risk>{{ n(selectedPreset.maxRisk, { key: 'percent' }) }}</template>
|
|
328
328
|
<template #impact>{{ n(selectedPreset.maxImpact, { key: 'percent' }) }}</template>
|
|
329
|
+
<template #complexity>{{ n(selectedPreset.maxComplexity, { key: 'percent' }) }}</template>
|
|
330
|
+
<template #attempts>{{ selectedPreset.ciMaxAttempts }}</template>
|
|
331
|
+
</i18n-t>
|
|
332
|
+
<!-- Auto-merge off: the ceilings never apply, so quoting them would misdescribe it. -->
|
|
333
|
+
<i18n-t v-else keypath="inspector.runSettings.riskPolicyManual" tag="span" scope="global">
|
|
334
|
+
<template #name>
|
|
335
|
+
<span class="text-slate-300">{{ selectedPreset.name }}</span>
|
|
336
|
+
</template>
|
|
329
337
|
<template #attempts>{{ selectedPreset.ciMaxAttempts }}</template>
|
|
330
338
|
</i18n-t>
|
|
331
339
|
<span v-if="!block.riskPolicyId" class="text-slate-500">{{
|
|
@@ -662,6 +662,11 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
662
662
|
>
|
|
663
663
|
</i18n-t>
|
|
664
664
|
|
|
665
|
+
<!-- Why the findings never ask a technical question: this gate settles the
|
|
666
|
+
product/business layer, and the architect/researcher steps settle the rest.
|
|
667
|
+
Without this a reader reads the missing technical questions as an oversight. -->
|
|
668
|
+
<p class="mb-4 text-xs text-slate-500">{{ t('requirements.scopeNote') }}</p>
|
|
669
|
+
|
|
665
670
|
<!-- empty state — the reviewer runs automatically as the first pipeline
|
|
666
671
|
gate step, so there's nothing to do here until then -->
|
|
667
672
|
<div
|
|
@@ -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>
|