@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.
- package/app/components/board/BoardCanvas.vue +15 -0
- package/app/components/board/nodes/TaskCard.vue +26 -11
- package/app/components/focus/BlockFocusView.vue +29 -1
- package/app/components/layout/AccountFailureKindRules.vue +285 -0
- package/app/components/layout/AccountPlatformAlertSettings.vue +43 -1
- package/app/components/panels/InspectorPanel.vue +49 -4
- package/app/components/panels/OperatorDashboardPanel.vue +7 -19
- package/app/components/panels/inspector/TaskExecution.vue +24 -2
- package/app/components/riskPolicy/RiskPolicyPreview.vue +33 -2
- package/app/components/settings/MergeRolePolicyEditor.logic.spec.ts +146 -0
- package/app/components/settings/MergeRolePolicyEditor.logic.ts +133 -0
- package/app/components/settings/MergeRolePolicyEditor.vue +226 -0
- package/app/components/settings/RiskPolicyPanel.vue +35 -1
- package/app/composables/useRunStart.spec.ts +204 -0
- package/app/composables/useRunStart.ts +112 -0
- package/app/stores/execution/commands.ts +20 -3
- package/app/types/execution.ts +1 -0
- package/app/types/merge.ts +6 -0
- package/app/utils/failureKinds.spec.ts +82 -0
- package/app/utils/failureKinds.ts +91 -0
- package/app/utils/riskPolicy.spec.ts +32 -1
- package/app/utils/riskPolicy.ts +29 -1
- package/i18n/locales/de.json +55 -5
- package/i18n/locales/en.json +61 -5
- package/i18n/locales/es.json +55 -5
- package/i18n/locales/fr.json +55 -5
- package/i18n/locales/he.json +55 -5
- package/i18n/locales/it.json +55 -5
- package/i18n/locales/ja.json +55 -5
- package/i18n/locales/pl.json +55 -5
- package/i18n/locales/tr.json +55 -5
- package/i18n/locales/uk.json +55 -5
- 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="
|
|
270
|
-
|
|
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>
|