@cat-factory/app 0.156.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/panels/inspector/TaskRunSettings.vue +23 -0
- package/app/components/settings/IssueTrackerPanel.vue +15 -0
- 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/stores/tracker.ts +2 -0
- package/app/types/merge.ts +7 -0
- package/i18n/locales/de.json +43 -3
- package/i18n/locales/en.json +61 -3
- package/i18n/locales/es.json +43 -3
- package/i18n/locales/fr.json +43 -3
- package/i18n/locales/he.json +43 -3
- package/i18n/locales/it.json +43 -3
- package/i18n/locales/ja.json +43 -3
- package/i18n/locales/pl.json +43 -3
- package/i18n/locales/tr.json +43 -3
- package/i18n/locales/uk.json +43 -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>
|
|
@@ -183,6 +183,11 @@ function setCommentOnPrOpen(value: WritebackOverride | null) {
|
|
|
183
183
|
function setResolveOnMerge(value: WritebackOverride | null) {
|
|
184
184
|
board.updateBlock(props.block.id, { trackerResolveOnMerge: value })
|
|
185
185
|
}
|
|
186
|
+
// Only consulted for runs started through the public API — a task started here keeps its in-app
|
|
187
|
+
// clarification window regardless (docs/initiatives/headless-clarification-loop.md).
|
|
188
|
+
function setQuestionsOnPark(value: WritebackOverride | null) {
|
|
189
|
+
board.updateBlock(props.block.id, { trackerQuestionsOnPark: value })
|
|
190
|
+
}
|
|
186
191
|
function writebackMenu(set: (value: WritebackOverride | null) => void) {
|
|
187
192
|
return [
|
|
188
193
|
[
|
|
@@ -212,6 +217,9 @@ const commentOnPrOpenLabel = computed(() =>
|
|
|
212
217
|
const resolveOnMergeLabel = computed(() =>
|
|
213
218
|
writebackLabel(props.block.trackerResolveOnMerge, tracker.settings.writebackResolveOnMerge),
|
|
214
219
|
)
|
|
220
|
+
const questionsOnParkLabel = computed(() =>
|
|
221
|
+
writebackLabel(props.block.trackerQuestionsOnPark, tracker.settings.writebackQuestionsOnPark),
|
|
222
|
+
)
|
|
215
223
|
|
|
216
224
|
// ---- technical label (tri-state) -------------------------------------------
|
|
217
225
|
// Whether this is a purely technical task (the implementer then treats the task
|
|
@@ -520,6 +528,21 @@ const technicalLabel = computed(() => {
|
|
|
520
528
|
</UButton>
|
|
521
529
|
</UDropdownMenu>
|
|
522
530
|
</div>
|
|
531
|
+
<div class="flex items-center justify-between">
|
|
532
|
+
<span class="text-[11px] text-slate-400">{{
|
|
533
|
+
t('inspector.runSettings.questionsOnPark')
|
|
534
|
+
}}</span>
|
|
535
|
+
<UDropdownMenu :items="writebackMenu(setQuestionsOnPark)">
|
|
536
|
+
<UButton
|
|
537
|
+
size="xs"
|
|
538
|
+
variant="ghost"
|
|
539
|
+
color="neutral"
|
|
540
|
+
trailing-icon="i-lucide-chevron-down"
|
|
541
|
+
>
|
|
542
|
+
{{ questionsOnParkLabel }}
|
|
543
|
+
</UButton>
|
|
544
|
+
</UDropdownMenu>
|
|
545
|
+
</div>
|
|
523
546
|
</div>
|
|
524
547
|
<div class="mt-1 text-[11px] text-slate-500">
|
|
525
548
|
{{ t('inspector.runSettings.writebackHint') }}
|
|
@@ -29,6 +29,7 @@ const jiraProjectKey = ref('')
|
|
|
29
29
|
const linearTeamId = ref('')
|
|
30
30
|
const commentOnPrOpen = ref(false)
|
|
31
31
|
const resolveOnMerge = ref(false)
|
|
32
|
+
const questionsOnPark = ref(false)
|
|
32
33
|
const saving = ref(false)
|
|
33
34
|
|
|
34
35
|
function hydrate() {
|
|
@@ -37,6 +38,7 @@ function hydrate() {
|
|
|
37
38
|
linearTeamId.value = tracker.settings.linearTeamId ?? ''
|
|
38
39
|
commentOnPrOpen.value = tracker.settings.writebackCommentOnPrOpen
|
|
39
40
|
resolveOnMerge.value = tracker.settings.writebackResolveOnMerge
|
|
41
|
+
questionsOnPark.value = tracker.settings.writebackQuestionsOnPark
|
|
40
42
|
}
|
|
41
43
|
onMounted(() => {
|
|
42
44
|
hydrate()
|
|
@@ -111,6 +113,7 @@ async function save() {
|
|
|
111
113
|
linearTeamId: trackerKind.value === 'linear' ? linearTeamId.value.trim() : null,
|
|
112
114
|
writebackCommentOnPrOpen: commentOnPrOpen.value,
|
|
113
115
|
writebackResolveOnMerge: resolveOnMerge.value,
|
|
116
|
+
writebackQuestionsOnPark: questionsOnPark.value,
|
|
114
117
|
})
|
|
115
118
|
toast.add({
|
|
116
119
|
title: t('settings.issueTracker.toast.saved'),
|
|
@@ -591,6 +594,18 @@ const STATUS_UI: Record<
|
|
|
591
594
|
</span>
|
|
592
595
|
</span>
|
|
593
596
|
</label>
|
|
597
|
+
|
|
598
|
+
<label class="flex items-start gap-3 rounded-lg border border-slate-700 bg-slate-800/40 p-3">
|
|
599
|
+
<USwitch v-model="questionsOnPark" />
|
|
600
|
+
<span class="text-sm">
|
|
601
|
+
<span class="block text-slate-200">
|
|
602
|
+
{{ t('settings.issueTracker.writeback.questionsOnPark.label') }}
|
|
603
|
+
</span>
|
|
604
|
+
<span class="block text-xs text-slate-500">
|
|
605
|
+
{{ t('settings.issueTracker.writeback.questionsOnPark.help') }}
|
|
606
|
+
</span>
|
|
607
|
+
</span>
|
|
608
|
+
</label>
|
|
594
609
|
</section>
|
|
595
610
|
|
|
596
611
|
<div class="flex justify-end">
|
|
@@ -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>
|