@cat-factory/app 0.226.0 → 0.228.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/README.md +25 -0
- package/app/components/board/nodes/TaskCard.vue +68 -8
- package/app/components/outcome/OutcomeSummaryWindow.vue +614 -0
- package/app/components/panels/MergerResultView.vue +7 -0
- package/app/components/panels/ResultWindowShell.logic.spec.ts +4 -0
- package/app/components/panels/inspector/TaskExecution.vue +33 -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/composables/useRunDeepLink.ts +12 -4
- package/app/modular/result-views.ts +4 -0
- package/app/stores/ui/resultViews.ts +28 -0
- package/app/stores/ui.dispatch.spec.ts +55 -0
- package/app/types/merge.ts +2 -0
- package/app/utils/riskPolicy.spec.ts +21 -2
- package/app/utils/riskPolicy.ts +12 -0
- package/app/utils/runOutcome.spec.ts +492 -0
- package/app/utils/runOutcome.ts +509 -0
- package/i18n/locales/de.json +124 -6
- package/i18n/locales/en.json +127 -6
- package/i18n/locales/es.json +124 -6
- package/i18n/locales/fr.json +124 -6
- package/i18n/locales/he.json +124 -6
- package/i18n/locales/it.json +124 -6
- package/i18n/locales/ja.json +124 -6
- package/i18n/locales/pl.json +124 -6
- package/i18n/locales/tr.json +124 -6
- package/i18n/locales/uk.json +124 -6
- package/package.json +2 -2
|
@@ -19,6 +19,7 @@ import type { ChangeClass, ReviewEffort } from '~/types/merge'
|
|
|
19
19
|
import MergeEffortChips from '~/components/merge/MergeEffortChips.vue'
|
|
20
20
|
import InputGateNotice from '~/components/inputGate/InputGateNotice.vue'
|
|
21
21
|
import { inputGateNoticeFor } from '~/utils/inputGate'
|
|
22
|
+
import { composeRunOutcome, hasOutcomeToShow } from '~/utils/runOutcome'
|
|
22
23
|
|
|
23
24
|
const props = defineProps<{ block: Block }>()
|
|
24
25
|
|
|
@@ -87,6 +88,22 @@ const failedRun = computed(() => {
|
|
|
87
88
|
// CURRENT status, so the error trail stays viewable after a restart clears the top banner.
|
|
88
89
|
const failureHistory = computed(() => agentRuns.byBlock[props.block.id]?.failureHistory ?? [])
|
|
89
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Whether this task has a result worth reading in product terms: a pull request, or a step that
|
|
93
|
+
* recorded evidence. Asked of the shared reduction rather than re-derived here, so the panel and
|
|
94
|
+
* the board card can never disagree about which runs have an outcome to open.
|
|
95
|
+
*
|
|
96
|
+
* The spec is deliberately not loaded for this check: it only ever adds TITLES to requirement
|
|
97
|
+
* rows, never sections, so nothing about whether there is something to show depends on it. The
|
|
98
|
+
* window loads it on open.
|
|
99
|
+
*/
|
|
100
|
+
const outcomeReadable = computed(() =>
|
|
101
|
+
hasOutcomeToShow(composeRunOutcome({ block: props.block, instance: instance.value ?? null })),
|
|
102
|
+
)
|
|
103
|
+
function openOutcome() {
|
|
104
|
+
ui.openOutcome(props.block.id, instance.value?.id ?? null)
|
|
105
|
+
}
|
|
106
|
+
|
|
90
107
|
const pr = computed(() => props.block.pullRequest)
|
|
91
108
|
/** A PR is merged once the block is `done`; otherwise it is open awaiting merge. */
|
|
92
109
|
const prMerged = computed(() => props.block.status === 'done')
|
|
@@ -605,6 +622,22 @@ async function mergePr() {
|
|
|
605
622
|
<!-- error trail of prior attempts (survives a retry/restart that cleared the banner) -->
|
|
606
623
|
<AgentFailureHistory :failures="failureHistory" />
|
|
607
624
|
|
|
625
|
+
<!-- Read the result: the outcome summary is the way in, and the pull request below is the
|
|
626
|
+
way to the diff. Offered whenever there is evidence or a PR to read, which includes a
|
|
627
|
+
merged task whose run instance is long gone. -->
|
|
628
|
+
<UButton
|
|
629
|
+
v-if="outcomeReadable"
|
|
630
|
+
color="primary"
|
|
631
|
+
variant="soft"
|
|
632
|
+
size="sm"
|
|
633
|
+
icon="i-lucide-clipboard-check"
|
|
634
|
+
block
|
|
635
|
+
data-testid="inspector-open-outcome"
|
|
636
|
+
@click="openOutcome"
|
|
637
|
+
>
|
|
638
|
+
{{ t('inspector.execution.readOutcome') }}
|
|
639
|
+
</UButton>
|
|
640
|
+
|
|
608
641
|
<!-- Open PR: link straight to it on GitHub -->
|
|
609
642
|
<div v-if="pr" class="space-y-2">
|
|
610
643
|
<span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
@@ -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',
|
|
@@ -82,10 +82,18 @@ export function useRunDeepLink(): void {
|
|
|
82
82
|
if (!ready || applied) return
|
|
83
83
|
applied = true
|
|
84
84
|
if (link.blockId) ui.select(link.blockId)
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
85
|
+
// Three views are served: the run's outcome summary (the non-code answer to "what did this
|
|
86
|
+
// change"), the observability panel, and the Tester's result window (where the screenshots
|
|
87
|
+
// the report's environment-lifecycle section lists are rendered). An unknown view still
|
|
88
|
+
// lands the user on the right board and task rather than failing the navigation.
|
|
89
|
+
//
|
|
90
|
+
// `outcome` is passed the block as well as the run: it is the one of the three that stays
|
|
91
|
+
// readable on a task whose run the snapshot no longer carries, and a link followed weeks
|
|
92
|
+
// after the work merged is exactly that case. The engine emits no `view=outcome` link
|
|
93
|
+
// today (its report links a REVIEWER to the two run-scoped panels); this is the entry
|
|
94
|
+
// point for one, and for a URL a person shares.
|
|
95
|
+
if (link.view === 'outcome') ui.openRunOutcome(link.runId, link.blockId)
|
|
96
|
+
else if (link.view === 'observability') ui.openObservability(link.runId)
|
|
89
97
|
else if (link.view === 'test-evidence') ui.openTestEvidence(link.runId)
|
|
90
98
|
stop?.()
|
|
91
99
|
},
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Component } from 'vue'
|
|
2
2
|
import { defineModule } from '@modular-vue/core'
|
|
3
3
|
import { RESULT_VIEW_IDS, type ResultViewId } from '@cat-factory/contracts'
|
|
4
|
+
import OutcomeSummaryWindow from '~/components/outcome/OutcomeSummaryWindow.vue'
|
|
4
5
|
import RequirementsReviewWindow from '~/components/requirements/RequirementsReviewWindow.vue'
|
|
5
6
|
import ClarityReviewWindow from '~/components/clarity/ClarityReviewWindow.vue'
|
|
6
7
|
import BrainstormWindow from '~/components/brainstorm/BrainstormWindow.vue'
|
|
@@ -51,6 +52,9 @@ import type { ResultViewContribution } from './slots'
|
|
|
51
52
|
* that could ship. Consumer namespaced ids are validated separately by `pairById`.
|
|
52
53
|
*/
|
|
53
54
|
const BUILT_IN_RESULT_VIEWS: Record<ResultViewId, Component> = {
|
|
55
|
+
// The run's non-code outcome summary: what changed in product terms, with the captured
|
|
56
|
+
// evidence, and the diff one click away. RUN-keyed (no step), opened by `ui.openOutcome`.
|
|
57
|
+
outcome: OutcomeSummaryWindow,
|
|
54
58
|
'requirements-review': RequirementsReviewWindow,
|
|
55
59
|
'clarity-review': ClarityReviewWindow,
|
|
56
60
|
// Shared by both brainstorm stages (requirements + architecture); the window reads the stage.
|
|
@@ -113,6 +113,32 @@ export function createUiResultViews() {
|
|
|
113
113
|
stepDetail.value = { instanceId, stepIndex }
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Open a task's NON-CODE OUTCOME summary — what the run changed in product terms, with the
|
|
118
|
+
* evidence behind it, and the diff one click away. BLOCK-keyed rather than run-keyed because a
|
|
119
|
+
* merged task keeps its pull request long after its run instance is gone, and that task is
|
|
120
|
+
* exactly the one somebody comes back to read. The run rides along when there is one, which is
|
|
121
|
+
* where every piece of evidence comes from; `stepIndex` stays null because the summary is
|
|
122
|
+
* composed from the WHOLE run and there is no step for it to be about.
|
|
123
|
+
*/
|
|
124
|
+
function openOutcome(blockId: string, instanceId: string | null = null) {
|
|
125
|
+
resultView.value = { view: 'outcome', blockId, instanceId, stepIndex: null }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Open the outcome summary from a caller that knows the RUN: the `outcome` deep link.
|
|
130
|
+
*
|
|
131
|
+
* The run id is a LOOKUP here, not the key — the window is block-keyed (above) — so a link
|
|
132
|
+
* naming its block opens on it even when the store never hydrated that run, which is the
|
|
133
|
+
* normal state of following a link into a task that finished long ago. Falling back to the
|
|
134
|
+
* run's own `blockId` keeps a link that carries only `run=` working. Only a link that
|
|
135
|
+
* resolves neither is a silent no-op, matching the run-step openers.
|
|
136
|
+
*/
|
|
137
|
+
function openRunOutcome(instanceId: string, blockId: string | null = null) {
|
|
138
|
+
const resolved = blockId ?? useExecutionStore().getInstance(instanceId)?.blockId ?? null
|
|
139
|
+
if (resolved) openOutcome(resolved, instanceId)
|
|
140
|
+
}
|
|
141
|
+
|
|
116
142
|
function openRequirementReview(blockId: string) {
|
|
117
143
|
resultView.value = { view: 'requirements-review', blockId, instanceId: null, stepIndex: null }
|
|
118
144
|
}
|
|
@@ -185,6 +211,8 @@ export function createUiResultViews() {
|
|
|
185
211
|
openForkDecision,
|
|
186
212
|
openPrReview,
|
|
187
213
|
openTestEvidence,
|
|
214
|
+
openOutcome,
|
|
215
|
+
openRunOutcome,
|
|
188
216
|
closeResultView,
|
|
189
217
|
closeRequirementReview,
|
|
190
218
|
openStepDetail,
|