@cat-factory/app 0.162.2 → 0.164.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 +4 -1
- package/app/components/board/RecurringPipelineModal.vue +9 -22
- package/app/components/focus/BlockFocusView.vue +25 -19
- package/app/components/layout/CommandBar.vue +8 -0
- package/app/components/layout/IntegrationsHub.vue +7 -0
- package/app/components/panels/InspectorPanel.vue +13 -0
- package/app/components/pipeline/PipelinePicker.vue +38 -16
- package/app/components/pipeline/PipelinePreview.vue +65 -23
- package/app/components/tasks/BugHuntModal.vue +361 -0
- package/app/components/tasks/ContextIssuePicker.logic.spec.ts +89 -0
- package/app/components/tasks/ContextIssuePicker.logic.ts +74 -0
- package/app/components/tasks/ContextIssuePicker.vue +71 -21
- package/app/components/tasks/TaskImportModal.vue +2 -4
- package/app/composables/api/bugHunt.ts +40 -0
- package/app/composables/useApi.ts +2 -0
- package/app/pages/index.vue +2 -0
- package/app/stores/bugHunt.spec.ts +103 -0
- package/app/stores/bugHunt.ts +148 -0
- package/app/stores/ui/modals.ts +14 -0
- package/app/types/bugHunt.ts +21 -0
- package/app/types/domain.ts +1 -0
- package/app/utils/pipeline.spec.ts +41 -1
- package/app/utils/pipeline.ts +9 -0
- package/i18n/locales/de.json +61 -5
- package/i18n/locales/en.json +64 -5
- package/i18n/locales/es.json +61 -5
- package/i18n/locales/fr.json +61 -5
- package/i18n/locales/he.json +61 -5
- package/i18n/locales/it.json +61 -5
- package/i18n/locales/ja.json +61 -5
- package/i18n/locales/pl.json +61 -5
- package/i18n/locales/tr.json +61 -5
- package/i18n/locales/uk.json +61 -5
- package/package.json +2 -2
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import {
|
|
2
|
+
adoptBugHuntCandidateContract,
|
|
3
|
+
listTrackerBoardsContract,
|
|
4
|
+
runBugHuntContract,
|
|
5
|
+
} from '@cat-factory/contracts'
|
|
6
|
+
import type { RunBugHuntInput, TaskSourceKind } from '~/types/domain'
|
|
7
|
+
import type { ApiContext } from './context'
|
|
8
|
+
|
|
9
|
+
/** Bug hunt: list a tracker's boards, rank a board's open unassigned bugs, adopt one. */
|
|
10
|
+
export function bugHuntApi({ send, sendWith, ws, pwHeaders }: ApiContext) {
|
|
11
|
+
return {
|
|
12
|
+
// The boards a hunt can run against (Jira projects / Linear teams / GitHub repos). Errors
|
|
13
|
+
// when the source can't enumerate them, which the modal turns into a free-text board field.
|
|
14
|
+
listTrackerBoards: (workspaceId: string, source: TaskSourceKind) =>
|
|
15
|
+
send(listTrackerBoardsContract, { pathPrefix: ws(workspaceId), pathParams: { source } }),
|
|
16
|
+
|
|
17
|
+
// Scan the board for open, unassigned bugs and rank them by impact vs complexity. A live
|
|
18
|
+
// external call plus a model call, so it can take a while — the modal shows progress.
|
|
19
|
+
runBugHunt: (workspaceId: string, source: TaskSourceKind, body: RunBugHuntInput) =>
|
|
20
|
+
send(runBugHuntContract, {
|
|
21
|
+
pathPrefix: ws(workspaceId),
|
|
22
|
+
pathParams: { source },
|
|
23
|
+
body,
|
|
24
|
+
}),
|
|
25
|
+
|
|
26
|
+
// Adopt the confirmed candidate as a bug task and start its run. Carries the personal
|
|
27
|
+
// password header when the pipeline resolves an individual-usage model, like every start.
|
|
28
|
+
adoptBugHuntCandidate: (
|
|
29
|
+
workspaceId: string,
|
|
30
|
+
source: TaskSourceKind,
|
|
31
|
+
body: { externalId: string; containerId: string; pipelineId?: string },
|
|
32
|
+
password?: string,
|
|
33
|
+
) =>
|
|
34
|
+
sendWith(pwHeaders(password), adoptBugHuntCandidateContract, {
|
|
35
|
+
pathPrefix: ws(workspaceId),
|
|
36
|
+
pathParams: { source },
|
|
37
|
+
body,
|
|
38
|
+
}),
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -44,6 +44,7 @@ import { reviewsApi } from './api/reviews'
|
|
|
44
44
|
import { slackApi } from './api/slack'
|
|
45
45
|
import { specApi } from './api/spec'
|
|
46
46
|
import { tasksApi } from './api/tasks'
|
|
47
|
+
import { bugHuntApi } from './api/bugHunt'
|
|
47
48
|
import { testSecretsApi } from './api/testSecrets'
|
|
48
49
|
import { userSecretsApi } from './api/userSecrets'
|
|
49
50
|
import { userSettingsApi } from './api/userSettings'
|
|
@@ -119,6 +120,7 @@ export function useApi() {
|
|
|
119
120
|
...executionApi(ctx),
|
|
120
121
|
...documentsApi(ctx),
|
|
121
122
|
...tasksApi(ctx),
|
|
123
|
+
...bugHuntApi(ctx),
|
|
122
124
|
...reviewsApi(ctx),
|
|
123
125
|
...followUpsApi(ctx),
|
|
124
126
|
...forkDecisionApi(ctx),
|
package/app/pages/index.vue
CHANGED
|
@@ -45,6 +45,7 @@ const TaskSourceConnectModal = defineAsyncComponent(
|
|
|
45
45
|
() => import('~/components/tasks/TaskSourceConnectModal.vue'),
|
|
46
46
|
)
|
|
47
47
|
const TaskImportModal = defineAsyncComponent(() => import('~/components/tasks/TaskImportModal.vue'))
|
|
48
|
+
const BugHuntModal = defineAsyncComponent(() => import('~/components/tasks/BugHuntModal.vue'))
|
|
48
49
|
const RecurringPipelineModal = defineAsyncComponent(
|
|
49
50
|
() => import('~/components/board/RecurringPipelineModal.vue'),
|
|
50
51
|
)
|
|
@@ -398,6 +399,7 @@ watch(
|
|
|
398
399
|
first open (its own chunk) rather than bloating the initial bundle. -->
|
|
399
400
|
<TaskSourceConnectModal v-if="ui.taskConnect" />
|
|
400
401
|
<TaskImportModal v-if="ui.taskImport" />
|
|
402
|
+
<BugHuntModal v-if="ui.bugHunt" />
|
|
401
403
|
<RecurringPipelineModal v-if="ui.addRecurringFrameId" />
|
|
402
404
|
<ObservabilityPanel v-if="ui.observabilityInstanceId" />
|
|
403
405
|
<OperatorDashboardPanel v-if="ui.operatorDashboardOpen" />
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import { useBugHuntStore } from '~/stores/bugHunt'
|
|
3
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
4
|
+
import { ApiError } from '~/composables/api/errors'
|
|
5
|
+
import type { TaskSourceKind, TrackerBoardsView } from '~/types/domain'
|
|
6
|
+
|
|
7
|
+
// What the board picker does with a FAILED board read. Only one failure means "this tracker
|
|
8
|
+
// cannot enumerate boards, so type one in"; every other failure has to stay visible as an error,
|
|
9
|
+
// or a tracker outage silently wears the same clothes as an unsupported provider.
|
|
10
|
+
|
|
11
|
+
function apiError(statusCode: number, code: string, details?: Record<string, unknown>): ApiError {
|
|
12
|
+
return new ApiError(statusCode, { error: { code, message: 'nope', details } })
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The unsupported-source refusal `BugHuntService.listBoards` raises. */
|
|
16
|
+
const boardsUnsupported = () =>
|
|
17
|
+
Promise.reject(apiError(400, 'validation', { reason: 'boards_unsupported' }))
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Stub `useApi` ONCE, behind a handler the test can swap. The store resolves `useApi()` at setup,
|
|
21
|
+
* so re-stubbing after `useBugHuntStore()` would leave it holding the first stub for ever.
|
|
22
|
+
*/
|
|
23
|
+
function stubApi(): {
|
|
24
|
+
store: ReturnType<typeof useBugHuntStore>
|
|
25
|
+
serve: (fn: () => Promise<unknown>) => void
|
|
26
|
+
} {
|
|
27
|
+
let handler: () => Promise<unknown> = () => Promise.resolve({ source: 'jira', boards: [] })
|
|
28
|
+
vi.stubGlobal('useApi', () => ({
|
|
29
|
+
listTrackerBoards: (_ws: string, _source: TaskSourceKind) =>
|
|
30
|
+
handler() as Promise<TrackerBoardsView>,
|
|
31
|
+
}))
|
|
32
|
+
return {
|
|
33
|
+
store: useBugHuntStore(),
|
|
34
|
+
serve: (fn) => {
|
|
35
|
+
handler = fn
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('bug hunt store — board listing failures', () => {
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
useWorkspaceStore().workspaceId = 'ws1'
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('keeps the backend reason so the picker can offer free text for an unsupported source', async () => {
|
|
46
|
+
const { store, serve } = stubApi()
|
|
47
|
+
serve(boardsUnsupported)
|
|
48
|
+
|
|
49
|
+
await store.loadBoards('jira')
|
|
50
|
+
|
|
51
|
+
expect(store.boards).toEqual([])
|
|
52
|
+
expect(store.boardsErrorReason).toBe('boards_unsupported')
|
|
53
|
+
expect(store.boardsError).toBeTruthy()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('records NO reason for a failure that is simply an unreachable tracker', async () => {
|
|
57
|
+
const { store, serve } = stubApi()
|
|
58
|
+
serve(() => Promise.reject(apiError(502, 'upstream')))
|
|
59
|
+
|
|
60
|
+
await store.loadBoards('jira')
|
|
61
|
+
|
|
62
|
+
// The modal keys its free-text fallback on the reason, so a null one keeps this an error.
|
|
63
|
+
expect(store.boardsErrorReason).toBeNull()
|
|
64
|
+
expect(store.boardsError).toBeTruthy()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('clears a previous failure when a later source lists its boards fine', async () => {
|
|
68
|
+
const { store, serve } = stubApi()
|
|
69
|
+
serve(boardsUnsupported)
|
|
70
|
+
await store.loadBoards('jira')
|
|
71
|
+
|
|
72
|
+
serve(() =>
|
|
73
|
+
Promise.resolve({ source: 'linear', boards: [{ id: 't1', name: 'Eng', key: 'ENG' }] }),
|
|
74
|
+
)
|
|
75
|
+
await store.loadBoards('linear')
|
|
76
|
+
|
|
77
|
+
expect(store.boardsError).toBeNull()
|
|
78
|
+
expect(store.boardsErrorReason).toBeNull()
|
|
79
|
+
expect(store.boards.map((b) => b.id)).toEqual(['t1'])
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('a source switch mid-flight never lands the older tracker failure on the newer one', async () => {
|
|
83
|
+
const { store, serve } = stubApi()
|
|
84
|
+
let rejectJira!: (e: unknown) => void
|
|
85
|
+
serve(
|
|
86
|
+
() =>
|
|
87
|
+
new Promise((_res, rej) => {
|
|
88
|
+
rejectJira = rej
|
|
89
|
+
}),
|
|
90
|
+
)
|
|
91
|
+
const inFlight = store.loadBoards('jira')
|
|
92
|
+
|
|
93
|
+
serve(() => Promise.resolve({ source: 'linear', boards: [] }))
|
|
94
|
+
await store.loadBoards('linear')
|
|
95
|
+
rejectJira(apiError(400, 'validation', { reason: 'boards_unsupported' }))
|
|
96
|
+
await inFlight
|
|
97
|
+
|
|
98
|
+
// The slower tracker's refusal must not flip the newer tracker's picker to free text.
|
|
99
|
+
expect(store.boardsSource).toBe('linear')
|
|
100
|
+
expect(store.boardsErrorReason).toBeNull()
|
|
101
|
+
expect(store.boardsError).toBeNull()
|
|
102
|
+
})
|
|
103
|
+
})
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
import type { BugHuntResult, RunBugHuntInput, TaskSourceKind, TrackerBoard } from '~/types/domain'
|
|
4
|
+
import { apiErrorEnvelope } from '~/composables/api/errors'
|
|
5
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
6
|
+
import { usePersonalSubscriptionsStore } from '~/stores/personalSubscriptions'
|
|
7
|
+
|
|
8
|
+
/** The backend's `error.details.reason` code, when it sent one (see `useApi`'s error envelope). */
|
|
9
|
+
function errorReasonOf(error: unknown): string | null {
|
|
10
|
+
const details = apiErrorEnvelope(error)?.details
|
|
11
|
+
if (!details || typeof details !== 'object') return null
|
|
12
|
+
const reason = (details as Record<string, unknown>).reason
|
|
13
|
+
return typeof reason === 'string' ? reason : null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Bug-hunt state: the boards of the tracker being browsed, the last hunt's ranked candidates,
|
|
18
|
+
* and the actions behind the three steps (list boards → run the hunt → adopt one candidate).
|
|
19
|
+
*
|
|
20
|
+
* Nothing here is persisted server-side — a hunt is a live read plus a ranking, so the store
|
|
21
|
+
* holds the whole feature's state and a page reload starts a fresh hunt. That is deliberate:
|
|
22
|
+
* a stale ranking of a board that has since moved on is worse than no ranking.
|
|
23
|
+
*/
|
|
24
|
+
export const useBugHuntStore = defineStore('bugHunt', () => {
|
|
25
|
+
const api = useApi()
|
|
26
|
+
const workspace = useWorkspaceStore()
|
|
27
|
+
|
|
28
|
+
/** Boards of the last source `loadBoards` was called for, keyed so a source switch re-fetches. */
|
|
29
|
+
const boards = ref<TrackerBoard[]>([])
|
|
30
|
+
const boardsSource = ref<TaskSourceKind | null>(null)
|
|
31
|
+
const boardsLoading = ref(false)
|
|
32
|
+
/**
|
|
33
|
+
* Why board listing failed, if it did. Kept rather than swallowed: a source whose provider
|
|
34
|
+
* can't enumerate boards is a normal, actionable state (type the board in yourself), and an
|
|
35
|
+
* empty picker alone doesn't say that.
|
|
36
|
+
*/
|
|
37
|
+
const boardsError = ref<string | null>(null)
|
|
38
|
+
/**
|
|
39
|
+
* The backend's machine-readable reason for that failure. Only `boards_unsupported` means
|
|
40
|
+
* "this tracker cannot enumerate boards, so type one in"; every other failure (an unreachable
|
|
41
|
+
* site, an expired token) is a real error and must be shown as one — offering a free-text
|
|
42
|
+
* field there would just move the same failure to the next click.
|
|
43
|
+
*/
|
|
44
|
+
const boardsErrorReason = ref<string | null>(null)
|
|
45
|
+
|
|
46
|
+
const result = ref<BugHuntResult | null>(null)
|
|
47
|
+
const hunting = ref(false)
|
|
48
|
+
const huntError = ref<string | null>(null)
|
|
49
|
+
/** The candidate currently being adopted, so only its own row shows a spinner. */
|
|
50
|
+
const adopting = ref<string | null>(null)
|
|
51
|
+
|
|
52
|
+
const candidates = computed(() => result.value?.candidates ?? [])
|
|
53
|
+
const hasResult = computed(() => result.value !== null)
|
|
54
|
+
|
|
55
|
+
/** Load the boards a hunt can run against for one source. */
|
|
56
|
+
async function loadBoards(source: TaskSourceKind): Promise<void> {
|
|
57
|
+
boardsLoading.value = true
|
|
58
|
+
boardsError.value = null
|
|
59
|
+
boardsErrorReason.value = null
|
|
60
|
+
boardsSource.value = source
|
|
61
|
+
try {
|
|
62
|
+
const view = await api.listTrackerBoards(workspace.requireId(), source)
|
|
63
|
+
// A source switch mid-flight would otherwise land the slower response over the newer
|
|
64
|
+
// one, leaving the picker showing another tracker's boards.
|
|
65
|
+
if (boardsSource.value !== source) return
|
|
66
|
+
boards.value = view.boards
|
|
67
|
+
} catch (e) {
|
|
68
|
+
if (boardsSource.value !== source) return
|
|
69
|
+
boards.value = []
|
|
70
|
+
boardsError.value = e instanceof Error ? e.message : String(e)
|
|
71
|
+
boardsErrorReason.value = errorReasonOf(e)
|
|
72
|
+
} finally {
|
|
73
|
+
boardsLoading.value = false
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Run a hunt and keep its ranked result. Returns false when the scan itself failed. */
|
|
78
|
+
async function hunt(source: TaskSourceKind, input: RunBugHuntInput): Promise<boolean> {
|
|
79
|
+
hunting.value = true
|
|
80
|
+
huntError.value = null
|
|
81
|
+
try {
|
|
82
|
+
result.value = await api.runBugHunt(workspace.requireId(), source, input)
|
|
83
|
+
return true
|
|
84
|
+
} catch (e) {
|
|
85
|
+
result.value = null
|
|
86
|
+
huntError.value = e instanceof Error ? e.message : String(e)
|
|
87
|
+
return false
|
|
88
|
+
} finally {
|
|
89
|
+
hunting.value = false
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Adopt a candidate as a bug task and start its run. Rides the personal password like every
|
|
95
|
+
* other run start, so an individual-usage model prompts here rather than failing the start.
|
|
96
|
+
* Returns the new block's id on success, null when the user cancelled the credential prompt.
|
|
97
|
+
*/
|
|
98
|
+
async function adopt(
|
|
99
|
+
source: TaskSourceKind,
|
|
100
|
+
externalId: string,
|
|
101
|
+
containerId: string,
|
|
102
|
+
pipelineId?: string,
|
|
103
|
+
): Promise<string | null> {
|
|
104
|
+
const personal = usePersonalSubscriptionsStore()
|
|
105
|
+
adopting.value = externalId
|
|
106
|
+
try {
|
|
107
|
+
let blockId: string | null = null
|
|
108
|
+
const ok = await personal.withCredential(async (password) => {
|
|
109
|
+
const adopted = await api.adoptBugHuntCandidate(
|
|
110
|
+
workspace.requireId(),
|
|
111
|
+
source,
|
|
112
|
+
{ externalId, containerId, ...(pipelineId ? { pipelineId } : {}) },
|
|
113
|
+
password,
|
|
114
|
+
)
|
|
115
|
+
blockId = adopted.block.id
|
|
116
|
+
await workspace.refresh()
|
|
117
|
+
})
|
|
118
|
+
return ok ? blockId : null
|
|
119
|
+
} finally {
|
|
120
|
+
adopting.value = null
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Drop the last hunt (on close, or when the source/board changes under it). */
|
|
125
|
+
function reset(): void {
|
|
126
|
+
result.value = null
|
|
127
|
+
huntError.value = null
|
|
128
|
+
adopting.value = null
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
boards,
|
|
133
|
+
boardsSource,
|
|
134
|
+
boardsLoading,
|
|
135
|
+
boardsError,
|
|
136
|
+
boardsErrorReason,
|
|
137
|
+
result,
|
|
138
|
+
candidates,
|
|
139
|
+
hasResult,
|
|
140
|
+
hunting,
|
|
141
|
+
huntError,
|
|
142
|
+
adopting,
|
|
143
|
+
loadBoards,
|
|
144
|
+
hunt,
|
|
145
|
+
adopt,
|
|
146
|
+
reset,
|
|
147
|
+
}
|
|
148
|
+
})
|
package/app/stores/ui/modals.ts
CHANGED
|
@@ -180,6 +180,10 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
|
|
|
180
180
|
// the create-in target AND scopes the issue search to the frame's linked repo.
|
|
181
181
|
// Null → the unscoped "import an issue" surface (workspace-wide search).
|
|
182
182
|
const taskImport = ref<{ source: TaskSourceKind | null; containerId: string | null } | null>(null)
|
|
183
|
+
// Bug hunt: pick a tracker + one of its boards, rank its open unassigned bugs, adopt one.
|
|
184
|
+
// `containerId` (a service frame or module) preselects where an adopted bug lands; null →
|
|
185
|
+
// opened standalone, and the modal offers every container on the board.
|
|
186
|
+
const bugHunt = ref<{ source: TaskSourceKind | null; containerId: string | null } | null>(null)
|
|
183
187
|
|
|
184
188
|
// Add-task modal: the container (service frame or module) a new task is being
|
|
185
189
|
// added to, or null when closed. The user types the title + description; nothing
|
|
@@ -255,6 +259,13 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
|
|
|
255
259
|
function closeTaskImport() {
|
|
256
260
|
taskImport.value = null
|
|
257
261
|
}
|
|
262
|
+
function openBugHunt(source: TaskSourceKind | null = null, containerId: string | null = null) {
|
|
263
|
+
resetHubReturn()
|
|
264
|
+
bugHunt.value = { source, containerId }
|
|
265
|
+
}
|
|
266
|
+
function closeBugHunt() {
|
|
267
|
+
bugHunt.value = null
|
|
268
|
+
}
|
|
258
269
|
function openAddTask(containerId: string, prefill: AddTaskPrefill | null = null) {
|
|
259
270
|
addTaskPrefill.value = prefill
|
|
260
271
|
addTaskContainerId.value = containerId
|
|
@@ -289,6 +300,7 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
|
|
|
289
300
|
spawnPreview,
|
|
290
301
|
taskConnect,
|
|
291
302
|
taskImport,
|
|
303
|
+
bugHunt,
|
|
292
304
|
addTaskContainerId,
|
|
293
305
|
addTaskPrefill,
|
|
294
306
|
reviewFrictionContext,
|
|
@@ -306,6 +318,8 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
|
|
|
306
318
|
closeTaskConnect,
|
|
307
319
|
openTaskImport,
|
|
308
320
|
closeTaskImport,
|
|
321
|
+
openBugHunt,
|
|
322
|
+
closeBugHunt,
|
|
309
323
|
openAddTask,
|
|
310
324
|
closeAddTask,
|
|
311
325
|
openReviewFriction,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Bug hunt: pick a connected tracker + one of its boards, get its open and
|
|
3
|
+
// unassigned bugs ranked by impact against implementation complexity, then adopt
|
|
4
|
+
// one onto the board and run the bug-fix pipeline on it.
|
|
5
|
+
//
|
|
6
|
+
// The interactive dual of the recurring `bug-intake` step. All wire shapes are
|
|
7
|
+
// sourced from @cat-factory/contracts (single source of truth).
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
export type {
|
|
11
|
+
TrackerBoard,
|
|
12
|
+
TrackerBoardsView,
|
|
13
|
+
BugCandidate,
|
|
14
|
+
BugHuntAnalysis,
|
|
15
|
+
BugHuntAnalysisStatus,
|
|
16
|
+
BugHuntCandidate,
|
|
17
|
+
BugHuntConfidence,
|
|
18
|
+
BugHuntResult,
|
|
19
|
+
RunBugHuntInput,
|
|
20
|
+
AdoptBugHuntCandidateInput,
|
|
21
|
+
} from '@cat-factory/contracts'
|
package/app/types/domain.ts
CHANGED
|
@@ -170,6 +170,7 @@ export type * from './fragments'
|
|
|
170
170
|
export type * from './skills'
|
|
171
171
|
export type * from './documents'
|
|
172
172
|
export type * from './tasks'
|
|
173
|
+
export type * from './bugHunt'
|
|
173
174
|
export type * from './bootstrap'
|
|
174
175
|
export type * from './envConfigRepair'
|
|
175
176
|
export type * from './github'
|
|
@@ -1,13 +1,53 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import { pipelineAllowedForTaskType, purposeAllowsAgentCategory } from '@cat-factory/contracts'
|
|
3
3
|
import type { Block, Pipeline } from '~/types/domain'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
pipelineAllowedForManualStart,
|
|
6
|
+
pipelineDisplaySteps,
|
|
7
|
+
pipelineGateCount,
|
|
8
|
+
} from '~/utils/pipeline'
|
|
5
9
|
|
|
6
10
|
// A minimal pipeline: only the fields the launch/task-type filters read matter here.
|
|
7
11
|
function pipeline(over: Partial<Pipeline> = {}): Pipeline {
|
|
8
12
|
return { id: 'pl_x', name: 'X', agentKinds: ['coder'], ...over } as Pipeline
|
|
9
13
|
}
|
|
10
14
|
|
|
15
|
+
// The data behind every picker's preview pane: the ordered steps a run will actually execute.
|
|
16
|
+
describe('pipelineDisplaySteps', () => {
|
|
17
|
+
it('keeps the authored order and flags the gated steps', () => {
|
|
18
|
+
const p = pipeline({
|
|
19
|
+
agentKinds: ['task-estimator', 'coder', 'reviewer'],
|
|
20
|
+
gates: [false, true, false],
|
|
21
|
+
})
|
|
22
|
+
expect(pipelineDisplaySteps(p)).toEqual([
|
|
23
|
+
{ kind: 'task-estimator', gated: false },
|
|
24
|
+
{ kind: 'coder', gated: true },
|
|
25
|
+
{ kind: 'reviewer', gated: false },
|
|
26
|
+
])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('drops steps disabled by default — they never run, so listing them would misdescribe it', () => {
|
|
30
|
+
// The short `enabled` array also pins "no entry ⇒ enabled": `tester` has none and stays.
|
|
31
|
+
const p = pipeline({ agentKinds: ['architect', 'coder', 'tester'], enabled: [false, true] })
|
|
32
|
+
expect(pipelineDisplaySteps(p).map((s) => s.kind)).toEqual(['coder', 'tester'])
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('pipelineGateCount', () => {
|
|
37
|
+
it('counts only the gates that a run can actually stop at', () => {
|
|
38
|
+
expect(pipelineGateCount(pipeline({ agentKinds: ['coder'] }))).toBe(0)
|
|
39
|
+
expect(
|
|
40
|
+
pipelineGateCount(pipeline({ agentKinds: ['coder', 'merger'], gates: [true, true] })),
|
|
41
|
+
).toBe(2)
|
|
42
|
+
// A gate declared on a disabled step gates nothing: the step is skipped at run.
|
|
43
|
+
expect(
|
|
44
|
+
pipelineGateCount(
|
|
45
|
+
pipeline({ agentKinds: ['coder', 'merger'], gates: [true, true], enabled: [true, false] }),
|
|
46
|
+
),
|
|
47
|
+
).toBe(1)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
11
51
|
describe('pipelineAllowedForTaskType', () => {
|
|
12
52
|
it('a document task offers ONLY document-purpose pipelines', () => {
|
|
13
53
|
expect(pipelineAllowedForTaskType(pipeline({ purpose: 'document' }), 'document')).toBe(true)
|
package/app/utils/pipeline.ts
CHANGED
|
@@ -29,6 +29,15 @@ export function pipelineDisplaySteps(pipeline: Pipeline): PipelineDisplayStep[]
|
|
|
29
29
|
.map(({ kind, gated }) => ({ kind, gated }))
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* How many times a run of `pipeline` will stop for a human. Counted off the DISPLAYED steps, not
|
|
34
|
+
* `pipeline.gates`, because a gate declared on a step that is disabled by default gates nothing —
|
|
35
|
+
* that step never runs, so promising a stop there would misstate what the pipeline does.
|
|
36
|
+
*/
|
|
37
|
+
export function pipelineGateCount(pipeline: Pipeline): number {
|
|
38
|
+
return pipelineDisplaySteps(pipeline).filter((s) => s.gated).length
|
|
39
|
+
}
|
|
40
|
+
|
|
32
41
|
// Re-exported so a picker can import the task-type gate from the same module as the
|
|
33
42
|
// launch/frame gates it composes with (the classifier itself lives in `@cat-factory/contracts`).
|
|
34
43
|
export { pipelineAllowedForTaskType }
|
package/i18n/locales/de.json
CHANGED
|
@@ -1650,7 +1650,8 @@
|
|
|
1650
1650
|
"title": "Diesen Dienst archivieren?",
|
|
1651
1651
|
"body": "„{name}“ und seine Aufgaben werden vom Board ausgeblendet. Du kannst den Dienst jederzeit wiederherstellen."
|
|
1652
1652
|
},
|
|
1653
|
-
"runBlocked": "Blockiert durch eine unerledigte Abhängigkeit: {names} | Blockiert durch {count} unerledigte Abhängigkeiten: {names}"
|
|
1653
|
+
"runBlocked": "Blockiert durch eine unerledigte Abhängigkeit: {names} | Blockiert durch {count} unerledigte Abhängigkeiten: {names}",
|
|
1654
|
+
"huntBugs": "Fehler jagen"
|
|
1654
1655
|
}
|
|
1655
1656
|
},
|
|
1656
1657
|
"layout": {
|
|
@@ -1989,7 +1990,8 @@
|
|
|
1989
1990
|
"accountSettings": "Konto-Einstellungen",
|
|
1990
1991
|
"localModels": "Meine lokalen Runner",
|
|
1991
1992
|
"sandbox": "Sandbox öffnen",
|
|
1992
|
-
"shortcuts": "Tastenkürzel"
|
|
1993
|
+
"shortcuts": "Tastenkürzel",
|
|
1994
|
+
"bugHunt": "Fehlerjagd"
|
|
1993
1995
|
},
|
|
1994
1996
|
"keywords": {
|
|
1995
1997
|
"newPipeline": "pipeline agents chain",
|
|
@@ -2009,7 +2011,8 @@
|
|
|
2009
2011
|
"accountSettings": "account team members roles invitations email api keys fragment best practice library context organization personal",
|
|
2010
2012
|
"localModels": "local model runner ollama lm studio llamacpp vllm endpoint",
|
|
2011
2013
|
"sandbox": "sandbox prompt model test experiment judge fixture benchmark evaluate",
|
|
2012
|
-
"shortcuts": "keyboard shortcuts keys hotkeys cheatsheet help"
|
|
2014
|
+
"shortcuts": "keyboard shortcuts keys hotkeys cheatsheet help",
|
|
2015
|
+
"bugHunt": "fehler bug jagd triage backlog tracker nicht zugewiesen"
|
|
2013
2016
|
}
|
|
2014
2017
|
},
|
|
2015
2018
|
"shortcuts": {
|
|
@@ -2102,6 +2105,10 @@
|
|
|
2102
2105
|
},
|
|
2103
2106
|
"documentTemplates": {
|
|
2104
2107
|
"label": "Vorlagen & Beispiele"
|
|
2108
|
+
},
|
|
2109
|
+
"bugHunt": {
|
|
2110
|
+
"label": "Fehlerjagd",
|
|
2111
|
+
"description": "Offene, nicht zugewiesene Fehler eines Boards bewerten und einen zum Beheben auswählen"
|
|
2105
2112
|
}
|
|
2106
2113
|
}
|
|
2107
2114
|
},
|
|
@@ -3238,7 +3245,11 @@
|
|
|
3238
3245
|
"attachByReference": "{ref} per Referenz anhängen",
|
|
3239
3246
|
"noMatches": "Keine passenden Issues.",
|
|
3240
3247
|
"emptySearchable": "Nach Titel suchen oder ein importiertes Issue auswählen.",
|
|
3241
|
-
"emptyRefOnly": "Fügen Sie eine Issue-URL oder einen -Schlüssel ein, um es anzuhängen."
|
|
3248
|
+
"emptyRefOnly": "Fügen Sie eine Issue-URL oder einen -Schlüssel ein, um es anzuhängen.",
|
|
3249
|
+
"sourceLabel": "Quelle",
|
|
3250
|
+
"noSource": "Quelle wählen",
|
|
3251
|
+
"connectSource": "{label} verbinden",
|
|
3252
|
+
"enableSource": "{label} aktivieren"
|
|
3242
3253
|
},
|
|
3243
3254
|
"contextIssues": {
|
|
3244
3255
|
"title": "Kontext-Issues",
|
|
@@ -3300,6 +3311,49 @@
|
|
|
3300
3311
|
"oauthOr": "oder mit einem API-Schlüssel verbinden"
|
|
3301
3312
|
}
|
|
3302
3313
|
},
|
|
3314
|
+
"bugHunt": {
|
|
3315
|
+
"title": "Fehlerjagd",
|
|
3316
|
+
"intro": "Durchsuche ein Tracker-Board nach offenen, nicht zugewiesenen Fehlern und bewerte sie nach Auswirkung im Verhältnis zum geschätzten Aufwand. Wähle einen aus, und er wird zu einer Aufgabe, die die Fehlerbehebungs-Pipeline durchläuft.",
|
|
3317
|
+
"connectFirst": "Verbinde oder aktiviere zuerst eine Aufgabenquelle.",
|
|
3318
|
+
"connectSource": "{label} verbinden",
|
|
3319
|
+
"needFrameFirst": "Füge zuerst einen Service-Rahmen zum Board hinzu, damit ein übernommener Fehler irgendwo landen kann.",
|
|
3320
|
+
"tracker": "Tracker",
|
|
3321
|
+
"board": "Board",
|
|
3322
|
+
"pickBoard": "Board auswählen",
|
|
3323
|
+
"boardPlaceholder": "Projektschlüssel, Team-ID oder owner/repo",
|
|
3324
|
+
"boardsFailed": "Boards konnten nicht geladen werden: {reason}",
|
|
3325
|
+
"issueType": "Vorgangstyp",
|
|
3326
|
+
"issueTypeHelp": "Standard ist bug. Wird von Trackern ohne Vorgangstypen ignoriert.",
|
|
3327
|
+
"labels": "Labels",
|
|
3328
|
+
"labelsHelp": "Durch Komma getrennt. Alle müssen vorhanden sein.",
|
|
3329
|
+
"adoptInto": "Ausgewählten Fehler hinzufügen zu",
|
|
3330
|
+
"run": "Jagen",
|
|
3331
|
+
"running": "Board wird gelesen und die Funde werden bewertet…",
|
|
3332
|
+
"huntFailed": "Die Jagd ist fehlgeschlagen",
|
|
3333
|
+
"notAssessed": "Nicht bewertet",
|
|
3334
|
+
"recommended": "Empfohlen",
|
|
3335
|
+
"adopt": "Diesen wählen",
|
|
3336
|
+
"adopted": "{id} übernommen",
|
|
3337
|
+
"adoptedRunning": "Die Fehlerbehebungs-Pipeline läuft dafür.",
|
|
3338
|
+
"adoptFailed": "Dieser Fehler konnte nicht übernommen werden",
|
|
3339
|
+
"noCandidates": "Auf diesem Board gab es keine offenen, nicht zugewiesenen Fehler.",
|
|
3340
|
+
"ratings": "Auswirkung {impact}/5, Aufwand {complexity}/5, Konfidenz {confidence}",
|
|
3341
|
+
"viaModel": "Bewertet von {model}.",
|
|
3342
|
+
"truncated": "Es wurden nur die ersten {count} passenden Fehler geprüft; dieses Board enthält mehr.",
|
|
3343
|
+
"comments": "{count} Kommentar | {count} Kommentare",
|
|
3344
|
+
"confidence": {
|
|
3345
|
+
"high": "hoch",
|
|
3346
|
+
"medium": "mittel",
|
|
3347
|
+
"low": "niedrig"
|
|
3348
|
+
},
|
|
3349
|
+
"status": {
|
|
3350
|
+
"ranked": "Sortiert nach Auswirkung im Verhältnis zum geschätzten Aufwand.",
|
|
3351
|
+
"unavailable": "Es ist kein Bewertungsmodell konfiguriert, daher sind diese unbewertet.",
|
|
3352
|
+
"failed": "Die Bewertung konnte nicht abgeschlossen werden, daher sind diese unbewertet.",
|
|
3353
|
+
"over_budget": "Dieser Arbeitsbereich hat sein Ausgabenbudget überschritten, daher wurden diese nicht bewertet.",
|
|
3354
|
+
"empty": "Nichts zu bewerten."
|
|
3355
|
+
}
|
|
3356
|
+
},
|
|
3303
3357
|
"pipeline": {
|
|
3304
3358
|
"iterationCap": {
|
|
3305
3359
|
"extraRound": "Noch eine Runde",
|
|
@@ -3308,10 +3362,12 @@
|
|
|
3308
3362
|
},
|
|
3309
3363
|
"preview": {
|
|
3310
3364
|
"stepCount": "{count} Schritt | {count} Schritte",
|
|
3365
|
+
"gateCount": "{count} Freigabe | {count} Freigaben",
|
|
3311
3366
|
"gated": "Manuelle Freigabe nach diesem Schritt"
|
|
3312
3367
|
},
|
|
3313
3368
|
"picker": {
|
|
3314
|
-
"noneHint": "Keine Standard-Pipeline. Du wählst beim Ausführen der Aufgabe eine aus."
|
|
3369
|
+
"noneHint": "Keine Standard-Pipeline. Du wählst beim Ausführen der Aufgabe eine aus.",
|
|
3370
|
+
"hoverHint": "Fahre über eine Pipeline, um die Schritte zu sehen, die sie ausführt."
|
|
3315
3371
|
},
|
|
3316
3372
|
"builder": {
|
|
3317
3373
|
"title": "Pipeline-Builder",
|