@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.
@@ -0,0 +1,179 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { GUIDED_REVIEW_POST_LEASE_MS } from '@cat-factory/contracts'
3
+ import {
4
+ GUIDED_REVIEW_FAILURE_REASONS,
5
+ guidedReviewDroppedDraftReasonSchema,
6
+ } from '@cat-factory/contracts'
7
+ import en from '../../../i18n/locales/en.json'
8
+ import {
9
+ NEW_THREAD_TAB,
10
+ draftEdit,
11
+ isEditableDraft,
12
+ postableSelection,
13
+ canAsk,
14
+ citationLabel,
15
+ draftAnchor,
16
+ droppedAnchor,
17
+ failureKey,
18
+ keptDrafts,
19
+ isStale,
20
+ reviewTaskTarget,
21
+ suggestedQuestionThread,
22
+ threadTabs,
23
+ } from './GuidedReview.logic'
24
+
25
+ describe('citationLabel', () => {
26
+ it('names a span, a line, or a whole file', () => {
27
+ expect(citationLabel({ path: 'a.ts', startLine: 3, endLine: 9 })).toBe('a.ts:3-9')
28
+ expect(citationLabel({ path: 'a.ts', startLine: 3, endLine: 3 })).toBe('a.ts:3')
29
+ expect(citationLabel({ path: 'a.ts', startLine: 3 })).toBe('a.ts:3')
30
+ expect(citationLabel({ path: 'a.ts' })).toBe('a.ts')
31
+ })
32
+ })
33
+
34
+ describe('canAsk', () => {
35
+ it('waits only on a live answer in this thread', () => {
36
+ expect(canAsk([{ role: 'user', status: 'complete' }])).toBe(true)
37
+ expect(canAsk([{ role: 'assistant', status: 'pending' }])).toBe(false)
38
+ expect(canAsk([{ role: 'assistant', status: 'running' }])).toBe(false)
39
+ expect(canAsk([{ role: 'assistant', status: 'failed' }])).toBe(true)
40
+ })
41
+ })
42
+
43
+ describe('threadTabs', () => {
44
+ const view = { threads: [{ id: 't1' }, { id: 't2' }] } as never
45
+ it('lists threads oldest first and adds the unsaved tab only while it is open', () => {
46
+ expect(threadTabs(view, false)).toEqual(['t1', 't2'])
47
+ expect(threadTabs(view, true)).toEqual(['t1', 't2', NEW_THREAD_TAB])
48
+ expect(threadTabs(undefined, true)).toEqual([NEW_THREAD_TAB])
49
+ })
50
+ })
51
+
52
+ describe('suggestedQuestionThread', () => {
53
+ it('asks the suggestion in a thread of its own', () => {
54
+ expect(suggestedQuestionThread('Is it bounded?')).toEqual({
55
+ question: { content: 'Is it bounded?' },
56
+ })
57
+ })
58
+ })
59
+
60
+ describe('reviewTaskTarget', () => {
61
+ const repo = { owner: 'acme', name: 'shop', provider: 'gitlab' as const }
62
+ it('prefers the stored number and falls back to the URL', () => {
63
+ expect(reviewTaskTarget({ prNumber: 7 }, repo)).toEqual({
64
+ owner: 'acme',
65
+ repo: 'shop',
66
+ prNumber: 7,
67
+ provider: 'gitlab',
68
+ })
69
+ expect(
70
+ reviewTaskTarget({ prUrl: 'https://gitlab.example/acme/shop/-/merge_requests/42' }, repo)
71
+ ?.prNumber,
72
+ ).toBe(42)
73
+ expect(reviewTaskTarget({ prUrl: 'https://github.com/acme/shop/pull/5' }, repo)?.prNumber).toBe(
74
+ 5,
75
+ )
76
+ })
77
+
78
+ it('is null without a repository or a number', () => {
79
+ expect(reviewTaskTarget({ prNumber: 7 }, undefined)).toBeNull()
80
+ expect(reviewTaskTarget({ prUrl: 'https://example.com/nothing' }, repo)).toBeNull()
81
+ })
82
+
83
+ it('reads the URL the way the dispatch does', () => {
84
+ expect(reviewTaskTarget({ prUrl: 'acme/shop#88' }, repo)?.prNumber).toBe(88)
85
+ expect(
86
+ reviewTaskTarget({ prNumber: 0, prUrl: 'https://github.com/acme/shop/pull/5' }, repo)
87
+ ?.prNumber,
88
+ ).toBe(5)
89
+ })
90
+ })
91
+
92
+ describe('failure and draft labels', () => {
93
+ it('translates each failure reason under its own key', () => {
94
+ expect(failureKey('budget_exhausted')).toBe('guidedReview.failure.budget_exhausted')
95
+ })
96
+
97
+ it('ships copy for every failure and dropped-draft reason the contract can send', () => {
98
+ const catalog = en.guidedReview as unknown as Record<string, Record<string, string>>
99
+ for (const reason of GUIDED_REVIEW_FAILURE_REASONS) {
100
+ expect(catalog.failure![reason], reason).toBeTruthy()
101
+ }
102
+ for (const reason of guidedReviewDroppedDraftReasonSchema.options) {
103
+ expect(catalog.dropped![reason], reason).toBeTruthy()
104
+ }
105
+ })
106
+
107
+ it('names where a draft sits, or would have', () => {
108
+ expect(droppedAnchor({ path: 'a.ts', line: 9 })).toBe('a.ts:9')
109
+ expect(droppedAnchor({ path: '(none)', line: null })).toBe('(none)')
110
+ expect(draftAnchor({ path: 'a.ts', line: 9, startLine: null })).toBe('a.ts:9')
111
+ expect(draftAnchor({ path: 'a.ts', line: 9, startLine: 4 })).toBe('a.ts:4-9')
112
+ })
113
+
114
+ it('counts the drafts one message produced', () => {
115
+ expect(keptDrafts([{ messageId: 'm1' }, { messageId: 'm2' }, { messageId: 'm1' }], 'm1')).toBe(
116
+ 2,
117
+ )
118
+ })
119
+ })
120
+
121
+ describe('isStale', () => {
122
+ it('is stale only when a newer head is known', () => {
123
+ expect(isStale('a', 'b')).toBe(true)
124
+ expect(isStale('a', 'a')).toBe(false)
125
+ expect(isStale('a', null)).toBe(false)
126
+ })
127
+ })
128
+
129
+ describe('draft editing and posting', () => {
130
+ it('treats proposed and failed drafts as still editable', () => {
131
+ expect(
132
+ ['proposed', 'failed', 'posting', 'posted', 'discarded'].map((status) =>
133
+ isEditableDraft({ status: status as never }),
134
+ ),
135
+ ).toEqual([true, true, false, false, false])
136
+ })
137
+
138
+ it('posts only the selected drafts that are still postable, in list order', () => {
139
+ const drafts = [
140
+ { id: 'a', status: 'proposed' as const, updatedAt: 0 },
141
+ { id: 'b', status: 'posted' as const, updatedAt: 0 },
142
+ { id: 'c', status: 'failed' as const, updatedAt: 0 },
143
+ ]
144
+ expect(postableSelection(drafts, new Set(['c', 'b', 'a']), 1)).toEqual(['a', 'c'])
145
+ })
146
+
147
+ it('offers a posting draft again only once its claim outlives the lease', () => {
148
+ const stranded = [{ id: 'p', status: 'posting' as const, updatedAt: 1_000 }]
149
+ const selected = new Set(['p'])
150
+ expect(postableSelection(stranded, selected, 1_000 + GUIDED_REVIEW_POST_LEASE_MS)).toEqual([])
151
+ expect(postableSelection(stranded, selected, 1_001 + GUIDED_REVIEW_POST_LEASE_MS)).toEqual([
152
+ 'p',
153
+ ])
154
+ })
155
+
156
+ it('sends only what an edit changed, and nothing for an untouched form', () => {
157
+ const draft = { body: 'Bound the retries.', line: 3, startLine: null, side: 'RIGHT' as const }
158
+ expect(draftEdit(draft, { body: ' Bound the retries. ', line: 3, side: 'RIGHT' })).toEqual({})
159
+ expect(draftEdit(draft, { body: 'Cap at three.', line: 4, side: 'RIGHT' })).toEqual({
160
+ body: 'Cap at three.',
161
+ line: 4,
162
+ })
163
+ })
164
+
165
+ it('drops the start of a multi-line draft only when the edit moves it', () => {
166
+ const span = { body: 'Span.', line: 8, startLine: 5, side: 'RIGHT' as const }
167
+ expect(draftEdit(span, { body: 'Reworded.', line: 8, side: 'RIGHT' })).toEqual({
168
+ body: 'Reworded.',
169
+ })
170
+ expect(draftEdit(span, { body: 'Span.', line: 3, side: 'RIGHT' })).toEqual({
171
+ line: 3,
172
+ startLine: null,
173
+ })
174
+ expect(draftEdit(span, { body: 'Span.', line: 8, side: 'LEFT' })).toEqual({
175
+ startLine: null,
176
+ side: 'LEFT',
177
+ })
178
+ })
179
+ })
@@ -0,0 +1,146 @@
1
+ import {
2
+ isPostableDraft,
3
+ resolvePrNumber,
4
+ type GuidedReviewFailureReason,
5
+ } from '@cat-factory/contracts'
6
+ import type {
7
+ GuidedReviewCommentDraft,
8
+ GuidedReviewMessage,
9
+ GuidedReviewSessionView,
10
+ OpenGuidedReviewInput,
11
+ OpenGuidedReviewThreadInput,
12
+ } from '~/types/domain'
13
+
14
+ /** A cited span as one short label: `path:12-18`, `path:12`, or the bare path. */
15
+ export function citationLabel(anchor: {
16
+ path: string
17
+ startLine?: number
18
+ endLine?: number
19
+ }): string {
20
+ if (anchor.startLine === undefined) return anchor.path
21
+ if (anchor.endLine === undefined || anchor.endLine === anchor.startLine) {
22
+ return `${anchor.path}:${anchor.startLine}`
23
+ }
24
+ return `${anchor.path}:${anchor.startLine}-${anchor.endLine}`
25
+ }
26
+
27
+ /** A message that is still being produced: the thread is waiting on it. */
28
+ export function isLive(message: Pick<GuidedReviewMessage, 'status'>): boolean {
29
+ return message.status === 'pending' || message.status === 'running'
30
+ }
31
+
32
+ /** Whether a thread may take another question now. Other threads never affect this. */
33
+ export function canAsk(messages: Pick<GuidedReviewMessage, 'status' | 'role'>[]): boolean {
34
+ return !messages.some((m) => m.role === 'assistant' && isLive(m))
35
+ }
36
+
37
+ /**
38
+ * The tab ids the window shows, oldest thread first, then the unsaved new-thread tab when one is
39
+ * open. A new thread is only created on its first question, so an empty tab never reaches the
40
+ * server.
41
+ */
42
+ export const NEW_THREAD_TAB = '__new__'
43
+
44
+ export function threadTabs(
45
+ view: GuidedReviewSessionView | undefined,
46
+ draftOpen: boolean,
47
+ ): string[] {
48
+ const ids = (view?.threads ?? []).map((t) => t.id)
49
+ return draftOpen ? [...ids, NEW_THREAD_TAB] : ids
50
+ }
51
+
52
+ /**
53
+ * What clicking a suggested question does: open a NEW thread asking it. A thread keeps one line
54
+ * of inquiry, so a suggestion never joins the context of whatever tab happens to be active, and a
55
+ * busy tab can never refuse it.
56
+ */
57
+ export function suggestedQuestionThread(question: string): OpenGuidedReviewThreadInput {
58
+ return { question: { content: question } }
59
+ }
60
+
61
+ /**
62
+ * The pull request a `review` task targets, as a guided review opens it: its number from the
63
+ * task fields or its URL, and the repository of the service it sits in. Null when either is
64
+ * unknown.
65
+ */
66
+ export function reviewTaskTarget(
67
+ fields: { prNumber?: number; prUrl?: string } | null | undefined,
68
+ repo: { owner: string; name: string; provider?: 'github' | 'gitlab' } | undefined,
69
+ ): OpenGuidedReviewInput | null {
70
+ if (!repo) return null
71
+ const prNumber = resolvePrNumber(fields)
72
+ if (prNumber === null) return null
73
+ return {
74
+ owner: repo.owner,
75
+ repo: repo.name,
76
+ prNumber,
77
+ ...(repo.provider ? { provider: repo.provider } : {}),
78
+ }
79
+ }
80
+
81
+ /** The catalog key a failure reason is translated under. */
82
+ export function failureKey(reason: GuidedReviewFailureReason): string {
83
+ return `guidedReview.failure.${reason}`
84
+ }
85
+
86
+ /** Where a refused draft would have gone: `path:line`, or the path when it named no line. */
87
+ export function droppedAnchor(dropped: { path: string; line: number | null }): string {
88
+ return dropped.line === null ? dropped.path : `${dropped.path}:${dropped.line}`
89
+ }
90
+
91
+ /** A draft's anchor: `path:line`, or `path:start-line` for a span. */
92
+ export function draftAnchor(draft: {
93
+ path: string
94
+ line: number
95
+ startLine: number | null
96
+ }): string {
97
+ return draft.startLine
98
+ ? `${draft.path}:${draft.startLine}-${draft.line}`
99
+ : `${draft.path}:${draft.line}`
100
+ }
101
+
102
+ /** How many drafts one comment-drafts message produced that are still listed. */
103
+ export function keptDrafts(drafts: { messageId: string }[], messageId: string): number {
104
+ return drafts.filter((d) => d.messageId === messageId).length
105
+ }
106
+
107
+ /** The session is reviewing an older head than the PR's latest known one. */
108
+ export function isStale(
109
+ reviewedHeadSha: string,
110
+ latestHeadSha: string | null | undefined,
111
+ ): boolean {
112
+ return !!latestHeadSha && latestHeadSha !== reviewedHeadSha
113
+ }
114
+
115
+ /** A draft the reviewer may still edit, discard or post: `proposed`, or a `failed` post. */
116
+ export function isEditableDraft(draft: Pick<GuidedReviewCommentDraft, 'status'>): boolean {
117
+ return draft.status === 'proposed' || draft.status === 'failed'
118
+ }
119
+
120
+ /** The selected drafts a post may claim at `now`, in the order they are listed. */
121
+ export function postableSelection(
122
+ drafts: Pick<GuidedReviewCommentDraft, 'id' | 'status' | 'updatedAt'>[],
123
+ selected: ReadonlySet<string>,
124
+ now: number,
125
+ ): string[] {
126
+ return drafts.filter((d) => selected.has(d.id) && isPostableDraft(d, now)).map((d) => d.id)
127
+ }
128
+
129
+ /**
130
+ * The fields an inline edit actually changed, against the draft as loaded. Empty when nothing
131
+ * moved, so the editor saves nothing rather than bumping the draft's revision for no change. The
132
+ * editor places a single line, so moving a multi-line draft drops its start: the server would
133
+ * otherwise keep the old start against the new line and refuse the span.
134
+ */
135
+ export function draftEdit(
136
+ draft: Pick<GuidedReviewCommentDraft, 'body' | 'line' | 'startLine' | 'side'>,
137
+ form: { body: string; line: number; side: 'LEFT' | 'RIGHT' },
138
+ ): { body?: string; line?: number; startLine?: null; side?: 'LEFT' | 'RIGHT' } {
139
+ const moved = form.line !== draft.line || form.side !== draft.side
140
+ return {
141
+ ...(form.body.trim() !== draft.body ? { body: form.body.trim() } : {}),
142
+ ...(form.line !== draft.line ? { line: form.line } : {}),
143
+ ...(moved && draft.startLine !== null ? { startLine: null } : {}),
144
+ ...(form.side !== draft.side ? { side: form.side } : {}),
145
+ }
146
+ }
@@ -0,0 +1,145 @@
1
+ <script setup lang="ts">
2
+ // One comment draft: its anchor, body and rationale, with selection for posting and an inline
3
+ // editor. Only a `proposed` or `failed` draft is editable, though a `posting` one whose poster died
4
+ // can be selected for posting again; the server refuses a stale edit as
5
+ // `draft_conflict` and a line outside the diff, both reported through the error funnel.
6
+ import type { GuidedReviewCommentDraft } from '~/types/domain'
7
+ import MarkdownProse from '~/components/common/MarkdownProse.vue'
8
+ import {
9
+ draftAnchor,
10
+ draftEdit,
11
+ isEditableDraft,
12
+ } from '~/components/guidedReview/GuidedReview.logic'
13
+
14
+ const props = defineProps<{
15
+ sessionId: string
16
+ draft: GuidedReviewCommentDraft
17
+ selected: boolean
18
+ /** Whether a post may claim this draft now, including a stranded `posting` one. */
19
+ postable: boolean
20
+ }>()
21
+ const emit = defineEmits<{ 'update:selected': [value: boolean] }>()
22
+ const { t } = useI18n()
23
+ const store = useGuidedReviewStore()
24
+ const { present } = usePipelineErrorToast()
25
+
26
+ const editing = ref(false)
27
+ const saving = ref(false)
28
+ const form = reactive({ body: '', line: 1, side: 'RIGHT' as 'LEFT' | 'RIGHT' })
29
+ const editable = computed(() => isEditableDraft(props.draft))
30
+ const STATUS_COLOR = {
31
+ proposed: 'neutral',
32
+ posting: 'info',
33
+ posted: 'success',
34
+ failed: 'error',
35
+ discarded: 'neutral',
36
+ } as const
37
+
38
+ function startEdit(): void {
39
+ form.body = props.draft.body
40
+ form.line = props.draft.line
41
+ form.side = props.draft.side
42
+ editing.value = true
43
+ }
44
+
45
+ async function save(): Promise<void> {
46
+ const changes = draftEdit(props.draft, form)
47
+ if (Object.keys(changes).length === 0) {
48
+ editing.value = false
49
+ return
50
+ }
51
+ saving.value = true
52
+ try {
53
+ await store.editDraft(props.sessionId, props.draft.id, { rev: props.draft.rev, ...changes })
54
+ editing.value = false
55
+ } catch (error) {
56
+ present(error, 'guidedReview.errors.editDraft')
57
+ } finally {
58
+ saving.value = false
59
+ }
60
+ }
61
+
62
+ async function discard(): Promise<void> {
63
+ saving.value = true
64
+ try {
65
+ await store.editDraft(props.sessionId, props.draft.id, { rev: props.draft.rev, discard: true })
66
+ emit('update:selected', false)
67
+ } catch (error) {
68
+ present(error, 'guidedReview.errors.editDraft')
69
+ } finally {
70
+ saving.value = false
71
+ }
72
+ }
73
+ </script>
74
+
75
+ <template>
76
+ <div
77
+ class="rounded-md border border-default p-2 text-sm"
78
+ :class="{ 'opacity-60': draft.status === 'discarded' }"
79
+ data-testid="guided-review-draft"
80
+ >
81
+ <div class="flex items-center gap-2">
82
+ <UCheckbox
83
+ v-if="postable"
84
+ :model-value="selected"
85
+ :aria-label="t('guidedReview.drafts.select')"
86
+ @update:model-value="emit('update:selected', $event === true)"
87
+ />
88
+ <p class="flex-1 text-xs text-dimmed">
89
+ {{ draftAnchor(draft) }} ({{ t(`guidedReview.drafts.side.${draft.side}`) }})
90
+ </p>
91
+ <UBadge size="sm" variant="subtle" :color="STATUS_COLOR[draft.status]">
92
+ {{ t(`guidedReview.drafts.status.${draft.status}`) }}
93
+ </UBadge>
94
+ </div>
95
+
96
+ <div v-if="editing" class="mt-2 space-y-2">
97
+ <UTextarea v-model="form.body" :rows="3" autoresize class="w-full" />
98
+ <div class="flex flex-wrap items-center gap-2">
99
+ <UInputNumber
100
+ v-model="form.line"
101
+ :min="1"
102
+ size="sm"
103
+ :aria-label="t('guidedReview.drafts.line')"
104
+ />
105
+ <USelect
106
+ v-model="form.side"
107
+ size="sm"
108
+ :items="[
109
+ { label: t('guidedReview.drafts.side.RIGHT'), value: 'RIGHT' },
110
+ { label: t('guidedReview.drafts.side.LEFT'), value: 'LEFT' },
111
+ ]"
112
+ :aria-label="t('guidedReview.drafts.sideLabel')"
113
+ />
114
+ <UButton size="sm" color="primary" :loading="saving" @click="save">
115
+ {{ t('guidedReview.drafts.save') }}
116
+ </UButton>
117
+ <UButton size="sm" variant="ghost" :disabled="saving" @click="editing = false">
118
+ {{ t('guidedReview.drafts.cancel') }}
119
+ </UButton>
120
+ </div>
121
+ </div>
122
+ <template v-else>
123
+ <MarkdownProse class="mt-1" :text="draft.body" />
124
+ <p v-if="draft.rationale" class="mt-1 text-xs text-muted">{{ draft.rationale }}</p>
125
+ <p v-if="draft.status === 'failed' && draft.postError" class="mt-1 text-xs text-error">
126
+ {{ t('guidedReview.drafts.postFailed') }}: {{ draft.postError }}
127
+ </p>
128
+ <div v-if="editable" class="mt-2 flex gap-2">
129
+ <UButton size="xs" variant="ghost" icon="i-lucide-pencil" @click="startEdit">
130
+ {{ t('guidedReview.drafts.edit') }}
131
+ </UButton>
132
+ <UButton
133
+ size="xs"
134
+ variant="ghost"
135
+ color="neutral"
136
+ icon="i-lucide-trash-2"
137
+ :loading="saving"
138
+ @click="discard"
139
+ >
140
+ {{ t('guidedReview.drafts.discard') }}
141
+ </UButton>
142
+ </div>
143
+ </template>
144
+ </div>
145
+ </template>
@@ -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>