@cat-factory/app 0.304.0 → 0.305.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 +123 -0
- package/app/components/guidedReview/GuidedReview.logic.ts +108 -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 +177 -0
- package/app/components/panels/inspector/TaskReviewTarget.vue +36 -0
- package/app/composables/useNavContributions.ts +1 -0
- package/app/modular/nav-contributions.ts +20 -0
- package/app/pages/index.vue +4 -0
- package/app/stores/ui/modals.ts +25 -1
- package/app/types/guided-review.ts +2 -0
- package/i18n/locales/de.json +78 -0
- package/i18n/locales/en.json +78 -0
- package/i18n/locales/es.json +78 -0
- package/i18n/locales/fr.json +78 -0
- package/i18n/locales/he.json +78 -0
- package/i18n/locales/it.json +78 -0
- package/i18n/locales/ja.json +78 -0
- package/i18n/locales/pl.json +78 -0
- package/i18n/locales/tr.json +78 -0
- package/i18n/locales/uk.json +78 -0
- package/package.json +2 -2
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
GUIDED_REVIEW_FAILURE_REASONS,
|
|
4
|
+
guidedReviewDroppedDraftReasonSchema,
|
|
5
|
+
} from '@cat-factory/contracts'
|
|
6
|
+
import en from '../../../i18n/locales/en.json'
|
|
7
|
+
import {
|
|
8
|
+
NEW_THREAD_TAB,
|
|
9
|
+
canAsk,
|
|
10
|
+
citationLabel,
|
|
11
|
+
draftAnchor,
|
|
12
|
+
droppedAnchor,
|
|
13
|
+
failureKey,
|
|
14
|
+
keptDrafts,
|
|
15
|
+
isStale,
|
|
16
|
+
reviewTaskTarget,
|
|
17
|
+
suggestedQuestionThread,
|
|
18
|
+
threadTabs,
|
|
19
|
+
} from './GuidedReview.logic'
|
|
20
|
+
|
|
21
|
+
describe('citationLabel', () => {
|
|
22
|
+
it('names a span, a line, or a whole file', () => {
|
|
23
|
+
expect(citationLabel({ path: 'a.ts', startLine: 3, endLine: 9 })).toBe('a.ts:3-9')
|
|
24
|
+
expect(citationLabel({ path: 'a.ts', startLine: 3, endLine: 3 })).toBe('a.ts:3')
|
|
25
|
+
expect(citationLabel({ path: 'a.ts', startLine: 3 })).toBe('a.ts:3')
|
|
26
|
+
expect(citationLabel({ path: 'a.ts' })).toBe('a.ts')
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('canAsk', () => {
|
|
31
|
+
it('waits only on a live answer in this thread', () => {
|
|
32
|
+
expect(canAsk([{ role: 'user', status: 'complete' }])).toBe(true)
|
|
33
|
+
expect(canAsk([{ role: 'assistant', status: 'pending' }])).toBe(false)
|
|
34
|
+
expect(canAsk([{ role: 'assistant', status: 'running' }])).toBe(false)
|
|
35
|
+
expect(canAsk([{ role: 'assistant', status: 'failed' }])).toBe(true)
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('threadTabs', () => {
|
|
40
|
+
const view = { threads: [{ id: 't1' }, { id: 't2' }] } as never
|
|
41
|
+
it('lists threads oldest first and adds the unsaved tab only while it is open', () => {
|
|
42
|
+
expect(threadTabs(view, false)).toEqual(['t1', 't2'])
|
|
43
|
+
expect(threadTabs(view, true)).toEqual(['t1', 't2', NEW_THREAD_TAB])
|
|
44
|
+
expect(threadTabs(undefined, true)).toEqual([NEW_THREAD_TAB])
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('suggestedQuestionThread', () => {
|
|
49
|
+
it('asks the suggestion in a thread of its own', () => {
|
|
50
|
+
expect(suggestedQuestionThread('Is it bounded?')).toEqual({
|
|
51
|
+
question: { content: 'Is it bounded?' },
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('reviewTaskTarget', () => {
|
|
57
|
+
const repo = { owner: 'acme', name: 'shop', provider: 'gitlab' as const }
|
|
58
|
+
it('prefers the stored number and falls back to the URL', () => {
|
|
59
|
+
expect(reviewTaskTarget({ prNumber: 7 }, repo)).toEqual({
|
|
60
|
+
owner: 'acme',
|
|
61
|
+
repo: 'shop',
|
|
62
|
+
prNumber: 7,
|
|
63
|
+
provider: 'gitlab',
|
|
64
|
+
})
|
|
65
|
+
expect(
|
|
66
|
+
reviewTaskTarget({ prUrl: 'https://gitlab.example/acme/shop/-/merge_requests/42' }, repo)
|
|
67
|
+
?.prNumber,
|
|
68
|
+
).toBe(42)
|
|
69
|
+
expect(reviewTaskTarget({ prUrl: 'https://github.com/acme/shop/pull/5' }, repo)?.prNumber).toBe(
|
|
70
|
+
5,
|
|
71
|
+
)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('is null without a repository or a number', () => {
|
|
75
|
+
expect(reviewTaskTarget({ prNumber: 7 }, undefined)).toBeNull()
|
|
76
|
+
expect(reviewTaskTarget({ prUrl: 'https://example.com/nothing' }, repo)).toBeNull()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('reads the URL the way the dispatch does', () => {
|
|
80
|
+
expect(reviewTaskTarget({ prUrl: 'acme/shop#88' }, repo)?.prNumber).toBe(88)
|
|
81
|
+
expect(
|
|
82
|
+
reviewTaskTarget({ prNumber: 0, prUrl: 'https://github.com/acme/shop/pull/5' }, repo)
|
|
83
|
+
?.prNumber,
|
|
84
|
+
).toBe(5)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('failure and draft labels', () => {
|
|
89
|
+
it('translates each failure reason under its own key', () => {
|
|
90
|
+
expect(failureKey('budget_exhausted')).toBe('guidedReview.failure.budget_exhausted')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('ships copy for every failure and dropped-draft reason the contract can send', () => {
|
|
94
|
+
const catalog = en.guidedReview as unknown as Record<string, Record<string, string>>
|
|
95
|
+
for (const reason of GUIDED_REVIEW_FAILURE_REASONS) {
|
|
96
|
+
expect(catalog.failure![reason], reason).toBeTruthy()
|
|
97
|
+
}
|
|
98
|
+
for (const reason of guidedReviewDroppedDraftReasonSchema.options) {
|
|
99
|
+
expect(catalog.dropped![reason], reason).toBeTruthy()
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('names where a draft sits, or would have', () => {
|
|
104
|
+
expect(droppedAnchor({ path: 'a.ts', line: 9 })).toBe('a.ts:9')
|
|
105
|
+
expect(droppedAnchor({ path: '(none)', line: null })).toBe('(none)')
|
|
106
|
+
expect(draftAnchor({ path: 'a.ts', line: 9, startLine: null })).toBe('a.ts:9')
|
|
107
|
+
expect(draftAnchor({ path: 'a.ts', line: 9, startLine: 4 })).toBe('a.ts:4-9')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('counts the drafts one message produced', () => {
|
|
111
|
+
expect(keptDrafts([{ messageId: 'm1' }, { messageId: 'm2' }, { messageId: 'm1' }], 'm1')).toBe(
|
|
112
|
+
2,
|
|
113
|
+
)
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe('isStale', () => {
|
|
118
|
+
it('is stale only when a newer head is known', () => {
|
|
119
|
+
expect(isStale('a', 'b')).toBe(true)
|
|
120
|
+
expect(isStale('a', 'a')).toBe(false)
|
|
121
|
+
expect(isStale('a', null)).toBe(false)
|
|
122
|
+
})
|
|
123
|
+
})
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { resolvePrNumber, type GuidedReviewFailureReason } from '@cat-factory/contracts'
|
|
2
|
+
import type {
|
|
3
|
+
GuidedReviewMessage,
|
|
4
|
+
GuidedReviewSessionView,
|
|
5
|
+
OpenGuidedReviewInput,
|
|
6
|
+
OpenGuidedReviewThreadInput,
|
|
7
|
+
} from '~/types/domain'
|
|
8
|
+
|
|
9
|
+
/** A cited span as one short label: `path:12-18`, `path:12`, or the bare path. */
|
|
10
|
+
export function citationLabel(anchor: {
|
|
11
|
+
path: string
|
|
12
|
+
startLine?: number
|
|
13
|
+
endLine?: number
|
|
14
|
+
}): string {
|
|
15
|
+
if (anchor.startLine === undefined) return anchor.path
|
|
16
|
+
if (anchor.endLine === undefined || anchor.endLine === anchor.startLine) {
|
|
17
|
+
return `${anchor.path}:${anchor.startLine}`
|
|
18
|
+
}
|
|
19
|
+
return `${anchor.path}:${anchor.startLine}-${anchor.endLine}`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A message that is still being produced: the thread is waiting on it. */
|
|
23
|
+
export function isLive(message: Pick<GuidedReviewMessage, 'status'>): boolean {
|
|
24
|
+
return message.status === 'pending' || message.status === 'running'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Whether a thread may take another question now. Other threads never affect this. */
|
|
28
|
+
export function canAsk(messages: Pick<GuidedReviewMessage, 'status' | 'role'>[]): boolean {
|
|
29
|
+
return !messages.some((m) => m.role === 'assistant' && isLive(m))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The tab ids the window shows, oldest thread first, then the unsaved new-thread tab when one is
|
|
34
|
+
* open. A new thread is only created on its first question, so an empty tab never reaches the
|
|
35
|
+
* server.
|
|
36
|
+
*/
|
|
37
|
+
export const NEW_THREAD_TAB = '__new__'
|
|
38
|
+
|
|
39
|
+
export function threadTabs(
|
|
40
|
+
view: GuidedReviewSessionView | undefined,
|
|
41
|
+
draftOpen: boolean,
|
|
42
|
+
): string[] {
|
|
43
|
+
const ids = (view?.threads ?? []).map((t) => t.id)
|
|
44
|
+
return draftOpen ? [...ids, NEW_THREAD_TAB] : ids
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* What clicking a suggested question does: open a NEW thread asking it. A thread keeps one line
|
|
49
|
+
* of inquiry, so a suggestion never joins the context of whatever tab happens to be active, and a
|
|
50
|
+
* busy tab can never refuse it.
|
|
51
|
+
*/
|
|
52
|
+
export function suggestedQuestionThread(question: string): OpenGuidedReviewThreadInput {
|
|
53
|
+
return { question: { content: question } }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The pull request a `review` task targets, as a guided review opens it: its number from the
|
|
58
|
+
* task fields or its URL, and the repository of the service it sits in. Null when either is
|
|
59
|
+
* unknown.
|
|
60
|
+
*/
|
|
61
|
+
export function reviewTaskTarget(
|
|
62
|
+
fields: { prNumber?: number; prUrl?: string } | null | undefined,
|
|
63
|
+
repo: { owner: string; name: string; provider?: 'github' | 'gitlab' } | undefined,
|
|
64
|
+
): OpenGuidedReviewInput | null {
|
|
65
|
+
if (!repo) return null
|
|
66
|
+
const prNumber = resolvePrNumber(fields)
|
|
67
|
+
if (prNumber === null) return null
|
|
68
|
+
return {
|
|
69
|
+
owner: repo.owner,
|
|
70
|
+
repo: repo.name,
|
|
71
|
+
prNumber,
|
|
72
|
+
...(repo.provider ? { provider: repo.provider } : {}),
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The catalog key a failure reason is translated under. */
|
|
77
|
+
export function failureKey(reason: GuidedReviewFailureReason): string {
|
|
78
|
+
return `guidedReview.failure.${reason}`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Where a refused draft would have gone: `path:line`, or the path when it named no line. */
|
|
82
|
+
export function droppedAnchor(dropped: { path: string; line: number | null }): string {
|
|
83
|
+
return dropped.line === null ? dropped.path : `${dropped.path}:${dropped.line}`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A draft's anchor: `path:line`, or `path:start-line` for a span. */
|
|
87
|
+
export function draftAnchor(draft: {
|
|
88
|
+
path: string
|
|
89
|
+
line: number
|
|
90
|
+
startLine: number | null
|
|
91
|
+
}): string {
|
|
92
|
+
return draft.startLine
|
|
93
|
+
? `${draft.path}:${draft.startLine}-${draft.line}`
|
|
94
|
+
: `${draft.path}:${draft.line}`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** How many drafts one comment-drafts message produced that are still listed. */
|
|
98
|
+
export function keptDrafts(drafts: { messageId: string }[], messageId: string): number {
|
|
99
|
+
return drafts.filter((d) => d.messageId === messageId).length
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The session is reviewing an older head than the PR's latest known one. */
|
|
103
|
+
export function isStale(
|
|
104
|
+
reviewedHeadSha: string,
|
|
105
|
+
latestHeadSha: string | null | undefined,
|
|
106
|
+
): boolean {
|
|
107
|
+
return !!latestHeadSha && latestHeadSha !== reviewedHeadSha
|
|
108
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// A failed overview or answer: the translated reason, with the raw cause behind a disclosure.
|
|
3
|
+
import type { GuidedReviewFailure } from '~/types/domain'
|
|
4
|
+
import { failureKey } from '~/components/guidedReview/GuidedReview.logic'
|
|
5
|
+
|
|
6
|
+
defineProps<{ failure: GuidedReviewFailure }>()
|
|
7
|
+
const { t } = useI18n()
|
|
8
|
+
const expanded = ref(false)
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<template>
|
|
12
|
+
<div
|
|
13
|
+
class="space-y-1 rounded-md bg-elevated/60 p-2 text-sm text-toned"
|
|
14
|
+
data-testid="guided-review-failure"
|
|
15
|
+
>
|
|
16
|
+
<p class="flex items-start gap-2">
|
|
17
|
+
<UIcon name="i-lucide-circle-alert" class="mt-0.5 h-4 w-4 shrink-0 text-error" />
|
|
18
|
+
<span>{{ t(failureKey(failure.reason)) }}</span>
|
|
19
|
+
</p>
|
|
20
|
+
<template v-if="failure.detail">
|
|
21
|
+
<UButton
|
|
22
|
+
size="xs"
|
|
23
|
+
variant="link"
|
|
24
|
+
:icon="expanded ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
|
|
25
|
+
data-testid="guided-review-failure-toggle"
|
|
26
|
+
@click="expanded = !expanded"
|
|
27
|
+
>
|
|
28
|
+
{{ t('guidedReview.failure.detail') }}
|
|
29
|
+
</UButton>
|
|
30
|
+
<pre
|
|
31
|
+
v-if="expanded"
|
|
32
|
+
class="whitespace-pre-wrap break-words text-xs text-muted"
|
|
33
|
+
data-testid="guided-review-failure-detail"
|
|
34
|
+
>{{ failure.detail }}</pre>
|
|
35
|
+
</template>
|
|
36
|
+
</div>
|
|
37
|
+
</template>
|
|
@@ -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>
|