@cat-factory/app 0.304.0 → 0.306.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/guidedReview/GuidedReview.logic.spec.ts +179 -0
- package/app/components/guidedReview/GuidedReview.logic.ts +146 -0
- package/app/components/guidedReview/GuidedReviewDraftCard.vue +145 -0
- package/app/components/guidedReview/GuidedReviewFailure.vue +37 -0
- package/app/components/guidedReview/GuidedReviewModal.vue +243 -0
- package/app/components/guidedReview/GuidedReviewOverview.vue +160 -0
- package/app/components/guidedReview/GuidedReviewPicker.vue +95 -0
- package/app/components/guidedReview/GuidedReviewThread.vue +254 -0
- package/app/components/panels/inspector/TaskReviewTarget.vue +36 -0
- package/app/composables/api/guidedReview.ts +27 -0
- package/app/composables/useNavContributions.ts +1 -0
- package/app/composables/usePipelineErrorToast.ts +8 -0
- package/app/modular/nav-contributions.ts +20 -0
- package/app/pages/index.vue +4 -0
- package/app/stores/guidedReview.ts +28 -0
- package/app/stores/ui/modals.ts +25 -1
- package/app/types/guided-review.ts +5 -0
- package/i18n/locales/de.json +105 -2
- package/i18n/locales/en.json +105 -2
- package/i18n/locales/es.json +105 -2
- package/i18n/locales/fr.json +105 -2
- package/i18n/locales/he.json +105 -2
- package/i18n/locales/it.json +105 -2
- package/i18n/locales/ja.json +105 -2
- package/i18n/locales/pl.json +105 -2
- package/i18n/locales/tr.json +105 -2
- package/i18n/locales/uk.json +105 -2
- package/package.json +2 -2
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Guided PR review: the overview of a pull request beside tabbed exploration threads. Opened on a
|
|
3
|
+
// session, on a pull request (opening or reusing the caller's session for it), or on neither, in
|
|
4
|
+
// which case it starts with a picker. Live updates arrive through the `guidedReview` event, which
|
|
5
|
+
// the store follows; this window only loads what it shows.
|
|
6
|
+
import type { OpenGuidedReviewInput } from '~/types/domain'
|
|
7
|
+
import GuidedReviewOverview from '~/components/guidedReview/GuidedReviewOverview.vue'
|
|
8
|
+
import GuidedReviewThread from '~/components/guidedReview/GuidedReviewThread.vue'
|
|
9
|
+
import GuidedReviewPicker from '~/components/guidedReview/GuidedReviewPicker.vue'
|
|
10
|
+
import {
|
|
11
|
+
NEW_THREAD_TAB,
|
|
12
|
+
isStale,
|
|
13
|
+
suggestedQuestionThread,
|
|
14
|
+
threadTabs,
|
|
15
|
+
} from '~/components/guidedReview/GuidedReview.logic'
|
|
16
|
+
|
|
17
|
+
const { t } = useI18n()
|
|
18
|
+
const ui = useUiStore()
|
|
19
|
+
const store = useGuidedReviewStore()
|
|
20
|
+
const github = useGitHubStore()
|
|
21
|
+
const { present } = usePipelineErrorToast()
|
|
22
|
+
|
|
23
|
+
const open = computed({
|
|
24
|
+
get: () => ui.guidedReview !== null,
|
|
25
|
+
set: (value: boolean) => {
|
|
26
|
+
if (!value) ui.closeGuidedReview()
|
|
27
|
+
},
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const sessionId = ref<string | null>(null)
|
|
31
|
+
const opening = ref(false)
|
|
32
|
+
const refreshing = ref(false)
|
|
33
|
+
const asking = ref(false)
|
|
34
|
+
const loadingRepos = ref(false)
|
|
35
|
+
const activeTab = ref<string>(NEW_THREAD_TAB)
|
|
36
|
+
const draftOpen = ref(true)
|
|
37
|
+
|
|
38
|
+
const view = computed(() => (sessionId.value ? store.sessions[sessionId.value] : undefined))
|
|
39
|
+
const session = computed(() => view.value?.session)
|
|
40
|
+
const tabs = computed(() => threadTabs(view.value, draftOpen.value))
|
|
41
|
+
const outOfDate = computed(
|
|
42
|
+
() => !!sessionId.value && store.refetchFailures[sessionId.value] !== undefined,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
const latestHeadSha = computed(() => {
|
|
46
|
+
const s = session.value
|
|
47
|
+
if (!s) return null
|
|
48
|
+
const repo = github.repos.find((r) => String(r.githubId) === s.repoId)
|
|
49
|
+
const pull = repo ? github.pullsForRepo(repo.githubId).find((p) => p.number === s.prNumber) : null
|
|
50
|
+
return pull?.headSha ?? null
|
|
51
|
+
})
|
|
52
|
+
const stale = computed(
|
|
53
|
+
() => !!session.value && isStale(session.value.reviewedHeadSha, latestHeadSha.value),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
onModalOpen(open, () => {
|
|
57
|
+
const target = ui.guidedReview
|
|
58
|
+
sessionId.value = null
|
|
59
|
+
draftOpen.value = true
|
|
60
|
+
activeTab.value = NEW_THREAD_TAB
|
|
61
|
+
void loadRepos()
|
|
62
|
+
if (target?.sessionId) void show(target.sessionId)
|
|
63
|
+
else if (target?.target) void openFor(target.target)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
async function loadRepos(): Promise<void> {
|
|
67
|
+
loadingRepos.value = true
|
|
68
|
+
try {
|
|
69
|
+
await github.ensureLoaded()
|
|
70
|
+
} catch (error) {
|
|
71
|
+
present(error, 'guidedReview.errors.repos')
|
|
72
|
+
} finally {
|
|
73
|
+
loadingRepos.value = false
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function show(id: string): Promise<void> {
|
|
78
|
+
sessionId.value = id
|
|
79
|
+
try {
|
|
80
|
+
await store.loadSession(id)
|
|
81
|
+
const first = store.sessions[id]?.threads[0]
|
|
82
|
+
if (first) {
|
|
83
|
+
draftOpen.value = false
|
|
84
|
+
activeTab.value = first.id
|
|
85
|
+
}
|
|
86
|
+
} catch (error) {
|
|
87
|
+
// Back to the picker: with no session to show, the loading state would never end.
|
|
88
|
+
sessionId.value = null
|
|
89
|
+
present(error, 'guidedReview.errors.load')
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function openFor(target: OpenGuidedReviewInput): Promise<void> {
|
|
94
|
+
opening.value = true
|
|
95
|
+
try {
|
|
96
|
+
const opened = await store.open(target)
|
|
97
|
+
await show(opened.session.id)
|
|
98
|
+
} catch (error) {
|
|
99
|
+
present(error, 'guidedReview.errors.open')
|
|
100
|
+
} finally {
|
|
101
|
+
opening.value = false
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
watch(activeTab, (tab) => {
|
|
106
|
+
if (sessionId.value && tab !== NEW_THREAD_TAB && !store.threads[tab]) {
|
|
107
|
+
void store
|
|
108
|
+
.loadThread(sessionId.value, tab)
|
|
109
|
+
.catch((error) => present(error, 'guidedReview.errors.load'))
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
function threadTitle(id: string): string {
|
|
114
|
+
if (id === NEW_THREAD_TAB) return t('guidedReview.thread.new')
|
|
115
|
+
return view.value?.threads.find((th) => th.id === id)?.title || t('guidedReview.thread.untitled')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function threadBusy(id: string): boolean {
|
|
119
|
+
return !!view.value?.threads.find((th) => th.id === id)?.pendingMessageId
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function newThread(): void {
|
|
123
|
+
draftOpen.value = true
|
|
124
|
+
activeTab.value = NEW_THREAD_TAB
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function onCreated(threadId: string): void {
|
|
128
|
+
draftOpen.value = false
|
|
129
|
+
activeTab.value = threadId
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function askSuggested(question: string): Promise<void> {
|
|
133
|
+
if (!sessionId.value || asking.value) return
|
|
134
|
+
asking.value = true
|
|
135
|
+
try {
|
|
136
|
+
const created = await store.openThread(sessionId.value, suggestedQuestionThread(question))
|
|
137
|
+
onCreated(created.thread.id)
|
|
138
|
+
} catch (error) {
|
|
139
|
+
present(error, 'guidedReview.errors.ask')
|
|
140
|
+
} finally {
|
|
141
|
+
asking.value = false
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function refresh(): Promise<void> {
|
|
146
|
+
if (!sessionId.value) return
|
|
147
|
+
refreshing.value = true
|
|
148
|
+
try {
|
|
149
|
+
await store.refresh(sessionId.value)
|
|
150
|
+
} catch (error) {
|
|
151
|
+
present(error, 'guidedReview.errors.refresh')
|
|
152
|
+
} finally {
|
|
153
|
+
refreshing.value = false
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const title = computed(() =>
|
|
158
|
+
session.value
|
|
159
|
+
? t('guidedReview.titleFor', {
|
|
160
|
+
repo: `${session.value.owner}/${session.value.repo}`,
|
|
161
|
+
number: session.value.prNumber,
|
|
162
|
+
title: session.value.prTitle,
|
|
163
|
+
})
|
|
164
|
+
: t('guidedReview.title'),
|
|
165
|
+
)
|
|
166
|
+
</script>
|
|
167
|
+
|
|
168
|
+
<template>
|
|
169
|
+
<UModal v-model:open="open" fullscreen :title="title" data-testid="guided-review-modal">
|
|
170
|
+
<template #body>
|
|
171
|
+
<GuidedReviewPicker
|
|
172
|
+
v-if="!sessionId"
|
|
173
|
+
:opening="opening"
|
|
174
|
+
:loading="loadingRepos"
|
|
175
|
+
@open="openFor"
|
|
176
|
+
/>
|
|
177
|
+
|
|
178
|
+
<p v-else-if="!session" class="flex items-center gap-2 text-sm text-muted">
|
|
179
|
+
<UIcon name="i-lucide-loader-circle" class="h-4 w-4 animate-spin" />
|
|
180
|
+
{{ t('guidedReview.loading') }}
|
|
181
|
+
</p>
|
|
182
|
+
|
|
183
|
+
<div v-else class="flex h-full min-h-0 flex-col gap-4 lg:flex-row">
|
|
184
|
+
<div class="min-h-0 space-y-3 overflow-y-auto lg:w-2/5 lg:pr-2">
|
|
185
|
+
<p
|
|
186
|
+
v-if="outOfDate"
|
|
187
|
+
class="flex items-center gap-2 text-xs text-warning"
|
|
188
|
+
data-testid="guided-review-out-of-date"
|
|
189
|
+
>
|
|
190
|
+
<UIcon name="i-lucide-triangle-alert" class="h-4 w-4 shrink-0" />
|
|
191
|
+
{{ t('guidedReview.outOfDate') }}
|
|
192
|
+
</p>
|
|
193
|
+
<GuidedReviewOverview
|
|
194
|
+
:session="session"
|
|
195
|
+
:stale="stale"
|
|
196
|
+
:refreshing="refreshing"
|
|
197
|
+
:asking="asking"
|
|
198
|
+
@ask="askSuggested"
|
|
199
|
+
@refresh="refresh"
|
|
200
|
+
/>
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
<div class="flex min-h-[24rem] flex-1 flex-col gap-3 lg:min-h-0">
|
|
204
|
+
<div class="flex flex-wrap items-center gap-1" role="tablist">
|
|
205
|
+
<UButton
|
|
206
|
+
v-for="tab in tabs"
|
|
207
|
+
:key="tab"
|
|
208
|
+
role="tab"
|
|
209
|
+
size="sm"
|
|
210
|
+
:aria-selected="tab === activeTab"
|
|
211
|
+
:variant="tab === activeTab ? 'soft' : 'ghost'"
|
|
212
|
+
:color="tab === activeTab ? 'primary' : 'neutral'"
|
|
213
|
+
:icon="threadBusy(tab) ? 'i-lucide-loader-circle' : undefined"
|
|
214
|
+
:ui="{ leadingIcon: threadBusy(tab) ? 'animate-spin' : '' }"
|
|
215
|
+
:data-testid="`guided-review-tab-${tab}`"
|
|
216
|
+
@click="activeTab = tab"
|
|
217
|
+
>
|
|
218
|
+
<span class="max-w-48 truncate">{{ threadTitle(tab) }}</span>
|
|
219
|
+
</UButton>
|
|
220
|
+
<UButton
|
|
221
|
+
v-if="!draftOpen"
|
|
222
|
+
size="sm"
|
|
223
|
+
variant="ghost"
|
|
224
|
+
icon="i-lucide-plus"
|
|
225
|
+
:aria-label="t('guidedReview.thread.new')"
|
|
226
|
+
data-testid="guided-review-new-thread"
|
|
227
|
+
@click="newThread"
|
|
228
|
+
/>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
<GuidedReviewThread
|
|
232
|
+
:key="activeTab"
|
|
233
|
+
class="min-h-0 flex-1"
|
|
234
|
+
:session-id="session.id"
|
|
235
|
+
:view="activeTab === NEW_THREAD_TAB ? undefined : store.threads[activeTab]"
|
|
236
|
+
:drafts="view?.drafts.filter((d) => d.threadId === activeTab) ?? []"
|
|
237
|
+
@created="onCreated"
|
|
238
|
+
/>
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
</template>
|
|
242
|
+
</UModal>
|
|
243
|
+
</template>
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The structured explanation of the PR, and the status of producing it. Suggested questions are
|
|
3
|
+
// chips the window turns into new threads.
|
|
4
|
+
import type { GuidedReviewSession } from '~/types/domain'
|
|
5
|
+
import MarkdownProse from '~/components/common/MarkdownProse.vue'
|
|
6
|
+
import GuidedReviewFailure from '~/components/guidedReview/GuidedReviewFailure.vue'
|
|
7
|
+
import { citationLabel } from '~/components/guidedReview/GuidedReview.logic'
|
|
8
|
+
|
|
9
|
+
const props = defineProps<{
|
|
10
|
+
session: GuidedReviewSession
|
|
11
|
+
stale: boolean
|
|
12
|
+
refreshing: boolean
|
|
13
|
+
/** A suggested question is opening its thread; the chips wait so one click makes one thread. */
|
|
14
|
+
asking: boolean
|
|
15
|
+
}>()
|
|
16
|
+
const emit = defineEmits<{ ask: [question: string]; refresh: [] }>()
|
|
17
|
+
const { t } = useI18n()
|
|
18
|
+
|
|
19
|
+
const overview = computed(() => props.session.overview)
|
|
20
|
+
const content = computed(() => overview.value.content)
|
|
21
|
+
const working = computed(
|
|
22
|
+
() => overview.value.status === 'pending' || overview.value.status === 'running',
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
const SEVERITY_COLOR = { high: 'error', medium: 'warning', low: 'neutral' } as const
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<template>
|
|
29
|
+
<section class="space-y-4" data-testid="guided-review-overview">
|
|
30
|
+
<div class="flex flex-wrap items-center gap-2 text-sm">
|
|
31
|
+
<span class="font-medium text-highlighted">
|
|
32
|
+
{{ t('guidedReview.overview.title') }}
|
|
33
|
+
</span>
|
|
34
|
+
<UBadge v-if="stale" color="warning" variant="soft" data-testid="guided-review-stale">
|
|
35
|
+
{{ t('guidedReview.overview.stale') }}
|
|
36
|
+
</UBadge>
|
|
37
|
+
<span class="flex-1" />
|
|
38
|
+
<UButton
|
|
39
|
+
size="xs"
|
|
40
|
+
variant="ghost"
|
|
41
|
+
icon="i-lucide-refresh-cw"
|
|
42
|
+
:loading="refreshing"
|
|
43
|
+
data-testid="guided-review-refresh"
|
|
44
|
+
@click="emit('refresh')"
|
|
45
|
+
>
|
|
46
|
+
{{ t('guidedReview.overview.refresh') }}
|
|
47
|
+
</UButton>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<p
|
|
51
|
+
v-if="working"
|
|
52
|
+
class="flex items-center gap-2 text-sm text-muted"
|
|
53
|
+
data-testid="guided-review-overview-working"
|
|
54
|
+
>
|
|
55
|
+
<UIcon name="i-lucide-loader-circle" class="h-4 w-4 animate-spin" />
|
|
56
|
+
{{ t('guidedReview.overview.working') }}
|
|
57
|
+
</p>
|
|
58
|
+
|
|
59
|
+
<GuidedReviewFailure
|
|
60
|
+
v-else-if="overview.status === 'failed' && overview.failure"
|
|
61
|
+
:failure="overview.failure"
|
|
62
|
+
/>
|
|
63
|
+
|
|
64
|
+
<template v-else-if="content">
|
|
65
|
+
<MarkdownProse :text="content.summary" class="text-sm" />
|
|
66
|
+
<div v-if="content.intent" class="text-sm">
|
|
67
|
+
<h4 class="mb-1 text-xs font-semibold uppercase text-dimmed">
|
|
68
|
+
{{ t('guidedReview.overview.intent') }}
|
|
69
|
+
</h4>
|
|
70
|
+
<MarkdownProse :text="content.intent" />
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<div v-if="content.meaningfulChanges.length" class="space-y-2">
|
|
74
|
+
<h4 class="text-xs font-semibold uppercase text-dimmed">
|
|
75
|
+
{{ t('guidedReview.overview.changes') }}
|
|
76
|
+
</h4>
|
|
77
|
+
<div
|
|
78
|
+
v-for="(change, i) in content.meaningfulChanges"
|
|
79
|
+
:key="`c${i}`"
|
|
80
|
+
class="rounded-md border border-default p-2 text-sm"
|
|
81
|
+
>
|
|
82
|
+
<p class="font-medium">{{ change.title }}</p>
|
|
83
|
+
<MarkdownProse :text="change.detail" class="text-muted" />
|
|
84
|
+
<div class="mt-1 flex flex-wrap gap-1">
|
|
85
|
+
<UBadge v-for="p in change.paths" :key="p" size="sm" variant="outline" color="neutral">
|
|
86
|
+
{{ p }}
|
|
87
|
+
</UBadge>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<div v-if="content.consequences.length" class="space-y-1">
|
|
93
|
+
<h4 class="text-xs font-semibold uppercase text-dimmed">
|
|
94
|
+
{{ t('guidedReview.overview.consequences') }}
|
|
95
|
+
</h4>
|
|
96
|
+
<div v-for="(c, i) in content.consequences" :key="`q${i}`" class="text-sm">
|
|
97
|
+
<span class="font-medium">{{ c.title }}</span>
|
|
98
|
+
<MarkdownProse :text="c.detail" class="text-muted" />
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<div v-if="content.risks.length" class="space-y-2">
|
|
103
|
+
<h4 class="text-xs font-semibold uppercase text-dimmed">
|
|
104
|
+
{{ t('guidedReview.overview.risks') }}
|
|
105
|
+
</h4>
|
|
106
|
+
<div v-for="(risk, i) in content.risks" :key="`r${i}`" class="text-sm">
|
|
107
|
+
<div class="flex items-center gap-2">
|
|
108
|
+
<UBadge :color="SEVERITY_COLOR[risk.severity]" variant="soft" size="sm">
|
|
109
|
+
{{ t(`guidedReview.severity.${risk.severity}`) }}
|
|
110
|
+
</UBadge>
|
|
111
|
+
<span class="font-medium">{{ risk.title }}</span>
|
|
112
|
+
</div>
|
|
113
|
+
<MarkdownProse :text="risk.detail" class="text-muted" />
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<div v-if="content.focusAreas.length" class="space-y-2">
|
|
118
|
+
<h4 class="text-xs font-semibold uppercase text-dimmed">
|
|
119
|
+
{{ t('guidedReview.overview.focus') }}
|
|
120
|
+
</h4>
|
|
121
|
+
<div v-for="(area, i) in content.focusAreas" :key="`f${i}`" class="text-sm">
|
|
122
|
+
<p class="font-medium">{{ area.title }}</p>
|
|
123
|
+
<MarkdownProse :text="area.why" class="text-muted" />
|
|
124
|
+
<div class="mt-1 flex flex-wrap gap-1">
|
|
125
|
+
<UBadge
|
|
126
|
+
v-for="(a, j) in area.anchors"
|
|
127
|
+
:key="j"
|
|
128
|
+
size="sm"
|
|
129
|
+
variant="outline"
|
|
130
|
+
color="neutral"
|
|
131
|
+
>
|
|
132
|
+
{{ citationLabel(a) }}
|
|
133
|
+
</UBadge>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<div v-if="content.suggestedQuestions.length" class="space-y-2">
|
|
139
|
+
<h4 class="text-xs font-semibold uppercase text-dimmed">
|
|
140
|
+
{{ t('guidedReview.overview.suggested') }}
|
|
141
|
+
</h4>
|
|
142
|
+
<div class="flex flex-wrap gap-2">
|
|
143
|
+
<UButton
|
|
144
|
+
v-for="q in content.suggestedQuestions"
|
|
145
|
+
:key="q.id"
|
|
146
|
+
size="xs"
|
|
147
|
+
variant="soft"
|
|
148
|
+
icon="i-lucide-message-circle-question"
|
|
149
|
+
class="text-start"
|
|
150
|
+
:disabled="asking"
|
|
151
|
+
:data-testid="`guided-review-suggested-${q.id}`"
|
|
152
|
+
@click="emit('ask', q.question)"
|
|
153
|
+
>
|
|
154
|
+
{{ q.question }}
|
|
155
|
+
</UButton>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
</template>
|
|
159
|
+
</section>
|
|
160
|
+
</template>
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Pick a linked repository and one of its pull requests (an open one from the synced list, or any
|
|
3
|
+
// number typed in) to open a guided review on.
|
|
4
|
+
import type { OpenGuidedReviewInput } from '~/types/domain'
|
|
5
|
+
|
|
6
|
+
defineProps<{ opening: boolean; loading: boolean }>()
|
|
7
|
+
const emit = defineEmits<{ open: [target: OpenGuidedReviewInput] }>()
|
|
8
|
+
const { t } = useI18n()
|
|
9
|
+
const github = useGitHubStore()
|
|
10
|
+
|
|
11
|
+
const repoId = ref<number | undefined>(undefined)
|
|
12
|
+
const prNumber = ref<number | undefined>(undefined)
|
|
13
|
+
|
|
14
|
+
const repoItems = computed(() =>
|
|
15
|
+
github.repos.map((r) => ({ label: `${r.owner}/${r.name}`, value: r.githubId })),
|
|
16
|
+
)
|
|
17
|
+
const repo = computed(() => github.repos.find((r) => r.githubId === repoId.value))
|
|
18
|
+
const openPulls = computed(() =>
|
|
19
|
+
repo.value ? github.pullsForRepo(repo.value.githubId).filter((p) => p.state === 'open') : [],
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
function submit(number = prNumber.value): void {
|
|
23
|
+
const r = repo.value
|
|
24
|
+
if (!r || !number) return
|
|
25
|
+
emit('open', {
|
|
26
|
+
owner: r.owner,
|
|
27
|
+
repo: r.name,
|
|
28
|
+
prNumber: number,
|
|
29
|
+
...(r.provider ? { provider: r.provider } : {}),
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<template>
|
|
35
|
+
<div class="mx-auto max-w-xl space-y-4" data-testid="guided-review-picker">
|
|
36
|
+
<p class="text-sm text-muted">{{ t('guidedReview.picker.intro') }}</p>
|
|
37
|
+
<p v-if="loading" class="flex items-center gap-2 text-sm text-muted">
|
|
38
|
+
<UIcon name="i-lucide-loader-circle" class="h-4 w-4 animate-spin" />
|
|
39
|
+
{{ t('guidedReview.picker.loading') }}
|
|
40
|
+
</p>
|
|
41
|
+
<p v-else-if="!github.repos.length" class="text-sm text-toned">
|
|
42
|
+
{{ t('guidedReview.picker.noRepos') }}
|
|
43
|
+
</p>
|
|
44
|
+
<template v-else>
|
|
45
|
+
<UFormField :label="t('guidedReview.picker.repo')">
|
|
46
|
+
<USelectMenu
|
|
47
|
+
v-model="repoId"
|
|
48
|
+
:items="repoItems"
|
|
49
|
+
value-key="value"
|
|
50
|
+
class="w-full"
|
|
51
|
+
data-testid="guided-review-picker-repo"
|
|
52
|
+
/>
|
|
53
|
+
</UFormField>
|
|
54
|
+
<div v-if="openPulls.length" class="space-y-1">
|
|
55
|
+
<p class="text-xs font-semibold uppercase text-dimmed">
|
|
56
|
+
{{ t('guidedReview.picker.openPulls') }}
|
|
57
|
+
</p>
|
|
58
|
+
<UButton
|
|
59
|
+
v-for="pr in openPulls"
|
|
60
|
+
:key="pr.number"
|
|
61
|
+
block
|
|
62
|
+
size="sm"
|
|
63
|
+
variant="ghost"
|
|
64
|
+
color="neutral"
|
|
65
|
+
class="justify-start"
|
|
66
|
+
:disabled="opening"
|
|
67
|
+
:data-testid="`guided-review-picker-pr-${pr.number}`"
|
|
68
|
+
@click="submit(pr.number)"
|
|
69
|
+
>
|
|
70
|
+
<span class="truncate">#{{ pr.number }} {{ pr.title }}</span>
|
|
71
|
+
</UButton>
|
|
72
|
+
</div>
|
|
73
|
+
<div class="flex items-end gap-2">
|
|
74
|
+
<UFormField :label="t('guidedReview.picker.number')" class="flex-1">
|
|
75
|
+
<UInputNumber
|
|
76
|
+
v-model="prNumber"
|
|
77
|
+
:min="1"
|
|
78
|
+
class="w-full"
|
|
79
|
+
data-testid="guided-review-picker-number"
|
|
80
|
+
/>
|
|
81
|
+
</UFormField>
|
|
82
|
+
<UButton
|
|
83
|
+
color="primary"
|
|
84
|
+
icon="i-lucide-scan-search"
|
|
85
|
+
:loading="opening"
|
|
86
|
+
:disabled="!repo || !prNumber"
|
|
87
|
+
data-testid="guided-review-picker-open"
|
|
88
|
+
@click="submit()"
|
|
89
|
+
>
|
|
90
|
+
{{ t('guidedReview.picker.open') }}
|
|
91
|
+
</UButton>
|
|
92
|
+
</div>
|
|
93
|
+
</template>
|
|
94
|
+
</div>
|
|
95
|
+
</template>
|