@cat-factory/app 0.217.2 → 0.219.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.
Files changed (33) hide show
  1. package/app/components/board/BoardCanvas.vue +15 -0
  2. package/app/components/board/nodes/TaskCard.vue +26 -11
  3. package/app/components/focus/BlockFocusView.vue +29 -1
  4. package/app/components/layout/AccountFailureKindRules.vue +285 -0
  5. package/app/components/layout/AccountPlatformAlertSettings.vue +43 -1
  6. package/app/components/panels/InspectorPanel.vue +49 -4
  7. package/app/components/panels/OperatorDashboardPanel.vue +7 -19
  8. package/app/components/panels/inspector/TaskExecution.vue +24 -2
  9. package/app/components/riskPolicy/RiskPolicyPreview.vue +33 -2
  10. package/app/components/settings/MergeRolePolicyEditor.logic.spec.ts +146 -0
  11. package/app/components/settings/MergeRolePolicyEditor.logic.ts +133 -0
  12. package/app/components/settings/MergeRolePolicyEditor.vue +226 -0
  13. package/app/components/settings/RiskPolicyPanel.vue +35 -1
  14. package/app/composables/useRunStart.spec.ts +204 -0
  15. package/app/composables/useRunStart.ts +112 -0
  16. package/app/stores/execution/commands.ts +20 -3
  17. package/app/types/execution.ts +1 -0
  18. package/app/types/merge.ts +6 -0
  19. package/app/utils/failureKinds.spec.ts +82 -0
  20. package/app/utils/failureKinds.ts +91 -0
  21. package/app/utils/riskPolicy.spec.ts +32 -1
  22. package/app/utils/riskPolicy.ts +29 -1
  23. package/i18n/locales/de.json +55 -5
  24. package/i18n/locales/en.json +61 -5
  25. package/i18n/locales/es.json +55 -5
  26. package/i18n/locales/fr.json +55 -5
  27. package/i18n/locales/he.json +55 -5
  28. package/i18n/locales/it.json +55 -5
  29. package/i18n/locales/ja.json +55 -5
  30. package/i18n/locales/pl.json +55 -5
  31. package/i18n/locales/tr.json +55 -5
  32. package/i18n/locales/uk.json +55 -5
  33. package/package.json +2 -2
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { isDryRun } from '@cat-factory/contracts'
2
3
  import type { Block } from '~/types/domain'
3
4
  import { agentKindMeta } from '~/utils/catalog'
4
5
  import {
@@ -48,6 +49,12 @@ const reviewStageLabel = computed(() =>
48
49
 
49
50
  const instance = computed(() => execution.getInstance(props.block.executionId))
50
51
 
52
+ // Whether this run may land its work at all. Read through the contracts helper rather than an
53
+ // equality here, so a run persisted before the mode existed (absent ⇒ live) is never badged as a
54
+ // sandbox. Both routes into the mode look identical from here on purpose: what the reader needs
55
+ // is that nothing will merge, and WHY is answered by the run's own notes and the merge decision.
56
+ const sandboxed = computed(() => isDryRun(instance.value?.mode))
57
+
51
58
  // Nothing to show yet: no run, no failed run, no PR, and not awaiting a merge — render an
52
59
  // empty state instead of a blank gap so the section reads as "no runs yet" rather than broken.
53
60
  const isEmpty = computed(
@@ -266,8 +273,23 @@ async function mergePr() {
266
273
  <!-- running pipeline -->
267
274
  <div v-if="instance">
268
275
  <div class="mb-1 flex items-center justify-between">
269
- <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
270
- {{ instance.pipelineName }}
276
+ <span class="flex min-w-0 items-center gap-1.5">
277
+ <span class="truncate text-[11px] font-semibold uppercase tracking-wide text-slate-400">
278
+ {{ instance.pipelineName }}
279
+ </span>
280
+ <!-- A sandboxed run looks exactly like one that simply has not reached the merge yet,
281
+ right up until it stops there, so it says what it is from the start. -->
282
+ <UBadge
283
+ v-if="sandboxed"
284
+ color="warning"
285
+ variant="subtle"
286
+ size="sm"
287
+ icon="i-lucide-shield"
288
+ :title="t('inspector.execution.dryRunHint')"
289
+ data-testid="run-dry-run"
290
+ >
291
+ {{ t('inspector.execution.dryRun') }}
292
+ </UBadge>
271
293
  </span>
272
294
  <div class="flex items-center gap-1">
273
295
  <!-- Stop without deleting: halts the run but keeps it readable + retryable. -->
@@ -8,12 +8,29 @@
8
8
  // A policy with auto-merge off has no thresholds to explain (the master switch wins before
9
9
  // any score is compared), so it says so instead of listing ceilings that never apply.
10
10
  import { computed } from 'vue'
11
- import type { RiskPolicy } from '~/types/merge'
12
- import { riskPolicyCeilings, type RiskPolicyAxis } from '~/utils/riskPolicy'
11
+ import type { RiskPolicy, WorkspaceRole } from '~/types/merge'
12
+ import { riskPolicyCeilings, rolePolicySummary, type RiskPolicyAxis } from '~/utils/riskPolicy'
13
13
 
14
14
  const props = defineProps<{ policy: RiskPolicy }>()
15
15
  const { t, n } = useI18n()
16
16
 
17
+ // The role layer, when the policy has one. Worth a line here rather than only in settings: this
18
+ // preview is what a task's merge-policy picker shows, and "runs started by a member never merge"
19
+ // changes what picking this policy means for whoever is reading it.
20
+ const ROLE_LABEL: Record<WorkspaceRole, () => string> = {
21
+ admin: () => t('merge.role.admin'),
22
+ member: () => t('merge.role.member'),
23
+ viewer: () => t('merge.role.viewer'),
24
+ }
25
+ const roleLayer = computed(() => {
26
+ const { sandboxed, narrowed } = rolePolicySummary(props.policy)
27
+ return {
28
+ sandboxed: sandboxed.map((r) => ROLE_LABEL[r]()).join(', '),
29
+ narrowed: narrowed.map((r) => ROLE_LABEL[r]()).join(', '),
30
+ any: sandboxed.length > 0 || narrowed.length > 0,
31
+ }
32
+ })
33
+
17
34
  // Exhaustive over the axis union with LITERAL keys, so both i18n drift guards apply: the
18
35
  // typed-key check catches a renamed key, and the Record catches a new axis.
19
36
  const AXIS_LABEL: Record<RiskPolicyAxis, () => string> = {
@@ -68,6 +85,20 @@ const ceilings = computed(() =>
68
85
  </p>
69
86
  </div>
70
87
 
88
+ <!-- Who started the run changes what may land, on a policy that says so. -->
89
+ <div v-if="roleLayer.any" data-testid="risk-policy-preview-roles">
90
+ <div class="mb-1 flex items-center gap-1 text-[10px] uppercase tracking-wide text-slate-500">
91
+ <UIcon name="i-lucide-users" class="h-3 w-3" />
92
+ {{ t('riskPolicy.preview.roleHeading') }}
93
+ </div>
94
+ <p v-if="roleLayer.sandboxed" class="text-[12px] leading-snug text-slate-400">
95
+ {{ t('riskPolicy.preview.roleSandboxed', { roles: roleLayer.sandboxed }) }}
96
+ </p>
97
+ <p v-if="roleLayer.narrowed" class="text-[12px] leading-snug text-slate-400">
98
+ {{ t('riskPolicy.preview.roleNarrowed', { roles: roleLayer.narrowed }) }}
99
+ </p>
100
+ </div>
101
+
71
102
  <div>
72
103
  <div class="mb-1 flex items-center gap-1 text-[10px] uppercase tracking-wide text-slate-500">
73
104
  <UIcon name="i-lucide-wrench" class="h-3 w-3" />
@@ -0,0 +1,146 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { MERGE_CLASS_RULES } from '@cat-factory/contracts'
3
+ import type { ClassRulesByRole, MergeClassRules } from '~/types/merge'
4
+ import {
5
+ INHERIT_RULE,
6
+ narrowingOptionsFor,
7
+ roleClassRuleRows,
8
+ roleNarrowedCount,
9
+ setRoleClassRule,
10
+ toggleDryRunRole,
11
+ } from '~/components/settings/MergeRolePolicyEditor.logic'
12
+
13
+ describe('narrowingOptionsFor', () => {
14
+ it('offers only the rules that would actually narrow the base', () => {
15
+ expect(narrowingOptionsFor('always')).toEqual(['thresholds', 'never'])
16
+ expect(narrowingOptionsFor('thresholds')).toEqual(['never'])
17
+ })
18
+
19
+ // Nothing is stricter than "always require review", so the editor has nothing to offer a role
20
+ // on that class rather than an option that silently does nothing.
21
+ it('offers nothing on a class the policy already routes to a human', () => {
22
+ expect(narrowingOptionsFor('never')).toEqual([])
23
+ })
24
+ })
25
+
26
+ // The sentinel is a VALUE the select renders as an item, and Reka UI's `SelectItem` throws on an
27
+ // empty-string value (it reserves `''` for "cleared, show the placeholder"). Nothing here can see
28
+ // that widget, so the invariant it imposes is asserted where it is authored: the failure it
29
+ // prevents is not a wrong label, it is the settings panel throwing when a role group is expanded.
30
+ describe('INHERIT_RULE', () => {
31
+ it('is a non-empty value the select can carry as an item', () => {
32
+ expect(INHERIT_RULE).not.toBe('')
33
+ expect(INHERIT_RULE.length).toBeGreaterThan(0)
34
+ })
35
+
36
+ // It is also not one of the rules, or clearing a row would be indistinguishable from setting it.
37
+ it('cannot collide with a real rule', () => {
38
+ expect(MERGE_CLASS_RULES).not.toContain(INHERIT_RULE as string)
39
+ })
40
+ })
41
+
42
+ describe('roleClassRuleRows', () => {
43
+ const base: MergeClassRules = { docs: 'always', schema: 'never' }
44
+
45
+ // The sentinel is truthy, so anything asking "did this role author a rule" by testing the
46
+ // SELECTION rather than the stored entry reads inheritance as a stored rule: it would offer
47
+ // "same as this policy" twice on every untouched row, and flag every one of them redundant.
48
+ it('never treats inheritance as a stored rule', () => {
49
+ for (const row of roleClassRuleRows(base, undefined)) {
50
+ expect(row.options).not.toContain(INHERIT_RULE as string)
51
+ expect(row.redundant).toBe(false)
52
+ }
53
+ })
54
+
55
+ it('reads an absent entry as inheritance, never as thresholds', () => {
56
+ const rows = roleClassRuleRows(base, undefined)
57
+ expect(rows.map((r) => r.selected)).toEqual([
58
+ INHERIT_RULE,
59
+ INHERIT_RULE,
60
+ INHERIT_RULE,
61
+ INHERIT_RULE,
62
+ INHERIT_RULE,
63
+ INHERIT_RULE,
64
+ ])
65
+ expect(rows.find((r) => r.changeClass === 'docs')?.base).toBe('always')
66
+ expect(rows.find((r) => r.changeClass === 'source')?.base).toBe('thresholds')
67
+ })
68
+
69
+ it('surfaces the role rule and marks it as governing when it narrows', () => {
70
+ const rows = roleClassRuleRows(base, { docs: 'never' })
71
+ const docs = rows.find((r) => r.changeClass === 'docs')
72
+ expect(docs?.selected).toBe('never')
73
+ expect(docs?.redundant).toBe(false)
74
+ })
75
+
76
+ // A base edit can turn a stored role rule into a no-op. The row keeps it (so it can be read and
77
+ // cleared) and says it no longer does anything, rather than dropping it and reading as clean.
78
+ it('keeps a rule the base rule has overtaken, flagged as having no effect', () => {
79
+ const rows = roleClassRuleRows({ docs: 'never' }, { docs: 'thresholds' })
80
+ const docs = rows.find((r) => r.changeClass === 'docs')
81
+ expect(docs?.selected).toBe('thresholds')
82
+ expect(docs?.redundant).toBe(true)
83
+ expect(docs?.options).toContain('thresholds')
84
+ })
85
+ })
86
+
87
+ describe('setRoleClassRule', () => {
88
+ it('writes a rule under the role', () => {
89
+ expect(setRoleClassRule({}, 'member', 'source', 'never')).toEqual({
90
+ member: { source: 'never' },
91
+ })
92
+ })
93
+
94
+ it('clears back to an omission, so absent stays absent', () => {
95
+ const before: ClassRulesByRole = { member: { source: 'never', docs: 'never' } }
96
+ expect(setRoleClassRule(before, 'member', 'docs', INHERIT_RULE)).toEqual({
97
+ member: { source: 'never' },
98
+ })
99
+ })
100
+
101
+ // `{}` is the identity for the whole feature, so emptying a role's last rule must reach it:
102
+ // a `{ member: {} }` left behind would read as a role that had been given a policy.
103
+ it('drops the role entirely once its last rule is cleared', () => {
104
+ const before: ClassRulesByRole = { admin: { docs: 'never' }, member: { source: 'never' } }
105
+ expect(setRoleClassRule(before, 'member', 'source', INHERIT_RULE)).toEqual({
106
+ admin: { docs: 'never' },
107
+ })
108
+ })
109
+
110
+ it('leaves the other roles untouched', () => {
111
+ const before: ClassRulesByRole = { admin: { docs: 'never' } }
112
+ expect(setRoleClassRule(before, 'member', 'source', 'thresholds')).toEqual({
113
+ admin: { docs: 'never' },
114
+ member: { source: 'thresholds' },
115
+ })
116
+ })
117
+ })
118
+
119
+ describe('toggleDryRunRole', () => {
120
+ it('adds and removes a role', () => {
121
+ expect(toggleDryRunRole([], 'member', true)).toEqual(['member'])
122
+ expect(toggleDryRunRole(['member'], 'member', false)).toEqual([])
123
+ })
124
+
125
+ it('keeps the shared role order whatever order the toggles arrived in', () => {
126
+ expect(toggleDryRunRole(['viewer'], 'admin', true)).toEqual(['admin', 'viewer'])
127
+ expect(toggleDryRunRole(['viewer', 'admin'], 'member', true)).toEqual([
128
+ 'admin',
129
+ 'member',
130
+ 'viewer',
131
+ ])
132
+ })
133
+
134
+ it('is idempotent on a role already in the state asked for', () => {
135
+ expect(toggleDryRunRole(['member'], 'member', true)).toEqual(['member'])
136
+ expect(toggleDryRunRole([], 'member', false)).toEqual([])
137
+ })
138
+ })
139
+
140
+ describe('roleNarrowedCount', () => {
141
+ it('counts the classes a role authored, and reads absence as zero', () => {
142
+ expect(roleNarrowedCount(undefined)).toBe(0)
143
+ expect(roleNarrowedCount({})).toBe(0)
144
+ expect(roleNarrowedCount({ docs: 'never', source: 'never' })).toBe(2)
145
+ })
146
+ })
@@ -0,0 +1,133 @@
1
+ // Pure state math behind <MergeRolePolicyEditor>: what a preset's ROLE layer currently says, and
2
+ // what each edit turns it into.
3
+ //
4
+ // It lives outside the SFC because the interesting parts are not the happy path. Two of them:
5
+ //
6
+ // - **Absent is not a rule.** A role that authored nothing on a class is governed by the base
7
+ // rule, and that silence must survive a round trip through this editor: an edit clears back to
8
+ // an OMISSION rather than writing `thresholds`, and a role left with nothing drops out of the
9
+ // map entirely, so `{}` stays the identity the wire contract says it is.
10
+ // - **Narrow-only.** `narrowMergeClassRule` (contracts, the same implementation the engine
11
+ // applies) takes the stricter of the base and the role rule, so a role rule that is looser than
12
+ // the base does nothing at all. Offering one would tell an operator they had written a policy
13
+ // when they had written a no-op, so the options a row offers are exactly the rules that would
14
+ // change its outcome, plus whatever is already stored.
15
+ import {
16
+ MERGE_CLASS_RULES,
17
+ narrowMergeClassRule,
18
+ RULEABLE_CHANGE_CLASSES,
19
+ WORKSPACE_ROLES,
20
+ } from '@cat-factory/contracts'
21
+ import type {
22
+ ClassRulesByRole,
23
+ MergeClassRule,
24
+ MergeClassRules,
25
+ RuleableChangeClass,
26
+ WorkspaceRole,
27
+ } from '~/types/merge'
28
+
29
+ /**
30
+ * The "this role adds nothing here" selection, stored as an OMISSION.
31
+ *
32
+ * It must be a non-empty string, and not because of a style preference: the select this feeds is
33
+ * Nuxt UI's `USelect` over Reka UI, whose `SelectItem` THROWS on an empty-string value (the empty
34
+ * string is how that widget spells "cleared, show the placeholder", so an item may not claim it).
35
+ * A `''` sentinel therefore does not degrade, it takes down the settings panel the moment a role
36
+ * group is expanded. It is a member of the selection union rather than `undefined` for the same
37
+ * reason: the row has to be able to OFFER inheriting as a choice, which is a value.
38
+ */
39
+ export const INHERIT_RULE = 'inherit' as const
40
+ export type RoleRuleSelection = MergeClassRule | typeof INHERIT_RULE
41
+
42
+ /** One class's row inside one role's group. */
43
+ export interface RoleClassRuleRow {
44
+ changeClass: RuleableChangeClass
45
+ /** What the preset's base `classRules` say for this class (what the row inherits). */
46
+ base: MergeClassRule
47
+ /** The role's own entry, or {@link INHERIT_RULE} when it authored none. */
48
+ selected: RoleRuleSelection
49
+ /**
50
+ * The rules this row may be set to: the ones strictly stricter than `base` (a looser one is
51
+ * discarded by the engine), plus the stored value whatever it is, so a rule that a later base
52
+ * edit turned into a no-op stays visible and clearable instead of vanishing from its own row.
53
+ */
54
+ options: MergeClassRule[]
55
+ /**
56
+ * The stored rule no longer changes anything, because the base rule is already at least as
57
+ * strict. Not an error and not silently dropped: the operator wrote it down, and the honest
58
+ * report is that the base map now covers it.
59
+ */
60
+ redundant: boolean
61
+ }
62
+
63
+ /**
64
+ * The rules that would actually narrow `base`. Empty for `never`, which is already the strictest
65
+ * rule there is, so a class the preset always routes to a human offers a role nothing to add.
66
+ */
67
+ export function narrowingOptionsFor(base: MergeClassRule): MergeClassRule[] {
68
+ return MERGE_CLASS_RULES.filter((rule) => narrowMergeClassRule(base, rule) !== base)
69
+ }
70
+
71
+ /** One role's rows, in the shared class order, against the preset's base rules. */
72
+ export function roleClassRuleRows(
73
+ base: MergeClassRules,
74
+ entry: MergeClassRules | undefined,
75
+ ): RoleClassRuleRow[] {
76
+ return RULEABLE_CHANGE_CLASSES.map((changeClass) => {
77
+ const baseRule = base[changeClass] ?? 'thresholds'
78
+ // The role's own rule, or nothing at all. Held as `undefined` rather than folded into
79
+ // `selected` up front because "did this role author a rule here" is the question the two
80
+ // lines below both ask, and an absent entry is the one answer that is not one of the three
81
+ // rules. Testing the SENTINEL for that would tie them to whatever string it happens to be.
82
+ const stored = entry?.[changeClass]
83
+ const narrowing = narrowingOptionsFor(baseRule)
84
+ return {
85
+ changeClass,
86
+ base: baseRule,
87
+ selected: stored ?? INHERIT_RULE,
88
+ options: stored && !narrowing.includes(stored) ? [...narrowing, stored] : narrowing,
89
+ redundant: !!stored && narrowMergeClassRule(baseRule, stored) === baseRule,
90
+ }
91
+ })
92
+ }
93
+
94
+ /**
95
+ * Set (or clear) one role's rule for one class, pruning back to the identity: clearing the last
96
+ * rule a role carried removes the role's entry, so a preset an operator has emptied is byte-for-byte
97
+ * the `{}` a preset that never had a role rule carries.
98
+ */
99
+ export function setRoleClassRule(
100
+ byRole: ClassRulesByRole,
101
+ role: WorkspaceRole,
102
+ changeClass: RuleableChangeClass,
103
+ rule: RoleRuleSelection,
104
+ ): ClassRulesByRole {
105
+ const entry: MergeClassRules = { ...byRole[role] }
106
+ if (rule === INHERIT_RULE) delete entry[changeClass]
107
+ else entry[changeClass] = rule
108
+ const next: ClassRulesByRole = { ...byRole }
109
+ if (Object.keys(entry).length === 0) delete next[role]
110
+ else next[role] = entry
111
+ return next
112
+ }
113
+
114
+ /**
115
+ * Add or remove a role from the sandboxed list, keeping it in the shared role order so two presets
116
+ * carrying the same policy carry the same array (and a diff of a preset reads as an edit rather
117
+ * than a reshuffle).
118
+ */
119
+ export function toggleDryRunRole(
120
+ roles: readonly WorkspaceRole[],
121
+ role: WorkspaceRole,
122
+ sandboxed: boolean,
123
+ ): WorkspaceRole[] {
124
+ const next = new Set(roles)
125
+ if (sandboxed) next.add(role)
126
+ else next.delete(role)
127
+ return WORKSPACE_ROLES.filter((r) => next.has(r))
128
+ }
129
+
130
+ /** How many classes a role has authored a rule for (the collapsed group's summary). */
131
+ export function roleNarrowedCount(entry: MergeClassRules | undefined): number {
132
+ return entry ? Object.keys(entry).length : 0
133
+ }
@@ -0,0 +1,226 @@
1
+ <script setup lang="ts">
2
+ // The ROLE layer of one merge preset: what happens to a run depending on WHO started it.
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.
9
+ //
10
+ // Composition is NARROW-ONLY, so this editor only offers a role rules STRICTER than the base one
11
+ // (see MergeRolePolicyEditor.logic.ts). A looser rule is discarded by the engine, and an editor
12
+ // that offered it would be reporting a policy that does nothing.
13
+ import { computed, ref } from 'vue'
14
+ import { RULEABLE_CHANGE_CLASSES, WORKSPACE_ROLES } from '@cat-factory/contracts'
15
+ import type {
16
+ ClassRulesByRole,
17
+ DryRunRoles,
18
+ MergeClassRule,
19
+ MergeClassRules,
20
+ RuleableChangeClass,
21
+ WorkspaceRole,
22
+ } from '~/types/merge'
23
+ import {
24
+ INHERIT_RULE,
25
+ roleClassRuleRows,
26
+ roleNarrowedCount,
27
+ setRoleClassRule,
28
+ toggleDryRunRole,
29
+ type RoleRuleSelection,
30
+ } from '~/components/settings/MergeRolePolicyEditor.logic'
31
+
32
+ const props = defineProps<{
33
+ /** The preset's per-role narrowing map; a role with no entry is exactly the base rules. */
34
+ classRulesByRole: ClassRulesByRole
35
+ /** The roles whose runs this preset sandboxes. */
36
+ dryRunRoles: DryRunRoles
37
+ /** The base rules the role layer narrows, so each row can show what it inherits. */
38
+ classRules: MergeClassRules
39
+ /**
40
+ * Whether the preset auto-merges at all. With the master switch off, every pull request already
41
+ * waits for a human, so a role's per-class narrowing has nothing left to subtract. The SANDBOX
42
+ * still does: it refuses the MANUAL merge too, which is the one thing the master switch leaves
43
+ * open. The two halves of this editor therefore stop being equally meaningful, and saying so is
44
+ * what keeps the inert half from reading as a policy that is doing something.
45
+ */
46
+ autoMergeEnabled: boolean
47
+ disabled?: boolean
48
+ }>()
49
+
50
+ const emit = defineEmits<{
51
+ 'update:classRulesByRole': [ClassRulesByRole]
52
+ 'update:dryRunRoles': [DryRunRoles]
53
+ }>()
54
+
55
+ const { t } = useI18n()
56
+
57
+ // Role + class + rule labels. Exhaustive Records keyed off the contract unions (a new member fails
58
+ // the typecheck) holding LITERAL catalog keys so the typed-message-key check sees them. The role
59
+ // names are the roster's, so the two surfaces name a tier identically.
60
+ const ROLE_LABEL_KEYS: Record<WorkspaceRole, string> = {
61
+ admin: 'layout.workspaceMembers.roles.admin',
62
+ member: 'layout.workspaceMembers.roles.member',
63
+ viewer: 'layout.workspaceMembers.roles.viewer',
64
+ }
65
+ const CLASS_LABEL_KEYS: Record<RuleableChangeClass, string> = {
66
+ docs: 'merge.changeClass.docs',
67
+ test: 'merge.changeClass.test',
68
+ dependency: 'merge.changeClass.dependency',
69
+ config: 'merge.changeClass.config',
70
+ source: 'merge.changeClass.source',
71
+ schema: 'merge.changeClass.schema',
72
+ }
73
+ const RULE_LABEL_KEYS: Record<MergeClassRule, string> = {
74
+ thresholds: 'settings.riskPolicy.classRules.rule.thresholds',
75
+ always: 'settings.riskPolicy.classRules.rule.always',
76
+ never: 'settings.riskPolicy.classRules.rule.never',
77
+ }
78
+
79
+ /** Which role groups are expanded. Collapsed by default: most presets narrow nothing. */
80
+ const expanded = ref<Partial<Record<WorkspaceRole, boolean>>>({})
81
+ function toggleExpanded(role: WorkspaceRole) {
82
+ expanded.value = { ...expanded.value, [role]: !expanded.value[role] }
83
+ }
84
+
85
+ const roles = computed(() =>
86
+ WORKSPACE_ROLES.map((role) => {
87
+ const entry: MergeClassRules | undefined = props.classRulesByRole[role]
88
+ const narrowed = roleNarrowedCount(entry)
89
+ return {
90
+ role,
91
+ label: t(ROLE_LABEL_KEYS[role]),
92
+ sandboxed: props.dryRunRoles.includes(role),
93
+ narrowed,
94
+ open: !!expanded.value[role],
95
+ rows: roleClassRuleRows(props.classRules, entry).map((row) => ({
96
+ ...row,
97
+ label: t(CLASS_LABEL_KEYS[row.changeClass]),
98
+ // The stored rule first (it may be one a later base edit made redundant), then whatever
99
+ // still narrows. "Same as policy" is the absent state and always leads.
100
+ items: [
101
+ { value: INHERIT_RULE, label: t('settings.riskPolicy.roleRules.inherit') },
102
+ ...row.options.map((rule) => ({ value: rule, label: t(RULE_LABEL_KEYS[rule]) })),
103
+ ],
104
+ })),
105
+ }
106
+ }),
107
+ )
108
+
109
+ /** How many classes carry a base rule stricter than nothing, for the "narrows nothing" hint. */
110
+ const anyBaseRule = computed(() =>
111
+ RULEABLE_CHANGE_CLASSES.some((changeClass) => !!props.classRules[changeClass]),
112
+ )
113
+
114
+ function setSandboxed(role: WorkspaceRole, sandboxed: boolean) {
115
+ emit('update:dryRunRoles', toggleDryRunRole(props.dryRunRoles, role, sandboxed))
116
+ }
117
+
118
+ function setRule(role: WorkspaceRole, changeClass: RuleableChangeClass, rule: RoleRuleSelection) {
119
+ emit('update:classRulesByRole', setRoleClassRule(props.classRulesByRole, role, changeClass, rule))
120
+ }
121
+ </script>
122
+
123
+ <template>
124
+ <div data-testid="merge-role-policy" class="space-y-2">
125
+ <div>
126
+ <span class="block text-[10px] uppercase tracking-wide text-slate-500">
127
+ {{ t('settings.riskPolicy.roleRules.heading') }}
128
+ </span>
129
+ <p class="mt-0.5 text-[11px] leading-snug text-slate-500">
130
+ {{ t('settings.riskPolicy.roleRules.help') }}
131
+ </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). -->
134
+ <p
135
+ v-if="!autoMergeEnabled"
136
+ class="mt-1 text-[11px] leading-snug text-amber-400/90"
137
+ data-testid="merge-role-auto-merge-off"
138
+ >
139
+ {{ t('settings.riskPolicy.roleRules.autoMergeOffWarning') }}
140
+ </p>
141
+ </div>
142
+
143
+ <div
144
+ v-for="group in roles"
145
+ :key="group.role"
146
+ class="rounded-md border border-slate-700/50 bg-slate-900/30 px-2 py-1.5"
147
+ :data-testid="`merge-role-group-${group.role}`"
148
+ >
149
+ <div class="flex flex-wrap items-center gap-2">
150
+ <span class="min-w-[5rem] text-xs text-slate-300">{{ group.label }}</span>
151
+ <USwitch
152
+ :model-value="group.sandboxed"
153
+ size="sm"
154
+ :disabled="disabled"
155
+ :label="t('settings.riskPolicy.roleRules.sandboxLabel')"
156
+ :data-testid="`merge-role-sandbox-${group.role}`"
157
+ @update:model-value="setSandboxed(group.role, $event)"
158
+ />
159
+ <UButton
160
+ color="neutral"
161
+ variant="ghost"
162
+ size="xs"
163
+ :icon="group.open ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
164
+ :data-testid="`merge-role-expand-${group.role}`"
165
+ @click="toggleExpanded(group.role)"
166
+ >
167
+ {{
168
+ group.narrowed === 0
169
+ ? t('settings.riskPolicy.roleRules.noRules')
170
+ : t(
171
+ 'settings.riskPolicy.roleRules.ruleCount',
172
+ { count: group.narrowed },
173
+ group.narrowed,
174
+ )
175
+ }}
176
+ </UButton>
177
+ </div>
178
+
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. -->
182
+ <p
183
+ v-if="group.sandboxed"
184
+ class="mt-1 text-[11px] leading-snug text-amber-400/90"
185
+ :data-testid="`merge-role-sandbox-note-${group.role}`"
186
+ >
187
+ {{ t('settings.riskPolicy.roleRules.sandboxNote') }}
188
+ </p>
189
+
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"
205
+ 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)"
210
+ />
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}`"
219
+ >
220
+ {{ t('settings.riskPolicy.roleRules.redundant') }}
221
+ </span>
222
+ </div>
223
+ </div>
224
+ </div>
225
+ </div>
226
+ </template>