@cat-factory/app 0.267.0 → 0.268.1
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/layout/AccountRiskPolicySettings.vue +120 -0
- package/app/components/settings/AccountSettingsPanel.vue +17 -2
- package/app/components/settings/RiskPolicyCreateForm.vue +164 -0
- package/app/components/settings/RiskPolicyEditorRow.vue +315 -0
- package/app/components/settings/RiskPolicyInheritedRow.vue +69 -0
- package/app/components/settings/RiskPolicyPanel.vue +176 -603
- package/app/composables/api/presets.ts +50 -13
- package/app/composables/usePipelineErrorToast.ts +11 -0
- package/app/composables/useRiskPolicyHealth.ts +14 -1
- package/app/composables/useRunStart.spec.ts +9 -5
- package/app/stores/riskPolicies.ts +123 -13
- package/app/types/merge.ts +5 -0
- package/app/utils/riskPolicy.spec.ts +28 -0
- package/app/utils/riskPolicy.ts +24 -1
- package/app/utils/riskPolicyDraft.ts +203 -0
- package/i18n/locales/de.json +45 -4
- package/i18n/locales/en.json +45 -4
- package/i18n/locales/es.json +45 -4
- package/i18n/locales/fr.json +45 -4
- package/i18n/locales/he.json +45 -4
- package/i18n/locales/it.json +45 -4
- package/i18n/locales/ja.json +45 -4
- package/i18n/locales/pl.json +45 -4
- package/i18n/locales/tr.json +45 -4
- package/i18n/locales/uk.json +45 -4
- package/package.json +2 -2
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
|
+
cloneRiskPolicyContract,
|
|
2
3
|
createRiskPolicyContract,
|
|
4
|
+
listRiskPolicySuppressionsContract,
|
|
5
|
+
restoreRiskPolicyContract,
|
|
6
|
+
suppressRiskPolicyContract,
|
|
3
7
|
listMergeClassRollupsContract,
|
|
4
8
|
tagMergeReviewEffortContract,
|
|
5
9
|
createConsensusGroupContract,
|
|
@@ -16,7 +20,7 @@ import {
|
|
|
16
20
|
updateRiskPolicyContract,
|
|
17
21
|
updateModelPresetContract,
|
|
18
22
|
} from '@cat-factory/contracts'
|
|
19
|
-
import type { ReviewEffort, UpdateRiskPolicyInput } from '~/types/merge'
|
|
23
|
+
import type { ReviewEffort, RiskPolicyTier, UpdateRiskPolicyInput } from '~/types/merge'
|
|
20
24
|
import type { CreateModelPresetInput, UpdateModelPresetInput } from '~/types/model-presets'
|
|
21
25
|
import type { CreateConsensusGroupInput, UpdateConsensusGroupInput } from '~/types/consensus'
|
|
22
26
|
import type { SendParams } from './client'
|
|
@@ -28,30 +32,63 @@ import type { ApiContext } from './context'
|
|
|
28
32
|
type CreateRiskPolicyBody = NonNullable<SendParams<typeof createRiskPolicyContract>['body']>
|
|
29
33
|
|
|
30
34
|
/** The per-workspace preset libraries: merge-threshold policy + model->agent mapping. */
|
|
31
|
-
export function presetsApi({ send, ws }: ApiContext) {
|
|
35
|
+
export function presetsApi({ send, ws, scope }: ApiContext) {
|
|
32
36
|
return {
|
|
33
|
-
// ----
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
// ---- risk policies (per-task auto-merge policy library) ---------------
|
|
38
|
+
// The four CRUD calls are TIER-scoped (`account` or `workspace`, ADR 0055): the same routes are
|
|
39
|
+
// mounted under both prefixes, so one method serves either library and the caller states which
|
|
40
|
+
// one it is managing. The workspace read answers the MERGED library (its own rows plus the
|
|
41
|
+
// account policies it inherits, each tagged with its tier).
|
|
42
|
+
listRiskPolicies: (kind: RiskPolicyTier, id: string) =>
|
|
43
|
+
send(listRiskPoliciesContract, { pathPrefix: scope(kind, id) }),
|
|
44
|
+
|
|
45
|
+
createRiskPolicy: (kind: RiskPolicyTier, id: string, body: CreateRiskPolicyBody) =>
|
|
46
|
+
send(createRiskPolicyContract, { pathPrefix: scope(kind, id), body }),
|
|
47
|
+
|
|
48
|
+
updateRiskPolicy: (
|
|
49
|
+
kind: RiskPolicyTier,
|
|
50
|
+
id: string,
|
|
51
|
+
presetId: string,
|
|
52
|
+
body: UpdateRiskPolicyInput,
|
|
53
|
+
) =>
|
|
41
54
|
send(updateRiskPolicyContract, {
|
|
42
|
-
pathPrefix:
|
|
55
|
+
pathPrefix: scope(kind, id),
|
|
43
56
|
pathParams: { presetId },
|
|
44
57
|
body,
|
|
45
58
|
}),
|
|
46
59
|
|
|
47
|
-
deleteRiskPolicy: (
|
|
48
|
-
send(deleteRiskPolicyContract, { pathPrefix:
|
|
60
|
+
deleteRiskPolicy: (kind: RiskPolicyTier, id: string, presetId: string) =>
|
|
61
|
+
send(deleteRiskPolicyContract, { pathPrefix: scope(kind, id), pathParams: { presetId } }),
|
|
49
62
|
|
|
50
63
|
// Restore a built-in preset to its current catalog definition (adopt an update, repair a
|
|
51
64
|
// drifted one, or materialise a new built-in that appeared). Custom presets reject this.
|
|
65
|
+
// Workspace-only: the built-in catalog is copied into BOARDS, so only a board has one to restore.
|
|
52
66
|
reseedRiskPolicy: (workspaceId: string, presetId: string) =>
|
|
53
67
|
send(reseedRiskPolicyContract, { pathPrefix: ws(workspaceId), pathParams: { presetId } }),
|
|
54
68
|
|
|
69
|
+
// ---- inheritance (workspace only) -------------------------------------
|
|
70
|
+
// Copy an inherited account policy into the board's own tier, under a fresh id, so the board can
|
|
71
|
+
// edit its numbers. `name` is optional; the SPA sends the localized "copy" label.
|
|
72
|
+
cloneRiskPolicy: (workspaceId: string, presetId: string, body: { name?: string }) =>
|
|
73
|
+
send(cloneRiskPolicyContract, {
|
|
74
|
+
pathPrefix: ws(workspaceId),
|
|
75
|
+
pathParams: { presetId },
|
|
76
|
+
body,
|
|
77
|
+
}),
|
|
78
|
+
|
|
79
|
+
// Hide an inherited account policy from this board, and the inverse. Deliberately NOT the delete
|
|
80
|
+
// above: that removes a row the board owns, this withholds one it does not and is reversible.
|
|
81
|
+
suppressRiskPolicy: (workspaceId: string, presetId: string) =>
|
|
82
|
+
send(suppressRiskPolicyContract, { pathPrefix: ws(workspaceId), pathParams: { presetId } }),
|
|
83
|
+
|
|
84
|
+
restoreRiskPolicy: (workspaceId: string, presetId: string) =>
|
|
85
|
+
send(restoreRiskPolicyContract, { pathPrefix: ws(workspaceId), pathParams: { presetId } }),
|
|
86
|
+
|
|
87
|
+
// What the board is hiding. Its own read because a hidden policy is by construction absent from
|
|
88
|
+
// the list above, so without it the editor could offer no way back.
|
|
89
|
+
listRiskPolicySuppressions: (workspaceId: string) =>
|
|
90
|
+
send(listRiskPolicySuppressionsContract, { pathPrefix: ws(workspaceId) }),
|
|
91
|
+
|
|
55
92
|
// ---- merge track record (the per-class evidence behind the policy) -----
|
|
56
93
|
// Every class in ONE request (a single SQL aggregate server-side), so the preset editor can
|
|
57
94
|
// show each class's rule next to the numbers that justify widening it without fanning out.
|
|
@@ -91,6 +91,17 @@ const CONFLICT_INFO: Record<Exclude<ConflictReason, BespokeConflictReason>, Conf
|
|
|
91
91
|
titleKey: 'errors.conflict.title.input_gate_parked',
|
|
92
92
|
descriptionKey: 'errors.conflict.description.input_gate_parked',
|
|
93
93
|
},
|
|
94
|
+
// The two halves of "this policy is not yours to change" (ADR 0055). They get opposite copy on
|
|
95
|
+
// purpose: the first sends the reader to the clone action, the second to the delete, and one
|
|
96
|
+
// shared string could only describe whichever case the reader was not in.
|
|
97
|
+
risk_policy_inherited: {
|
|
98
|
+
titleKey: 'errors.conflict.title.risk_policy_inherited',
|
|
99
|
+
descriptionKey: 'errors.conflict.description.risk_policy_inherited',
|
|
100
|
+
},
|
|
101
|
+
risk_policy_not_inherited: {
|
|
102
|
+
titleKey: 'errors.conflict.title.risk_policy_not_inherited',
|
|
103
|
+
descriptionKey: 'errors.conflict.description.risk_policy_not_inherited',
|
|
104
|
+
},
|
|
94
105
|
task_limit_reached: {
|
|
95
106
|
titleKey: 'errors.conflict.title.task_limit_reached',
|
|
96
107
|
descriptionKey: 'errors.conflict.description.task_limit_reached',
|
|
@@ -31,16 +31,29 @@ function builtinName(id: string, stored: RiskPolicy | undefined): string {
|
|
|
31
31
|
* versions the snapshot ships ARE the set of built-in ids, so detection is entirely client-side:
|
|
32
32
|
* a stored preset is a built-in iff its id is a catalog key, and a catalog key with no stored
|
|
33
33
|
* preset is a new built-in.
|
|
34
|
+
*
|
|
35
|
+
* Asked of the WORKSPACE tier alone. Reseeding writes a board-owned row, so only a board-owned row
|
|
36
|
+
* can be out of date, and only an id no tier resolves is genuinely missing (ADR 0055).
|
|
34
37
|
*/
|
|
35
38
|
export function useRiskPolicyHealth() {
|
|
36
39
|
const store = useRiskPoliciesStore()
|
|
37
40
|
|
|
38
41
|
const issues = computed<RiskPolicyIssue[]>(() => {
|
|
39
42
|
const out: RiskPolicyIssue[] = []
|
|
40
|
-
|
|
43
|
+
// The BOARD'S OWN rows only. Since ADR 0055 `presets` is the merged two-tier library, and an
|
|
44
|
+
// account entry carries no `version`, so indexing the merge read an account policy that happens
|
|
45
|
+
// to use a catalog id as a stored built-in stuck at version 0 — an advisory whose reseed would
|
|
46
|
+
// have written a board-owned row shadowing the account's deliberate posture.
|
|
47
|
+
const own = store.presets.filter((p) => p.tier === 'workspace')
|
|
48
|
+
const byId = new Map(own.map((p) => [p.id, p]))
|
|
49
|
+
const inheritedIds = new Set(store.presets.filter((p) => p.tier === 'account').map((p) => p.id))
|
|
41
50
|
for (const [id, catalogVersion] of Object.entries(store.catalogVersions)) {
|
|
42
51
|
const stored = byId.get(id)
|
|
43
52
|
if (!stored) {
|
|
53
|
+
// A built-in the board does not hold, that its ACCOUNT defines, is not missing: the board
|
|
54
|
+
// already resolves a policy under that id. Adding the catalog copy would shadow it, so the
|
|
55
|
+
// advisory stays silent rather than offering a one-click override of the org's choice.
|
|
56
|
+
if (inheritedIds.has(id)) continue
|
|
44
57
|
out.push({ type: 'new', id, name: builtinName(id, undefined) })
|
|
45
58
|
continue
|
|
46
59
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest'
|
|
2
2
|
import { nextTick, ref, type Ref } from 'vue'
|
|
3
3
|
import type { Block, Pipeline } from '~/types/domain'
|
|
4
|
-
import type {
|
|
4
|
+
import type { RiskPolicyLibraryEntry, WorkspaceRole } from '~/types/merge'
|
|
5
5
|
import { useBoardStore } from '~/stores/board'
|
|
6
6
|
import { useExecutionStore } from '~/stores/execution'
|
|
7
7
|
import { useRiskPoliciesStore } from '~/stores/riskPolicies'
|
|
@@ -14,7 +14,7 @@ import { useDryRunPolicy, useRunStart } from '~/composables/useRunStart'
|
|
|
14
14
|
// stubbed, since neither an HTTP call nor an auth gate is what these assertions are about.
|
|
15
15
|
const pipeline = { id: 'pl_build', name: 'Build' } as Pipeline
|
|
16
16
|
|
|
17
|
-
const preset = (over: Partial<
|
|
17
|
+
const preset = (over: Partial<RiskPolicyLibraryEntry> = {}): RiskPolicyLibraryEntry =>
|
|
18
18
|
({
|
|
19
19
|
id: 'mp_balanced',
|
|
20
20
|
name: 'Balanced',
|
|
@@ -35,9 +35,13 @@ const preset = (over: Partial<RiskPolicy> = {}): RiskPolicy =>
|
|
|
35
35
|
classRulesByRole: {},
|
|
36
36
|
dryRunRoles: [],
|
|
37
37
|
isDefault: true,
|
|
38
|
+
// The board's OWN tier: what these assertions are about is the policy the board resolves, and an
|
|
39
|
+
// inherited one resolves identically (that is the point of the merge), so the fixture states the
|
|
40
|
+
// ordinary case rather than parameterising a tier nothing here branches on.
|
|
41
|
+
tier: 'workspace',
|
|
38
42
|
createdAt: 0,
|
|
39
43
|
...over,
|
|
40
|
-
}) as
|
|
44
|
+
}) as RiskPolicyLibraryEntry
|
|
41
45
|
|
|
42
46
|
/**
|
|
43
47
|
* Seed a board governed by `policy`, signed in as `role`, and hand back the composable.
|
|
@@ -48,8 +52,8 @@ const preset = (over: Partial<RiskPolicy> = {}): RiskPolicy =>
|
|
|
48
52
|
*/
|
|
49
53
|
function setup(options: {
|
|
50
54
|
role: WorkspaceRole | null
|
|
51
|
-
policy?:
|
|
52
|
-
otherPolicy?:
|
|
55
|
+
policy?: RiskPolicyLibraryEntry
|
|
56
|
+
otherPolicy?: RiskPolicyLibraryEntry
|
|
53
57
|
advanced?: boolean
|
|
54
58
|
}) {
|
|
55
59
|
const start = vi.fn().mockResolvedValue(true)
|
|
@@ -1,18 +1,27 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
2
|
import { computed, ref } from 'vue'
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
RiskPolicyLibraryEntry,
|
|
5
|
+
RiskPolicySuppression,
|
|
6
|
+
UpdateRiskPolicyInput,
|
|
7
|
+
} from '~/types/merge'
|
|
4
8
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
5
9
|
|
|
6
10
|
/**
|
|
7
|
-
* The
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
+
* The board's risk policy library — what a task picks its auto-merge policy from (the `merger` step
|
|
12
|
+
* compares the PR assessment against the resolved policy).
|
|
13
|
+
*
|
|
14
|
+
* Since ADR 0055 the list is the MERGE of two tiers: the board's own policies and the ones it
|
|
15
|
+
* inherits from its account, each entry carrying the `tier` that owns it. An inherited entry is
|
|
16
|
+
* read-only here and offers two actions instead of an edit — clone it into the board, or hide it —
|
|
17
|
+
* and the backend refuses the writes as well, so the affordances and the rules agree.
|
|
18
|
+
*
|
|
19
|
+
* Hydrated from the workspace snapshot, which carries the same merged list.
|
|
11
20
|
*/
|
|
12
21
|
export const useRiskPoliciesStore = defineStore('riskPolicies', () => {
|
|
13
22
|
const api = useApi()
|
|
14
23
|
|
|
15
|
-
const presets = ref<
|
|
24
|
+
const presets = ref<RiskPolicyLibraryEntry[]>([])
|
|
16
25
|
/**
|
|
17
26
|
* Current built-in catalog versions (`seedRiskPolicies()`), keyed by preset id, from the
|
|
18
27
|
* workspace snapshot. The keys ARE the set of built-in ids: a stored preset whose id is a
|
|
@@ -21,9 +30,24 @@ export const useRiskPoliciesStore = defineStore('riskPolicies', () => {
|
|
|
21
30
|
* `useRiskPolicyHealth`.
|
|
22
31
|
*/
|
|
23
32
|
const catalogVersions = ref<Record<string, number>>({})
|
|
33
|
+
/**
|
|
34
|
+
* The account policies this board is HIDING. Loaded on demand rather than from the snapshot: a
|
|
35
|
+
* hidden policy is by construction absent from `presets`, so this is the only way back, and it is
|
|
36
|
+
* read when the settings panel opens rather than on every board load.
|
|
37
|
+
*/
|
|
38
|
+
const suppressions = ref<RiskPolicySuppression[]>([])
|
|
24
39
|
|
|
25
|
-
|
|
26
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Adopt the server's list AS SENT, never re-sorted.
|
|
42
|
+
*
|
|
43
|
+
* `mergeRiskPolicyTiers` answers oldest-first within each tier with the ACCOUNT tier first, and
|
|
44
|
+
* both repositories order by `created_at`, so the order already carries the tier grouping every
|
|
45
|
+
* reader wants. Re-sorting the merged list by timestamp interleaved the two, which the settings
|
|
46
|
+
* panel hid by re-splitting per tier and the task picker showed as inherited and own policies
|
|
47
|
+
* shuffled together.
|
|
48
|
+
*/
|
|
49
|
+
function hydrate(list: RiskPolicyLibraryEntry[], versions?: Record<string, number>) {
|
|
50
|
+
presets.value = [...list]
|
|
27
51
|
if (versions) catalogVersions.value = versions
|
|
28
52
|
}
|
|
29
53
|
|
|
@@ -31,7 +55,7 @@ export const useRiskPoliciesStore = defineStore('riskPolicies', () => {
|
|
|
31
55
|
const defaultPreset = computed(() => presets.value.find((p) => p.isDefault) ?? null)
|
|
32
56
|
|
|
33
57
|
/** Resolve a task's effective preset by id, falling back to the default. */
|
|
34
|
-
function resolve(presetId: string | undefined):
|
|
58
|
+
function resolve(presetId: string | undefined): RiskPolicyLibraryEntry | null {
|
|
35
59
|
if (presetId) {
|
|
36
60
|
const picked = presets.value.find((p) => p.id === presetId)
|
|
37
61
|
if (picked) return picked
|
|
@@ -39,23 +63,23 @@ export const useRiskPoliciesStore = defineStore('riskPolicies', () => {
|
|
|
39
63
|
return defaultPreset.value
|
|
40
64
|
}
|
|
41
65
|
|
|
42
|
-
async function create(input: Parameters<typeof api.createRiskPolicy>[
|
|
66
|
+
async function create(input: Parameters<typeof api.createRiskPolicy>[2]) {
|
|
43
67
|
const ws = useWorkspaceStore()
|
|
44
|
-
const created = await api.createRiskPolicy(ws.requireId(), input)
|
|
68
|
+
const created = await api.createRiskPolicy('workspace', ws.requireId(), input)
|
|
45
69
|
await ws.refresh()
|
|
46
70
|
return created
|
|
47
71
|
}
|
|
48
72
|
|
|
49
73
|
async function update(presetId: string, patch: UpdateRiskPolicyInput) {
|
|
50
74
|
const ws = useWorkspaceStore()
|
|
51
|
-
const updated = await api.updateRiskPolicy(ws.requireId(), presetId, patch)
|
|
75
|
+
const updated = await api.updateRiskPolicy('workspace', ws.requireId(), presetId, patch)
|
|
52
76
|
await ws.refresh()
|
|
53
77
|
return updated
|
|
54
78
|
}
|
|
55
79
|
|
|
56
80
|
async function remove(presetId: string) {
|
|
57
81
|
const ws = useWorkspaceStore()
|
|
58
|
-
await api.deleteRiskPolicy(ws.requireId(), presetId)
|
|
82
|
+
await api.deleteRiskPolicy('workspace', ws.requireId(), presetId)
|
|
59
83
|
await ws.refresh()
|
|
60
84
|
}
|
|
61
85
|
|
|
@@ -71,9 +95,41 @@ export const useRiskPoliciesStore = defineStore('riskPolicies', () => {
|
|
|
71
95
|
return updated
|
|
72
96
|
}
|
|
73
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Copy an inherited account policy into the board, under a fresh id, so the board can edit it.
|
|
100
|
+
* `name` is supplied by the caller because the label is localized copy and the backend does not
|
|
101
|
+
* localize prose.
|
|
102
|
+
*/
|
|
103
|
+
async function clone(presetId: string, name?: string) {
|
|
104
|
+
const ws = useWorkspaceStore()
|
|
105
|
+
const created = await api.cloneRiskPolicy(ws.requireId(), presetId, name ? { name } : {})
|
|
106
|
+
await ws.refresh()
|
|
107
|
+
return created
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Hide an inherited account policy from this board, then re-read what is hidden. */
|
|
111
|
+
async function hide(presetId: string) {
|
|
112
|
+
const ws = useWorkspaceStore()
|
|
113
|
+
await api.suppressRiskPolicy(ws.requireId(), presetId)
|
|
114
|
+
await Promise.all([ws.refresh(), loadSuppressions()])
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Stop hiding one, so the board offers it again. */
|
|
118
|
+
async function unhide(presetId: string) {
|
|
119
|
+
const ws = useWorkspaceStore()
|
|
120
|
+
await api.restoreRiskPolicy(ws.requireId(), presetId)
|
|
121
|
+
await Promise.all([ws.refresh(), loadSuppressions()])
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function loadSuppressions() {
|
|
125
|
+
const ws = useWorkspaceStore()
|
|
126
|
+
suppressions.value = await api.listRiskPolicySuppressions(ws.requireId())
|
|
127
|
+
}
|
|
128
|
+
|
|
74
129
|
return {
|
|
75
130
|
presets,
|
|
76
131
|
catalogVersions,
|
|
132
|
+
suppressions,
|
|
77
133
|
defaultPreset,
|
|
78
134
|
resolve,
|
|
79
135
|
hydrate,
|
|
@@ -81,5 +137,59 @@ export const useRiskPoliciesStore = defineStore('riskPolicies', () => {
|
|
|
81
137
|
update,
|
|
82
138
|
remove,
|
|
83
139
|
reseed,
|
|
140
|
+
clone,
|
|
141
|
+
hide,
|
|
142
|
+
unhide,
|
|
143
|
+
loadSuppressions,
|
|
84
144
|
}
|
|
85
145
|
})
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The ACCOUNT tier of the same library: the postures an org authors once, which every board under
|
|
149
|
+
* it inherits (ADR 0055).
|
|
150
|
+
*
|
|
151
|
+
* Its own store rather than a scope flag on the one above, because the two have different lifetimes
|
|
152
|
+
* and different sources of truth: the board library rides the workspace snapshot and refreshes with
|
|
153
|
+
* it, while the account library is loaded on demand by the account settings panel and has no
|
|
154
|
+
* snapshot to fold into. Keyed by account id so switching accounts cannot show the previous one's
|
|
155
|
+
* policies.
|
|
156
|
+
*/
|
|
157
|
+
export const useAccountRiskPoliciesStore = defineStore('accountRiskPolicies', () => {
|
|
158
|
+
const api = useApi()
|
|
159
|
+
|
|
160
|
+
const byAccount = ref<Record<string, RiskPolicyLibraryEntry[]>>({})
|
|
161
|
+
const loading = ref(false)
|
|
162
|
+
|
|
163
|
+
const policies = (accountId: string) => byAccount.value[accountId] ?? []
|
|
164
|
+
|
|
165
|
+
async function load(accountId: string) {
|
|
166
|
+
loading.value = true
|
|
167
|
+
try {
|
|
168
|
+
byAccount.value = {
|
|
169
|
+
...byAccount.value,
|
|
170
|
+
[accountId]: await api.listRiskPolicies('account', accountId),
|
|
171
|
+
}
|
|
172
|
+
} finally {
|
|
173
|
+
loading.value = false
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function create(accountId: string, input: Parameters<typeof api.createRiskPolicy>[2]) {
|
|
178
|
+
const created = await api.createRiskPolicy('account', accountId, input)
|
|
179
|
+
await load(accountId)
|
|
180
|
+
return created
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function update(accountId: string, presetId: string, patch: UpdateRiskPolicyInput) {
|
|
184
|
+
const updated = await api.updateRiskPolicy('account', accountId, presetId, patch)
|
|
185
|
+
await load(accountId)
|
|
186
|
+
return updated
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function remove(accountId: string, presetId: string) {
|
|
190
|
+
await api.deleteRiskPolicy('account', accountId, presetId)
|
|
191
|
+
await load(accountId)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return { policies, loading, load, create, update, remove }
|
|
195
|
+
})
|
package/app/types/merge.ts
CHANGED
|
@@ -25,4 +25,9 @@ export type {
|
|
|
25
25
|
RiskPolicy,
|
|
26
26
|
CreateRiskPolicyInput,
|
|
27
27
|
UpdateRiskPolicyInput,
|
|
28
|
+
// The two tiers a policy can be stored at, the merged library entry a board picks from, and one
|
|
29
|
+
// account policy a board is hiding (ADR 0055).
|
|
30
|
+
RiskPolicyTier,
|
|
31
|
+
RiskPolicyLibraryEntry,
|
|
32
|
+
RiskPolicySuppression,
|
|
28
33
|
} from '@cat-factory/contracts'
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import type { RiskPolicy } from '~/types/merge'
|
|
3
|
+
import { RISK_POLICY_NAME_MAX_LENGTH } from '@cat-factory/contracts'
|
|
3
4
|
import {
|
|
4
5
|
RISK_POLICY_AXES,
|
|
5
6
|
RISK_POLICY_CEILING_FIELD,
|
|
6
7
|
riskPolicyCeilings,
|
|
8
|
+
riskPolicyCopyName,
|
|
7
9
|
rolePolicySummary,
|
|
8
10
|
} from '~/utils/riskPolicy'
|
|
9
11
|
|
|
@@ -101,3 +103,29 @@ describe('rolePolicySummary', () => {
|
|
|
101
103
|
expect(rolePolicySummary(p)).toEqual({ sandboxed: ['member'], narrowed: [], scoped: [] })
|
|
102
104
|
})
|
|
103
105
|
})
|
|
106
|
+
|
|
107
|
+
describe('riskPolicyCopyName', () => {
|
|
108
|
+
// The template the SPA actually uses for the clone action.
|
|
109
|
+
const copy = (name: string) => `${name} (copy)`
|
|
110
|
+
|
|
111
|
+
it('leaves a name with room to spare alone', () => {
|
|
112
|
+
expect(riskPolicyCopyName('Balanced', copy)).toBe('Balanced (copy)')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('keeps the copy marker by trimming the SOURCE name, not the result', () => {
|
|
116
|
+
// 58 chars, so ' (copy)' would put the composed name 5 over the 60-char contract limit. The
|
|
117
|
+
// clone action was simply unavailable for such a policy: a 422 against a name with no field to
|
|
118
|
+
// edit. The marker is the informative half, so the source name is what gives way.
|
|
119
|
+
const long = 'Balanced posture for revenue services with schema review!!'
|
|
120
|
+
expect(long.length).toBe(58)
|
|
121
|
+
const composed = riskPolicyCopyName(long, copy)
|
|
122
|
+
expect(composed.length).toBeLessThanOrEqual(RISK_POLICY_NAME_MAX_LENGTH)
|
|
123
|
+
expect(composed.endsWith('(copy)')).toBe(true)
|
|
124
|
+
expect(long.startsWith(composed.slice(0, composed.indexOf(' (copy)')))).toBe(true)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('still answers within the limit when the template alone would blow the budget', () => {
|
|
128
|
+
const verbose = (name: string) => `${name} ${'x'.repeat(RISK_POLICY_NAME_MAX_LENGTH)}`
|
|
129
|
+
expect(riskPolicyCopyName('Balanced', verbose).length).toBe(RISK_POLICY_NAME_MAX_LENGTH)
|
|
130
|
+
})
|
|
131
|
+
})
|
package/app/utils/riskPolicy.ts
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
|
-
import { WORKSPACE_ROLES } from '@cat-factory/contracts'
|
|
1
|
+
import { RISK_POLICY_NAME_MAX_LENGTH, WORKSPACE_ROLES } from '@cat-factory/contracts'
|
|
2
2
|
import type { RiskPolicy, WorkspaceRole } from '~/types/merge'
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* The name a CLONE of an inherited policy is created under, guaranteed to satisfy the contract's
|
|
6
|
+
* length limit.
|
|
7
|
+
*
|
|
8
|
+
* The label is composed here because the backend does not localize prose, which puts the contract's
|
|
9
|
+
* ceiling on this side of the wire too: a source name near the limit plus whatever the locale's
|
|
10
|
+
* template adds is over it, and the clone action then failed with a 422 against a name the operator
|
|
11
|
+
* had no field to shorten. The SOURCE name is what gets trimmed rather than the composed string, so
|
|
12
|
+
* the copy marker (the informative half) always survives.
|
|
13
|
+
*
|
|
14
|
+
* `compose` is passed in rather than `t` so this stays a pure function with no i18n dependency.
|
|
15
|
+
*/
|
|
16
|
+
export function riskPolicyCopyName(sourceName: string, compose: (name: string) => string): string {
|
|
17
|
+
const full = compose(sourceName)
|
|
18
|
+
if (full.length <= RISK_POLICY_NAME_MAX_LENGTH) return full
|
|
19
|
+
// What the template itself costs, so the trim leaves room for it rather than guessing.
|
|
20
|
+
const overhead = full.length - sourceName.length
|
|
21
|
+
const room = Math.max(1, RISK_POLICY_NAME_MAX_LENGTH - overhead)
|
|
22
|
+
// The final slice is the backstop for a template long enough to blow the budget on its own,
|
|
23
|
+
// where no source name would fit: better a truncated label than an action that cannot be used.
|
|
24
|
+
return compose(sourceName.slice(0, room).trimEnd()).slice(0, RISK_POLICY_NAME_MAX_LENGTH)
|
|
25
|
+
}
|
|
26
|
+
|
|
4
27
|
/**
|
|
5
28
|
* The three axes a `merger` agent scores a pull request on. Presentation order is
|
|
6
29
|
* risk → impact → complexity: what the PR could break first, then how far it reaches, then
|