@cat-factory/app 0.157.0 → 0.158.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/layout/NotificationsInbox.vue +63 -1
- package/app/components/merge/MergeEffortChips.vue +122 -0
- package/app/components/panels/MergerResultView.vue +42 -7
- package/app/components/panels/inspector/TaskExecution.vue +57 -13
- package/app/components/settings/MergeClassRulesEditor.vue +140 -0
- package/app/components/settings/RiskPolicyPanel.vue +19 -1
- package/app/components/slack/SlackPanel.vue +2 -0
- package/app/composables/api/execution.ts +9 -2
- package/app/composables/api/notifications.ts +6 -2
- package/app/composables/api/presets.ts +21 -1
- package/app/stores/execution/commands.ts +8 -3
- package/app/stores/mergeTrackRecords.ts +61 -0
- package/app/stores/notifications.ts +10 -3
- package/app/types/merge.ts +7 -0
- package/i18n/locales/de.json +38 -3
- package/i18n/locales/en.json +56 -3
- package/i18n/locales/es.json +38 -3
- package/i18n/locales/fr.json +38 -3
- package/i18n/locales/he.json +38 -3
- package/i18n/locales/it.json +38 -3
- package/i18n/locales/ja.json +38 -3
- package/i18n/locales/pl.json +38 -3
- package/i18n/locales/tr.json +38 -3
- package/i18n/locales/uk.json +38 -3
- package/package.json +2 -2
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { Notification } from '~/types/domain'
|
|
3
|
+
import type { ReviewEffort } from '~/types/merge'
|
|
3
4
|
|
|
4
5
|
// The board's notification inbox: a bell with an open-count badge that opens a
|
|
5
6
|
// panel of human-actionable items (a PR awaiting a merge decision, a completed
|
|
@@ -13,6 +14,7 @@ const notifications = useNotificationsStore()
|
|
|
13
14
|
const ui = useUiStore()
|
|
14
15
|
const access = useWorkspaceAccess()
|
|
15
16
|
const execution = useExecutionStore()
|
|
17
|
+
const trackRecords = useMergeTrackRecordsStore()
|
|
16
18
|
const toast = useToast()
|
|
17
19
|
|
|
18
20
|
const busy = ref<string | null>(null)
|
|
@@ -34,6 +36,10 @@ type Accent = 'warning' | 'primary' | 'error'
|
|
|
34
36
|
const META: Record<Notification['type'], { icon: string; color: Accent }> = {
|
|
35
37
|
merge_review: { icon: 'i-lucide-git-pull-request-arrow', color: 'warning' },
|
|
36
38
|
pipeline_complete: { icon: 'i-lucide-circle-check', color: 'primary' },
|
|
39
|
+
// A PR merged directly on the provider left its merge track record untagged. Purely a nudge:
|
|
40
|
+
// "act" records the picked reviewer effort (nothing at all if none was picked) and dismissing
|
|
41
|
+
// it is always fine — the record stays valid untagged.
|
|
42
|
+
merge_tag_request: { icon: 'i-lucide-tag', color: 'primary' },
|
|
37
43
|
ci_failed: { icon: 'i-lucide-triangle-alert', color: 'error' },
|
|
38
44
|
test_failed: { icon: 'i-lucide-flask-conical', color: 'error' },
|
|
39
45
|
// Clicking the title opens the review window for the task (see `reveal`); "act" just marks
|
|
@@ -89,6 +95,7 @@ const META: Record<Notification['type'], { icon: string; color: Accent }> = {
|
|
|
89
95
|
const ACTION_KEYS: Record<Notification['type'], string> = {
|
|
90
96
|
merge_review: 'layout.notifications.action.merge_review',
|
|
91
97
|
pipeline_complete: 'layout.notifications.action.pipeline_complete',
|
|
98
|
+
merge_tag_request: 'layout.notifications.action.merge_tag_request',
|
|
92
99
|
ci_failed: 'layout.notifications.action.ci_failed',
|
|
93
100
|
test_failed: 'layout.notifications.action.test_failed',
|
|
94
101
|
requirement_review: 'layout.notifications.action.requirement_review',
|
|
@@ -127,10 +134,57 @@ function accent(n: Notification): Accent {
|
|
|
127
134
|
return isUrgent(n) ? 'error' : META[n.type].color
|
|
128
135
|
}
|
|
129
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Which cards collect a reviewer-effort tag: the two that MERGE a PR, plus the post-hoc nudge for
|
|
139
|
+
* one that was merged on the provider. Everything else has nothing to tag.
|
|
140
|
+
*/
|
|
141
|
+
function collectsEffort(n: Notification): boolean {
|
|
142
|
+
return (
|
|
143
|
+
n.type === 'merge_review' || n.type === 'pipeline_complete' || n.type === 'merge_tag_request'
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The human's picked effort per card, preselected from evidence rather than starting blank: if the
|
|
149
|
+
* run's `pr-reviewer` step actually recorded findings, review comments plausibly drove rework
|
|
150
|
+
* (`minor`); if it did not, the PR needed nothing (`none`). One tap either confirms that guess or
|
|
151
|
+
* corrects it. `undefined` means the card collects no tag at all.
|
|
152
|
+
*/
|
|
153
|
+
const effortByCard = reactive<Record<string, ReviewEffort | null>>({})
|
|
154
|
+
|
|
155
|
+
/** Whether the run's PR review surfaced any findings — the default-preselection signal. */
|
|
156
|
+
function hadReviewFindings(n: Notification): boolean {
|
|
157
|
+
const instance = n.executionId ? execution.getInstance(n.executionId) : undefined
|
|
158
|
+
return (instance?.steps ?? []).some((s) => (s.prReview?.findings?.length ?? 0) > 0)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function effortFor(n: Notification): ReviewEffort | null {
|
|
162
|
+
if (!(n.id in effortByCard)) effortByCard[n.id] = hadReviewFindings(n) ? 'minor' : 'none'
|
|
163
|
+
return effortByCard[n.id] ?? null
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The change class the engine recorded for the run, when classification resolved one. */
|
|
167
|
+
function changeClassOf(n: Notification) {
|
|
168
|
+
return n.payload?.changeClass
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Load the per-class rollups ONCE, the first time an effort-collecting card is in the inbox, so a
|
|
172
|
+
// merge card can show what that class has historically needed. Not part of the workspace snapshot
|
|
173
|
+
// (it's settings-screen/card context, not board state) and a single request for every class.
|
|
174
|
+
watch(
|
|
175
|
+
() => notifications.open.some(collectsEffort),
|
|
176
|
+
(needed) => {
|
|
177
|
+
if (needed && !trackRecords.loaded && !trackRecords.loading) void trackRecords.load()
|
|
178
|
+
},
|
|
179
|
+
{ immediate: true },
|
|
180
|
+
)
|
|
181
|
+
|
|
130
182
|
async function act(n: Notification) {
|
|
131
183
|
busy.value = n.id
|
|
132
184
|
try {
|
|
133
|
-
|
|
185
|
+
// Only the merge cards carry a tag; passing `undefined` elsewhere keeps the historical
|
|
186
|
+
// no-body act, so a non-merge card's action is completely unchanged.
|
|
187
|
+
await notifications.act(n.id, collectsEffort(n) ? effortFor(n) : undefined)
|
|
134
188
|
toast.add({
|
|
135
189
|
title: t('layout.notifications.toast.acted'),
|
|
136
190
|
color: 'success',
|
|
@@ -327,6 +381,14 @@ function revealDecision(n: Notification) {
|
|
|
327
381
|
<UIcon name="i-lucide-external-link" class="h-3 w-3" />
|
|
328
382
|
{{ t('layout.notifications.openPr') }}
|
|
329
383
|
</a>
|
|
384
|
+
<MergeEffortChips
|
|
385
|
+
v-if="collectsEffort(n)"
|
|
386
|
+
:model-value="effortFor(n)"
|
|
387
|
+
:change-class="changeClassOf(n)"
|
|
388
|
+
:rollup="changeClassOf(n) ? trackRecords.byClass[changeClassOf(n)!] : null"
|
|
389
|
+
:disabled="busy === n.id || !access.canExecuteRuns.value"
|
|
390
|
+
@update:model-value="effortByCard[n.id] = $event"
|
|
391
|
+
/>
|
|
330
392
|
<div class="mt-2 flex items-center gap-1.5">
|
|
331
393
|
<UButton
|
|
332
394
|
data-testid="notification-act"
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The reviewer-effort tag picker: ONE TAP, not a form. Shown on a merge-decision card (and the
|
|
3
|
+
// external-merge nudge) so the human who confirms a merge also records how much review the PR
|
|
4
|
+
// actually needed — the ground truth the auto-merge score thresholds are trying to approximate.
|
|
5
|
+
//
|
|
6
|
+
// Tagging is never mandatory: the parent can always act without a selection, and an untagged
|
|
7
|
+
// merge records a null tag. This component only chooses; the parent performs the action.
|
|
8
|
+
//
|
|
9
|
+
// See docs/initiatives/merge-track-record.md.
|
|
10
|
+
import { computed } from 'vue'
|
|
11
|
+
import { REVIEW_EFFORTS } from '@cat-factory/contracts'
|
|
12
|
+
import type { ChangeClass, MergeClassRollup, ReviewEffort } from '~/types/merge'
|
|
13
|
+
|
|
14
|
+
const props = defineProps<{
|
|
15
|
+
/** The currently picked effort, or null for "not chosen". */
|
|
16
|
+
modelValue: ReviewEffort | null
|
|
17
|
+
/** The run's change class, when classification resolved one — shown as context. */
|
|
18
|
+
changeClass?: ChangeClass
|
|
19
|
+
/** That class's accumulated track record, so the human sees what the class usually needs. */
|
|
20
|
+
rollup?: MergeClassRollup | null
|
|
21
|
+
disabled?: boolean
|
|
22
|
+
}>()
|
|
23
|
+
|
|
24
|
+
const emit = defineEmits<{ 'update:modelValue': [ReviewEffort | null] }>()
|
|
25
|
+
|
|
26
|
+
const { t, n } = useI18n()
|
|
27
|
+
|
|
28
|
+
// Per-effort label + icon. Exhaustive Records keyed off the union (a missing member fails the
|
|
29
|
+
// typecheck) with LITERAL catalog keys so the typed-message-keys check sees them; leaf keys
|
|
30
|
+
// mirror the enum value verbatim.
|
|
31
|
+
const EFFORT_LABEL_KEYS: Record<ReviewEffort, string> = {
|
|
32
|
+
none: 'merge.effort.none',
|
|
33
|
+
minor: 'merge.effort.minor',
|
|
34
|
+
major: 'merge.effort.major',
|
|
35
|
+
}
|
|
36
|
+
const EFFORT_ICONS: Record<ReviewEffort, string> = {
|
|
37
|
+
none: 'i-lucide-check',
|
|
38
|
+
minor: 'i-lucide-pencil-line',
|
|
39
|
+
major: 'i-lucide-hammer',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Per-class label. Same exhaustive-Record discipline; `unknown` is included because a card can
|
|
43
|
+
// carry no class at all (classification unavailable) and the caller may still render context.
|
|
44
|
+
const CLASS_LABEL_KEYS: Record<ChangeClass, string> = {
|
|
45
|
+
docs: 'merge.changeClass.docs',
|
|
46
|
+
test: 'merge.changeClass.test',
|
|
47
|
+
dependency: 'merge.changeClass.dependency',
|
|
48
|
+
config: 'merge.changeClass.config',
|
|
49
|
+
source: 'merge.changeClass.source',
|
|
50
|
+
schema: 'merge.changeClass.schema',
|
|
51
|
+
unknown: 'merge.changeClass.unknown',
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const chips = computed(() =>
|
|
55
|
+
REVIEW_EFFORTS.map((effort) => ({
|
|
56
|
+
effort,
|
|
57
|
+
label: t(EFFORT_LABEL_KEYS[effort]),
|
|
58
|
+
icon: EFFORT_ICONS[effort],
|
|
59
|
+
})),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
const classLabel = computed(() =>
|
|
63
|
+
props.changeClass ? t(CLASS_LABEL_KEYS[props.changeClass]) : null,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The class's history line — how many of this class have landed and what share auto-merged. Null
|
|
68
|
+
* until the class has at least one landed record, so a fresh workspace shows nothing rather than
|
|
69
|
+
* a misleading "0%".
|
|
70
|
+
*/
|
|
71
|
+
const history = computed(() => {
|
|
72
|
+
const r = props.rollup
|
|
73
|
+
if (!r || r.merged === 0) return null
|
|
74
|
+
// Percentages go through vue-i18n's named `percent` format, never a hand-rolled `* 100 + '%'`.
|
|
75
|
+
return t('merge.effort.classHistory', {
|
|
76
|
+
merged: r.merged,
|
|
77
|
+
autoShare: n(r.autoMerged / r.merged, 'percent'),
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
/** Toggle: tapping the picked chip clears the tag (back to untagged). */
|
|
82
|
+
function pick(effort: ReviewEffort) {
|
|
83
|
+
emit('update:modelValue', props.modelValue === effort ? null : effort)
|
|
84
|
+
}
|
|
85
|
+
</script>
|
|
86
|
+
|
|
87
|
+
<template>
|
|
88
|
+
<div data-testid="merge-effort-chips" class="mt-2">
|
|
89
|
+
<div class="flex items-center gap-1.5">
|
|
90
|
+
<span class="text-[10px] uppercase tracking-wide text-slate-500">
|
|
91
|
+
{{ t('merge.effort.prompt') }}
|
|
92
|
+
</span>
|
|
93
|
+
<UBadge
|
|
94
|
+
v-if="classLabel"
|
|
95
|
+
data-testid="merge-effort-class"
|
|
96
|
+
color="neutral"
|
|
97
|
+
variant="subtle"
|
|
98
|
+
size="sm"
|
|
99
|
+
>
|
|
100
|
+
{{ classLabel }}
|
|
101
|
+
</UBadge>
|
|
102
|
+
</div>
|
|
103
|
+
<div class="mt-1 flex flex-wrap items-center gap-1">
|
|
104
|
+
<UButton
|
|
105
|
+
v-for="chip in chips"
|
|
106
|
+
:key="chip.effort"
|
|
107
|
+
:data-testid="`merge-effort-${chip.effort}`"
|
|
108
|
+
:color="modelValue === chip.effort ? 'primary' : 'neutral'"
|
|
109
|
+
:variant="modelValue === chip.effort ? 'solid' : 'outline'"
|
|
110
|
+
size="xs"
|
|
111
|
+
:icon="chip.icon"
|
|
112
|
+
:disabled="disabled"
|
|
113
|
+
@click="pick(chip.effort)"
|
|
114
|
+
>
|
|
115
|
+
{{ chip.label }}
|
|
116
|
+
</UButton>
|
|
117
|
+
</div>
|
|
118
|
+
<p v-if="history" data-testid="merge-effort-history" class="mt-1 text-[10px] text-slate-500">
|
|
119
|
+
{{ history }}
|
|
120
|
+
</p>
|
|
121
|
+
</div>
|
|
122
|
+
</template>
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// banner — instead of the agent's raw JSON. Opened via the universal result-view host,
|
|
8
8
|
// the same seam the requirements / tester windows use.
|
|
9
9
|
import { computed } from 'vue'
|
|
10
|
-
import type { MergeAxis, MergeDecision } from '@cat-factory/contracts'
|
|
10
|
+
import type { ChangeClass, MergeAxis, MergeDecision } from '@cat-factory/contracts'
|
|
11
11
|
import StepRunMeta from '~/components/panels/StepRunMeta.vue'
|
|
12
12
|
import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
|
|
13
13
|
import MarkdownProse from '~/components/common/MarkdownProse.vue'
|
|
@@ -68,6 +68,8 @@ const REASON_KEYS: Record<MergeDecision['reason'], string> = {
|
|
|
68
68
|
no_assessment: 'panels.mergerResult.reason.no_assessment',
|
|
69
69
|
merge_failed: 'panels.mergerResult.reason.merge_failed',
|
|
70
70
|
merge_partial: 'panels.mergerResult.reason.merge_partial',
|
|
71
|
+
class_auto_merge: 'panels.mergerResult.reason.class_auto_merge',
|
|
72
|
+
class_requires_review: 'panels.mergerResult.reason.class_requires_review',
|
|
71
73
|
}
|
|
72
74
|
const OUTCOME_KEYS: Record<MergeDecision['outcome'], string> = {
|
|
73
75
|
auto_merged: 'panels.mergerResult.outcome.auto_merged',
|
|
@@ -81,6 +83,28 @@ const AXIS_KEYS: Record<MergeAxis, string> = {
|
|
|
81
83
|
|
|
82
84
|
const outcomeText = computed(() => (decision.value ? t(OUTCOME_KEYS[decision.value.outcome]) : ''))
|
|
83
85
|
|
|
86
|
+
// Per-change-class label — exhaustive over the union so a new class fails typecheck here until it
|
|
87
|
+
// has a key. Only the classes a decision can actually carry are reachable (`unknown` never lands
|
|
88
|
+
// on a decision: the engine omits the field instead), but the map stays total by construction.
|
|
89
|
+
const CLASS_KEYS: Record<ChangeClass, string> = {
|
|
90
|
+
docs: 'merge.changeClass.docs',
|
|
91
|
+
test: 'merge.changeClass.test',
|
|
92
|
+
dependency: 'merge.changeClass.dependency',
|
|
93
|
+
config: 'merge.changeClass.config',
|
|
94
|
+
source: 'merge.changeClass.source',
|
|
95
|
+
schema: 'merge.changeClass.schema',
|
|
96
|
+
unknown: 'merge.changeClass.unknown',
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The DETERMINISTIC change class the engine derived from the PR's changed files, when it resolved
|
|
101
|
+
* one. Shown beside the outcome so a reader sees WHAT KIND of change the decision was made about —
|
|
102
|
+
* which is what a `class_auto_merge` / `class_requires_review` reason is actually keyed on.
|
|
103
|
+
*/
|
|
104
|
+
const changeClassLabel = computed(() =>
|
|
105
|
+
decision.value?.changeClass ? t(CLASS_KEYS[decision.value.changeClass]) : null,
|
|
106
|
+
)
|
|
107
|
+
|
|
84
108
|
/** The three axes with their score + preset ceiling, for the bar rows. */
|
|
85
109
|
const axes = computed(() => {
|
|
86
110
|
const d = decision.value
|
|
@@ -150,12 +174,23 @@ const reasonText = computed(() => {
|
|
|
150
174
|
:class="merged ? 'text-emerald-300' : 'text-amber-300'"
|
|
151
175
|
/>
|
|
152
176
|
<div class="min-w-0">
|
|
153
|
-
<
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
177
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
178
|
+
<p
|
|
179
|
+
class="text-sm font-semibold"
|
|
180
|
+
:class="merged ? 'text-emerald-200' : 'text-amber-200'"
|
|
181
|
+
>
|
|
182
|
+
{{ outcomeText }}
|
|
183
|
+
</p>
|
|
184
|
+
<UBadge
|
|
185
|
+
v-if="changeClassLabel"
|
|
186
|
+
data-testid="merger-change-class"
|
|
187
|
+
color="neutral"
|
|
188
|
+
variant="subtle"
|
|
189
|
+
size="sm"
|
|
190
|
+
>
|
|
191
|
+
{{ changeClassLabel }}
|
|
192
|
+
</UBadge>
|
|
193
|
+
</div>
|
|
159
194
|
<p class="mt-0.5 text-[13px] leading-relaxed text-slate-300">{{ reasonText }}</p>
|
|
160
195
|
</div>
|
|
161
196
|
</div>
|
|
@@ -13,6 +13,8 @@ import EmptyState from '~/components/common/EmptyState.vue'
|
|
|
13
13
|
import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
|
|
14
14
|
import { useNowTick, stepDurationLabel } from '~/composables/useStepTimer'
|
|
15
15
|
import type { PipelineStep } from '~/types/execution'
|
|
16
|
+
import type { ChangeClass, ReviewEffort } from '~/types/merge'
|
|
17
|
+
import MergeEffortChips from '~/components/merge/MergeEffortChips.vue'
|
|
16
18
|
|
|
17
19
|
const props = defineProps<{ block: Block }>()
|
|
18
20
|
|
|
@@ -21,6 +23,7 @@ const agentRuns = useAgentRunsStore()
|
|
|
21
23
|
const ui = useUiStore()
|
|
22
24
|
const models = useModelsStore()
|
|
23
25
|
const reviews = useReviewStage()
|
|
26
|
+
const trackRecords = useMergeTrackRecordsStore()
|
|
24
27
|
const access = useWorkspaceAccess()
|
|
25
28
|
const { t, te } = useI18n()
|
|
26
29
|
const { confirm } = useConfirm()
|
|
@@ -203,6 +206,39 @@ async function resetRun() {
|
|
|
203
206
|
}
|
|
204
207
|
}
|
|
205
208
|
|
|
209
|
+
/**
|
|
210
|
+
* The reviewer-effort tag for this merge, preselected from evidence rather than starting blank: if
|
|
211
|
+
* the run's `pr-reviewer` step recorded findings, review comments plausibly drove rework
|
|
212
|
+
* (`minor`); if it did not, the PR needed nothing (`none`). One tap confirms or corrects it, and
|
|
213
|
+
* `null` merges untagged — tagging is a nudge, never a gate.
|
|
214
|
+
*/
|
|
215
|
+
const mergeEffort = ref<ReviewEffort | null>(null)
|
|
216
|
+
|
|
217
|
+
watch(
|
|
218
|
+
() => props.block.status === 'pr_ready',
|
|
219
|
+
(awaiting) => {
|
|
220
|
+
if (!awaiting) return
|
|
221
|
+
// ONE request for every class, only once and only when a merge decision is actually pending.
|
|
222
|
+
if (!trackRecords.loaded && !trackRecords.loading) void trackRecords.load()
|
|
223
|
+
// Preselect from evidence rather than leaving it blank. Set HERE (not at setup) because the
|
|
224
|
+
// run instance may still be loading when this component first mounts.
|
|
225
|
+
mergeEffort.value ??= (instance.value?.steps ?? []).some(
|
|
226
|
+
(s) => (s.prReview?.findings?.length ?? 0) > 0,
|
|
227
|
+
)
|
|
228
|
+
? 'minor'
|
|
229
|
+
: 'none'
|
|
230
|
+
},
|
|
231
|
+
{ immediate: true },
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
/** The change class the engine recorded on the run's merger step, when one resolved. */
|
|
235
|
+
const mergeChangeClass = computed<ChangeClass | undefined>(() => {
|
|
236
|
+
const decision = instance.value?.steps.find((s) => s.agentKind === 'merger')?.custom as
|
|
237
|
+
| { changeClass?: ChangeClass }
|
|
238
|
+
| undefined
|
|
239
|
+
return decision?.changeClass
|
|
240
|
+
})
|
|
241
|
+
|
|
206
242
|
// Merging a PR is consequential and effectively irreversible — confirm first. `execution.mergePr`
|
|
207
243
|
// surfaces its own error toast, so no catch is needed here.
|
|
208
244
|
async function mergePr() {
|
|
@@ -213,7 +249,7 @@ async function mergePr() {
|
|
|
213
249
|
icon: 'i-lucide-git-merge',
|
|
214
250
|
})
|
|
215
251
|
if (!ok) return
|
|
216
|
-
await execution.mergePr(props.block.id)
|
|
252
|
+
await execution.mergePr(props.block.id, mergeEffort.value)
|
|
217
253
|
}
|
|
218
254
|
</script>
|
|
219
255
|
|
|
@@ -548,17 +584,25 @@ async function mergePr() {
|
|
|
548
584
|
:description="t('inspector.execution.empty.body')"
|
|
549
585
|
/>
|
|
550
586
|
|
|
551
|
-
<!-- PR ready: merge -->
|
|
552
|
-
<
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
587
|
+
<!-- PR ready: record how much review it needed, then merge. -->
|
|
588
|
+
<template v-if="block.status === 'pr_ready'">
|
|
589
|
+
<MergeEffortChips
|
|
590
|
+
v-model="mergeEffort"
|
|
591
|
+
:change-class="mergeChangeClass"
|
|
592
|
+
:rollup="mergeChangeClass ? trackRecords.byClass[mergeChangeClass] : null"
|
|
593
|
+
/>
|
|
594
|
+
<UButton
|
|
595
|
+
class="mt-2"
|
|
596
|
+
color="success"
|
|
597
|
+
variant="solid"
|
|
598
|
+
size="sm"
|
|
599
|
+
icon="i-lucide-git-merge"
|
|
600
|
+
block
|
|
601
|
+
data-testid="inspector-merge-pr"
|
|
602
|
+
@click="mergePr"
|
|
603
|
+
>
|
|
604
|
+
{{ t('inspector.execution.mergePr') }}
|
|
605
|
+
</UButton>
|
|
606
|
+
</template>
|
|
563
607
|
</InspectorSection>
|
|
564
608
|
</template>
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Per-change-class auto-merge rules for one merge preset, shown NEXT TO each class's accumulated
|
|
3
|
+
// track record — the number that justifies widening a rule. That pairing is the whole point: an
|
|
4
|
+
// operator flips `docs` to "always auto-merge" because the history says humans found nothing to
|
|
5
|
+
// say about docs changes, not on a hunch.
|
|
6
|
+
//
|
|
7
|
+
// `unknown` is deliberately not listed: no rule may ever match it (an unclassifiable diff must
|
|
8
|
+
// fall back to the score ceilings), so offering one would be a lie. See
|
|
9
|
+
// docs/initiatives/merge-track-record.md.
|
|
10
|
+
import { computed } from 'vue'
|
|
11
|
+
import { autoMergeShare, frictionlessShare, RULEABLE_CHANGE_CLASSES } from '@cat-factory/contracts'
|
|
12
|
+
import type { MergeClassRule, MergeClassRules } from '~/types/merge'
|
|
13
|
+
|
|
14
|
+
const props = defineProps<{
|
|
15
|
+
/** The preset's current rules; an absent class means "use the score ceilings". */
|
|
16
|
+
modelValue: MergeClassRules
|
|
17
|
+
/** Whether the preset auto-merges at all — a rule can never override `autoMergeEnabled: false`. */
|
|
18
|
+
autoMergeEnabled: boolean
|
|
19
|
+
disabled?: boolean
|
|
20
|
+
}>()
|
|
21
|
+
|
|
22
|
+
const emit = defineEmits<{ 'update:modelValue': [MergeClassRules] }>()
|
|
23
|
+
|
|
24
|
+
const { t, n } = useI18n()
|
|
25
|
+
const trackRecords = useMergeTrackRecordsStore()
|
|
26
|
+
|
|
27
|
+
// Load the rollups once when the editor mounts. ONE request returns every class (a single SQL
|
|
28
|
+
// aggregate server-side), so this never fans out per class.
|
|
29
|
+
onMounted(() => {
|
|
30
|
+
if (!trackRecords.loaded && !trackRecords.loading) void trackRecords.load()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
// Per-class + per-rule labels. Exhaustive Records keyed off the unions (a missing member fails the
|
|
34
|
+
// typecheck) with LITERAL catalog keys so the typed-message-keys check sees them.
|
|
35
|
+
const CLASS_LABEL_KEYS: Record<(typeof RULEABLE_CHANGE_CLASSES)[number], string> = {
|
|
36
|
+
docs: 'merge.changeClass.docs',
|
|
37
|
+
test: 'merge.changeClass.test',
|
|
38
|
+
dependency: 'merge.changeClass.dependency',
|
|
39
|
+
config: 'merge.changeClass.config',
|
|
40
|
+
source: 'merge.changeClass.source',
|
|
41
|
+
schema: 'merge.changeClass.schema',
|
|
42
|
+
}
|
|
43
|
+
const RULE_LABEL_KEYS: Record<MergeClassRule, string> = {
|
|
44
|
+
thresholds: 'settings.riskPolicy.classRules.rule.thresholds',
|
|
45
|
+
always: 'settings.riskPolicy.classRules.rule.always',
|
|
46
|
+
never: 'settings.riskPolicy.classRules.rule.never',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const RULE_OPTIONS = computed<{ value: MergeClassRule; label: string }[]>(() => [
|
|
50
|
+
{ value: 'thresholds', label: t(RULE_LABEL_KEYS.thresholds) },
|
|
51
|
+
{ value: 'always', label: t(RULE_LABEL_KEYS.always) },
|
|
52
|
+
{ value: 'never', label: t(RULE_LABEL_KEYS.never) },
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
/** One row: the class, its current rule, and the evidence behind it. */
|
|
56
|
+
const rows = computed(() =>
|
|
57
|
+
RULEABLE_CHANGE_CLASSES.map((changeClass) => {
|
|
58
|
+
const rollup = trackRecords.byClass[changeClass]
|
|
59
|
+
const auto = autoMergeShare(rollup)
|
|
60
|
+
const frictionless = frictionlessShare(rollup)
|
|
61
|
+
return {
|
|
62
|
+
changeClass,
|
|
63
|
+
label: t(CLASS_LABEL_KEYS[changeClass]),
|
|
64
|
+
rule: props.modelValue[changeClass] ?? ('thresholds' as MergeClassRule),
|
|
65
|
+
merged: rollup.merged,
|
|
66
|
+
// Null until something has landed / been tagged, so a fresh workspace shows "no data yet"
|
|
67
|
+
// rather than a misleading 0%.
|
|
68
|
+
autoShare: auto,
|
|
69
|
+
frictionlessShare: frictionless,
|
|
70
|
+
}
|
|
71
|
+
}),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
/** Set (or clear) a class's rule. `thresholds` is stored as an OMISSION — it IS the default. */
|
|
75
|
+
function setRule(changeClass: (typeof RULEABLE_CHANGE_CLASSES)[number], rule: MergeClassRule) {
|
|
76
|
+
const next: MergeClassRules = { ...props.modelValue }
|
|
77
|
+
if (rule === 'thresholds') delete next[changeClass]
|
|
78
|
+
else next[changeClass] = rule
|
|
79
|
+
emit('update:modelValue', next)
|
|
80
|
+
}
|
|
81
|
+
</script>
|
|
82
|
+
|
|
83
|
+
<template>
|
|
84
|
+
<div data-testid="merge-class-rules" class="space-y-2">
|
|
85
|
+
<div>
|
|
86
|
+
<span class="block text-[10px] uppercase tracking-wide text-slate-500">
|
|
87
|
+
{{ t('settings.riskPolicy.classRules.heading') }}
|
|
88
|
+
</span>
|
|
89
|
+
<p class="mt-0.5 text-[11px] leading-snug text-slate-500">
|
|
90
|
+
{{ t('settings.riskPolicy.classRules.help') }}
|
|
91
|
+
</p>
|
|
92
|
+
<p v-if="!autoMergeEnabled" class="mt-1 text-[11px] leading-snug text-amber-400/90">
|
|
93
|
+
{{ t('settings.riskPolicy.classRules.autoMergeOffWarning') }}
|
|
94
|
+
</p>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<div
|
|
98
|
+
v-for="row in rows"
|
|
99
|
+
:key="row.changeClass"
|
|
100
|
+
class="flex flex-wrap items-center gap-2 rounded-md border border-slate-700/50 bg-slate-900/30 px-2 py-1.5"
|
|
101
|
+
:data-testid="`merge-class-row-${row.changeClass}`"
|
|
102
|
+
>
|
|
103
|
+
<span class="min-w-[7rem] text-xs text-slate-300">{{ row.label }}</span>
|
|
104
|
+
<USelect
|
|
105
|
+
:model-value="row.rule"
|
|
106
|
+
:items="RULE_OPTIONS"
|
|
107
|
+
value-key="value"
|
|
108
|
+
size="sm"
|
|
109
|
+
class="w-44"
|
|
110
|
+
:disabled="disabled"
|
|
111
|
+
:data-testid="`merge-class-rule-${row.changeClass}`"
|
|
112
|
+
@update:model-value="setRule(row.changeClass, $event as MergeClassRule)"
|
|
113
|
+
/>
|
|
114
|
+
<span
|
|
115
|
+
class="text-[11px] text-slate-500"
|
|
116
|
+
:data-testid="`merge-class-record-${row.changeClass}`"
|
|
117
|
+
>
|
|
118
|
+
<template v-if="row.merged === 0">
|
|
119
|
+
{{ t('settings.riskPolicy.classRules.noData') }}
|
|
120
|
+
</template>
|
|
121
|
+
<template v-else>
|
|
122
|
+
{{
|
|
123
|
+
t('settings.riskPolicy.classRules.record', {
|
|
124
|
+
merged: row.merged,
|
|
125
|
+
autoShare: n(row.autoShare ?? 0, 'percent'),
|
|
126
|
+
})
|
|
127
|
+
}}
|
|
128
|
+
<template v-if="row.frictionlessShare !== null">
|
|
129
|
+
·
|
|
130
|
+
{{
|
|
131
|
+
t('settings.riskPolicy.classRules.frictionless', {
|
|
132
|
+
share: n(row.frictionlessShare, 'percent'),
|
|
133
|
+
})
|
|
134
|
+
}}
|
|
135
|
+
</template>
|
|
136
|
+
</template>
|
|
137
|
+
</span>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
</template>
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// task inspector's "Merge policy" dropdown selects from. Exactly one preset is the
|
|
6
6
|
// default; it cannot be deleted or un-defaulted (the backend enforces this too).
|
|
7
7
|
import { computed, reactive, ref, watch } from 'vue'
|
|
8
|
-
import type { RiskPolicy, RequirementConcernLevel } from '~/types/merge'
|
|
8
|
+
import type { MergeClassRules, RiskPolicy, RequirementConcernLevel } from '~/types/merge'
|
|
9
9
|
import type { StepGating } from '@cat-factory/contracts'
|
|
10
10
|
|
|
11
11
|
const { t } = useI18n()
|
|
@@ -43,6 +43,9 @@ interface Draft {
|
|
|
43
43
|
maxRequirementIterations: number
|
|
44
44
|
maxRequirementConcernAllowed: RequirementConcernLevel
|
|
45
45
|
autoMergeEnabled: boolean
|
|
46
|
+
// Per-change-class auto-merge rules. An OMITTED class means "use the score ceilings above",
|
|
47
|
+
// so `{}` is the identity — the editor stores `thresholds` as an omission for that reason.
|
|
48
|
+
classRules: MergeClassRules
|
|
46
49
|
// Implementation-fork decision gating (edited 0..100, stored 0..1); disabled ⇒ off in `auto`.
|
|
47
50
|
forkEnabled: boolean
|
|
48
51
|
forkMinComplexity: number
|
|
@@ -79,6 +82,7 @@ function toDraft(p: RiskPolicy): Draft {
|
|
|
79
82
|
maxRequirementIterations: p.maxRequirementIterations,
|
|
80
83
|
maxRequirementConcernAllowed: p.maxRequirementConcernAllowed,
|
|
81
84
|
autoMergeEnabled: p.autoMergeEnabled,
|
|
85
|
+
classRules: { ...p.classRules },
|
|
82
86
|
forkEnabled: p.forkDecision?.enabled ?? false,
|
|
83
87
|
forkMinComplexity: Math.round((p.forkDecision?.minComplexity ?? 0.5) * 100),
|
|
84
88
|
forkMinRisk: Math.round((p.forkDecision?.minRisk ?? 0.4) * 100),
|
|
@@ -121,6 +125,7 @@ async function save(p: RiskPolicy) {
|
|
|
121
125
|
maxRequirementIterations: d.maxRequirementIterations,
|
|
122
126
|
maxRequirementConcernAllowed: d.maxRequirementConcernAllowed,
|
|
123
127
|
autoMergeEnabled: d.autoMergeEnabled,
|
|
128
|
+
classRules: d.classRules,
|
|
124
129
|
forkDecision: forkGating(d),
|
|
125
130
|
})
|
|
126
131
|
toast.add({
|
|
@@ -176,6 +181,7 @@ const draft = reactive<Draft>({
|
|
|
176
181
|
maxRequirementIterations: 6,
|
|
177
182
|
maxRequirementConcernAllowed: 'none',
|
|
178
183
|
autoMergeEnabled: true,
|
|
184
|
+
classRules: {},
|
|
179
185
|
forkEnabled: false,
|
|
180
186
|
forkMinComplexity: 50,
|
|
181
187
|
forkMinRisk: 40,
|
|
@@ -196,10 +202,12 @@ async function create() {
|
|
|
196
202
|
maxRequirementIterations: draft.maxRequirementIterations,
|
|
197
203
|
maxRequirementConcernAllowed: draft.maxRequirementConcernAllowed,
|
|
198
204
|
autoMergeEnabled: draft.autoMergeEnabled,
|
|
205
|
+
classRules: draft.classRules,
|
|
199
206
|
forkDecision: forkGating(draft),
|
|
200
207
|
})
|
|
201
208
|
draft.name = ''
|
|
202
209
|
draft.autoMergeEnabled = true
|
|
210
|
+
draft.classRules = {}
|
|
203
211
|
toast.add({
|
|
204
212
|
title: t('settings.riskPolicy.toast.created'),
|
|
205
213
|
icon: 'i-lucide-check',
|
|
@@ -341,6 +349,16 @@ async function create() {
|
|
|
341
349
|
</label>
|
|
342
350
|
</div>
|
|
343
351
|
|
|
352
|
+
<!-- Per-change-class auto-merge rules, each shown beside that class's accumulated track
|
|
353
|
+
record — the number that justifies widening the rule. -->
|
|
354
|
+
<div class="mt-3 rounded-md border border-slate-800 bg-slate-900/40 p-3">
|
|
355
|
+
<MergeClassRulesEditor
|
|
356
|
+
v-model="drafts[p.id]!.classRules"
|
|
357
|
+
:auto-merge-enabled="drafts[p.id]!.autoMergeEnabled"
|
|
358
|
+
:disabled="busy === p.id"
|
|
359
|
+
/>
|
|
360
|
+
</div>
|
|
361
|
+
|
|
344
362
|
<!-- Implementation-fork decision gate: propose materially different approaches before the
|
|
345
363
|
Coder writes code (in `auto` tri-state, gated on the task estimate). -->
|
|
346
364
|
<div class="mt-3 rounded-md border border-slate-800 bg-slate-900/40 p-3">
|
|
@@ -32,6 +32,7 @@ const back = useIntegrationBack(open)
|
|
|
32
32
|
const ROUTABLE = computed<{ type: NotificationType; label: string }[]>(() => [
|
|
33
33
|
{ type: 'merge_review', label: t('slack.routable.merge_review') },
|
|
34
34
|
{ type: 'pipeline_complete', label: t('slack.routable.pipeline_complete') },
|
|
35
|
+
{ type: 'merge_tag_request', label: t('slack.routable.merge_tag_request') },
|
|
35
36
|
{ type: 'ci_failed', label: t('slack.routable.ci_failed') },
|
|
36
37
|
{ type: 'test_failed', label: t('slack.routable.test_failed') },
|
|
37
38
|
{ type: 'requirement_review', label: t('slack.routable.requirement_review') },
|
|
@@ -54,6 +55,7 @@ const ROLE_OPTIONS = computed<{ label: string; value: SlackMemberRole }[]>(() =>
|
|
|
54
55
|
const routes = reactive<Record<NotificationType, SlackRoute>>({
|
|
55
56
|
merge_review: { enabled: false, channel: '' },
|
|
56
57
|
pipeline_complete: { enabled: false, channel: '' },
|
|
58
|
+
merge_tag_request: { enabled: false, channel: '' },
|
|
57
59
|
ci_failed: { enabled: false, channel: '' },
|
|
58
60
|
test_failed: { enabled: false, channel: '' },
|
|
59
61
|
requirement_review: { enabled: false, channel: '' },
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from '@cat-factory/contracts'
|
|
18
18
|
import type { RequestStepChangesInput } from '@cat-factory/contracts'
|
|
19
19
|
import type { IterationCapChoice } from '~/types/execution'
|
|
20
|
+
import type { ReviewEffort } from '~/types/merge'
|
|
20
21
|
import type { ApiContext } from './context'
|
|
21
22
|
|
|
22
23
|
/** Run lifecycle (start/cancel/decisions/approvals/restart) + LLM metrics + spend. */
|
|
@@ -38,8 +39,14 @@ export function executionApi({ send, sendWith, ws, pwHeaders }: ApiContext) {
|
|
|
38
39
|
cancelExecution: (workspaceId: string, blockId: string) =>
|
|
39
40
|
send(cancelExecutionContract, { pathPrefix: ws(workspaceId), pathParams: { blockId } }),
|
|
40
41
|
|
|
41
|
-
|
|
42
|
-
|
|
42
|
+
// `reviewEffort` records the reviewer-effort tag onto the block's merge track record in the
|
|
43
|
+
// same request as the merge (see the notification `act` counterpart). Always optional.
|
|
44
|
+
mergeBlock: (workspaceId: string, blockId: string, reviewEffort?: ReviewEffort | null) =>
|
|
45
|
+
send(mergeBlockContract, {
|
|
46
|
+
pathPrefix: ws(workspaceId),
|
|
47
|
+
pathParams: { blockId },
|
|
48
|
+
body: reviewEffort === undefined ? {} : { reviewEffort },
|
|
49
|
+
}),
|
|
43
50
|
|
|
44
51
|
resolveDecision: (
|
|
45
52
|
workspaceId: string,
|