@cat-factory/app 0.312.0 → 0.314.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/README.md +12 -0
- package/app/components/board/AddTaskModal.vue +29 -30
- package/app/components/board/ReviewFrictionDialog.vue +8 -5
- package/app/components/layout/AiProvidersBanner.vue +3 -2
- package/app/components/layout/GitHubPatBanner.vue +2 -2
- package/app/components/layout/GitHubPatPermissionsBanner.vue +4 -12
- package/app/components/layout/InfraSetupBanner.vue +6 -7
- package/app/components/pipeline/PipelinePicker.vue +8 -2
- package/app/components/queue/DescribeWorkModal.vue +367 -0
- package/app/components/queue/QueueView.vue +18 -0
- package/app/components/setup/SetupChecklistPanel.vue +340 -0
- package/app/composables/useNavContributions.ts +2 -0
- package/app/composables/useSetupChecklist.ts +122 -0
- package/app/composables/useSetupRemedies.ts +72 -0
- package/app/composables/useTaskActions.ts +51 -28
- package/app/modular/nav-contributions.spec.ts +3 -0
- package/app/modular/nav-contributions.ts +46 -0
- package/app/pages/index.vue +6 -0
- package/app/stores/github/context.ts +1 -0
- package/app/stores/github/probe.ts +7 -3
- package/app/stores/github.ts +9 -0
- package/app/stores/ui/modals.ts +42 -0
- package/app/utils/describeWork.spec.ts +76 -0
- package/app/utils/describeWork.ts +71 -0
- package/app/utils/reviewFriction.ts +47 -0
- package/app/utils/setupChecklist.spec.ts +184 -0
- package/app/utils/setupChecklist.ts +229 -0
- package/i18n/locales/de.json +97 -3
- package/i18n/locales/en.json +97 -3
- package/i18n/locales/es.json +97 -3
- package/i18n/locales/fr.json +97 -3
- package/i18n/locales/he.json +97 -3
- package/i18n/locales/it.json +97 -3
- package/i18n/locales/ja.json +97 -3
- package/i18n/locales/pl.json +97 -3
- package/i18n/locales/tr.json +97 -3
- package/i18n/locales/uk.json +97 -3
- package/package.json +1 -1
|
@@ -196,6 +196,7 @@ export interface NavCommandSpec {
|
|
|
196
196
|
* dead button. Consumer modules don't use these — they carry their own `run`.
|
|
197
197
|
*/
|
|
198
198
|
export const NAV_ACTIONS = [
|
|
199
|
+
'describeWork',
|
|
199
200
|
'assistant',
|
|
200
201
|
'guidedReview',
|
|
201
202
|
'buildPipeline',
|
|
@@ -210,6 +211,7 @@ export const NAV_ACTIONS = [
|
|
|
210
211
|
'foundationalServices',
|
|
211
212
|
'mergeThresholds',
|
|
212
213
|
'workspaceSettings',
|
|
214
|
+
'setupChecklist',
|
|
213
215
|
'modelConfiguration',
|
|
214
216
|
'serviceFragmentDefaults',
|
|
215
217
|
'localModels',
|
|
@@ -345,6 +347,26 @@ const S = (...s: NavSurface[]) => s as readonly NavSurface[]
|
|
|
345
347
|
* the whole sidebar costs it nothing it is there to do.
|
|
346
348
|
*/
|
|
347
349
|
export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
350
|
+
{
|
|
351
|
+
// The one-sentence intake (issue #2258): what needs doing, a service, feature or bug. First in
|
|
352
|
+
// Create, because filing a task is the most frequent thing anyone creates, and until now it had
|
|
353
|
+
// no entry here at all (it lived on each frame). Every tier and the intake role: describing
|
|
354
|
+
// work is what that role is for.
|
|
355
|
+
id: 'describe-work',
|
|
356
|
+
labelKey: 'queue.describeWork',
|
|
357
|
+
icon: 'i-lucide-plus',
|
|
358
|
+
surfaces: S('sidebar', 'command'),
|
|
359
|
+
gate: (g) => g.canWriteBoard && g.boardHasService,
|
|
360
|
+
action: 'describeWork',
|
|
361
|
+
intake: true,
|
|
362
|
+
testId: 'nav-describe-work',
|
|
363
|
+
sidebar: { group: 'create', order: 1 },
|
|
364
|
+
command: {
|
|
365
|
+
group: 'create',
|
|
366
|
+
order: 1,
|
|
367
|
+
keywordsKey: 'layout.commandBar.keywords.describeWork',
|
|
368
|
+
},
|
|
369
|
+
},
|
|
348
370
|
{
|
|
349
371
|
// The in-app assistant. `intake: true` and not `advanced`, because it is the shortest route
|
|
350
372
|
// to the two things a narrowed role is on the board to do (put a repository on it, file a
|
|
@@ -538,6 +560,30 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
538
560
|
keywordsKey: 'layout.commandBar.keywords.workspaceSettings',
|
|
539
561
|
},
|
|
540
562
|
},
|
|
563
|
+
{
|
|
564
|
+
// Every prerequisite for "a task can run and merge", composed from the probes the setup
|
|
565
|
+
// banners already read (issue #2258). First in Configuration, because a workspace that cannot
|
|
566
|
+
// run a task yet needs it before anything else there. Both tiers: getting to green is not an
|
|
567
|
+
// advanced job. Gated on either permission, since its rows open both kinds of surface. Not
|
|
568
|
+
// `intake`: the designer surface never configures the platform, and the setup banners give it
|
|
569
|
+
// no actionable card either. Each row still applies the banners' per-owner rule
|
|
570
|
+
// (`utils/setupAudience.ts`), because holding one of these permissions does not make a caller
|
|
571
|
+
// the owner of every gap.
|
|
572
|
+
id: 'setup-checklist',
|
|
573
|
+
labelKey: 'nav.setupChecklist',
|
|
574
|
+
icon: 'i-lucide-list-checks',
|
|
575
|
+
surfaces: S('sidebar', 'command'),
|
|
576
|
+
gate: (g) => g.canManageIntegrations || g.canManageSettings,
|
|
577
|
+
action: 'setupChecklist',
|
|
578
|
+
testId: 'nav-setup-checklist',
|
|
579
|
+
sidebar: { group: 'configuration', order: 5 },
|
|
580
|
+
command: {
|
|
581
|
+
group: 'workspace',
|
|
582
|
+
order: 120,
|
|
583
|
+
labelKey: 'layout.commandBar.cmd.setupChecklist',
|
|
584
|
+
keywordsKey: 'layout.commandBar.keywords.setupChecklist',
|
|
585
|
+
},
|
|
586
|
+
},
|
|
541
587
|
{
|
|
542
588
|
id: 'model-config',
|
|
543
589
|
labelKey: 'nav.modelConfiguration',
|
package/app/pages/index.vue
CHANGED
|
@@ -104,6 +104,9 @@ const ModelProvidersHub = defineAsyncView(() => import('~/components/layout/Mode
|
|
|
104
104
|
const PersonalSetupModal = defineAsyncView(
|
|
105
105
|
() => import('~/components/layout/PersonalSetupModal.vue'),
|
|
106
106
|
)
|
|
107
|
+
const SetupChecklistPanel = defineAsyncView(
|
|
108
|
+
() => import('~/components/setup/SetupChecklistPanel.vue'),
|
|
109
|
+
)
|
|
107
110
|
const WorkspaceSettingsPanel = defineAsyncView(
|
|
108
111
|
() => import('~/components/settings/WorkspaceSettingsPanel.vue'),
|
|
109
112
|
)
|
|
@@ -161,6 +164,7 @@ const TutorialNudge = defineAsyncView(() => import('~/components/tutorial/Tutori
|
|
|
161
164
|
const RolePrompt = defineAsyncView(() => import('~/components/layout/RolePrompt.vue'))
|
|
162
165
|
// The queue: the board's second view (issue #2258), loaded the first time someone opens it.
|
|
163
166
|
const QueueView = defineAsyncView(() => import('~/components/queue/QueueView.vue'))
|
|
167
|
+
const DescribeWorkModal = defineAsyncView(() => import('~/components/queue/DescribeWorkModal.vue'))
|
|
164
168
|
|
|
165
169
|
const workspace = useWorkspaceStore()
|
|
166
170
|
const github = useGitHubStore()
|
|
@@ -529,6 +533,7 @@ watch(
|
|
|
529
533
|
<AddTaskModal />
|
|
530
534
|
<ReviewFrictionDialog v-if="ui.reviewFrictionContext" />
|
|
531
535
|
<CreateInitiativeModal />
|
|
536
|
+
<DescribeWorkModal v-if="ui.describeWorkOpen" />
|
|
532
537
|
<CommandBar />
|
|
533
538
|
<PersonalCredentialModal />
|
|
534
539
|
<ConfirmDialog />
|
|
@@ -564,6 +569,7 @@ watch(
|
|
|
564
569
|
<IntegrationsHub v-if="ui.integrationsOpen" />
|
|
565
570
|
<ModelProvidersHub v-if="ui.modelProvidersOpen" />
|
|
566
571
|
<PersonalSetupModal v-if="ui.personalSetupOpen" />
|
|
572
|
+
<SetupChecklistPanel v-if="ui.setupChecklistOpen" />
|
|
567
573
|
<WorkspaceSettingsPanel v-if="ui.workspaceSettingsOpen" />
|
|
568
574
|
<AccountSettingsPanel v-if="ui.accountSettingsOpen" />
|
|
569
575
|
<ObservabilityConnectionPanel v-if="ui.observabilityConnectionOpen" />
|
|
@@ -26,6 +26,7 @@ export interface GitHubStoreContext {
|
|
|
26
26
|
api: ReturnType<typeof useApi>
|
|
27
27
|
workspace: ReturnType<typeof useWorkspaceStore>
|
|
28
28
|
available: Ref<boolean | null>
|
|
29
|
+
unavailableCause: Ref<'disabled' | 'failed' | null>
|
|
29
30
|
connection: Ref<GitHubConnection | null>
|
|
30
31
|
/** The connect surfaces this deployment can serve (GitHub App / GitLab PAT / …). */
|
|
31
32
|
connectOptions: Ref<VcsConnectOption[]>
|
|
@@ -2,6 +2,7 @@ import type { Ref } from 'vue'
|
|
|
2
2
|
import type { GitHubPatCheck } from '~/types/domain'
|
|
3
3
|
import { useSingleFlightProbe } from '~/composables/useSingleFlightProbe'
|
|
4
4
|
import type { GitHubStoreContext } from '~/stores/github/context'
|
|
5
|
+
import { apiErrorStatus } from '~/composables/api/errors'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* The board-load probe: everything the store learns about the deployment's VCS setup in one round
|
|
@@ -37,7 +38,7 @@ export function createGitHubProbe(
|
|
|
37
38
|
ctx: GitHubStoreContext,
|
|
38
39
|
patCheck: Ref<GitHubPatCheck | null>,
|
|
39
40
|
): { probe: () => Promise<void>; ensureProbed: () => Promise<void> } {
|
|
40
|
-
const { api, workspace, available, connection, connectOptions } = ctx
|
|
41
|
+
const { api, workspace, available, unavailableCause, connection, connectOptions } = ctx
|
|
41
42
|
|
|
42
43
|
async function runPatCheck(): Promise<void> {
|
|
43
44
|
// Which board asked, captured BEFORE the await: a workspace switch mid-flight must not land
|
|
@@ -65,11 +66,14 @@ export function createGitHubProbe(
|
|
|
65
66
|
.catch(() => []),
|
|
66
67
|
])
|
|
67
68
|
available.value = true
|
|
69
|
+
unavailableCause.value = null
|
|
68
70
|
connection.value = conn
|
|
69
71
|
connectOptions.value = options
|
|
70
|
-
} catch {
|
|
71
|
-
// 503 (integration disabled) or any error → hide the UI entry points.
|
|
72
|
+
} catch (err) {
|
|
73
|
+
// 503 (integration disabled) or any error → hide the UI entry points. The cause is kept, so
|
|
74
|
+
// a surface that states the setup can tell "switched off" from "could not ask".
|
|
72
75
|
available.value = false
|
|
76
|
+
unavailableCause.value = apiErrorStatus(err) === 503 ? 'disabled' : 'failed'
|
|
73
77
|
connection.value = null
|
|
74
78
|
connectOptions.value = []
|
|
75
79
|
}
|
package/app/stores/github.ts
CHANGED
|
@@ -38,6 +38,12 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
38
38
|
|
|
39
39
|
/** null = unknown (not probed yet), true/false = integration on/off. */
|
|
40
40
|
const available = ref<boolean | null>(null)
|
|
41
|
+
/**
|
|
42
|
+
* Why `available` is false: the deployment switched the integration off (a 503), or the probe
|
|
43
|
+
* itself failed. Both hide the GitHub UI, but only the first is a fact about the deployment;
|
|
44
|
+
* the second is an unanswered question. Null while `available` is not false.
|
|
45
|
+
*/
|
|
46
|
+
const unavailableCause = ref<'disabled' | 'failed' | null>(null)
|
|
41
47
|
/** The workspace's VCS connection (App installation or PAT), or null when not connected. */
|
|
42
48
|
const connection = ref<GitHubConnection | null>(null)
|
|
43
49
|
/** The connect surfaces this deployment serves; resolved by the probe alongside `connection`. */
|
|
@@ -160,6 +166,7 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
160
166
|
api,
|
|
161
167
|
workspace,
|
|
162
168
|
available,
|
|
169
|
+
unavailableCause,
|
|
163
170
|
connection,
|
|
164
171
|
connectOptions,
|
|
165
172
|
installations,
|
|
@@ -207,6 +214,7 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
207
214
|
*/
|
|
208
215
|
function reset() {
|
|
209
216
|
available.value = null
|
|
217
|
+
unavailableCause.value = null
|
|
210
218
|
connection.value = null
|
|
211
219
|
connectOptions.value = []
|
|
212
220
|
patCheck.value = null
|
|
@@ -221,6 +229,7 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
221
229
|
|
|
222
230
|
return {
|
|
223
231
|
available,
|
|
232
|
+
unavailableCause,
|
|
224
233
|
connection,
|
|
225
234
|
connectOptions,
|
|
226
235
|
patCheck,
|
package/app/stores/ui/modals.ts
CHANGED
|
@@ -27,6 +27,14 @@ export interface AddTaskPrefill {
|
|
|
27
27
|
description?: string
|
|
28
28
|
/** Context items staged on the new task (e.g. the source issue), linked once created. */
|
|
29
29
|
context?: PendingContext[]
|
|
30
|
+
/**
|
|
31
|
+
* Choices a person already made on another surface (the one-sentence intake), carried over so
|
|
32
|
+
* the hand-off does not quietly reset them. Absent means the form's own defaults.
|
|
33
|
+
*/
|
|
34
|
+
taskType?: 'feature' | 'bug'
|
|
35
|
+
pipelineId?: string
|
|
36
|
+
riskPolicyId?: string
|
|
37
|
+
modelPresetId?: string
|
|
30
38
|
}
|
|
31
39
|
|
|
32
40
|
/**
|
|
@@ -90,6 +98,11 @@ export interface ReviewFrictionModalContext {
|
|
|
90
98
|
* started a second pipeline run against the same request.
|
|
91
99
|
*/
|
|
92
100
|
pending?: () => boolean
|
|
101
|
+
/**
|
|
102
|
+
* Close the form that raised the friction. "Go review" and each debt row leave for a waiting
|
|
103
|
+
* task, and the form must not stay open over it; only the opener knows which form it is.
|
|
104
|
+
*/
|
|
105
|
+
onLeave: () => void
|
|
93
106
|
}
|
|
94
107
|
|
|
95
108
|
/** Clears both hub came-from markers; injected into the slices whose `open*` handlers reset them. */
|
|
@@ -340,6 +353,17 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
|
|
|
340
353
|
function closeStartFromDesign() {
|
|
341
354
|
startFromDesign.value = null
|
|
342
355
|
}
|
|
356
|
+
// The one-sentence intake ("Describe new work", issue #2258): open, and the service it opened
|
|
357
|
+
// on, or null to let the reader pick one. The full form above stays the home of every other kind.
|
|
358
|
+
const describeWorkOpen = ref(false)
|
|
359
|
+
const describeWorkServiceId = ref<string | null>(null)
|
|
360
|
+
function openDescribeWork(serviceId: string | null = null) {
|
|
361
|
+
describeWorkServiceId.value = serviceId
|
|
362
|
+
describeWorkOpen.value = true
|
|
363
|
+
}
|
|
364
|
+
function closeDescribeWork() {
|
|
365
|
+
describeWorkOpen.value = false
|
|
366
|
+
}
|
|
343
367
|
function openAddTask(containerId: string, prefill: AddTaskPrefill | null = null) {
|
|
344
368
|
addTaskPrefill.value = prefill
|
|
345
369
|
addTaskContainerId.value = containerId
|
|
@@ -405,6 +429,10 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
|
|
|
405
429
|
closeStartFromDesign,
|
|
406
430
|
openAddTask,
|
|
407
431
|
closeAddTask,
|
|
432
|
+
describeWorkOpen,
|
|
433
|
+
describeWorkServiceId,
|
|
434
|
+
openDescribeWork,
|
|
435
|
+
closeDescribeWork,
|
|
408
436
|
openReviewFriction,
|
|
409
437
|
closeReviewFriction,
|
|
410
438
|
openAddRecurring,
|
|
@@ -728,6 +756,17 @@ function createSettingsModals(resetHubReturn: ResetHubReturn) {
|
|
|
728
756
|
// so a later plain open doesn't re-scroll. Null when no section was requested.
|
|
729
757
|
const accountSettingsScrollTarget = ref<string | null>(null)
|
|
730
758
|
|
|
759
|
+
// Setup checklist: every prerequisite for "a task can run and merge" in one list, composed
|
|
760
|
+
// from the probes the setup banners already read (issue #2258).
|
|
761
|
+
const setupChecklistOpen = ref(false)
|
|
762
|
+
function openSetupChecklist() {
|
|
763
|
+
resetHubReturn()
|
|
764
|
+
setupChecklistOpen.value = true
|
|
765
|
+
}
|
|
766
|
+
function closeSetupChecklist() {
|
|
767
|
+
setupChecklistOpen.value = false
|
|
768
|
+
}
|
|
769
|
+
|
|
731
770
|
function openWorkspaceSettings(tab = 'workspace') {
|
|
732
771
|
resetHubReturn()
|
|
733
772
|
workspaceSettingsTab.value = tab
|
|
@@ -765,6 +804,9 @@ function createSettingsModals(resetHubReturn: ResetHubReturn) {
|
|
|
765
804
|
}
|
|
766
805
|
|
|
767
806
|
return {
|
|
807
|
+
setupChecklistOpen,
|
|
808
|
+
openSetupChecklist,
|
|
809
|
+
closeSetupChecklist,
|
|
768
810
|
workspaceSettingsOpen,
|
|
769
811
|
workspaceSettingsTab,
|
|
770
812
|
accountSettingsOpen,
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { DERIVED_TITLE_MAX, splitDescribedWork } from '~/utils/describeWork'
|
|
3
|
+
|
|
4
|
+
describe('splitDescribedWork', () => {
|
|
5
|
+
it('uses one short sentence as the title and adds no description', () => {
|
|
6
|
+
expect(splitDescribedWork(' Fix the login redirect ')).toEqual({
|
|
7
|
+
title: 'Fix the login redirect',
|
|
8
|
+
})
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('titles with the first sentence and keeps the WHOLE text as the description', () => {
|
|
12
|
+
const text = 'Add an audit log. Record each sign-in with the time and the user.'
|
|
13
|
+
expect(splitDescribedWork(text)).toEqual({ title: 'Add an audit log', description: text })
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('reads only the first line for the title', () => {
|
|
17
|
+
const text = 'Rate limit per API key\nUse a token bucket. 100 requests a minute.'
|
|
18
|
+
expect(splitDescribedWork(text)).toEqual({ title: 'Rate limit per API key', description: text })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('shortens a long first sentence at a word boundary, with an ellipsis', () => {
|
|
22
|
+
const words = 'word '.repeat(40).trim()
|
|
23
|
+
const { title, description } = splitDescribedWork(words)
|
|
24
|
+
expect(title.length).toBeLessThanOrEqual(DERIVED_TITLE_MAX)
|
|
25
|
+
expect(title.endsWith('word…')).toBe(true)
|
|
26
|
+
expect(description).toBe(words)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('does not cut a dotted name such as a file or a version', () => {
|
|
30
|
+
expect(splitDescribedWork('Bump vite to 7.1 in package.json').title).toBe(
|
|
31
|
+
'Bump vite to 7.1 in package.json',
|
|
32
|
+
)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('does not end a sentence at a common abbreviation', () => {
|
|
36
|
+
const text = 'Fix e.g. the login redirect loop. It happens on Safari.'
|
|
37
|
+
expect(splitDescribedWork(text)).toEqual({
|
|
38
|
+
title: 'Fix e.g. the login redirect loop',
|
|
39
|
+
description: text,
|
|
40
|
+
})
|
|
41
|
+
expect(splitDescribedWork('Ask Dr. Smith about the export. She owns it.').title).toBe(
|
|
42
|
+
'Ask Dr. Smith about the export',
|
|
43
|
+
)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('treats a run of dots as one mark', () => {
|
|
47
|
+
expect(splitDescribedWork('Fix this... later, after the release.').title).toBe('Fix this')
|
|
48
|
+
expect(splitDescribedWork('Use a cache, e.g. Redis, for sessions.').title).toBe(
|
|
49
|
+
'Use a cache, e.g. Redis, for sessions',
|
|
50
|
+
)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('ends a sentence at a full-width mark, with or without a space after it', () => {
|
|
54
|
+
const text = '修复登录重定向。记录每次登录。'
|
|
55
|
+
expect(splitDescribedWork(text)).toEqual({ title: '修复登录重定向', description: text })
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('keeps a question or an exclamation mark, and drops a period', () => {
|
|
59
|
+
expect(splitDescribedWork('Why does login fail?')).toEqual({ title: 'Why does login fail?' })
|
|
60
|
+
// A final period says nothing the title does not, so it adds no description.
|
|
61
|
+
expect(splitDescribedWork('Fix the login redirect.')).toEqual({
|
|
62
|
+
title: 'Fix the login redirect',
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('never cuts an emoji in half when it shortens', () => {
|
|
67
|
+
const text = `${'a'.repeat(DERIVED_TITLE_MAX - 2)}😀😀😀 more words after it`
|
|
68
|
+
const { title } = splitDescribedWork(text)
|
|
69
|
+
expect(title).not.toMatch(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/)
|
|
70
|
+
expect(Array.from(title).length).toBeLessThanOrEqual(DERIVED_TITLE_MAX)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('returns an empty title for blank input, so the dialog cannot submit it', () => {
|
|
74
|
+
expect(splitDescribedWork(' \n ')).toEqual({ title: '' })
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** The longest title the one-box intake derives. Long enough for a sentence, short for a card. */
|
|
2
|
+
export const DERIVED_TITLE_MAX = 80
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Abbreviations whose period does not end a sentence. Only the common ones a task description
|
|
6
|
+
* starts with: the title is derived, so a miss costs a short title, never lost text (the whole text
|
|
7
|
+
* is the description).
|
|
8
|
+
*/
|
|
9
|
+
const ABBREVIATIONS = new Set(['e.g', 'i.e', 'etc', 'vs', 'cf', 'dr', 'mr', 'mrs', 'ms', 'st'])
|
|
10
|
+
|
|
11
|
+
/** Ends a sentence. Full-width marks end one with or without a following space. */
|
|
12
|
+
const FULL_WIDTH_END = new Set(['。', '!', '?'])
|
|
13
|
+
const KEPT_END = new Set(['?', '!', '?', '!'])
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Split what a person typed into the one-box intake into a task title and a description.
|
|
17
|
+
*
|
|
18
|
+
* The title is the first line, cut at its first sentence end, and shortened at a word boundary
|
|
19
|
+
* to {@link DERIVED_TITLE_MAX} with an ellipsis. A question or an exclamation keeps its mark, which
|
|
20
|
+
* is part of what it says; a period does not. The description is the WHOLE text, unedited,
|
|
21
|
+
* whenever it says more than the title does: nothing the person wrote is dropped, and the agents
|
|
22
|
+
* read the description, never the title alone. Returns an empty title for blank input.
|
|
23
|
+
*/
|
|
24
|
+
export function splitDescribedWork(text: string): { title: string; description?: string } {
|
|
25
|
+
const body = text.trim()
|
|
26
|
+
if (!body) return { title: '' }
|
|
27
|
+
const firstLine = body.split(/\r?\n/, 1)[0]!.trim()
|
|
28
|
+
const title = shorten(firstSentence(firstLine) || firstLine)
|
|
29
|
+
return stripEnd(title) === stripEnd(body) ? { title } : { title, description: body }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The first sentence of a line, ending mark included only when it is `?` or `!`. */
|
|
33
|
+
function firstSentence(line: string): string {
|
|
34
|
+
const chars = Array.from(line)
|
|
35
|
+
for (let i = 0; i < chars.length; i++) {
|
|
36
|
+
const c = chars[i]!
|
|
37
|
+
const next = chars[i + 1]
|
|
38
|
+
// A run of dots ("this... later") is ONE mark: only its last dot can end the sentence, and
|
|
39
|
+
// the whole run is cut with it.
|
|
40
|
+
if (c === '.' && next === '.') continue
|
|
41
|
+
const ends =
|
|
42
|
+
FULL_WIDTH_END.has(c) || ('.!?'.includes(c) && (next === undefined || /\s/.test(next)))
|
|
43
|
+
if (!ends) continue
|
|
44
|
+
if (c === '.' && isAbbreviation(chars.slice(0, i).join(''))) continue
|
|
45
|
+
let end = KEPT_END.has(c) ? i + 1 : i
|
|
46
|
+
while (c === '.' && end > 0 && chars[end - 1] === '.') end--
|
|
47
|
+
return chars.slice(0, end).join('').trim()
|
|
48
|
+
}
|
|
49
|
+
return line
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Whether the word before a period is an abbreviation ("e.g", "Dr"), not a sentence's last word. */
|
|
53
|
+
function isAbbreviation(before: string): boolean {
|
|
54
|
+
const word = before.split(/\s/).pop() ?? ''
|
|
55
|
+
return ABBREVIATIONS.has(word.toLowerCase())
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The text without its final sentence mark, so "Fix the login." says no more than "Fix the login". */
|
|
59
|
+
function stripEnd(s: string): string {
|
|
60
|
+
return s.replace(/[.!?。!?…]+$/u, '')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Shorten by CODE POINTS, so a cut never splits an emoji or another astral character in half. */
|
|
64
|
+
function shorten(line: string): string {
|
|
65
|
+
const chars = Array.from(line)
|
|
66
|
+
if (chars.length <= DERIVED_TITLE_MAX) return line
|
|
67
|
+
const cut = chars.slice(0, DERIVED_TITLE_MAX - 1).join('')
|
|
68
|
+
const lastSpace = cut.lastIndexOf(' ')
|
|
69
|
+
const keep = lastSpace > cut.length / 2 ? cut.slice(0, lastSpace) : cut
|
|
70
|
+
return `${keep.trimEnd()}…`
|
|
71
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ReviewFrictionModalContext } from '~/stores/ui/modals'
|
|
2
|
+
import type { parseConflict } from '~/composables/usePipelineErrorToast'
|
|
3
|
+
|
|
4
|
+
/** A conflict `parseConflict` recognised. */
|
|
5
|
+
type ParsedConflict = NonNullable<ReturnType<typeof parseConflict>>
|
|
6
|
+
|
|
7
|
+
/** Whether a create was refused by the opt-in review-debt friction gate. */
|
|
8
|
+
export function isReviewDebtConflict(conflict: ParsedConflict | null): conflict is ParsedConflict {
|
|
9
|
+
return conflict?.reason === 'review_debt_warn' || conflict?.reason === 'review_debt_blocked'
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Turn a parsed review-debt friction 409 into the dialog's context (see ReviewFrictionDialog.vue).
|
|
14
|
+
*
|
|
15
|
+
* Shared by every surface that creates a task, so the friction reads the same whichever form
|
|
16
|
+
* raised it. `retry` re-submits with `acknowledgeReviewDebt` and is offered only on the soft
|
|
17
|
+
* `warn` tier; `pending` is a GETTER over the opener's own in-flight flag, for the reason the
|
|
18
|
+
* context type documents; `leave` closes the opener when the person leaves to review.
|
|
19
|
+
*/
|
|
20
|
+
export function reviewFrictionContext(
|
|
21
|
+
conflict: ParsedConflict,
|
|
22
|
+
retry: () => void,
|
|
23
|
+
pending: () => boolean,
|
|
24
|
+
leave: () => void,
|
|
25
|
+
): ReviewFrictionModalContext {
|
|
26
|
+
const details = conflict.details
|
|
27
|
+
const rawDebt = Array.isArray(details.debt) ? details.debt : []
|
|
28
|
+
const debt = rawDebt.map((d) => {
|
|
29
|
+
const row = (d ?? {}) as { blockId?: unknown; title?: unknown; waitingMinutes?: unknown }
|
|
30
|
+
return {
|
|
31
|
+
blockId: typeof row.blockId === 'string' ? row.blockId : '',
|
|
32
|
+
title: typeof row.title === 'string' ? row.title : null,
|
|
33
|
+
waitingMinutes: typeof row.waitingMinutes === 'number' ? row.waitingMinutes : 0,
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
const isWarn = conflict.reason === 'review_debt_warn'
|
|
37
|
+
return {
|
|
38
|
+
kind: isWarn ? 'warn' : 'blocked',
|
|
39
|
+
reason:
|
|
40
|
+
details.friction === 'count' || details.friction === 'stuck' ? details.friction : undefined,
|
|
41
|
+
threshold: typeof details.threshold === 'number' ? details.threshold : null,
|
|
42
|
+
debt,
|
|
43
|
+
onConfirm: isWarn ? retry : null,
|
|
44
|
+
pending,
|
|
45
|
+
onLeave: leave,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { InfraSetup } from '@cat-factory/contracts'
|
|
3
|
+
import { deriveSetupChecklist, type SetupProbeInput } from '~/utils/setupChecklist'
|
|
4
|
+
import type { SetupActor } from '~/utils/setupAudience'
|
|
5
|
+
|
|
6
|
+
// The checklist adds no probe of its own; what it owes is to state each probe's answer without
|
|
7
|
+
// changing its meaning. Three rules matter most: an unanswered probe is never "missing" or
|
|
8
|
+
// "done", a broken setup is never "missing" (that would send the reader to create a second one),
|
|
9
|
+
// and a row only offers its action to someone whose save would land.
|
|
10
|
+
|
|
11
|
+
const ALL_CONFIGURED: InfraSetup = {
|
|
12
|
+
agentExecutor: 'configured',
|
|
13
|
+
ephemeralEnvironments: 'configured',
|
|
14
|
+
binaryStorage: 'configured',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ADMIN: SetupActor = {
|
|
18
|
+
fullSurface: true,
|
|
19
|
+
canManageIntegrations: true,
|
|
20
|
+
canExecuteRuns: true,
|
|
21
|
+
isAccountAdmin: true,
|
|
22
|
+
accountsEnabled: true,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SOURCE_OK: SetupProbeInput['sourceControl'] = {
|
|
26
|
+
available: true,
|
|
27
|
+
unavailableCause: null,
|
|
28
|
+
connected: true,
|
|
29
|
+
patMissing: false,
|
|
30
|
+
patProblem: null,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function input(overrides: Partial<SetupProbeInput> = {}): SetupProbeInput {
|
|
34
|
+
return {
|
|
35
|
+
model: { loaded: true, loadFailed: false, usable: true, presetBroken: false },
|
|
36
|
+
sourceControl: SOURCE_OK,
|
|
37
|
+
infra: ALL_CONFIGURED,
|
|
38
|
+
workspaceLoaded: true,
|
|
39
|
+
infraOwners: null,
|
|
40
|
+
providers: { loaded: true, needingConfig: [] },
|
|
41
|
+
actor: ADMIN,
|
|
42
|
+
...overrides,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function row(i: SetupProbeInput, id: string) {
|
|
47
|
+
const found = deriveSetupChecklist(i).rows.find((r) => r.id === id)
|
|
48
|
+
if (!found) throw new Error(`no row ${id}`)
|
|
49
|
+
return found
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('deriveSetupChecklist', () => {
|
|
53
|
+
it('reports a fully set-up workspace as done, with nothing left checking', () => {
|
|
54
|
+
const c = deriveSetupChecklist(input())
|
|
55
|
+
expect(c.done).toBe(c.total)
|
|
56
|
+
expect(c.checking).toBe(false)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('reports an unanswered probe as checking, never as missing or done', () => {
|
|
60
|
+
const c = deriveSetupChecklist(
|
|
61
|
+
input({
|
|
62
|
+
model: { loaded: false, loadFailed: false, usable: false, presetBroken: false },
|
|
63
|
+
sourceControl: { ...SOURCE_OK, available: null, connected: false },
|
|
64
|
+
infra: null,
|
|
65
|
+
workspaceLoaded: false,
|
|
66
|
+
}),
|
|
67
|
+
)
|
|
68
|
+
expect(c.rows.every((r) => r.status === 'checking')).toBe(true)
|
|
69
|
+
expect(c.checking).toBe(true)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('keeps a configured runner checking until the providers say whether it is complete', () => {
|
|
73
|
+
const waiting = input({ providers: { loaded: false, needingConfig: [] } })
|
|
74
|
+
expect(row(waiting, 'agentExecutor').status).toBe('checking')
|
|
75
|
+
expect(deriveSetupChecklist(waiting).checking).toBe(true)
|
|
76
|
+
// Storage has no provider connection, so it does not wait on them.
|
|
77
|
+
expect(row(waiting, 'binaryStorage').status).toBe('done')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('names the cause of each broken setup, and tells it from an absent one', () => {
|
|
81
|
+
expect(
|
|
82
|
+
row(input({ infra: { ...ALL_CONFIGURED, agentExecutor: 'unreachable' } }), 'agentExecutor'),
|
|
83
|
+
).toMatchObject({ status: 'attention', cause: 'unreachable' })
|
|
84
|
+
expect(
|
|
85
|
+
row(input({ providers: { loaded: true, needingConfig: ['runner-pool'] } }), 'agentExecutor'),
|
|
86
|
+
).toMatchObject({ status: 'attention', cause: 'incomplete' })
|
|
87
|
+
expect(
|
|
88
|
+
row(input({ infra: { ...ALL_CONFIGURED, agentExecutor: 'not_defined' } }), 'agentExecutor'),
|
|
89
|
+
).toMatchObject({ status: 'missing', cause: null })
|
|
90
|
+
expect(
|
|
91
|
+
row(
|
|
92
|
+
input({ model: { loaded: true, loadFailed: false, usable: true, presetBroken: true } }),
|
|
93
|
+
'model',
|
|
94
|
+
),
|
|
95
|
+
).toMatchObject({ status: 'attention', cause: 'presetBroken' })
|
|
96
|
+
expect(
|
|
97
|
+
row(input({ sourceControl: { ...SOURCE_OK, patProblem: 'rejected' } }), 'sourceControl'),
|
|
98
|
+
).toMatchObject({ status: 'attention', cause: 'rejected' })
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('states a failed probe as unanswered, never as an absent capability', () => {
|
|
102
|
+
expect(
|
|
103
|
+
row(
|
|
104
|
+
input({ model: { loaded: false, loadFailed: true, usable: false, presetBroken: false } }),
|
|
105
|
+
'model',
|
|
106
|
+
),
|
|
107
|
+
).toMatchObject({ status: 'attention', cause: 'probeFailed' })
|
|
108
|
+
const failed = input({
|
|
109
|
+
sourceControl: {
|
|
110
|
+
...SOURCE_OK,
|
|
111
|
+
available: false,
|
|
112
|
+
unavailableCause: 'failed',
|
|
113
|
+
connected: false,
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
expect(row(failed, 'sourceControl')).toMatchObject({
|
|
117
|
+
status: 'attention',
|
|
118
|
+
cause: 'probeFailed',
|
|
119
|
+
})
|
|
120
|
+
// A deployment that switched the integration off does not use it: no gap to close.
|
|
121
|
+
const disabled = input({
|
|
122
|
+
sourceControl: {
|
|
123
|
+
...SOURCE_OK,
|
|
124
|
+
available: false,
|
|
125
|
+
unavailableCause: 'disabled',
|
|
126
|
+
connected: false,
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
expect(row(disabled, 'sourceControl').status).toBe('not_needed')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('says so when the loaded workspace does not report its infrastructure, never spins', () => {
|
|
133
|
+
const i = input({ infra: null, workspaceLoaded: true })
|
|
134
|
+
expect(row(i, 'agentExecutor')).toMatchObject({ status: 'attention', cause: 'notReported' })
|
|
135
|
+
expect(deriveSetupChecklist(i).checking).toBe(false)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('leaves an area the deployment does not use out of the count', () => {
|
|
139
|
+
const c = deriveSetupChecklist(
|
|
140
|
+
input({ infra: { ...ALL_CONFIGURED, ephemeralEnvironments: 'not_applicable' } }),
|
|
141
|
+
)
|
|
142
|
+
expect(c.total).toBe(c.rows.length - 1)
|
|
143
|
+
expect(c.done).toBe(c.total)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
describe('who can act on a row (the banners rule, `utils/setupAudience.ts`)', () => {
|
|
147
|
+
const unconfigured: InfraSetup = {
|
|
148
|
+
agentExecutor: 'not_defined',
|
|
149
|
+
ephemeralEnvironments: 'not_defined',
|
|
150
|
+
binaryStorage: 'not_defined',
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
it('offers a board admin who is not an account admin only the gaps they own', () => {
|
|
154
|
+
const boardAdmin = { ...ADMIN, isAccountAdmin: false }
|
|
155
|
+
const i = input({
|
|
156
|
+
infra: unconfigured,
|
|
157
|
+
infraOwners: {
|
|
158
|
+
agentExecutor: 'workspace_admin',
|
|
159
|
+
ephemeralEnvironments: 'operator',
|
|
160
|
+
binaryStorage: 'account_admin',
|
|
161
|
+
},
|
|
162
|
+
actor: boardAdmin,
|
|
163
|
+
})
|
|
164
|
+
expect(row(i, 'agentExecutor').remedy).toBeNull()
|
|
165
|
+
expect(row(i, 'ephemeralEnvironments').remedy).toBe('operator')
|
|
166
|
+
expect(row(i, 'binaryStorage').remedy).toBe('account_admin')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('tells someone on the designer surface they can fix it themselves', () => {
|
|
170
|
+
const i = input({ infra: unconfigured, actor: { ...ADMIN, fullSurface: false } })
|
|
171
|
+
expect(row(i, 'agentExecutor').remedy).toBe('full_surface')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('gives every caller the token page for a personal access token', () => {
|
|
175
|
+
const member = { ...ADMIN, canManageIntegrations: false, isAccountAdmin: false }
|
|
176
|
+
const i = input({
|
|
177
|
+
sourceControl: { ...SOURCE_OK, patMissing: true, connected: false },
|
|
178
|
+
actor: member,
|
|
179
|
+
})
|
|
180
|
+
expect(row(i, 'sourceControl').remedy).toBeNull()
|
|
181
|
+
expect(row(input({ actor: member }), 'model').remedy).toBe('workspace_admin')
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
})
|