@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.
@@ -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
- any: sandboxed.length > 0 || narrowed.length > 0,
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
- // Two settings, edited together because they answer the same question at two strengths. A role can
5
- // be held to stricter per-class rules than the preset's base map (`classRulesByRole`), or held to
6
- // dry runs entirely (`dryRunRoles`): the pipeline works and opens its pull request, and nothing
7
- // merges. The second outranks the first, which is why a sandboxed role says so on its row rather
8
- // than leaving the class rules below reading as the policy.
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 only the sandbox half of
133
- this editor still changes anything (it refuses the manual merge as well). -->
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 the class rules below cannot make its runs
180
- any stricter. They stay editable (removing the sandbox brings them straight back) and
181
- the row says which of the two is actually governing. -->
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-1.5 border-t border-slate-800 pt-2">
191
- <p v-if="!anyBaseRule" class="text-[11px] leading-snug text-slate-500">
192
- {{ t('settings.riskPolicy.roleRules.baseHint') }}
193
- </p>
194
- <div
195
- v-for="row in group.rows"
196
- :key="row.changeClass"
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
- class="w-52"
207
- :disabled="disabled || row.items.length === 1"
208
- :data-testid="`merge-role-rule-${group.role}-${row.changeClass}`"
209
- @update:model-value="setRule(group.role, row.changeClass, $event as RoleRuleSelection)"
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
- <!-- Nothing left to narrow: the base rule already routes this class to a human. -->
212
- <span v-if="row.items.length === 1" class="text-[11px] text-slate-500">
213
- {{ t('settings.riskPolicy.roleRules.alreadyStrictest') }}
214
- </span>
215
- <span
216
- v-else-if="row.redundant"
217
- class="text-[11px] text-amber-400/90"
218
- :data-testid="`merge-role-redundant-${group.role}-${row.changeClass}`"
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
- {{ t('settings.riskPolicy.roleRules.redundant') }}
221
- </span>
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',
@@ -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
  })
@@ -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
  }