@cat-factory/app 0.226.0 → 0.227.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/panels/MergerResultView.vue +7 -0
- package/app/components/riskPolicy/RiskPolicyPreview.vue +6 -2
- package/app/components/settings/MergeRolePolicyEditor.logic.spec.ts +65 -2
- package/app/components/settings/MergeRolePolicyEditor.logic.ts +72 -0
- package/app/components/settings/MergeRolePolicyEditor.vue +133 -39
- package/app/components/settings/RiskPolicyPanel.vue +8 -0
- package/app/composables/usePipelineErrorToast.ts +4 -0
- package/app/types/merge.ts +2 -0
- package/app/utils/riskPolicy.spec.ts +21 -2
- package/app/utils/riskPolicy.ts +12 -0
- package/i18n/locales/de.json +17 -6
- package/i18n/locales/en.json +20 -6
- package/i18n/locales/es.json +17 -6
- package/i18n/locales/fr.json +17 -6
- package/i18n/locales/he.json +17 -6
- package/i18n/locales/it.json +17 -6
- package/i18n/locales/ja.json +17 -6
- package/i18n/locales/pl.json +17 -6
- package/i18n/locales/tr.json +17 -6
- package/i18n/locales/uk.json +17 -6
- package/package.json +2 -2
|
@@ -71,6 +71,7 @@ const REASON_KEYS: Record<MergeDecision['reason'], string> = {
|
|
|
71
71
|
class_auto_merge: 'panels.mergerResult.reason.class_auto_merge',
|
|
72
72
|
class_requires_review: 'panels.mergerResult.reason.class_requires_review',
|
|
73
73
|
role_requires_review: 'panels.mergerResult.reason.role_requires_review',
|
|
74
|
+
submission_not_allowed: 'panels.mergerResult.reason.submission_not_allowed',
|
|
74
75
|
dry_run: 'panels.mergerResult.reason.dry_run',
|
|
75
76
|
}
|
|
76
77
|
const OUTCOME_KEYS: Record<MergeDecision['outcome'], string> = {
|
|
@@ -152,6 +153,12 @@ const reasonText = computed(() => {
|
|
|
152
153
|
return t(REASON_KEYS[d.reason], {
|
|
153
154
|
preset: d.thresholds.presetName,
|
|
154
155
|
axes: axisLabels,
|
|
156
|
+
// The classes the initiator's role MAY land, so the refusal names what the remedy is
|
|
157
|
+
// measured against. Empty is a real policy (that role lands nothing) and reads as such
|
|
158
|
+
// through its own translated phrase rather than as a blank in the middle of a sentence.
|
|
159
|
+
classes: d.thresholds.submissionClasses?.length
|
|
160
|
+
? d.thresholds.submissionClasses.map((c) => t(CLASS_KEYS[c])).join(', ')
|
|
161
|
+
: t('panels.mergerResult.noSubmittableClasses'),
|
|
155
162
|
// Never blank: a role-scoped reason is only ever produced for a run that pinned one, and the
|
|
156
163
|
// fallback keeps the sentence readable rather than leaving a hole if that ever changes.
|
|
157
164
|
role: d.thresholds.initiatorRole
|
|
@@ -23,11 +23,12 @@ const ROLE_LABEL: Record<WorkspaceRole, () => string> = {
|
|
|
23
23
|
viewer: () => t('merge.role.viewer'),
|
|
24
24
|
}
|
|
25
25
|
const roleLayer = computed(() => {
|
|
26
|
-
const { sandboxed, narrowed } = rolePolicySummary(props.policy)
|
|
26
|
+
const { sandboxed, narrowed, scoped } = rolePolicySummary(props.policy)
|
|
27
27
|
return {
|
|
28
28
|
sandboxed: sandboxed.map((r) => ROLE_LABEL[r]()).join(', '),
|
|
29
29
|
narrowed: narrowed.map((r) => ROLE_LABEL[r]()).join(', '),
|
|
30
|
-
|
|
30
|
+
scoped: scoped.map((r) => ROLE_LABEL[r]()).join(', '),
|
|
31
|
+
any: sandboxed.length > 0 || narrowed.length > 0 || scoped.length > 0,
|
|
31
32
|
}
|
|
32
33
|
})
|
|
33
34
|
|
|
@@ -94,6 +95,9 @@ const ceilings = computed(() =>
|
|
|
94
95
|
<p v-if="roleLayer.sandboxed" class="text-[12px] leading-snug text-slate-400">
|
|
95
96
|
{{ t('riskPolicy.preview.roleSandboxed', { roles: roleLayer.sandboxed }) }}
|
|
96
97
|
</p>
|
|
98
|
+
<p v-if="roleLayer.scoped" class="text-[12px] leading-snug text-slate-400">
|
|
99
|
+
{{ t('riskPolicy.preview.roleScoped', { roles: roleLayer.scoped }) }}
|
|
100
|
+
</p>
|
|
97
101
|
<p v-if="roleLayer.narrowed" class="text-[12px] leading-snug text-slate-400">
|
|
98
102
|
{{ t('riskPolicy.preview.roleNarrowed', { roles: roleLayer.narrowed }) }}
|
|
99
103
|
</p>
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { MERGE_CLASS_RULES } from '@cat-factory/contracts'
|
|
3
|
-
import type { ClassRulesByRole, MergeClassRules } from '~/types/merge'
|
|
2
|
+
import { MERGE_CLASS_RULES, RULEABLE_CHANGE_CLASSES } from '@cat-factory/contracts'
|
|
3
|
+
import type { ClassRulesByRole, MergeClassRules, SubmissionClassesByRole } from '~/types/merge'
|
|
4
4
|
import {
|
|
5
5
|
INHERIT_RULE,
|
|
6
6
|
narrowingOptionsFor,
|
|
7
7
|
roleClassRuleRows,
|
|
8
8
|
roleNarrowedCount,
|
|
9
|
+
roleSubmissionRows,
|
|
10
|
+
roleSubmissionScoped,
|
|
9
11
|
setRoleClassRule,
|
|
12
|
+
setRoleSubmissionScoped,
|
|
10
13
|
toggleDryRunRole,
|
|
14
|
+
toggleSubmissionClass,
|
|
11
15
|
} from '~/components/settings/MergeRolePolicyEditor.logic'
|
|
12
16
|
|
|
13
17
|
describe('narrowingOptionsFor', () => {
|
|
@@ -144,3 +148,62 @@ describe('roleNarrowedCount', () => {
|
|
|
144
148
|
expect(roleNarrowedCount({ docs: 'never', source: 'never' })).toBe(2)
|
|
145
149
|
})
|
|
146
150
|
})
|
|
151
|
+
|
|
152
|
+
// The submission allowlist's editing shape turns on one distinction the wire contract makes and a
|
|
153
|
+
// tick-box grid cannot render on its own: an ABSENT entry (unrestricted) and an EMPTY list (lands
|
|
154
|
+
// nothing) are different policies. Every case below is about keeping those two apart.
|
|
155
|
+
describe('roleSubmissionScoped', () => {
|
|
156
|
+
it('reads an EMPTY allowlist as scoped, not as the unrestricted default', () => {
|
|
157
|
+
expect(roleSubmissionScoped({ member: [] }, 'member')).toBe(true)
|
|
158
|
+
expect(roleSubmissionScoped({ member: ['docs'] }, 'member')).toBe(true)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('reads a role with no entry as unrestricted', () => {
|
|
162
|
+
expect(roleSubmissionScoped({ member: ['docs'] }, 'admin')).toBe(false)
|
|
163
|
+
expect(roleSubmissionScoped({}, 'member')).toBe(false)
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
describe('roleSubmissionRows', () => {
|
|
168
|
+
it('renders one row per ruleable class, in the shared order, ticked by membership', () => {
|
|
169
|
+
const rows = roleSubmissionRows(['source', 'docs'])
|
|
170
|
+
expect(rows.map((r) => r.changeClass)).toEqual([...RULEABLE_CHANGE_CLASSES])
|
|
171
|
+
expect(rows.filter((r) => r.allowed).map((r) => r.changeClass)).toEqual(['docs', 'source'])
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe('setRoleSubmissionScoped', () => {
|
|
176
|
+
// Turning the switch ON must not silently impose a policy nobody clicked: seeding today's
|
|
177
|
+
// landable classes leaves the role exactly where it was, and the operator subtracts from there.
|
|
178
|
+
it('seeds every class when scoping is turned on', () => {
|
|
179
|
+
expect(setRoleSubmissionScoped({}, 'member', true).member).toEqual([...RULEABLE_CHANGE_CLASSES])
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('removes the entry entirely when scoping is turned off', () => {
|
|
183
|
+
const byRole: SubmissionClassesByRole = { member: [], admin: ['docs'] }
|
|
184
|
+
const next = setRoleSubmissionScoped(byRole, 'member', false)
|
|
185
|
+
expect('member' in next).toBe(false)
|
|
186
|
+
expect(next.admin).toEqual(['docs'])
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
describe('toggleSubmissionClass', () => {
|
|
191
|
+
it('adds and removes one class, keeping the shared class order', () => {
|
|
192
|
+
const ticked = toggleSubmissionClass({ member: ['source'] }, 'member', 'docs', true)
|
|
193
|
+
expect(ticked.member).toEqual(['docs', 'source'])
|
|
194
|
+
expect(toggleSubmissionClass(ticked, 'member', 'source', false).member).toEqual(['docs'])
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
// Unticking the last class is the policy "this role lands nothing", so it must NOT prune back
|
|
198
|
+
// to an absent entry, which would silently invert the click into "unrestricted".
|
|
199
|
+
it('leaves an EMPTY list rather than un-scoping the role', () => {
|
|
200
|
+
const next = toggleSubmissionClass({ member: ['docs'] }, 'member', 'docs', false)
|
|
201
|
+
expect(next.member).toEqual([])
|
|
202
|
+
expect(roleSubmissionScoped(next, 'member')).toBe(true)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('leaves other roles untouched', () => {
|
|
206
|
+
const next = toggleSubmissionClass({ admin: ['docs'] }, 'member', 'docs', true)
|
|
207
|
+
expect(next.admin).toEqual(['docs'])
|
|
208
|
+
})
|
|
209
|
+
})
|
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
MergeClassRule,
|
|
24
24
|
MergeClassRules,
|
|
25
25
|
RuleableChangeClass,
|
|
26
|
+
SubmissionClassesByRole,
|
|
26
27
|
WorkspaceRole,
|
|
27
28
|
} from '~/types/merge'
|
|
28
29
|
|
|
@@ -131,3 +132,74 @@ export function toggleDryRunRole(
|
|
|
131
132
|
export function roleNarrowedCount(entry: MergeClassRules | undefined): number {
|
|
132
133
|
return entry ? Object.keys(entry).length : 0
|
|
133
134
|
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// The per-role SUBMISSION ALLOWLIST: which change classes a role may LAND at all. A third
|
|
138
|
+
// setting rather than a fourth rule value, because it answers a different question: a class rule
|
|
139
|
+
// decides how much review landing takes, where this decides whether the platform lands it at all,
|
|
140
|
+
// and it is refused at BOTH exits, the manual merge included.
|
|
141
|
+
//
|
|
142
|
+
// The editing shape follows the wire shape's own distinction: an ABSENT entry is unrestricted
|
|
143
|
+
// and an EMPTY list is "this role lands nothing", which are different policies. So the row is a
|
|
144
|
+
// switch (scoped / not) plus a set of tick boxes, never a set of tick boxes alone. With tick
|
|
145
|
+
// boxes alone, "unrestricted" and "lands nothing" would be the same rendered state.
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
/** One class's tick box inside one role's allowlist. */
|
|
149
|
+
export interface RoleSubmissionRow {
|
|
150
|
+
changeClass: RuleableChangeClass
|
|
151
|
+
allowed: boolean
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Whether this role carries an allowlist at all (as opposed to being unrestricted). */
|
|
155
|
+
export function roleSubmissionScoped(
|
|
156
|
+
byRole: SubmissionClassesByRole,
|
|
157
|
+
role: WorkspaceRole,
|
|
158
|
+
): boolean {
|
|
159
|
+
return byRole[role] !== undefined
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** One role's tick boxes, in the shared class order. Empty rows for an unscoped role. */
|
|
163
|
+
export function roleSubmissionRows(entry: readonly RuleableChangeClass[]): RoleSubmissionRow[] {
|
|
164
|
+
return RULEABLE_CHANGE_CLASSES.map((changeClass) => ({
|
|
165
|
+
changeClass,
|
|
166
|
+
allowed: entry.includes(changeClass),
|
|
167
|
+
}))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Turn the allowlist on or off for a role. Turning it ON seeds the classes that are landable
|
|
172
|
+
* TODAY, which is behaviourally where the role already was, so the operator subtracts from a
|
|
173
|
+
* known state rather than starting from a policy ("lands nothing") they did not ask for.
|
|
174
|
+
*
|
|
175
|
+
* That seeded list is NOT the identity, and deliberately: from here on the role is scoped, so a
|
|
176
|
+
* class the vocabulary gains in a later release falls outside it. Opting in is what earns that.
|
|
177
|
+
*/
|
|
178
|
+
export function setRoleSubmissionScoped(
|
|
179
|
+
byRole: SubmissionClassesByRole,
|
|
180
|
+
role: WorkspaceRole,
|
|
181
|
+
scoped: boolean,
|
|
182
|
+
): SubmissionClassesByRole {
|
|
183
|
+
const next: SubmissionClassesByRole = { ...byRole }
|
|
184
|
+
if (scoped) next[role] = [...RULEABLE_CHANGE_CLASSES]
|
|
185
|
+
else delete next[role]
|
|
186
|
+
return next
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Tick or untick one class for a role that is already scoped, keeping the shared class order so
|
|
191
|
+
* two presets carrying the same policy carry the same array. Unticking the last class leaves an
|
|
192
|
+
* EMPTY list rather than removing the entry: that is the real policy "this role lands nothing",
|
|
193
|
+
* and silently promoting it to unrestricted would be the exact inversion of what was clicked.
|
|
194
|
+
*/
|
|
195
|
+
export function toggleSubmissionClass(
|
|
196
|
+
byRole: SubmissionClassesByRole,
|
|
197
|
+
role: WorkspaceRole,
|
|
198
|
+
changeClass: RuleableChangeClass,
|
|
199
|
+
allowed: boolean,
|
|
200
|
+
): SubmissionClassesByRole {
|
|
201
|
+
const current = new Set(byRole[role] ?? [])
|
|
202
|
+
if (allowed) current.add(changeClass)
|
|
203
|
+
else current.delete(changeClass)
|
|
204
|
+
return { ...byRole, [role]: RULEABLE_CHANGE_CLASSES.filter((c) => current.has(c)) }
|
|
205
|
+
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
// The ROLE layer of one merge preset: what happens to a run depending on WHO started it.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
// be held to stricter per-class rules than the preset's base map (`classRulesByRole`),
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// Three settings, edited together because they answer the same question at three strengths. A role
|
|
5
|
+
// can be held to stricter per-class rules than the preset's base map (`classRulesByRole`), held to
|
|
6
|
+
// an allowlist of the change classes it may LAND at all (`submissionClassesByRole`), or held to dry
|
|
7
|
+
// runs entirely (`dryRunRoles`): the pipeline works and opens its pull request, and nothing merges.
|
|
8
|
+
// The strongest of the three that applies is what governs, which is why a sandboxed role says so on
|
|
9
|
+
// its row rather than leaving the two below it reading as the policy.
|
|
9
10
|
//
|
|
10
11
|
// Composition is NARROW-ONLY, so this editor only offers a role rules STRICTER than the base one
|
|
11
12
|
// (see MergeRolePolicyEditor.logic.ts). A looser rule is discarded by the engine, and an editor
|
|
@@ -18,14 +19,19 @@ import type {
|
|
|
18
19
|
MergeClassRule,
|
|
19
20
|
MergeClassRules,
|
|
20
21
|
RuleableChangeClass,
|
|
22
|
+
SubmissionClassesByRole,
|
|
21
23
|
WorkspaceRole,
|
|
22
24
|
} from '~/types/merge'
|
|
23
25
|
import {
|
|
24
26
|
INHERIT_RULE,
|
|
25
27
|
roleClassRuleRows,
|
|
26
28
|
roleNarrowedCount,
|
|
29
|
+
roleSubmissionRows,
|
|
30
|
+
roleSubmissionScoped,
|
|
27
31
|
setRoleClassRule,
|
|
32
|
+
setRoleSubmissionScoped,
|
|
28
33
|
toggleDryRunRole,
|
|
34
|
+
toggleSubmissionClass,
|
|
29
35
|
type RoleRuleSelection,
|
|
30
36
|
} from '~/components/settings/MergeRolePolicyEditor.logic'
|
|
31
37
|
|
|
@@ -34,6 +40,12 @@ const props = defineProps<{
|
|
|
34
40
|
classRulesByRole: ClassRulesByRole
|
|
35
41
|
/** The roles whose runs this preset sandboxes. */
|
|
36
42
|
dryRunRoles: DryRunRoles
|
|
43
|
+
/**
|
|
44
|
+
* Which change classes each role may LAND. A role with no entry is unrestricted; an entry with
|
|
45
|
+
* no classes lands nothing. The editor keeps those two apart (a switch, then tick boxes),
|
|
46
|
+
* because tick boxes alone would render them identically.
|
|
47
|
+
*/
|
|
48
|
+
submissionClassesByRole: SubmissionClassesByRole
|
|
37
49
|
/** The base rules the role layer narrows, so each row can show what it inherits. */
|
|
38
50
|
classRules: MergeClassRules
|
|
39
51
|
/**
|
|
@@ -50,6 +62,7 @@ const props = defineProps<{
|
|
|
50
62
|
const emit = defineEmits<{
|
|
51
63
|
'update:classRulesByRole': [ClassRulesByRole]
|
|
52
64
|
'update:dryRunRoles': [DryRunRoles]
|
|
65
|
+
'update:submissionClassesByRole': [SubmissionClassesByRole]
|
|
53
66
|
}>()
|
|
54
67
|
|
|
55
68
|
const { t } = useI18n()
|
|
@@ -92,6 +105,14 @@ const roles = computed(() =>
|
|
|
92
105
|
sandboxed: props.dryRunRoles.includes(role),
|
|
93
106
|
narrowed,
|
|
94
107
|
open: !!expanded.value[role],
|
|
108
|
+
// Scoped-ness is read off the MAP rather than off the entry being truthy, so an empty
|
|
109
|
+
// allowlist (a real policy: this role lands nothing) stays scoped instead of reading as
|
|
110
|
+
// the unrestricted default.
|
|
111
|
+
submissionScoped: roleSubmissionScoped(props.submissionClassesByRole, role),
|
|
112
|
+
submissionRows: roleSubmissionRows(props.submissionClassesByRole[role] ?? []).map((row) => ({
|
|
113
|
+
...row,
|
|
114
|
+
label: t(CLASS_LABEL_KEYS[row.changeClass]),
|
|
115
|
+
})),
|
|
95
116
|
rows: roleClassRuleRows(props.classRules, entry).map((row) => ({
|
|
96
117
|
...row,
|
|
97
118
|
label: t(CLASS_LABEL_KEYS[row.changeClass]),
|
|
@@ -118,6 +139,24 @@ function setSandboxed(role: WorkspaceRole, sandboxed: boolean) {
|
|
|
118
139
|
function setRule(role: WorkspaceRole, changeClass: RuleableChangeClass, rule: RoleRuleSelection) {
|
|
119
140
|
emit('update:classRulesByRole', setRoleClassRule(props.classRulesByRole, role, changeClass, rule))
|
|
120
141
|
}
|
|
142
|
+
|
|
143
|
+
function setSubmissionScoped(role: WorkspaceRole, scoped: boolean) {
|
|
144
|
+
emit(
|
|
145
|
+
'update:submissionClassesByRole',
|
|
146
|
+
setRoleSubmissionScoped(props.submissionClassesByRole, role, scoped),
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function setSubmissionClass(
|
|
151
|
+
role: WorkspaceRole,
|
|
152
|
+
changeClass: RuleableChangeClass,
|
|
153
|
+
allowed: boolean,
|
|
154
|
+
) {
|
|
155
|
+
emit(
|
|
156
|
+
'update:submissionClassesByRole',
|
|
157
|
+
toggleSubmissionClass(props.submissionClassesByRole, role, changeClass, allowed),
|
|
158
|
+
)
|
|
159
|
+
}
|
|
121
160
|
</script>
|
|
122
161
|
|
|
123
162
|
<template>
|
|
@@ -129,8 +168,9 @@ function setRule(role: WorkspaceRole, changeClass: RuleableChangeClass, rule: Ro
|
|
|
129
168
|
<p class="mt-0.5 text-[11px] leading-snug text-slate-500">
|
|
130
169
|
{{ t('settings.riskPolicy.roleRules.help') }}
|
|
131
170
|
</p>
|
|
132
|
-
<!-- Auto-merge off already sends every pull request to a human, so
|
|
133
|
-
|
|
171
|
+
<!-- Auto-merge off already sends every pull request to a human, so the per-class narrowing
|
|
172
|
+
has nothing left to subtract. The other two settings still bite, because both refuse the
|
|
173
|
+
MANUAL merge as well, which is the one thing the master switch leaves open. -->
|
|
134
174
|
<p
|
|
135
175
|
v-if="!autoMergeEnabled"
|
|
136
176
|
class="mt-1 text-[11px] leading-snug text-amber-400/90"
|
|
@@ -174,11 +214,20 @@ function setRule(role: WorkspaceRole, changeClass: RuleableChangeClass, rule: Ro
|
|
|
174
214
|
)
|
|
175
215
|
}}
|
|
176
216
|
</UButton>
|
|
217
|
+
<!-- A collapsed group must not hide an active policy: the tally above counts class rules
|
|
218
|
+
only, and a role can land nothing at all while showing "No class limits". -->
|
|
219
|
+
<span
|
|
220
|
+
v-if="group.submissionScoped"
|
|
221
|
+
class="text-[11px] text-amber-400/90"
|
|
222
|
+
:data-testid="`merge-role-submission-badge-${group.role}`"
|
|
223
|
+
>
|
|
224
|
+
{{ t('settings.riskPolicy.roleRules.submissionBadge') }}
|
|
225
|
+
</span>
|
|
177
226
|
</div>
|
|
178
227
|
|
|
179
|
-
<!-- A sandboxed role merges nothing at all, so
|
|
180
|
-
|
|
181
|
-
|
|
228
|
+
<!-- A sandboxed role merges nothing at all, so neither limit below can make its runs any
|
|
229
|
+
stricter. Both stay editable (removing the sandbox brings them straight back) and the
|
|
230
|
+
row says which of the three is actually governing. -->
|
|
182
231
|
<p
|
|
183
232
|
v-if="group.sandboxed"
|
|
184
233
|
class="mt-1 text-[11px] leading-snug text-amber-400/90"
|
|
@@ -187,38 +236,83 @@ function setRule(role: WorkspaceRole, changeClass: RuleableChangeClass, rule: Ro
|
|
|
187
236
|
{{ t('settings.riskPolicy.roleRules.sandboxNote') }}
|
|
188
237
|
</p>
|
|
189
238
|
|
|
190
|
-
<div v-if="group.open" class="mt-2 space-y-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
<div
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
class="flex flex-wrap items-center gap-2"
|
|
198
|
-
:data-testid="`merge-role-row-${group.role}-${row.changeClass}`"
|
|
199
|
-
>
|
|
200
|
-
<span class="min-w-[7rem] text-xs text-slate-400">{{ row.label }}</span>
|
|
201
|
-
<USelect
|
|
202
|
-
:model-value="row.selected"
|
|
203
|
-
:items="row.items"
|
|
204
|
-
value-key="value"
|
|
239
|
+
<div v-if="group.open" class="mt-2 space-y-2 border-t border-slate-800 pt-2">
|
|
240
|
+
<!-- What this role may LAND at all. Above the class rules because it outranks them: a
|
|
241
|
+
class can be auto-mergeable under the rules and still outside this list, and then
|
|
242
|
+
nothing merges, through the review card's own button included. -->
|
|
243
|
+
<div class="space-y-1.5">
|
|
244
|
+
<USwitch
|
|
245
|
+
:model-value="group.submissionScoped"
|
|
205
246
|
size="sm"
|
|
206
|
-
|
|
207
|
-
:
|
|
208
|
-
:data-testid="`merge-role-
|
|
209
|
-
@update:model-value="
|
|
247
|
+
:disabled="disabled"
|
|
248
|
+
:label="t('settings.riskPolicy.roleRules.submissionLabel')"
|
|
249
|
+
:data-testid="`merge-role-submission-${group.role}`"
|
|
250
|
+
@update:model-value="setSubmissionScoped(group.role, $event)"
|
|
210
251
|
/>
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
252
|
+
<p class="text-[11px] leading-snug text-slate-500">
|
|
253
|
+
{{ t('settings.riskPolicy.roleRules.submissionHelp') }}
|
|
254
|
+
</p>
|
|
255
|
+
<div v-if="group.submissionScoped" class="flex flex-wrap gap-x-4 gap-y-1">
|
|
256
|
+
<UCheckbox
|
|
257
|
+
v-for="row in group.submissionRows"
|
|
258
|
+
:key="row.changeClass"
|
|
259
|
+
:model-value="row.allowed"
|
|
260
|
+
size="sm"
|
|
261
|
+
:disabled="disabled"
|
|
262
|
+
:label="row.label"
|
|
263
|
+
:data-testid="`merge-role-submission-class-${group.role}-${row.changeClass}`"
|
|
264
|
+
@update:model-value="setSubmissionClass(group.role, row.changeClass, $event === true)"
|
|
265
|
+
/>
|
|
266
|
+
</div>
|
|
267
|
+
<!-- An empty allowlist is a real policy, not an unfinished edit, so it says what it
|
|
268
|
+
does rather than reading as a switch somebody forgot to fill in. -->
|
|
269
|
+
<p
|
|
270
|
+
v-if="group.submissionScoped && !group.submissionRows.some((r) => r.allowed)"
|
|
271
|
+
class="text-[11px] leading-snug text-amber-400/90"
|
|
272
|
+
:data-testid="`merge-role-submission-none-${group.role}`"
|
|
273
|
+
>
|
|
274
|
+
{{ t('settings.riskPolicy.roleRules.submissionNone') }}
|
|
275
|
+
</p>
|
|
276
|
+
</div>
|
|
277
|
+
|
|
278
|
+
<div class="space-y-1.5 border-t border-slate-800/70 pt-2">
|
|
279
|
+
<p class="text-[11px] leading-snug text-slate-500">
|
|
280
|
+
{{ t('settings.riskPolicy.roleRules.classHeading') }}
|
|
281
|
+
</p>
|
|
282
|
+
<p v-if="!anyBaseRule" class="text-[11px] leading-snug text-slate-500">
|
|
283
|
+
{{ t('settings.riskPolicy.roleRules.baseHint') }}
|
|
284
|
+
</p>
|
|
285
|
+
<div
|
|
286
|
+
v-for="row in group.rows"
|
|
287
|
+
:key="row.changeClass"
|
|
288
|
+
class="flex flex-wrap items-center gap-2"
|
|
289
|
+
:data-testid="`merge-role-row-${group.role}-${row.changeClass}`"
|
|
219
290
|
>
|
|
220
|
-
{{
|
|
221
|
-
|
|
291
|
+
<span class="min-w-[7rem] text-xs text-slate-400">{{ row.label }}</span>
|
|
292
|
+
<USelect
|
|
293
|
+
:model-value="row.selected"
|
|
294
|
+
:items="row.items"
|
|
295
|
+
value-key="value"
|
|
296
|
+
size="sm"
|
|
297
|
+
class="w-52"
|
|
298
|
+
:disabled="disabled || row.items.length === 1"
|
|
299
|
+
:data-testid="`merge-role-rule-${group.role}-${row.changeClass}`"
|
|
300
|
+
@update:model-value="
|
|
301
|
+
setRule(group.role, row.changeClass, $event as RoleRuleSelection)
|
|
302
|
+
"
|
|
303
|
+
/>
|
|
304
|
+
<!-- Nothing left to narrow: the base rule already routes this class to a human. -->
|
|
305
|
+
<span v-if="row.items.length === 1" class="text-[11px] text-slate-500">
|
|
306
|
+
{{ t('settings.riskPolicy.roleRules.alreadyStrictest') }}
|
|
307
|
+
</span>
|
|
308
|
+
<span
|
|
309
|
+
v-else-if="row.redundant"
|
|
310
|
+
class="text-[11px] text-amber-400/90"
|
|
311
|
+
:data-testid="`merge-role-redundant-${group.role}-${row.changeClass}`"
|
|
312
|
+
>
|
|
313
|
+
{{ t('settings.riskPolicy.roleRules.redundant') }}
|
|
314
|
+
</span>
|
|
315
|
+
</div>
|
|
222
316
|
</div>
|
|
223
317
|
</div>
|
|
224
318
|
</div>
|
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
MergeClassRules,
|
|
12
12
|
RiskPolicy,
|
|
13
13
|
RequirementConcernLevel,
|
|
14
|
+
SubmissionClassesByRole,
|
|
14
15
|
} from '~/types/merge'
|
|
15
16
|
import type { StepGating } from '@cat-factory/contracts'
|
|
16
17
|
import {
|
|
@@ -90,6 +91,9 @@ interface Draft {
|
|
|
90
91
|
// which is why clearing one in the editor is a plain omission.
|
|
91
92
|
classRulesByRole: ClassRulesByRole
|
|
92
93
|
dryRunRoles: DryRunRoles
|
|
94
|
+
// Which classes each role may LAND at all. A role with no entry is unrestricted, so `{}` is the
|
|
95
|
+
// identity; an entry with no classes is the different policy that the role lands nothing.
|
|
96
|
+
submissionClassesByRole: SubmissionClassesByRole
|
|
93
97
|
// Implementation-fork decision gating (edited 0..100, stored 0..1); disabled ⇒ off in `auto`.
|
|
94
98
|
forkEnabled: boolean
|
|
95
99
|
forkMinComplexity: number
|
|
@@ -129,6 +133,7 @@ function toDraft(p: RiskPolicy): Draft {
|
|
|
129
133
|
classRules: { ...p.classRules },
|
|
130
134
|
classRulesByRole: { ...p.classRulesByRole },
|
|
131
135
|
dryRunRoles: [...p.dryRunRoles],
|
|
136
|
+
submissionClassesByRole: { ...p.submissionClassesByRole },
|
|
132
137
|
forkEnabled: p.forkDecision?.enabled ?? false,
|
|
133
138
|
forkMinComplexity: Math.round((p.forkDecision?.minComplexity ?? 0.5) * 100),
|
|
134
139
|
forkMinRisk: Math.round((p.forkDecision?.minRisk ?? 0.4) * 100),
|
|
@@ -174,6 +179,7 @@ async function save(p: RiskPolicy) {
|
|
|
174
179
|
classRules: d.classRules,
|
|
175
180
|
classRulesByRole: d.classRulesByRole,
|
|
176
181
|
dryRunRoles: d.dryRunRoles,
|
|
182
|
+
submissionClassesByRole: d.submissionClassesByRole,
|
|
177
183
|
forkDecision: forkGating(d),
|
|
178
184
|
})
|
|
179
185
|
toast.add({
|
|
@@ -235,6 +241,7 @@ const draft = reactive<Draft>({
|
|
|
235
241
|
classRules: {},
|
|
236
242
|
classRulesByRole: {},
|
|
237
243
|
dryRunRoles: [],
|
|
244
|
+
submissionClassesByRole: {},
|
|
238
245
|
forkEnabled: false,
|
|
239
246
|
forkMinComplexity: 50,
|
|
240
247
|
forkMinRisk: 40,
|
|
@@ -395,6 +402,7 @@ async function create() {
|
|
|
395
402
|
<MergeRolePolicyEditor
|
|
396
403
|
v-model:class-rules-by-role="drafts[p.id]!.classRulesByRole"
|
|
397
404
|
v-model:dry-run-roles="drafts[p.id]!.dryRunRoles"
|
|
405
|
+
v-model:submission-classes-by-role="drafts[p.id]!.submissionClassesByRole"
|
|
398
406
|
:class-rules="drafts[p.id]!.classRules"
|
|
399
407
|
:auto-merge-enabled="drafts[p.id]!.autoMergeEnabled"
|
|
400
408
|
:disabled="busy === p.id"
|
|
@@ -117,6 +117,10 @@ const CONFLICT_INFO: Record<Exclude<ConflictReason, BespokeConflictReason>, Conf
|
|
|
117
117
|
titleKey: 'errors.conflict.title.dry_run_not_mergeable',
|
|
118
118
|
descriptionKey: 'errors.conflict.description.dry_run_not_mergeable',
|
|
119
119
|
},
|
|
120
|
+
submission_not_allowed: {
|
|
121
|
+
titleKey: 'errors.conflict.title.submission_not_allowed',
|
|
122
|
+
descriptionKey: 'errors.conflict.description.submission_not_allowed',
|
|
123
|
+
},
|
|
120
124
|
github_not_connected: {
|
|
121
125
|
titleKey: 'errors.conflict.title.github_not_connected',
|
|
122
126
|
descriptionKey: 'errors.conflict.description.github_not_connected',
|
package/app/types/merge.ts
CHANGED
|
@@ -16,6 +16,8 @@ export type {
|
|
|
16
16
|
// runs are sandboxed (they open a pull request and merge nothing).
|
|
17
17
|
ClassRulesByRole,
|
|
18
18
|
DryRunRoles,
|
|
19
|
+
// Which change classes a role may LAND at all (absent ⇒ unrestricted, empty ⇒ nothing).
|
|
20
|
+
SubmissionClassesByRole,
|
|
19
21
|
WorkspaceRole,
|
|
20
22
|
MergeTrackRecord,
|
|
21
23
|
ReviewEffort,
|
|
@@ -27,6 +27,7 @@ const policy = (over: Partial<RiskPolicy> = {}): RiskPolicy =>
|
|
|
27
27
|
classRules: {},
|
|
28
28
|
classRulesByRole: {},
|
|
29
29
|
dryRunRoles: [],
|
|
30
|
+
submissionClassesByRole: {},
|
|
30
31
|
isDefault: true,
|
|
31
32
|
version: 1,
|
|
32
33
|
createdAt: 0,
|
|
@@ -61,7 +62,7 @@ describe('riskPolicyCeilings', () => {
|
|
|
61
62
|
|
|
62
63
|
describe('rolePolicySummary', () => {
|
|
63
64
|
it('is empty on a policy that treats every initiator alike', () => {
|
|
64
|
-
expect(rolePolicySummary(policy())).toEqual({ sandboxed: [], narrowed: [] })
|
|
65
|
+
expect(rolePolicySummary(policy())).toEqual({ sandboxed: [], narrowed: [], scoped: [] })
|
|
65
66
|
})
|
|
66
67
|
|
|
67
68
|
it('lists both layers in the shared role order', () => {
|
|
@@ -72,6 +73,7 @@ describe('rolePolicySummary', () => {
|
|
|
72
73
|
expect(rolePolicySummary(p)).toEqual({
|
|
73
74
|
sandboxed: ['member', 'viewer'],
|
|
74
75
|
narrowed: ['admin'],
|
|
76
|
+
scoped: [],
|
|
75
77
|
})
|
|
76
78
|
})
|
|
77
79
|
|
|
@@ -79,6 +81,23 @@ describe('rolePolicySummary', () => {
|
|
|
79
81
|
// limit that changes nothing about what that role's runs can do.
|
|
80
82
|
it('does not also report class rules for a role the policy sandboxes', () => {
|
|
81
83
|
const p = policy({ dryRunRoles: ['member'], classRulesByRole: { member: { docs: 'never' } } })
|
|
82
|
-
expect(rolePolicySummary(p)).toEqual({ sandboxed: ['member'], narrowed: [] })
|
|
84
|
+
expect(rolePolicySummary(p)).toEqual({ sandboxed: ['member'], narrowed: [], scoped: [] })
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('lists a role allowlisted to a subset of change classes', () => {
|
|
88
|
+
const p = policy({ submissionClassesByRole: { member: ['docs'] } })
|
|
89
|
+
expect(rolePolicySummary(p)).toEqual({ sandboxed: [], narrowed: [], scoped: ['member'] })
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// The most restrictive policy this setting can express, and the one a summary must not drop:
|
|
93
|
+
// an EMPTY allowlist is scoped, where an absent entry is unrestricted.
|
|
94
|
+
it('lists a role allowlisted to NOTHING', () => {
|
|
95
|
+
const p = policy({ submissionClassesByRole: { viewer: [] } })
|
|
96
|
+
expect(rolePolicySummary(p).scoped).toEqual(['viewer'])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('does not also report an allowlist for a role the policy sandboxes', () => {
|
|
100
|
+
const p = policy({ dryRunRoles: ['member'], submissionClassesByRole: { member: ['docs'] } })
|
|
101
|
+
expect(rolePolicySummary(p)).toEqual({ sandboxed: ['member'], narrowed: [], scoped: [] })
|
|
83
102
|
})
|
|
84
103
|
})
|
package/app/utils/riskPolicy.ts
CHANGED
|
@@ -52,6 +52,12 @@ export interface RolePolicySummary {
|
|
|
52
52
|
* both would read as two limits where the second changes nothing.
|
|
53
53
|
*/
|
|
54
54
|
narrowed: WorkspaceRole[]
|
|
55
|
+
/**
|
|
56
|
+
* Roles allowlisted to a subset of change classes they may LAND. Same suppression rule as
|
|
57
|
+
* `narrowed` and for the same reason, and listed SEPARATELY from it because the two are not
|
|
58
|
+
* degrees of one setting: this one bars landing outright, including through the manual merge.
|
|
59
|
+
*/
|
|
60
|
+
scoped: WorkspaceRole[]
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
/**
|
|
@@ -66,5 +72,11 @@ export function rolePolicySummary(p: RiskPolicy): RolePolicySummary {
|
|
|
66
72
|
narrowed: WORKSPACE_ROLES.filter(
|
|
67
73
|
(role) => !sandboxed.includes(role) && Object.keys(p.classRulesByRole[role] ?? {}).length > 0,
|
|
68
74
|
),
|
|
75
|
+
// Scoped-ness is the PRESENCE of an entry, never its length: a role allowlisted to nothing
|
|
76
|
+
// is the most restrictive policy this setting can express, and reading it as "no allowlist"
|
|
77
|
+
// would drop the one summary line a reader most needs to see.
|
|
78
|
+
scoped: WORKSPACE_ROLES.filter(
|
|
79
|
+
(role) => !sandboxed.includes(role) && p.submissionClassesByRole[role] !== undefined,
|
|
80
|
+
),
|
|
69
81
|
}
|
|
70
82
|
}
|