@cat-factory/app 0.117.0 → 0.119.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 +14 -0
- package/app/components/panels/StepResultViewHost.vue +4 -0
- package/app/components/prReview/PrReviewWindow.vue +336 -0
- package/app/components/slack/SlackPanel.vue +2 -0
- package/app/composables/api/prReview.ts +30 -0
- package/app/composables/useApi.ts +2 -0
- package/app/stores/board.ts +26 -1
- package/app/stores/prReview.ts +86 -0
- package/app/stores/ui.ts +26 -0
- package/app/stores/workspace.spec.ts +38 -0
- package/app/stores/workspace.ts +15 -4
- package/app/types/execution.ts +6 -0
- package/i18n/locales/de.json +48 -1
- package/i18n/locales/en.json +48 -1
- package/i18n/locales/es.json +48 -1
- package/i18n/locales/fr.json +48 -1
- package/i18n/locales/he.json +48 -1
- package/i18n/locales/it.json +48 -1
- package/i18n/locales/ja.json +48 -1
- package/i18n/locales/pl.json +48 -1
- package/i18n/locales/tr.json +48 -1
- package/i18n/locales/uk.json +48 -1
- package/package.json +2 -2
|
@@ -64,6 +64,9 @@ const META: Record<Notification['type'], { icon: string; color: Accent }> = {
|
|
|
64
64
|
// the title opens the fork-decision window (see `reveal`); "act" just marks it read (the
|
|
65
65
|
// choice is made in that window — pick a fork / enter a custom approach — not here).
|
|
66
66
|
fork_decision_pending: { icon: 'i-lucide-git-fork', color: 'warning' },
|
|
67
|
+
// The PR reviewer surfaced findings to triage. Clicking the title opens the PR-review window
|
|
68
|
+
// (see `reveal`); "act" just marks it read (findings are selected in that window, not here).
|
|
69
|
+
pr_review_ready: { icon: 'i-lucide-clipboard-check', color: 'primary' },
|
|
67
70
|
// The initiative loop needs attention (a blocked task, or completion). Clicking the title
|
|
68
71
|
// opens the initiative tracker window; "act" just marks it read.
|
|
69
72
|
initiative: { icon: 'i-lucide-milestone', color: 'primary' },
|
|
@@ -86,6 +89,7 @@ const ACTION_KEYS: Record<Notification['type'], string> = {
|
|
|
86
89
|
human_review: 'layout.notifications.action.human_review',
|
|
87
90
|
followup_pending: 'layout.notifications.action.followup_pending',
|
|
88
91
|
fork_decision_pending: 'layout.notifications.action.fork_decision_pending',
|
|
92
|
+
pr_review_ready: 'layout.notifications.action.pr_review_ready',
|
|
89
93
|
initiative: 'layout.notifications.action.initiative',
|
|
90
94
|
}
|
|
91
95
|
|
|
@@ -157,6 +161,7 @@ function reveal(n: Notification) {
|
|
|
157
161
|
else if (n.type === 'human_review') revealHumanReview(n)
|
|
158
162
|
else if (n.type === 'followup_pending') revealFollowUps(n)
|
|
159
163
|
else if (n.type === 'fork_decision_pending') revealForkDecision(n)
|
|
164
|
+
else if (n.type === 'pr_review_ready') revealPrReview(n)
|
|
160
165
|
else if (n.type === 'initiative') ui.openInitiativeTracker(n.blockId)
|
|
161
166
|
else ui.select(n.blockId)
|
|
162
167
|
}
|
|
@@ -191,6 +196,15 @@ function revealForkDecision(n: Notification) {
|
|
|
191
196
|
else if (n.blockId) ui.select(n.blockId)
|
|
192
197
|
}
|
|
193
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Open the PR deep-review window for a run parked awaiting a finding selection.
|
|
201
|
+
* Falls back to focusing the block when the run isn't loaded.
|
|
202
|
+
*/
|
|
203
|
+
function revealPrReview(n: Notification) {
|
|
204
|
+
if (n.executionId && execution.getInstance(n.executionId)) ui.openPrReview(n.executionId)
|
|
205
|
+
else if (n.blockId) ui.select(n.blockId)
|
|
206
|
+
}
|
|
207
|
+
|
|
194
208
|
/**
|
|
195
209
|
* Open the human-testing window for a parked `human-test` gate: find the run's parked
|
|
196
210
|
* human-test step and open it through the universal step dispatch (its archetype declares
|
|
@@ -23,6 +23,7 @@ import GenericStructuredResultView from '~/components/panels/GenericStructuredRe
|
|
|
23
23
|
import ServiceSpecWindow from '~/components/spec/ServiceSpecWindow.vue'
|
|
24
24
|
import FollowUpWindow from '~/components/followUp/FollowUpWindow.vue'
|
|
25
25
|
import ForkDecisionWindow from '~/components/forkDecision/ForkDecisionWindow.vue'
|
|
26
|
+
import PrReviewWindow from '~/components/prReview/PrReviewWindow.vue'
|
|
26
27
|
import MergerResultView from '~/components/panels/MergerResultView.vue'
|
|
27
28
|
import InitiativeTrackerWindow from '~/components/initiative/InitiativeTrackerWindow.vue'
|
|
28
29
|
import InitiativePlanningWindow from '~/components/initiative/InitiativePlanningWindow.vue'
|
|
@@ -57,6 +58,9 @@ const STEP_RESULT_VIEWS: Record<string, Component> = {
|
|
|
57
58
|
// human's pick / custom approach. Opened directly via `ui.openForkDecision` (the pipeline
|
|
58
59
|
// chip, the inspector button, and the `fork_decision_pending` card).
|
|
59
60
|
'fork-decision': ForkDecisionWindow,
|
|
61
|
+
// The PR deep-review: the reviewer's sliced, prioritized findings + the human's multi-select.
|
|
62
|
+
// Opened as the `pr-reviewer` step's result view and via the `pr_review_ready` inbox card.
|
|
63
|
+
'pr-review': PrReviewWindow,
|
|
60
64
|
// The merger's verdict: the PR's complexity/risk/impact scores + the engine's auto-merge
|
|
61
65
|
// or awaiting-review decision (and why), instead of the agent's raw JSON.
|
|
62
66
|
merger: MergerResultView,
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// PR deep-review window — the dedicated surface for the read-only `pr-reviewer`'s sliced,
|
|
3
|
+
// prioritized findings, opened via the universal result-view host. It reads the live review
|
|
4
|
+
// state straight off the run's `pr-reviewer` step (`step.prReview`, kept fresh by the
|
|
5
|
+
// execution stream) and lets a human multi-SELECT which findings matter, grouped by slice and
|
|
6
|
+
// sorted by severity, then resolve the review one of three ways: `Fix` (feed the selected
|
|
7
|
+
// findings to a Fixer that commits fixes onto the PR branch), `Post` (publish them as inline PR
|
|
8
|
+
// review comments), or `Finish` (just record the curated selection). Fix/Post act on the
|
|
9
|
+
// selection, so they require at least one selected finding.
|
|
10
|
+
import { computed, ref, watch } from 'vue'
|
|
11
|
+
import { useResultView } from '~/composables/useResultView'
|
|
12
|
+
import { useExecutionStore } from '~/stores/execution'
|
|
13
|
+
import { useBoardStore } from '~/stores/board'
|
|
14
|
+
import { usePrReviewStore } from '~/stores/prReview'
|
|
15
|
+
import type {
|
|
16
|
+
PrReviewFinding,
|
|
17
|
+
PrReviewResolution,
|
|
18
|
+
PrReviewSeverity,
|
|
19
|
+
PrReviewStepState,
|
|
20
|
+
} from '~/types/execution'
|
|
21
|
+
|
|
22
|
+
const execution = useExecutionStore()
|
|
23
|
+
const board = useBoardStore()
|
|
24
|
+
const prReview = usePrReviewStore()
|
|
25
|
+
|
|
26
|
+
const { t } = useI18n()
|
|
27
|
+
|
|
28
|
+
const { open, blockId, instanceId, stepIndex, close } = useResultView('pr-review', {
|
|
29
|
+
onOpen: (_id) => {
|
|
30
|
+
if (instanceId.value) void prReview.load(instanceId.value)
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
|
|
35
|
+
const instance = computed(() =>
|
|
36
|
+
instanceId.value === null ? null : (execution.getInstance(instanceId.value) ?? null),
|
|
37
|
+
)
|
|
38
|
+
const step = computed(() => {
|
|
39
|
+
if (instance.value === null || stepIndex.value === null) return null
|
|
40
|
+
return instance.value.steps[stepIndex.value] ?? null
|
|
41
|
+
})
|
|
42
|
+
const state = computed<PrReviewStepState | null>(() => step.value?.prReview ?? null)
|
|
43
|
+
const status = computed(() => state.value?.status ?? null)
|
|
44
|
+
const awaiting = computed(() => status.value === 'awaiting_selection')
|
|
45
|
+
// A resolution is executing (the Fixer is committing, or comments are being posted) — show a
|
|
46
|
+
// working state between the human's choice and the run advancing/the stream echoing `done`.
|
|
47
|
+
const working = computed(() => status.value === 'fixing' || status.value === 'posting')
|
|
48
|
+
const findings = computed<PrReviewFinding[]>(() => state.value?.findings ?? [])
|
|
49
|
+
|
|
50
|
+
/** Severity → chip classes (styling, not copy). */
|
|
51
|
+
const SEVERITY_CLASS: Record<PrReviewSeverity, string> = {
|
|
52
|
+
blocker: 'bg-rose-500/15 text-rose-300 ring-rose-500/30',
|
|
53
|
+
high: 'bg-orange-500/15 text-orange-300 ring-orange-500/30',
|
|
54
|
+
medium: 'bg-amber-500/15 text-amber-300 ring-amber-500/30',
|
|
55
|
+
low: 'bg-sky-500/15 text-sky-300 ring-sky-500/30',
|
|
56
|
+
nit: 'bg-slate-500/15 text-slate-300 ring-slate-500/30',
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Findings grouped under their slice (in the review's slice order), plus an "Other" bucket. */
|
|
60
|
+
const groups = computed(() => {
|
|
61
|
+
const slices = state.value?.slices ?? []
|
|
62
|
+
const byId = new Map<string, PrReviewFinding[]>()
|
|
63
|
+
const unsliced: PrReviewFinding[] = []
|
|
64
|
+
for (const f of findings.value) {
|
|
65
|
+
if (f.sliceId && slices.some((s) => s.id === f.sliceId)) {
|
|
66
|
+
const arr = byId.get(f.sliceId) ?? []
|
|
67
|
+
arr.push(f)
|
|
68
|
+
byId.set(f.sliceId, arr)
|
|
69
|
+
} else {
|
|
70
|
+
unsliced.push(f)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const out = slices
|
|
74
|
+
.map((s) => ({ id: s.id, title: s.title, rationale: s.rationale, items: byId.get(s.id) ?? [] }))
|
|
75
|
+
.filter((g) => g.items.length > 0)
|
|
76
|
+
if (unsliced.length > 0) {
|
|
77
|
+
out.push({ id: '__unsliced', title: t('prReview.unsliced'), rationale: '', items: unsliced })
|
|
78
|
+
}
|
|
79
|
+
return out
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// The human's selection — a set of finding ids. Defaults to every finding (the human deselects
|
|
83
|
+
// the noise), so "Finish" without touching anything keeps all. Re-seeded ONLY when the set of
|
|
84
|
+
// finding IDS actually changes — NOT on every execution-stream re-emit (which hands us a fresh
|
|
85
|
+
// `findings` array reference on each reconnect/resync). Keying on the id set keeps the human's
|
|
86
|
+
// in-progress curation from being silently reset by an unrelated live update.
|
|
87
|
+
const findingIdKey = computed(() => findings.value.map((f) => f.id).join('\n'))
|
|
88
|
+
const selected = ref<Set<string>>(new Set())
|
|
89
|
+
watch(
|
|
90
|
+
findingIdKey,
|
|
91
|
+
() => {
|
|
92
|
+
selected.value = new Set(findings.value.map((f) => f.id))
|
|
93
|
+
},
|
|
94
|
+
{ immediate: true },
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
function toggle(id: string): void {
|
|
98
|
+
const next = new Set(selected.value)
|
|
99
|
+
if (next.has(id)) next.delete(id)
|
|
100
|
+
else next.add(id)
|
|
101
|
+
selected.value = next
|
|
102
|
+
}
|
|
103
|
+
function selectAll(): void {
|
|
104
|
+
selected.value = new Set(findings.value.map((f) => f.id))
|
|
105
|
+
}
|
|
106
|
+
function clearAll(): void {
|
|
107
|
+
selected.value = new Set()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const canResolve = computed(() => awaiting.value && !prReview.resolving)
|
|
111
|
+
// Fix / Post act on the selection, so they need at least one selected finding; Finish always
|
|
112
|
+
// works (it just records the — possibly empty — curated selection and completes the review).
|
|
113
|
+
const hasSelection = computed(() => selected.value.size > 0)
|
|
114
|
+
|
|
115
|
+
async function onResolve(action: PrReviewResolution): Promise<void> {
|
|
116
|
+
const id = instanceId.value
|
|
117
|
+
if (!id || !canResolve.value) return
|
|
118
|
+
if ((action === 'fix' || action === 'post') && !hasSelection.value) return
|
|
119
|
+
await prReview.resolve(id, [...selected.value], action).catch(() => {})
|
|
120
|
+
}
|
|
121
|
+
</script>
|
|
122
|
+
|
|
123
|
+
<template>
|
|
124
|
+
<Teleport to="body">
|
|
125
|
+
<div
|
|
126
|
+
v-if="open"
|
|
127
|
+
data-testid="pr-review-window"
|
|
128
|
+
class="fixed inset-0 z-50 flex max-h-[100dvh] items-stretch justify-center bg-slate-950/70 backdrop-blur-sm"
|
|
129
|
+
@click.self="close"
|
|
130
|
+
>
|
|
131
|
+
<div
|
|
132
|
+
class="m-4 flex w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
|
|
133
|
+
role="dialog"
|
|
134
|
+
aria-modal="true"
|
|
135
|
+
>
|
|
136
|
+
<!-- Header -->
|
|
137
|
+
<header class="flex items-center gap-3 border-b border-slate-800 px-5 py-3">
|
|
138
|
+
<span
|
|
139
|
+
class="flex h-8 w-8 items-center justify-center rounded-lg bg-indigo-500/15 text-indigo-300"
|
|
140
|
+
>
|
|
141
|
+
<UIcon name="i-lucide-clipboard-check" class="h-4 w-4" />
|
|
142
|
+
</span>
|
|
143
|
+
<div class="min-w-0 flex-1">
|
|
144
|
+
<h2 class="truncate text-sm font-semibold text-slate-100">
|
|
145
|
+
{{
|
|
146
|
+
block ? t('prReview.titleWithBlock', { title: block.title }) : t('prReview.title')
|
|
147
|
+
}}
|
|
148
|
+
</h2>
|
|
149
|
+
<p class="truncate text-[11px] text-slate-400">{{ t('prReview.subtitle') }}</p>
|
|
150
|
+
</div>
|
|
151
|
+
<a
|
|
152
|
+
v-if="state?.prUrl"
|
|
153
|
+
:href="state.prUrl"
|
|
154
|
+
target="_blank"
|
|
155
|
+
rel="noopener"
|
|
156
|
+
class="rounded-md px-2 py-1 text-[11px] text-indigo-300 hover:bg-slate-800"
|
|
157
|
+
>
|
|
158
|
+
{{ t('prReview.openPr') }}
|
|
159
|
+
</a>
|
|
160
|
+
<button
|
|
161
|
+
class="rounded-md p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
|
|
162
|
+
@click="close"
|
|
163
|
+
>
|
|
164
|
+
<UIcon name="i-lucide-x" class="h-4 w-4" />
|
|
165
|
+
</button>
|
|
166
|
+
</header>
|
|
167
|
+
|
|
168
|
+
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
|
169
|
+
<!-- Reviewing: the read-only reviewer is still working. -->
|
|
170
|
+
<div
|
|
171
|
+
v-if="status === 'reviewing'"
|
|
172
|
+
class="flex h-full flex-col items-center justify-center gap-2 py-10 text-center text-slate-400"
|
|
173
|
+
>
|
|
174
|
+
<UIcon name="i-lucide-loader-circle" class="h-8 w-8 animate-spin opacity-60" />
|
|
175
|
+
<p class="text-sm">{{ t('prReview.reviewing.title') }}</p>
|
|
176
|
+
<p class="max-w-sm text-[11px] text-slate-500">{{ t('prReview.reviewing.hint') }}</p>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
<!-- A resolution is executing: the Fixer is committing / comments are being posted. -->
|
|
180
|
+
<div
|
|
181
|
+
v-else-if="working"
|
|
182
|
+
data-testid="pr-review-working"
|
|
183
|
+
class="flex h-full flex-col items-center justify-center gap-2 py-10 text-center text-slate-400"
|
|
184
|
+
>
|
|
185
|
+
<UIcon name="i-lucide-loader-circle" class="h-8 w-8 animate-spin opacity-60" />
|
|
186
|
+
<p class="text-sm">
|
|
187
|
+
{{ status === 'fixing' ? t('prReview.fixing.title') : t('prReview.posting.title') }}
|
|
188
|
+
</p>
|
|
189
|
+
<p class="max-w-sm text-[11px] text-slate-500">
|
|
190
|
+
{{ status === 'fixing' ? t('prReview.fixing.hint') : t('prReview.posting.hint') }}
|
|
191
|
+
</p>
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
<template v-else>
|
|
195
|
+
<p
|
|
196
|
+
v-if="prReview.error"
|
|
197
|
+
class="mb-3 rounded-md bg-rose-500/10 px-3 py-2 text-[12px] text-rose-300"
|
|
198
|
+
>
|
|
199
|
+
{{ prReview.error }}
|
|
200
|
+
</p>
|
|
201
|
+
|
|
202
|
+
<!-- The reviewer's overall assessment. -->
|
|
203
|
+
<p
|
|
204
|
+
v-if="state?.summary"
|
|
205
|
+
class="mb-3 rounded-md bg-slate-800/50 px-3 py-2 text-[12px] text-slate-300"
|
|
206
|
+
>
|
|
207
|
+
<span class="text-slate-500">{{ t('prReview.summaryLabel') }}</span>
|
|
208
|
+
{{ state.summary }}
|
|
209
|
+
</p>
|
|
210
|
+
|
|
211
|
+
<!-- A clean PR / resolved review with no findings. -->
|
|
212
|
+
<div
|
|
213
|
+
v-if="findings.length === 0"
|
|
214
|
+
class="rounded-xl border border-slate-800 bg-slate-900/60 px-4 py-6 text-center text-[13px] text-slate-300"
|
|
215
|
+
>
|
|
216
|
+
{{ t('prReview.noFindings') }}
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
<template v-else>
|
|
220
|
+
<!-- Selection toolbar -->
|
|
221
|
+
<div v-if="awaiting" class="mb-2 flex items-center gap-3 text-[11px] text-slate-400">
|
|
222
|
+
<span data-testid="pr-review-selected-count">
|
|
223
|
+
{{ t('prReview.selectedCount', { count: selected.size }) }}
|
|
224
|
+
</span>
|
|
225
|
+
<button class="text-indigo-300 hover:underline" @click="selectAll">
|
|
226
|
+
{{ t('prReview.selectAll') }}
|
|
227
|
+
</button>
|
|
228
|
+
<button class="text-indigo-300 hover:underline" @click="clearAll">
|
|
229
|
+
{{ t('prReview.clear') }}
|
|
230
|
+
</button>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<!-- Findings grouped by slice -->
|
|
234
|
+
<section v-for="g in groups" :key="g.id" class="mb-4">
|
|
235
|
+
<h3 class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
236
|
+
{{ g.title }}
|
|
237
|
+
</h3>
|
|
238
|
+
<p v-if="g.rationale" class="mb-1.5 text-[11px] text-slate-500">
|
|
239
|
+
{{ g.rationale }}
|
|
240
|
+
</p>
|
|
241
|
+
<article
|
|
242
|
+
v-for="f in g.items"
|
|
243
|
+
:key="f.id"
|
|
244
|
+
data-testid="pr-review-finding"
|
|
245
|
+
class="mb-1.5 rounded-xl border px-3 py-2 transition"
|
|
246
|
+
:class="
|
|
247
|
+
awaiting && selected.has(f.id)
|
|
248
|
+
? 'border-indigo-500/60 bg-indigo-500/5'
|
|
249
|
+
: 'border-slate-800 bg-slate-900/60'
|
|
250
|
+
"
|
|
251
|
+
>
|
|
252
|
+
<div class="flex items-start gap-2">
|
|
253
|
+
<input
|
|
254
|
+
v-if="awaiting"
|
|
255
|
+
type="checkbox"
|
|
256
|
+
class="mt-1 accent-indigo-500"
|
|
257
|
+
data-testid="pr-review-finding-toggle"
|
|
258
|
+
:checked="selected.has(f.id)"
|
|
259
|
+
@change="toggle(f.id)"
|
|
260
|
+
/>
|
|
261
|
+
<div class="min-w-0 flex-1">
|
|
262
|
+
<div class="flex flex-wrap items-center gap-1.5">
|
|
263
|
+
<span
|
|
264
|
+
class="rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase ring-1"
|
|
265
|
+
:class="SEVERITY_CLASS[f.severity]"
|
|
266
|
+
>
|
|
267
|
+
{{ t(`prReview.severity.${f.severity}`) }}
|
|
268
|
+
</span>
|
|
269
|
+
<span class="rounded bg-slate-800 px-1.5 py-0.5 text-[10px] text-slate-300">
|
|
270
|
+
{{ t(`prReview.category.${f.category}`) }}
|
|
271
|
+
</span>
|
|
272
|
+
<h4 class="min-w-0 flex-1 text-[13px] font-medium text-slate-100">
|
|
273
|
+
{{ f.title }}
|
|
274
|
+
</h4>
|
|
275
|
+
</div>
|
|
276
|
+
<p class="mt-0.5 text-[11px] text-slate-500">
|
|
277
|
+
{{ f.path
|
|
278
|
+
}}<template v-if="f.line != null">
|
|
279
|
+
· {{ t('prReview.line', { line: f.line }) }}</template
|
|
280
|
+
>
|
|
281
|
+
</p>
|
|
282
|
+
<p class="mt-1 whitespace-pre-wrap text-[12px] text-slate-300">
|
|
283
|
+
{{ f.detail }}
|
|
284
|
+
</p>
|
|
285
|
+
<p
|
|
286
|
+
v-if="f.suggestedFix"
|
|
287
|
+
class="mt-1 whitespace-pre-wrap rounded-md bg-slate-800/50 px-2 py-1 text-[11px] text-slate-300"
|
|
288
|
+
>
|
|
289
|
+
<span class="text-slate-500">{{ t('prReview.suggestedFix') }}</span>
|
|
290
|
+
{{ f.suggestedFix }}
|
|
291
|
+
</p>
|
|
292
|
+
</div>
|
|
293
|
+
</div>
|
|
294
|
+
</article>
|
|
295
|
+
</section>
|
|
296
|
+
</template>
|
|
297
|
+
</template>
|
|
298
|
+
</div>
|
|
299
|
+
|
|
300
|
+
<!-- Footer -->
|
|
301
|
+
<footer
|
|
302
|
+
v-if="awaiting"
|
|
303
|
+
class="flex items-center justify-end gap-2 border-t border-slate-800 px-5 py-3"
|
|
304
|
+
>
|
|
305
|
+
<UButton
|
|
306
|
+
color="neutral"
|
|
307
|
+
variant="ghost"
|
|
308
|
+
:disabled="!canResolve"
|
|
309
|
+
data-testid="pr-review-finish"
|
|
310
|
+
@click="onResolve('finish')"
|
|
311
|
+
>
|
|
312
|
+
{{ t('prReview.finish') }}
|
|
313
|
+
</UButton>
|
|
314
|
+
<UButton
|
|
315
|
+
color="neutral"
|
|
316
|
+
variant="soft"
|
|
317
|
+
:disabled="!canResolve || !hasSelection"
|
|
318
|
+
data-testid="pr-review-post"
|
|
319
|
+
@click="onResolve('post')"
|
|
320
|
+
>
|
|
321
|
+
{{ t('prReview.post') }}
|
|
322
|
+
</UButton>
|
|
323
|
+
<UButton
|
|
324
|
+
color="primary"
|
|
325
|
+
:loading="prReview.resolving"
|
|
326
|
+
:disabled="!canResolve || !hasSelection"
|
|
327
|
+
data-testid="pr-review-fix"
|
|
328
|
+
@click="onResolve('fix')"
|
|
329
|
+
>
|
|
330
|
+
{{ t('prReview.fix') }}
|
|
331
|
+
</UButton>
|
|
332
|
+
</footer>
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
</Teleport>
|
|
336
|
+
</template>
|
|
@@ -39,6 +39,7 @@ const ROUTABLE = computed<{ type: NotificationType; label: string }[]>(() => [
|
|
|
39
39
|
{ type: 'release_regression', label: t('slack.routable.release_regression') },
|
|
40
40
|
{ type: 'human_test_ready', label: t('slack.routable.human_test_ready') },
|
|
41
41
|
{ type: 'visual_confirmation_ready', label: t('slack.routable.visual_confirmation_ready') },
|
|
42
|
+
{ type: 'pr_review_ready', label: t('slack.routable.pr_review_ready') },
|
|
42
43
|
{ type: 'initiative', label: t('slack.routable.initiative') },
|
|
43
44
|
])
|
|
44
45
|
|
|
@@ -64,6 +65,7 @@ const routes = reactive<Record<NotificationType, SlackRoute>>({
|
|
|
64
65
|
human_review: { enabled: false, channel: '' },
|
|
65
66
|
followup_pending: { enabled: false, channel: '' },
|
|
66
67
|
fork_decision_pending: { enabled: false, channel: '' },
|
|
68
|
+
pr_review_ready: { enabled: false, channel: '' },
|
|
67
69
|
initiative: { enabled: false, channel: '' },
|
|
68
70
|
})
|
|
69
71
|
const mentionsEnabled = ref(false)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { getPrReviewContract, resolvePrReviewContract } from '@cat-factory/contracts'
|
|
2
|
+
import type { ApiContext } from './context'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The PR deep-review phase: the read-only `pr-reviewer` agent slices an open pull request and
|
|
6
|
+
* surfaces prioritized findings on the run's `pr-reviewer` step, then the run parks for a human
|
|
7
|
+
* to SELECT which findings matter. These endpoints read the surfaced findings and record the
|
|
8
|
+
* human's curated selection + resolution. The read returns null when no `pr-reviewer` step
|
|
9
|
+
* carries review state.
|
|
10
|
+
*/
|
|
11
|
+
export function prReviewApi({ send, ws }: ApiContext) {
|
|
12
|
+
return {
|
|
13
|
+
// The live PR-review state for a run (null when no pr-reviewer step carries one).
|
|
14
|
+
getPrReview: (workspaceId: string, executionId: string) =>
|
|
15
|
+
send(getPrReviewContract, { pathPrefix: ws(workspaceId), pathParams: { executionId } }),
|
|
16
|
+
|
|
17
|
+
// Resolve a parked PR review: the curated finding selection + how it was resolved
|
|
18
|
+
// (`finish` completes it, `fix` feeds a Fixer, `post` publishes inline PR comments).
|
|
19
|
+
resolvePrReview: (
|
|
20
|
+
workspaceId: string,
|
|
21
|
+
executionId: string,
|
|
22
|
+
body: { action?: 'finish' | 'fix' | 'post'; findingIds?: string[] },
|
|
23
|
+
) =>
|
|
24
|
+
send(resolvePrReviewContract, {
|
|
25
|
+
pathPrefix: ws(workspaceId),
|
|
26
|
+
pathParams: { executionId },
|
|
27
|
+
body,
|
|
28
|
+
}),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -9,6 +9,7 @@ import { documentsApi } from './api/documents'
|
|
|
9
9
|
import { executionApi } from './api/execution'
|
|
10
10
|
import { followUpsApi } from './api/followUps'
|
|
11
11
|
import { forkDecisionApi } from './api/forkDecision'
|
|
12
|
+
import { prReviewApi } from './api/prReview'
|
|
12
13
|
import { fragmentsApi } from './api/fragments'
|
|
13
14
|
import { githubApi } from './api/github'
|
|
14
15
|
import { humanReviewApi } from './api/humanReview'
|
|
@@ -110,6 +111,7 @@ export function useApi() {
|
|
|
110
111
|
...reviewsApi(ctx),
|
|
111
112
|
...followUpsApi(ctx),
|
|
112
113
|
...forkDecisionApi(ctx),
|
|
114
|
+
...prReviewApi(ctx),
|
|
113
115
|
...humanTestApi(ctx),
|
|
114
116
|
...visualConfirmApi(ctx),
|
|
115
117
|
...humanReviewApi(ctx),
|
package/app/stores/board.ts
CHANGED
|
@@ -42,6 +42,16 @@ export const useBoardStore = defineStore('board', () => {
|
|
|
42
42
|
params ?? {},
|
|
43
43
|
)
|
|
44
44
|
const blocks = ref<Block[]>([])
|
|
45
|
+
// Client-side monotonic guard against a stale full-snapshot `hydrate` CLOBBERING newer live
|
|
46
|
+
// state. A run's status transitions (…→ in_progress → pr_ready/done) reach the board as
|
|
47
|
+
// targeted `execution`-event `upsert`s; a `refresh()` whose snapshot was FETCHED earlier (its
|
|
48
|
+
// block still `in_progress`) can resolve AFTER such an upsert and, since `hydrate` REPLACES the
|
|
49
|
+
// list, overwrite the just-applied terminal status back to the stale value — with no further
|
|
50
|
+
// event to restore it (the documented real-time coherence hazard; reliably hit under CI
|
|
51
|
+
// latency). Blocks carry no server revision, so we stamp each live `upsert` with a monotonic
|
|
52
|
+
// sequence and let `hydrate` preserve any block upserted AFTER the refresh's captured baseline.
|
|
53
|
+
let liveUpsertSeq = 0
|
|
54
|
+
const liveUpsertAt = new Map<string, number>()
|
|
45
55
|
// Archived service frames (`archived === true`): hidden from the board but preserved and
|
|
46
56
|
// restorable with no expiry. Hydrated from the snapshot's `archivedServices`; the frames
|
|
47
57
|
// themselves are NOT in `blocks` (the snapshot filters an archived frame + its subtree out).
|
|
@@ -111,10 +121,22 @@ export const useBoardStore = defineStore('board', () => {
|
|
|
111
121
|
}
|
|
112
122
|
return s
|
|
113
123
|
}
|
|
114
|
-
|
|
124
|
+
/**
|
|
125
|
+
* Baseline for {@link hydrate}: capture this BEFORE a refresh's snapshot fetch and pass it
|
|
126
|
+
* back in, so a block that received a live `upsert` while the fetch was in flight is preserved
|
|
127
|
+
* (its live state is newer than the snapshot). Callers that don't pass a baseline get a plain
|
|
128
|
+
* full replace (initial load / board switch — no live-upsert race to guard).
|
|
129
|
+
*/
|
|
130
|
+
function hydrateBaseline(): number {
|
|
131
|
+
return liveUpsertSeq
|
|
132
|
+
}
|
|
133
|
+
function hydrate(next: Block[], since = liveUpsertSeq) {
|
|
115
134
|
const prev = new Map(blocks.value.map((b) => [b.id, b]))
|
|
116
135
|
const reconciled = next.map((n) => {
|
|
117
136
|
const existing = prev.get(n.id)
|
|
137
|
+
// A block live-`upsert`ed AFTER this refresh's fetch started is newer than the snapshot —
|
|
138
|
+
// keep the live version instead of clobbering it back to the stale snapshot value.
|
|
139
|
+
if (existing && (liveUpsertAt.get(n.id) ?? 0) > since) return existing
|
|
118
140
|
return existing && jsonFor(existing) === jsonFor(n) ? existing : n
|
|
119
141
|
})
|
|
120
142
|
// Keep blocks the user just deleted hidden while their delete is still pending.
|
|
@@ -147,6 +169,8 @@ export const useBoardStore = defineStore('board', () => {
|
|
|
147
169
|
function upsert(block: Block) {
|
|
148
170
|
// A live event for a block awaiting its deferred delete must not resurrect it.
|
|
149
171
|
if (pendingDoomed.has(block.id)) return
|
|
172
|
+
// Stamp the live-upsert order so a later, staler refresh `hydrate` can't clobber this.
|
|
173
|
+
liveUpsertAt.set(block.id, ++liveUpsertSeq)
|
|
150
174
|
const i = blocks.value.findIndex((b) => b.id === block.id)
|
|
151
175
|
if (i >= 0) blocks.value[i] = block
|
|
152
176
|
else blocks.value.push(block)
|
|
@@ -612,6 +636,7 @@ export const useBoardStore = defineStore('board', () => {
|
|
|
612
636
|
blocks,
|
|
613
637
|
archived,
|
|
614
638
|
hydrate,
|
|
639
|
+
hydrateBaseline,
|
|
615
640
|
hydrateArchived,
|
|
616
641
|
upsert,
|
|
617
642
|
...queries,
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import type { PrReviewResolution, PrReviewStepState } from '~/types/execution'
|
|
4
|
+
import { useApi } from '~/composables/useApi'
|
|
5
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
6
|
+
import { useExecutionStore } from '~/stores/execution'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The PR deep-review action surface. The live review state lives on the run's `pr-reviewer`
|
|
10
|
+
* step (`step.prReview`) and is kept fresh by the execution stream, so the window reads it
|
|
11
|
+
* straight off the execution store — this store only wraps the `resolve` action (and a warm-up
|
|
12
|
+
* `load`), tracks the in-flight state so the window can disable its controls, and reflects the
|
|
13
|
+
* returned state back onto the execution store so the UI updates immediately even before the
|
|
14
|
+
* stream echoes the change. Keyed by executionId, mirroring the fork-decision store.
|
|
15
|
+
*/
|
|
16
|
+
export const usePrReviewStore = defineStore('prReview', () => {
|
|
17
|
+
const api = useApi()
|
|
18
|
+
const workspace = useWorkspaceStore()
|
|
19
|
+
const execution = useExecutionStore()
|
|
20
|
+
|
|
21
|
+
/** True while a resolve call is in flight (drives the Finish button spinner / disabled state). */
|
|
22
|
+
const resolving = ref(false)
|
|
23
|
+
/** The last error message from an action, surfaced inline; cleared on the next action. */
|
|
24
|
+
const error = ref<string | null>(null)
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Reflect an authoritative PR-review state onto the run's `pr-reviewer` step. A pipeline could
|
|
28
|
+
* carry more than one such step, so target the step this review is about: prefer the step that
|
|
29
|
+
* is still awaiting a selection, then the current step, and only then the first step carrying
|
|
30
|
+
* review state. The stream corrects any mismatch; this keeps the immediate optimistic echo on
|
|
31
|
+
* the right step.
|
|
32
|
+
*/
|
|
33
|
+
function reflect(executionId: string, state: PrReviewStepState | null): void {
|
|
34
|
+
if (!state) return
|
|
35
|
+
const instance = execution.getInstance(executionId)
|
|
36
|
+
if (!instance) return
|
|
37
|
+
const isLive = (s: (typeof instance.steps)[number]) =>
|
|
38
|
+
s.agentKind === 'pr-reviewer' && s.prReview?.status === 'awaiting_selection'
|
|
39
|
+
const current = instance.steps[instance.currentStep]
|
|
40
|
+
const step =
|
|
41
|
+
instance.steps.find(isLive) ??
|
|
42
|
+
(current?.agentKind === 'pr-reviewer' && current.prReview ? current : undefined) ??
|
|
43
|
+
instance.steps.find((s) => s.prReview)
|
|
44
|
+
if (step) step.prReview = state
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Warm the live state from the GET (the stream also keeps it fresh). Best-effort. */
|
|
48
|
+
async function load(executionId: string): Promise<void> {
|
|
49
|
+
error.value = null
|
|
50
|
+
try {
|
|
51
|
+
const state = await api.getPrReview(workspace.requireId(), executionId)
|
|
52
|
+
reflect(executionId, state as PrReviewStepState | null)
|
|
53
|
+
} catch (e) {
|
|
54
|
+
error.value = e instanceof Error ? e.message : 'Failed to load'
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve the review: record the curated finding selection and act on it. `finish` completes
|
|
60
|
+
* the read-only review; `fix` feeds the selected findings to a Fixer (which commits fixes onto
|
|
61
|
+
* the reviewed PR's branch); `post` publishes them as inline PR review comments. The run then
|
|
62
|
+
* advances (or re-dispatches for `fix`). `fix`/`post` require ≥1 selected finding.
|
|
63
|
+
*/
|
|
64
|
+
async function resolve(
|
|
65
|
+
executionId: string,
|
|
66
|
+
findingIds: string[],
|
|
67
|
+
action: PrReviewResolution = 'finish',
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
error.value = null
|
|
70
|
+
resolving.value = true
|
|
71
|
+
try {
|
|
72
|
+
const state = await api.resolvePrReview(workspace.requireId(), executionId, {
|
|
73
|
+
action,
|
|
74
|
+
findingIds,
|
|
75
|
+
})
|
|
76
|
+
reflect(executionId, state as PrReviewStepState)
|
|
77
|
+
} catch (e) {
|
|
78
|
+
error.value = e instanceof Error ? e.message : 'Failed to resolve review'
|
|
79
|
+
throw e
|
|
80
|
+
} finally {
|
|
81
|
+
resolving.value = false
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { resolving, error, load, resolve }
|
|
86
|
+
})
|
package/app/stores/ui.ts
CHANGED
|
@@ -834,6 +834,31 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
834
834
|
stepIndex: idx,
|
|
835
835
|
}
|
|
836
836
|
}
|
|
837
|
+
// Open the PR deep-review window for a run's `pr-reviewer` step (from the `pr_review_ready`
|
|
838
|
+
// notification / the step). Resolves the step index from the run when not given, preferring
|
|
839
|
+
// the step parked awaiting a finding selection.
|
|
840
|
+
function openPrReview(instanceId: string, stepIndex: number | null = null) {
|
|
841
|
+
const execution = useExecutionStore()
|
|
842
|
+
const instance = execution.getInstance(instanceId)
|
|
843
|
+
if (!instance) return
|
|
844
|
+
const resolveIdx = () => {
|
|
845
|
+
const awaiting = instance.steps.findIndex(
|
|
846
|
+
(s) => s.agentKind === 'pr-reviewer' && s.prReview?.status === 'awaiting_selection',
|
|
847
|
+
)
|
|
848
|
+
if (awaiting >= 0) return awaiting
|
|
849
|
+
const current = instance.steps[instance.currentStep]
|
|
850
|
+
if (current?.agentKind === 'pr-reviewer' && current.prReview) return instance.currentStep
|
|
851
|
+
return instance.steps.findIndex((s) => s.agentKind === 'pr-reviewer' && s.prReview)
|
|
852
|
+
}
|
|
853
|
+
const idx = stepIndex ?? resolveIdx()
|
|
854
|
+
if (idx < 0) return
|
|
855
|
+
resultView.value = {
|
|
856
|
+
view: 'pr-review',
|
|
857
|
+
blockId: instance.blockId,
|
|
858
|
+
instanceId,
|
|
859
|
+
stepIndex: idx,
|
|
860
|
+
}
|
|
861
|
+
}
|
|
837
862
|
function closeResultView() {
|
|
838
863
|
resultView.value = null
|
|
839
864
|
}
|
|
@@ -1032,6 +1057,7 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
1032
1057
|
openInitiativePlanning,
|
|
1033
1058
|
openFollowUps,
|
|
1034
1059
|
openForkDecision,
|
|
1060
|
+
openPrReview,
|
|
1035
1061
|
closeRequirementReview,
|
|
1036
1062
|
openStepDetail,
|
|
1037
1063
|
closeStepDetail,
|