@cat-factory/app 0.116.10 → 0.118.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/components/board/AddTaskModal.vue +63 -4
- package/app/components/layout/NotificationsInbox.vue +14 -0
- package/app/components/panels/StepResultViewHost.vue +4 -0
- package/app/components/prReview/PrReviewWindow.vue +289 -0
- package/app/components/settings/WorkspaceSettingsPanel.vue +1 -0
- package/app/components/slack/SlackPanel.vue +2 -0
- package/app/composables/api/prReview.ts +29 -0
- package/app/composables/useApi.ts +2 -0
- package/app/stores/prReview.ts +80 -0
- package/app/stores/ui.ts +26 -0
- package/app/types/execution.ts +5 -0
- package/i18n/locales/de.json +49 -4
- package/i18n/locales/en.json +48 -3
- package/i18n/locales/es.json +49 -4
- package/i18n/locales/fr.json +49 -4
- package/i18n/locales/he.json +49 -4
- package/i18n/locales/it.json +49 -4
- package/i18n/locales/ja.json +49 -4
- package/i18n/locales/pl.json +49 -4
- package/i18n/locales/tr.json +49 -4
- package/i18n/locales/uk.json +49 -4
- package/package.json +2 -2
|
@@ -76,6 +76,11 @@ const TASK_TYPES = computed<{ value: TaskTypeChoice; label: string; icon: string
|
|
|
76
76
|
{ value: 'bug', label: t('board.addTask.types.bug'), icon: 'i-lucide-bug' },
|
|
77
77
|
{ value: 'document', label: t('board.addTask.types.document'), icon: 'i-lucide-file-text' },
|
|
78
78
|
{ value: 'spike', label: t('board.addTask.types.spike'), icon: 'i-lucide-flask-conical' },
|
|
79
|
+
{
|
|
80
|
+
value: 'review',
|
|
81
|
+
label: t('board.addTask.types.review'),
|
|
82
|
+
icon: 'i-lucide-clipboard-check',
|
|
83
|
+
},
|
|
79
84
|
{ value: 'recurring', label: t('board.addTask.types.recurring'), icon: 'i-lucide-repeat' },
|
|
80
85
|
]
|
|
81
86
|
// A document repository only accepts document/spike tasks (see BoardService.addTask).
|
|
@@ -103,6 +108,25 @@ const docKind = ref<DocKind | ''>('')
|
|
|
103
108
|
const docAudience = ref('')
|
|
104
109
|
const docTargetPath = ref('')
|
|
105
110
|
const docOutlineHints = ref('')
|
|
111
|
+
// Review-task fields: the target PR (entered as a full URL or a bare #number) + optional
|
|
112
|
+
// review focus. The single input is parsed into the contract's `prUrl`/`prNumber` fields.
|
|
113
|
+
const reviewPrRef = ref('')
|
|
114
|
+
const reviewFocus = ref('')
|
|
115
|
+
|
|
116
|
+
// Parse the PR-reference input into the contract fields: a bare positive integer (optionally
|
|
117
|
+
// `#`-prefixed) becomes `prNumber` (a PR on the service's linked repo); anything else is taken
|
|
118
|
+
// as a full URL (`prUrl`). Returns undefined when blank or unparseable — the caller uses that
|
|
119
|
+
// to require a target on a review task.
|
|
120
|
+
function parseReviewPrRef(raw: string): Pick<TaskTypeFields, 'prUrl' | 'prNumber'> | undefined {
|
|
121
|
+
const trimmed = raw.trim()
|
|
122
|
+
if (!trimmed) return undefined
|
|
123
|
+
const bareNumber = /^#?(\d+)$/.exec(trimmed)
|
|
124
|
+
if (bareNumber) {
|
|
125
|
+
const n = Number(bareNumber[1])
|
|
126
|
+
return Number.isSafeInteger(n) && n >= 1 ? { prNumber: n } : undefined
|
|
127
|
+
}
|
|
128
|
+
return { prUrl: trimmed }
|
|
129
|
+
}
|
|
106
130
|
// Per-kind specific fields (see DOC_KIND_FIELDS). Held in one keyed record; only the fields
|
|
107
131
|
// for the selected kind are shown and submitted, so a value from a previously-selected kind is
|
|
108
132
|
// never sent. The catalog keys below keep the labels/placeholders i18n and drift-guarded.
|
|
@@ -168,6 +192,11 @@ function buildTypeFields(): TaskTypeFields | undefined {
|
|
|
168
192
|
}
|
|
169
193
|
return Object.keys(f).length ? f : undefined
|
|
170
194
|
}
|
|
195
|
+
if (taskType.value === 'review') {
|
|
196
|
+
const f: TaskTypeFields = { ...parseReviewPrRef(reviewPrRef.value) }
|
|
197
|
+
if (reviewFocus.value.trim()) f.reviewFocus = reviewFocus.value.trim()
|
|
198
|
+
return Object.keys(f).length ? f : undefined
|
|
199
|
+
}
|
|
171
200
|
return undefined
|
|
172
201
|
}
|
|
173
202
|
|
|
@@ -381,6 +410,8 @@ watch(open, (isOpen) => {
|
|
|
381
410
|
docAudience.value = ''
|
|
382
411
|
docTargetPath.value = ''
|
|
383
412
|
docOutlineHints.value = ''
|
|
413
|
+
reviewPrRef.value = ''
|
|
414
|
+
reviewFocus.value = ''
|
|
384
415
|
for (const key of Object.keys(docKindFieldValues) as DocKindFieldKey[])
|
|
385
416
|
delete docKindFieldValues[key]
|
|
386
417
|
riskPolicyId.value = ''
|
|
@@ -434,10 +465,13 @@ const { requestClose } = useUnsavedGuard({
|
|
|
434
465
|
})
|
|
435
466
|
|
|
436
467
|
// A recurring task only needs a target frame (its details are filled in the schedule
|
|
437
|
-
// modal); every other type needs a title.
|
|
438
|
-
const canAdd = computed(() =>
|
|
439
|
-
isRecurring.value
|
|
440
|
-
)
|
|
468
|
+
// modal); every other type needs a title. A review task additionally needs a target PR.
|
|
469
|
+
const canAdd = computed(() => {
|
|
470
|
+
if (isRecurring.value) return recurringFrameId.value !== null
|
|
471
|
+
if (title.value.trim().length === 0) return false
|
|
472
|
+
if (taskType.value === 'review' && !parseReviewPrRef(reviewPrRef.value)) return false
|
|
473
|
+
return true
|
|
474
|
+
})
|
|
441
475
|
|
|
442
476
|
async function add() {
|
|
443
477
|
const containerId = ui.addTaskContainerId
|
|
@@ -720,6 +754,31 @@ async function add() {
|
|
|
720
754
|
</UFormField>
|
|
721
755
|
</div>
|
|
722
756
|
|
|
757
|
+
<div v-else-if="taskType === 'review'" class="space-y-3">
|
|
758
|
+
<UFormField
|
|
759
|
+
:label="t('board.addTask.review.prUrl')"
|
|
760
|
+
:hint="t('board.addTask.review.prUrlHint')"
|
|
761
|
+
required
|
|
762
|
+
>
|
|
763
|
+
<UInput
|
|
764
|
+
v-model="reviewPrRef"
|
|
765
|
+
placeholder="https://github.com/owner/repo/pull/123"
|
|
766
|
+
class="w-full"
|
|
767
|
+
/>
|
|
768
|
+
</UFormField>
|
|
769
|
+
<UFormField
|
|
770
|
+
:label="t('board.addTask.review.focus')"
|
|
771
|
+
:hint="t('board.addTask.optional')"
|
|
772
|
+
>
|
|
773
|
+
<UTextarea
|
|
774
|
+
v-model="reviewFocus"
|
|
775
|
+
:rows="2"
|
|
776
|
+
:placeholder="t('board.addTask.review.focusPlaceholder')"
|
|
777
|
+
class="w-full"
|
|
778
|
+
/>
|
|
779
|
+
</UFormField>
|
|
780
|
+
</div>
|
|
781
|
+
|
|
723
782
|
<div class="grid grid-cols-2 gap-3">
|
|
724
783
|
<UFormField :label="t('board.addTask.pipeline')">
|
|
725
784
|
<UDropdownMenu :items="pipelineMenu" class="w-full">
|
|
@@ -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,289 @@
|
|
|
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 finish the review. The Fixer / inline-comment resolutions are the
|
|
7
|
+
// tracked PR 3 follow-up; this window's `Finish review` records the curated selection.
|
|
8
|
+
import { computed, ref, watch } from 'vue'
|
|
9
|
+
import { useResultView } from '~/composables/useResultView'
|
|
10
|
+
import { useExecutionStore } from '~/stores/execution'
|
|
11
|
+
import { useBoardStore } from '~/stores/board'
|
|
12
|
+
import { usePrReviewStore } from '~/stores/prReview'
|
|
13
|
+
import type { PrReviewFinding, PrReviewSeverity, PrReviewStepState } from '~/types/execution'
|
|
14
|
+
|
|
15
|
+
const execution = useExecutionStore()
|
|
16
|
+
const board = useBoardStore()
|
|
17
|
+
const prReview = usePrReviewStore()
|
|
18
|
+
|
|
19
|
+
const { t } = useI18n()
|
|
20
|
+
|
|
21
|
+
const { open, blockId, instanceId, stepIndex, close } = useResultView('pr-review', {
|
|
22
|
+
onOpen: (_id) => {
|
|
23
|
+
if (instanceId.value) void prReview.load(instanceId.value)
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
|
|
28
|
+
const instance = computed(() =>
|
|
29
|
+
instanceId.value === null ? null : (execution.getInstance(instanceId.value) ?? null),
|
|
30
|
+
)
|
|
31
|
+
const step = computed(() => {
|
|
32
|
+
if (instance.value === null || stepIndex.value === null) return null
|
|
33
|
+
return instance.value.steps[stepIndex.value] ?? null
|
|
34
|
+
})
|
|
35
|
+
const state = computed<PrReviewStepState | null>(() => step.value?.prReview ?? null)
|
|
36
|
+
const status = computed(() => state.value?.status ?? null)
|
|
37
|
+
const awaiting = computed(() => status.value === 'awaiting_selection')
|
|
38
|
+
const findings = computed<PrReviewFinding[]>(() => state.value?.findings ?? [])
|
|
39
|
+
|
|
40
|
+
/** Severity → chip classes (styling, not copy). */
|
|
41
|
+
const SEVERITY_CLASS: Record<PrReviewSeverity, string> = {
|
|
42
|
+
blocker: 'bg-rose-500/15 text-rose-300 ring-rose-500/30',
|
|
43
|
+
high: 'bg-orange-500/15 text-orange-300 ring-orange-500/30',
|
|
44
|
+
medium: 'bg-amber-500/15 text-amber-300 ring-amber-500/30',
|
|
45
|
+
low: 'bg-sky-500/15 text-sky-300 ring-sky-500/30',
|
|
46
|
+
nit: 'bg-slate-500/15 text-slate-300 ring-slate-500/30',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Findings grouped under their slice (in the review's slice order), plus an "Other" bucket. */
|
|
50
|
+
const groups = computed(() => {
|
|
51
|
+
const slices = state.value?.slices ?? []
|
|
52
|
+
const byId = new Map<string, PrReviewFinding[]>()
|
|
53
|
+
const unsliced: PrReviewFinding[] = []
|
|
54
|
+
for (const f of findings.value) {
|
|
55
|
+
if (f.sliceId && slices.some((s) => s.id === f.sliceId)) {
|
|
56
|
+
const arr = byId.get(f.sliceId) ?? []
|
|
57
|
+
arr.push(f)
|
|
58
|
+
byId.set(f.sliceId, arr)
|
|
59
|
+
} else {
|
|
60
|
+
unsliced.push(f)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const out = slices
|
|
64
|
+
.map((s) => ({ id: s.id, title: s.title, rationale: s.rationale, items: byId.get(s.id) ?? [] }))
|
|
65
|
+
.filter((g) => g.items.length > 0)
|
|
66
|
+
if (unsliced.length > 0) {
|
|
67
|
+
out.push({ id: '__unsliced', title: t('prReview.unsliced'), rationale: '', items: unsliced })
|
|
68
|
+
}
|
|
69
|
+
return out
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// The human's selection — a set of finding ids. Defaults to every finding (the human deselects
|
|
73
|
+
// the noise), so "Finish" without touching anything keeps all. Re-seeded ONLY when the set of
|
|
74
|
+
// finding IDS actually changes — NOT on every execution-stream re-emit (which hands us a fresh
|
|
75
|
+
// `findings` array reference on each reconnect/resync). Keying on the id set keeps the human's
|
|
76
|
+
// in-progress curation from being silently reset by an unrelated live update.
|
|
77
|
+
const findingIdKey = computed(() => findings.value.map((f) => f.id).join('\n'))
|
|
78
|
+
const selected = ref<Set<string>>(new Set())
|
|
79
|
+
watch(
|
|
80
|
+
findingIdKey,
|
|
81
|
+
() => {
|
|
82
|
+
selected.value = new Set(findings.value.map((f) => f.id))
|
|
83
|
+
},
|
|
84
|
+
{ immediate: true },
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
function toggle(id: string): void {
|
|
88
|
+
const next = new Set(selected.value)
|
|
89
|
+
if (next.has(id)) next.delete(id)
|
|
90
|
+
else next.add(id)
|
|
91
|
+
selected.value = next
|
|
92
|
+
}
|
|
93
|
+
function selectAll(): void {
|
|
94
|
+
selected.value = new Set(findings.value.map((f) => f.id))
|
|
95
|
+
}
|
|
96
|
+
function clearAll(): void {
|
|
97
|
+
selected.value = new Set()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const canFinish = computed(() => awaiting.value && !prReview.resolving)
|
|
101
|
+
|
|
102
|
+
async function onFinish(): Promise<void> {
|
|
103
|
+
const id = instanceId.value
|
|
104
|
+
if (!id || !canFinish.value) return
|
|
105
|
+
await prReview.resolve(id, [...selected.value]).catch(() => {})
|
|
106
|
+
}
|
|
107
|
+
</script>
|
|
108
|
+
|
|
109
|
+
<template>
|
|
110
|
+
<Teleport to="body">
|
|
111
|
+
<div
|
|
112
|
+
v-if="open"
|
|
113
|
+
data-testid="pr-review-window"
|
|
114
|
+
class="fixed inset-0 z-50 flex max-h-[100dvh] items-stretch justify-center bg-slate-950/70 backdrop-blur-sm"
|
|
115
|
+
@click.self="close"
|
|
116
|
+
>
|
|
117
|
+
<div
|
|
118
|
+
class="m-4 flex w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
|
|
119
|
+
role="dialog"
|
|
120
|
+
aria-modal="true"
|
|
121
|
+
>
|
|
122
|
+
<!-- Header -->
|
|
123
|
+
<header class="flex items-center gap-3 border-b border-slate-800 px-5 py-3">
|
|
124
|
+
<span
|
|
125
|
+
class="flex h-8 w-8 items-center justify-center rounded-lg bg-indigo-500/15 text-indigo-300"
|
|
126
|
+
>
|
|
127
|
+
<UIcon name="i-lucide-clipboard-check" class="h-4 w-4" />
|
|
128
|
+
</span>
|
|
129
|
+
<div class="min-w-0 flex-1">
|
|
130
|
+
<h2 class="truncate text-sm font-semibold text-slate-100">
|
|
131
|
+
{{
|
|
132
|
+
block ? t('prReview.titleWithBlock', { title: block.title }) : t('prReview.title')
|
|
133
|
+
}}
|
|
134
|
+
</h2>
|
|
135
|
+
<p class="truncate text-[11px] text-slate-400">{{ t('prReview.subtitle') }}</p>
|
|
136
|
+
</div>
|
|
137
|
+
<a
|
|
138
|
+
v-if="state?.prUrl"
|
|
139
|
+
:href="state.prUrl"
|
|
140
|
+
target="_blank"
|
|
141
|
+
rel="noopener"
|
|
142
|
+
class="rounded-md px-2 py-1 text-[11px] text-indigo-300 hover:bg-slate-800"
|
|
143
|
+
>
|
|
144
|
+
{{ t('prReview.openPr') }}
|
|
145
|
+
</a>
|
|
146
|
+
<button
|
|
147
|
+
class="rounded-md p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
|
|
148
|
+
@click="close"
|
|
149
|
+
>
|
|
150
|
+
<UIcon name="i-lucide-x" class="h-4 w-4" />
|
|
151
|
+
</button>
|
|
152
|
+
</header>
|
|
153
|
+
|
|
154
|
+
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
|
155
|
+
<!-- Reviewing: the read-only reviewer is still working. -->
|
|
156
|
+
<div
|
|
157
|
+
v-if="status === 'reviewing'"
|
|
158
|
+
class="flex h-full flex-col items-center justify-center gap-2 py-10 text-center text-slate-400"
|
|
159
|
+
>
|
|
160
|
+
<UIcon name="i-lucide-loader-circle" class="h-8 w-8 animate-spin opacity-60" />
|
|
161
|
+
<p class="text-sm">{{ t('prReview.reviewing.title') }}</p>
|
|
162
|
+
<p class="max-w-sm text-[11px] text-slate-500">{{ t('prReview.reviewing.hint') }}</p>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<template v-else>
|
|
166
|
+
<p
|
|
167
|
+
v-if="prReview.error"
|
|
168
|
+
class="mb-3 rounded-md bg-rose-500/10 px-3 py-2 text-[12px] text-rose-300"
|
|
169
|
+
>
|
|
170
|
+
{{ prReview.error }}
|
|
171
|
+
</p>
|
|
172
|
+
|
|
173
|
+
<!-- The reviewer's overall assessment. -->
|
|
174
|
+
<p
|
|
175
|
+
v-if="state?.summary"
|
|
176
|
+
class="mb-3 rounded-md bg-slate-800/50 px-3 py-2 text-[12px] text-slate-300"
|
|
177
|
+
>
|
|
178
|
+
<span class="text-slate-500">{{ t('prReview.summaryLabel') }}</span>
|
|
179
|
+
{{ state.summary }}
|
|
180
|
+
</p>
|
|
181
|
+
|
|
182
|
+
<!-- A clean PR / resolved review with no findings. -->
|
|
183
|
+
<div
|
|
184
|
+
v-if="findings.length === 0"
|
|
185
|
+
class="rounded-xl border border-slate-800 bg-slate-900/60 px-4 py-6 text-center text-[13px] text-slate-300"
|
|
186
|
+
>
|
|
187
|
+
{{ t('prReview.noFindings') }}
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
<template v-else>
|
|
191
|
+
<!-- Selection toolbar -->
|
|
192
|
+
<div v-if="awaiting" class="mb-2 flex items-center gap-3 text-[11px] text-slate-400">
|
|
193
|
+
<span data-testid="pr-review-selected-count">
|
|
194
|
+
{{ t('prReview.selectedCount', { count: selected.size }) }}
|
|
195
|
+
</span>
|
|
196
|
+
<button class="text-indigo-300 hover:underline" @click="selectAll">
|
|
197
|
+
{{ t('prReview.selectAll') }}
|
|
198
|
+
</button>
|
|
199
|
+
<button class="text-indigo-300 hover:underline" @click="clearAll">
|
|
200
|
+
{{ t('prReview.clear') }}
|
|
201
|
+
</button>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
<!-- Findings grouped by slice -->
|
|
205
|
+
<section v-for="g in groups" :key="g.id" class="mb-4">
|
|
206
|
+
<h3 class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
207
|
+
{{ g.title }}
|
|
208
|
+
</h3>
|
|
209
|
+
<p v-if="g.rationale" class="mb-1.5 text-[11px] text-slate-500">
|
|
210
|
+
{{ g.rationale }}
|
|
211
|
+
</p>
|
|
212
|
+
<article
|
|
213
|
+
v-for="f in g.items"
|
|
214
|
+
:key="f.id"
|
|
215
|
+
data-testid="pr-review-finding"
|
|
216
|
+
class="mb-1.5 rounded-xl border px-3 py-2 transition"
|
|
217
|
+
:class="
|
|
218
|
+
awaiting && selected.has(f.id)
|
|
219
|
+
? 'border-indigo-500/60 bg-indigo-500/5'
|
|
220
|
+
: 'border-slate-800 bg-slate-900/60'
|
|
221
|
+
"
|
|
222
|
+
>
|
|
223
|
+
<div class="flex items-start gap-2">
|
|
224
|
+
<input
|
|
225
|
+
v-if="awaiting"
|
|
226
|
+
type="checkbox"
|
|
227
|
+
class="mt-1 accent-indigo-500"
|
|
228
|
+
data-testid="pr-review-finding-toggle"
|
|
229
|
+
:checked="selected.has(f.id)"
|
|
230
|
+
@change="toggle(f.id)"
|
|
231
|
+
/>
|
|
232
|
+
<div class="min-w-0 flex-1">
|
|
233
|
+
<div class="flex flex-wrap items-center gap-1.5">
|
|
234
|
+
<span
|
|
235
|
+
class="rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase ring-1"
|
|
236
|
+
:class="SEVERITY_CLASS[f.severity]"
|
|
237
|
+
>
|
|
238
|
+
{{ t(`prReview.severity.${f.severity}`) }}
|
|
239
|
+
</span>
|
|
240
|
+
<span class="rounded bg-slate-800 px-1.5 py-0.5 text-[10px] text-slate-300">
|
|
241
|
+
{{ t(`prReview.category.${f.category}`) }}
|
|
242
|
+
</span>
|
|
243
|
+
<h4 class="min-w-0 flex-1 text-[13px] font-medium text-slate-100">
|
|
244
|
+
{{ f.title }}
|
|
245
|
+
</h4>
|
|
246
|
+
</div>
|
|
247
|
+
<p class="mt-0.5 text-[11px] text-slate-500">
|
|
248
|
+
{{ f.path
|
|
249
|
+
}}<template v-if="f.line != null">
|
|
250
|
+
· {{ t('prReview.line', { line: f.line }) }}</template
|
|
251
|
+
>
|
|
252
|
+
</p>
|
|
253
|
+
<p class="mt-1 whitespace-pre-wrap text-[12px] text-slate-300">
|
|
254
|
+
{{ f.detail }}
|
|
255
|
+
</p>
|
|
256
|
+
<p
|
|
257
|
+
v-if="f.suggestedFix"
|
|
258
|
+
class="mt-1 whitespace-pre-wrap rounded-md bg-slate-800/50 px-2 py-1 text-[11px] text-slate-300"
|
|
259
|
+
>
|
|
260
|
+
<span class="text-slate-500">{{ t('prReview.suggestedFix') }}</span>
|
|
261
|
+
{{ f.suggestedFix }}
|
|
262
|
+
</p>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
</article>
|
|
266
|
+
</section>
|
|
267
|
+
</template>
|
|
268
|
+
</template>
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
<!-- Footer -->
|
|
272
|
+
<footer
|
|
273
|
+
v-if="awaiting"
|
|
274
|
+
class="flex items-center justify-end gap-2 border-t border-slate-800 px-5 py-3"
|
|
275
|
+
>
|
|
276
|
+
<UButton
|
|
277
|
+
color="primary"
|
|
278
|
+
:loading="prReview.resolving"
|
|
279
|
+
:disabled="!canFinish"
|
|
280
|
+
data-testid="pr-review-finish"
|
|
281
|
+
@click="onFinish"
|
|
282
|
+
>
|
|
283
|
+
{{ t('prReview.finish') }}
|
|
284
|
+
</UButton>
|
|
285
|
+
</footer>
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
</Teleport>
|
|
289
|
+
</template>
|
|
@@ -98,6 +98,7 @@ const TASK_TYPE_KEYS: Record<CreateTaskType, string> = {
|
|
|
98
98
|
bug: 'settings.workspaceSettings.taskTypes.bug',
|
|
99
99
|
document: 'settings.workspaceSettings.taskTypes.document',
|
|
100
100
|
spike: 'settings.workspaceSettings.taskTypes.spike',
|
|
101
|
+
review: 'settings.workspaceSettings.taskTypes.review',
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
const MODES = computed<{ value: TaskLimitMode; label: string }[]>(() => [
|
|
@@ -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,29 @@
|
|
|
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
|
+
resolvePrReview: (
|
|
19
|
+
workspaceId: string,
|
|
20
|
+
executionId: string,
|
|
21
|
+
body: { action?: 'finish'; findingIds?: string[] },
|
|
22
|
+
) =>
|
|
23
|
+
send(resolvePrReviewContract, {
|
|
24
|
+
pathPrefix: ws(workspaceId),
|
|
25
|
+
pathParams: { executionId },
|
|
26
|
+
body,
|
|
27
|
+
}),
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -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),
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import type { 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 complete the read-only review
|
|
60
|
+
* (the run then advances to done). PR 2 supports only the `finish` action.
|
|
61
|
+
*/
|
|
62
|
+
async function resolve(executionId: string, findingIds: string[]): Promise<void> {
|
|
63
|
+
error.value = null
|
|
64
|
+
resolving.value = true
|
|
65
|
+
try {
|
|
66
|
+
const state = await api.resolvePrReview(workspace.requireId(), executionId, {
|
|
67
|
+
action: 'finish',
|
|
68
|
+
findingIds,
|
|
69
|
+
})
|
|
70
|
+
reflect(executionId, state as PrReviewStepState)
|
|
71
|
+
} catch (e) {
|
|
72
|
+
error.value = e instanceof Error ? e.message : 'Failed to resolve review'
|
|
73
|
+
throw e
|
|
74
|
+
} finally {
|
|
75
|
+
resolving.value = false
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { resolving, error, load, resolve }
|
|
80
|
+
})
|
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,
|
package/app/types/execution.ts
CHANGED