mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Sync Linear statuses from the workspace board (#5430)
This commit is contained in:
@@ -4618,6 +4618,24 @@ describe('Store', () => {
|
||||
expect(store.getUI().sortBy).toBe('recent')
|
||||
})
|
||||
|
||||
it('defaults workspace board task status sync off and normalizes persisted values', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [],
|
||||
worktreeMeta: {},
|
||||
settings: {},
|
||||
ui: { syncTaskStatusFromWorkspaceBoard: 'yes' },
|
||||
githubCache: { pr: {}, issue: {} },
|
||||
workspaceSession: {}
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
expect(store.getUI().syncTaskStatusFromWorkspaceBoard).toBe(false)
|
||||
|
||||
store.updateUI({ syncTaskStatusFromWorkspaceBoard: true })
|
||||
expect(store.getUI().syncTaskStatusFromWorkspaceBoard).toBe(true)
|
||||
})
|
||||
|
||||
it('repairs the known-bad reordered default workspace statuses once on load', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
|
||||
@@ -4575,6 +4575,7 @@ export class Store {
|
||||
workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth(
|
||||
this.state.ui?.workspaceBoardColumnWidth
|
||||
),
|
||||
syncTaskStatusFromWorkspaceBoard: this.state.ui?.syncTaskStatusFromWorkspaceBoard === true,
|
||||
markdownTocPanelWidth: clampMarkdownTocPanelWidth(this.state.ui?.markdownTocPanelWidth),
|
||||
visibleWorkspaceHostIds: normalizeVisibleExecutionHostIds(
|
||||
this.state.ui?.visibleWorkspaceHostIds
|
||||
@@ -4647,6 +4648,10 @@ export class Store {
|
||||
workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth(
|
||||
sanitizedUpdates.workspaceBoardColumnWidth ?? this.state.ui?.workspaceBoardColumnWidth
|
||||
),
|
||||
syncTaskStatusFromWorkspaceBoard:
|
||||
sanitizedUpdates.syncTaskStatusFromWorkspaceBoard !== undefined
|
||||
? sanitizedUpdates.syncTaskStatusFromWorkspaceBoard === true
|
||||
: this.state.ui?.syncTaskStatusFromWorkspaceBoard === true,
|
||||
markdownTocPanelWidth: clampMarkdownTocPanelWidth(
|
||||
sanitizedUpdates.markdownTocPanelWidth ?? this.state.ui?.markdownTocPanelWidth
|
||||
),
|
||||
|
||||
@@ -179,6 +179,7 @@ const UiUpdate = z
|
||||
workspaceStatuses: z.array(WorkspaceStatusDefinition).optional(),
|
||||
workspaceBoardOpacity: z.number().finite().optional(),
|
||||
workspaceBoardColumnWidth: z.number().finite().optional(),
|
||||
syncTaskStatusFromWorkspaceBoard: z.boolean().optional(),
|
||||
_workspaceStatusesDefaultOrderMigrated: z.boolean().optional(),
|
||||
_workspaceStatusesDefaultWorkflowMigrated: z.boolean().optional(),
|
||||
_workspaceStatusesDefaultVisualsMigrated: z.boolean().optional(),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultOnboardingState, getDefaultSettings } from '../../../../shared/constants'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { getDefaultOnboardingState, getDefaultSettings } from '../../../../shared/constants'
|
||||
import { useAppStore } from '@/store'
|
||||
import OnboardingFlow from './OnboardingFlow'
|
||||
import { ONBOARDING_SKIP_CONFIRMATION_COPY } from './OnboardingSkipConfirmationDialog'
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { Repo, Worktree } from '../../../../shared/types'
|
||||
import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer'
|
||||
import type * as WorkspaceBoardTaskStatusSync from './workspace-board-task-status-sync'
|
||||
|
||||
type PointerDragParams = {
|
||||
onDropWorktreesInStatus: (args: {
|
||||
worktreeIds: readonly string[]
|
||||
status: string
|
||||
dropIndex: number
|
||||
}) => void
|
||||
onPinWorktrees: (worktreeIds: readonly string[]) => void
|
||||
}
|
||||
|
||||
type DocumentDropCapture = {
|
||||
onMoveWorktreeToStatus: (worktreeId: string, status: string) => void
|
||||
onPinWorktree: (worktreeId: string) => void
|
||||
options?: {
|
||||
onMoveWorktreesToStatus?: (worktreeIds: readonly string[], status: string) => void
|
||||
onPinWorktrees?: (worktreeIds: readonly string[]) => void
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
syncWorkspaceBoardTaskStatusesMock,
|
||||
toastErrorMock,
|
||||
toastWarningMock,
|
||||
pointerDragState,
|
||||
documentDropState
|
||||
} = vi.hoisted(() => ({
|
||||
syncWorkspaceBoardTaskStatusesMock: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
updated: 1,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
messages: [] as WorkspaceBoardTaskStatusSync.WorkspaceBoardTaskStatusSyncMessage[]
|
||||
})
|
||||
),
|
||||
toastErrorMock: vi.fn(),
|
||||
toastWarningMock: vi.fn(),
|
||||
pointerDragState: { current: null as PointerDragParams | null },
|
||||
documentDropState: { current: null as DocumentDropCapture | null }
|
||||
}))
|
||||
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
error: toastErrorMock,
|
||||
warning: toastWarningMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/sheet', () => ({
|
||||
Sheet: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SheetContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-slot="sheet-content">{children}</div>
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('./WorkspaceKanbanDrawerHeader', () => ({
|
||||
default: () => <div data-testid="workspace-board-header" />
|
||||
}))
|
||||
|
||||
vi.mock('./WorkspaceKanbanLaneGrid', () => ({
|
||||
default: () => <div data-testid="workspace-board-lanes" />
|
||||
}))
|
||||
|
||||
vi.mock('./WorkspaceKanbanAreaSelectionOverlay', () => ({
|
||||
default: React.forwardRef<HTMLDivElement>((_, ref) => <div ref={ref} />)
|
||||
}))
|
||||
|
||||
vi.mock('./WorkspaceKanbanPinDropTarget', () => ({
|
||||
default: () => <div data-testid="workspace-board-pin-target" />
|
||||
}))
|
||||
|
||||
vi.mock('./use-visible-workspace-kanban-worktree-ids', () => ({
|
||||
useVisibleWorkspaceKanbanWorktreeIds: ({ allWorktrees }: { allWorktrees: readonly Worktree[] }) =>
|
||||
new Set(allWorktrees.map((worktree) => worktree.id))
|
||||
}))
|
||||
|
||||
vi.mock('./use-workspace-kanban-selection', () => ({
|
||||
useWorkspaceKanbanSelection: () => ({
|
||||
selectedWorktreeIds: new Set<string>(),
|
||||
selectedWorktrees: [],
|
||||
selectionAnchorId: null,
|
||||
updateSelectionForGesture: vi.fn(),
|
||||
updateSelectionForArea: vi.fn(),
|
||||
clearSelection: vi.fn(),
|
||||
selectForContextMenu: vi.fn(() => [])
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./use-workspace-kanban-area-selection', () => ({
|
||||
useWorkspaceKanbanAreaSelection: () => ({
|
||||
handleAreaSelectionPointerDown: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./use-workspace-kanban-column-resize', () => ({
|
||||
useWorkspaceKanbanColumnResize: () => ({
|
||||
columnWidth: 308,
|
||||
isResizingColumn: false,
|
||||
onColumnResizeStart: vi.fn(),
|
||||
onColumnResizeKeyDown: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./use-workspace-kanban-create-worktree', () => ({
|
||||
useWorkspaceKanbanCreateWorktree: () => ({
|
||||
canCreateWorktree: true,
|
||||
createWorktreeForStatus: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./use-workspace-kanban-shift-wheel-scroll', () => ({
|
||||
useWorkspaceKanbanShiftWheelScroll: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./use-workspace-kanban-outside-dismiss', () => ({
|
||||
isWorkspaceBoardKeepOpenTarget: () => false,
|
||||
useWorkspaceKanbanOutsideDismiss: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/components/contextual-tours/use-contextual-tour', () => ({
|
||||
useContextualTour: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./use-workspace-kanban-card-pointer-drag', () => ({
|
||||
useWorkspaceKanbanCardPointerDrag: (params: PointerDragParams) => {
|
||||
pointerDragState.current = params
|
||||
return {
|
||||
isPointerDragActiveRef: { current: false },
|
||||
onCardPointerDownCapture: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./use-workspace-status-drop', () => ({
|
||||
useWorkspaceStatusDocumentDrop: (
|
||||
_containerRef: unknown,
|
||||
onMoveWorktreeToStatus: DocumentDropCapture['onMoveWorktreeToStatus'],
|
||||
onPinWorktree: DocumentDropCapture['onPinWorktree'],
|
||||
_onDragFinish: () => void,
|
||||
_enabled: boolean,
|
||||
options?: DocumentDropCapture['options']
|
||||
) => {
|
||||
documentDropState.current = { onMoveWorktreeToStatus, onPinWorktree, options }
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./workspace-board-task-status-sync', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof WorkspaceBoardTaskStatusSync>()
|
||||
return {
|
||||
...actual,
|
||||
syncWorkspaceBoardTaskStatuses: syncWorkspaceBoardTaskStatusesMock
|
||||
}
|
||||
})
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
let consoleInfoSpy: ReturnType<typeof vi.spyOn>
|
||||
let consoleWarnSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
const statuses = [
|
||||
{ id: 'todo', label: 'Todo' },
|
||||
{ id: 'in-review', label: 'In review' }
|
||||
]
|
||||
|
||||
function worktree(overrides: Partial<Worktree> = {}): Worktree {
|
||||
return {
|
||||
id: 'repo-a::/worktree-a',
|
||||
repoId: 'repo-a',
|
||||
displayName: 'Worktree A',
|
||||
path: '/worktree-a',
|
||||
branch: 'feature/a',
|
||||
baseBranch: 'main',
|
||||
isPinned: false,
|
||||
sortOrder: 1,
|
||||
lastActivityAt: 1,
|
||||
workspaceStatus: 'todo',
|
||||
linkedLinearIssue: 'ORC-1',
|
||||
linkedLinearIssueWorkspaceId: 'workspace-1',
|
||||
...overrides
|
||||
} as Worktree
|
||||
}
|
||||
|
||||
function repo(overrides: Partial<Repo> = {}): Repo {
|
||||
return {
|
||||
id: 'repo-a',
|
||||
path: '/repo-a',
|
||||
name: 'repo-a',
|
||||
connectionId: null,
|
||||
executionHostId: 'runtime:owner-env',
|
||||
...overrides
|
||||
} as Repo
|
||||
}
|
||||
|
||||
function renderDrawer(item: Worktree, enabled = true): void {
|
||||
const updateWorktreeMeta = vi.fn()
|
||||
const updateWorktreesMeta = vi.fn()
|
||||
const recordFeatureInteraction = vi.fn()
|
||||
useAppStore.setState({
|
||||
repos: [repo()],
|
||||
worktreesByRepo: { 'repo-a': [item] },
|
||||
activeWorktreeId: item.id,
|
||||
workspaceStatuses: statuses,
|
||||
syncTaskStatusFromWorkspaceBoard: enabled,
|
||||
setSyncTaskStatusFromWorkspaceBoard: vi.fn(),
|
||||
workspaceBoardColumnWidth: 308,
|
||||
sidebarOpen: true,
|
||||
sidebarWidth: 280,
|
||||
sortBy: 'manual',
|
||||
updateWorktreeMeta,
|
||||
updateWorktreesMeta,
|
||||
getKnownWorktreeById: (worktreeId: string) => (worktreeId === item.id ? item : undefined),
|
||||
recordFeatureInteraction
|
||||
})
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<WorkspaceKanbanDrawer
|
||||
open={true}
|
||||
dragPreview={false}
|
||||
preserveOpenForMenu={false}
|
||||
onOpenChange={vi.fn()}
|
||||
onMenuOpenChange={vi.fn()}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
pointerDragState.current = null
|
||||
documentDropState.current = null
|
||||
syncWorkspaceBoardTaskStatusesMock.mockClear()
|
||||
toastErrorMock.mockClear()
|
||||
toastWarningMock.mockClear()
|
||||
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
|
||||
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount()
|
||||
})
|
||||
consoleInfoSpy.mockRestore()
|
||||
consoleWarnSpy.mockRestore()
|
||||
container.remove()
|
||||
})
|
||||
|
||||
describe('WorkspaceKanbanDrawer task status sync wiring', () => {
|
||||
it('syncs Linear after a document-drop status move when the setting is enabled', () => {
|
||||
const item = worktree()
|
||||
renderDrawer(item)
|
||||
|
||||
act(() => {
|
||||
documentDropState.current?.onMoveWorktreeToStatus(item.id, 'in-review')
|
||||
})
|
||||
|
||||
expect(syncWorkspaceBoardTaskStatusesMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
worktreeIds: [item.id],
|
||||
targetStatus: { id: 'in-review', label: 'In review' },
|
||||
getSettingsForWorktree: expect.any(Function),
|
||||
getLatestWorkspaceStatus: expect.any(Function)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not sync when a document-drop status move happens while disabled', () => {
|
||||
const item = worktree()
|
||||
renderDrawer(item, false)
|
||||
|
||||
act(() => {
|
||||
documentDropState.current?.onMoveWorktreeToStatus(item.id, 'in-review')
|
||||
})
|
||||
|
||||
expect(syncWorkspaceBoardTaskStatusesMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('syncs pointer-drop status changes through the board callback', () => {
|
||||
const item = worktree()
|
||||
renderDrawer(item)
|
||||
|
||||
act(() => {
|
||||
pointerDragState.current?.onDropWorktreesInStatus({
|
||||
worktreeIds: [item.id],
|
||||
status: 'in-review',
|
||||
dropIndex: 0
|
||||
})
|
||||
})
|
||||
|
||||
expect(syncWorkspaceBoardTaskStatusesMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
worktreeIds: [item.id],
|
||||
targetStatus: { id: 'in-review', label: 'In review' }
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not sync manual-order-only drops that keep the same board status', () => {
|
||||
const item = worktree({ workspaceStatus: 'in-review' })
|
||||
renderDrawer(item)
|
||||
|
||||
act(() => {
|
||||
pointerDragState.current?.onDropWorktreesInStatus({
|
||||
worktreeIds: [item.id],
|
||||
status: 'in-review',
|
||||
dropIndex: 0
|
||||
})
|
||||
})
|
||||
|
||||
expect(syncWorkspaceBoardTaskStatusesMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not sync pin-only paths', () => {
|
||||
const item = worktree()
|
||||
renderDrawer(item)
|
||||
|
||||
act(() => {
|
||||
pointerDragState.current?.onPinWorktrees([item.id])
|
||||
documentDropState.current?.onPinWorktree(item.id)
|
||||
documentDropState.current?.options?.onPinWorktrees?.([item.id])
|
||||
})
|
||||
|
||||
expect(syncWorkspaceBoardTaskStatusesMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows a warning toast when task status sync is skipped with a message', async () => {
|
||||
syncWorkspaceBoardTaskStatusesMock.mockResolvedValueOnce({
|
||||
updated: 0,
|
||||
skipped: 1,
|
||||
failed: 0,
|
||||
messages: [{ kind: 'missing-workflow-state', statusLabel: 'In review' }]
|
||||
})
|
||||
const item = worktree()
|
||||
renderDrawer(item)
|
||||
|
||||
await act(async () => {
|
||||
documentDropState.current?.onMoveWorktreeToStatus(item.id, 'in-review')
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(toastWarningMock).toHaveBeenCalledWith(
|
||||
'Task status sync skipped',
|
||||
expect.objectContaining({
|
||||
description: '1 skipped. No matching Linear workflow state for In review.'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('shows an error toast when task status sync unexpectedly rejects', async () => {
|
||||
syncWorkspaceBoardTaskStatusesMock.mockRejectedValueOnce(new Error('Runtime disconnected'))
|
||||
const item = worktree()
|
||||
renderDrawer(item)
|
||||
|
||||
await act(async () => {
|
||||
documentDropState.current?.onMoveWorktreeToStatus(item.id, 'in-review')
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Workspace board task status sync failed',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(toastErrorMock).toHaveBeenCalledWith(
|
||||
'Task status sync failed',
|
||||
expect.objectContaining({
|
||||
description: '1 failed. Task status sync could not finish.'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useAllWorktrees, useRepoMap } from '@/store/selectors'
|
||||
import { Sheet, SheetContent } from '@/components/ui/sheet'
|
||||
import { toast } from 'sonner'
|
||||
import WorkspaceKanbanAreaSelectionOverlay from './WorkspaceKanbanAreaSelectionOverlay'
|
||||
import WorkspaceKanbanDrawerHeader from './WorkspaceKanbanDrawerHeader'
|
||||
import WorkspaceKanbanLaneGrid from './WorkspaceKanbanLaneGrid'
|
||||
@@ -24,7 +25,14 @@ import {
|
||||
useWorkspaceKanbanOutsideDismiss
|
||||
} from './use-workspace-kanban-outside-dismiss'
|
||||
import { useVisibleWorkspaceKanbanWorktreeIds } from './use-visible-workspace-kanban-worktree-ids'
|
||||
import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner'
|
||||
import { groupWorkspaceKanbanWorktrees } from './workspace-kanban-worktree-groups'
|
||||
import {
|
||||
getWorkspaceBoardTaskStatusSyncRequest,
|
||||
syncWorkspaceBoardTaskStatuses,
|
||||
type WorkspaceBoardTaskStatusSyncMessage,
|
||||
type WorkspaceBoardTaskStatusSyncResult
|
||||
} from './workspace-board-task-status-sync'
|
||||
import {
|
||||
buildManualOrderUpdatesForGroupDrop,
|
||||
shouldWriteManualOrderForGroupDrop,
|
||||
@@ -33,6 +41,7 @@ import {
|
||||
import type { WorkspaceStatus, WorktreeMeta } from '../../../../shared/types'
|
||||
import { makeWorkspaceStatusId } from '../../../../shared/workspace-statuses'
|
||||
import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type WorkspaceKanbanDrawerProps = {
|
||||
leftSidebarStyle?: React.CSSProperties
|
||||
@@ -43,6 +52,80 @@ type WorkspaceKanbanDrawerProps = {
|
||||
onMenuOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
function formatTaskStatusSyncMessage(message: WorkspaceBoardTaskStatusSyncMessage): string {
|
||||
switch (message.kind) {
|
||||
case 'issue-read-failed':
|
||||
return translate(
|
||||
'auto.components.sidebar.WorkspaceKanbanDrawer.c1d2e3f4a5',
|
||||
'Linear issue {{value0}} could not be read.',
|
||||
{ value0: message.issueIdentifier }
|
||||
)
|
||||
case 'missing-workflow-state':
|
||||
return translate(
|
||||
'auto.components.sidebar.WorkspaceKanbanDrawer.d2e3f4a5b6',
|
||||
'No matching Linear workflow state for {{value0}}.',
|
||||
{ value0: message.statusLabel }
|
||||
)
|
||||
case 'ambiguous-workflow-state':
|
||||
return translate(
|
||||
'auto.components.sidebar.WorkspaceKanbanDrawer.e3f4a5b6c7',
|
||||
'Multiple Linear workflow states match {{value0}}.',
|
||||
{ value0: message.statusLabel }
|
||||
)
|
||||
case 'update-failed':
|
||||
return translate(
|
||||
'auto.components.sidebar.WorkspaceKanbanDrawer.f4a5b6c7d8',
|
||||
'Could not update Linear issue {{value0}}.',
|
||||
{ value0: message.issueIdentifier }
|
||||
)
|
||||
case 'provider-error':
|
||||
return translate(
|
||||
'auto.components.sidebar.WorkspaceKanbanDrawer.a5b6c7d8e9',
|
||||
'Could not sync Linear issue {{value0}}.',
|
||||
{ value0: message.issueIdentifier }
|
||||
)
|
||||
case 'unexpected-error':
|
||||
return translate(
|
||||
'auto.components.sidebar.WorkspaceKanbanDrawer.b6c7d8e9f0',
|
||||
'Task status sync could not finish.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function formatTaskStatusSyncDescription(result: WorkspaceBoardTaskStatusSyncResult): string {
|
||||
const counts = [
|
||||
result.updated > 0
|
||||
? translate(
|
||||
'auto.components.sidebar.WorkspaceKanbanDrawer.c7d8e9f0a1',
|
||||
'{{value0}} updated',
|
||||
{
|
||||
value0: result.updated
|
||||
}
|
||||
)
|
||||
: null,
|
||||
result.skipped > 0
|
||||
? translate(
|
||||
'auto.components.sidebar.WorkspaceKanbanDrawer.d8e9f0a1b2',
|
||||
'{{value0}} skipped',
|
||||
{
|
||||
value0: result.skipped
|
||||
}
|
||||
)
|
||||
: null,
|
||||
result.failed > 0
|
||||
? translate('auto.components.sidebar.WorkspaceKanbanDrawer.e9f0a1b2c3', '{{value0}} failed', {
|
||||
value0: result.failed
|
||||
})
|
||||
: null
|
||||
].filter((part): part is string => part !== null)
|
||||
return [
|
||||
counts.join(', '),
|
||||
result.messages[0] ? formatTaskStatusSyncMessage(result.messages[0]) : null
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('. ')
|
||||
}
|
||||
|
||||
export default function WorkspaceKanbanDrawer({
|
||||
leftSidebarStyle,
|
||||
open,
|
||||
@@ -58,6 +141,10 @@ export default function WorkspaceKanbanDrawer({
|
||||
const updateWorktreesMeta = useAppStore((s) => s.updateWorktreesMeta)
|
||||
const workspaceStatuses = useAppStore((s) => s.workspaceStatuses)
|
||||
const setWorkspaceStatuses = useAppStore((s) => s.setWorkspaceStatuses)
|
||||
const syncTaskStatusFromWorkspaceBoard = useAppStore((s) => s.syncTaskStatusFromWorkspaceBoard)
|
||||
const setSyncTaskStatusFromWorkspaceBoard = useAppStore(
|
||||
(s) => s.setSyncTaskStatusFromWorkspaceBoard
|
||||
)
|
||||
const workspaceBoardColumnWidth = useAppStore((s) => s.workspaceBoardColumnWidth)
|
||||
const setWorkspaceBoardColumnWidth = useAppStore((s) => s.setWorkspaceBoardColumnWidth)
|
||||
const sortBy = useAppStore((s) => s.sortBy)
|
||||
@@ -117,6 +204,73 @@ export default function WorkspaceKanbanDrawer({
|
||||
})
|
||||
const { columnWidth, isResizingColumn, onColumnResizeStart, onColumnResizeKeyDown } =
|
||||
useWorkspaceKanbanColumnResize(workspaceBoardColumnWidth, setWorkspaceBoardColumnWidth)
|
||||
const handleTaskStatusSyncResult = useCallback((result: WorkspaceBoardTaskStatusSyncResult) => {
|
||||
if (result.failed === 0 && result.messages.length === 0) {
|
||||
return
|
||||
}
|
||||
const description = formatTaskStatusSyncDescription(result)
|
||||
if (result.failed > 0) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.sidebar.WorkspaceKanbanDrawer.1975a4e480',
|
||||
'Task status sync failed'
|
||||
),
|
||||
{ description }
|
||||
)
|
||||
return
|
||||
}
|
||||
toast.warning(
|
||||
translate(
|
||||
'auto.components.sidebar.WorkspaceKanbanDrawer.e02b0d92ff',
|
||||
'Task status sync skipped'
|
||||
),
|
||||
{ description }
|
||||
)
|
||||
}, [])
|
||||
const maybeSyncWorkspaceBoardTaskStatuses = useCallback(
|
||||
(worktreeIds: readonly string[], status: WorkspaceStatus) => {
|
||||
const request = getWorkspaceBoardTaskStatusSyncRequest({
|
||||
enabled: syncTaskStatusFromWorkspaceBoard,
|
||||
worktreeIds,
|
||||
status,
|
||||
worktreesById: worktreeById,
|
||||
workspaceStatuses
|
||||
})
|
||||
if (!request) {
|
||||
return
|
||||
}
|
||||
void syncWorkspaceBoardTaskStatuses({
|
||||
worktreeIds: request.worktreeIds,
|
||||
targetStatus: request.targetStatus,
|
||||
worktreesById: worktreeById,
|
||||
getSettingsForWorktree: (worktreeId) =>
|
||||
getSettingsForWorktreeRuntimeOwner(useAppStore.getState(), worktreeId),
|
||||
getLatestWorkspaceStatus: (worktreeId) =>
|
||||
useAppStore.getState().getKnownWorktreeById(worktreeId)?.workspaceStatus
|
||||
})
|
||||
.then((result) => {
|
||||
if (result.updated > 0 || result.failed > 0 || result.messages.length > 0) {
|
||||
console.info('Workspace board task status sync result', result)
|
||||
}
|
||||
handleTaskStatusSyncResult(result)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.warn('Workspace board task status sync failed', error)
|
||||
handleTaskStatusSyncResult({
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
failed: request.worktreeIds.length,
|
||||
messages: [
|
||||
{
|
||||
kind: 'unexpected-error',
|
||||
detail: error instanceof Error ? error.message : undefined
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
},
|
||||
[handleTaskStatusSyncResult, syncTaskStatusFromWorkspaceBoard, workspaceStatuses, worktreeById]
|
||||
)
|
||||
const moveWorktreeToStatus = useCallback(
|
||||
(worktreeId: string, status: WorkspaceStatus) => {
|
||||
const current = worktreeById.get(worktreeId)
|
||||
@@ -125,8 +279,9 @@ export default function WorkspaceKanbanDrawer({
|
||||
}
|
||||
useAppStore.getState().recordFeatureInteraction('workspace-board-actions')
|
||||
void updateWorktreeMeta(worktreeId, { workspaceStatus: status })
|
||||
maybeSyncWorkspaceBoardTaskStatuses([worktreeId], status)
|
||||
},
|
||||
[updateWorktreeMeta, workspaceStatuses, worktreeById]
|
||||
[maybeSyncWorkspaceBoardTaskStatuses, updateWorktreeMeta, workspaceStatuses, worktreeById]
|
||||
)
|
||||
const getSourceStatusKeys = useCallback(
|
||||
(worktreeIds: readonly string[]): WorkspaceStatus[] =>
|
||||
@@ -217,9 +372,11 @@ export default function WorkspaceKanbanDrawer({
|
||||
}
|
||||
useAppStore.getState().recordFeatureInteraction('workspace-board-actions')
|
||||
void updateWorktreesMeta(updates)
|
||||
maybeSyncWorkspaceBoardTaskStatuses(args.worktreeIds, args.status)
|
||||
},
|
||||
[
|
||||
boardDragGroups,
|
||||
maybeSyncWorkspaceBoardTaskStatuses,
|
||||
setSortBy,
|
||||
shouldWriteDropManualOrder,
|
||||
updateWorktreesMeta,
|
||||
@@ -555,6 +712,8 @@ export default function WorkspaceKanbanDrawer({
|
||||
<WorkspaceKanbanDrawerHeader
|
||||
selectedCount={selectedWorktrees.length}
|
||||
workspaceStatuses={workspaceStatuses}
|
||||
syncTaskStatusFromWorkspaceBoard={syncTaskStatusFromWorkspaceBoard}
|
||||
onSyncTaskStatusFromWorkspaceBoardChange={setSyncTaskStatusFromWorkspaceBoard}
|
||||
onRenameStatus={handleRenameStatus}
|
||||
onChangeStatusColor={handleChangeStatusColor}
|
||||
onChangeStatusIcon={handleChangeStatusIcon}
|
||||
|
||||
@@ -35,6 +35,8 @@ function renderHeader(onClose: () => void): React.ReactElement {
|
||||
return WorkspaceKanbanDrawerHeader({
|
||||
selectedCount: 0,
|
||||
workspaceStatuses: statuses,
|
||||
syncTaskStatusFromWorkspaceBoard: false,
|
||||
onSyncTaskStatusFromWorkspaceBoardChange: vi.fn(),
|
||||
onRenameStatus: vi.fn(),
|
||||
onChangeStatusColor: vi.fn(),
|
||||
onChangeStatusIcon: vi.fn(),
|
||||
|
||||
@@ -10,6 +10,8 @@ import { translate } from '@/i18n/i18n'
|
||||
type WorkspaceKanbanDrawerHeaderProps = {
|
||||
selectedCount: number
|
||||
workspaceStatuses: readonly WorkspaceStatusDefinition[]
|
||||
syncTaskStatusFromWorkspaceBoard: boolean
|
||||
onSyncTaskStatusFromWorkspaceBoardChange: (enabled: boolean) => void
|
||||
onRenameStatus: (statusId: string, label: string) => void
|
||||
onChangeStatusColor: (statusId: string, color: string) => void
|
||||
onChangeStatusIcon: (statusId: string, icon: string) => void
|
||||
@@ -23,6 +25,8 @@ type WorkspaceKanbanDrawerHeaderProps = {
|
||||
export default function WorkspaceKanbanDrawerHeader({
|
||||
selectedCount,
|
||||
workspaceStatuses,
|
||||
syncTaskStatusFromWorkspaceBoard,
|
||||
onSyncTaskStatusFromWorkspaceBoardChange,
|
||||
onRenameStatus,
|
||||
onChangeStatusColor,
|
||||
onChangeStatusIcon,
|
||||
@@ -69,6 +73,8 @@ export default function WorkspaceKanbanDrawerHeader({
|
||||
/>
|
||||
<WorkspaceKanbanSettingsMenu
|
||||
workspaceStatuses={workspaceStatuses}
|
||||
syncTaskStatusFromWorkspaceBoard={syncTaskStatusFromWorkspaceBoard}
|
||||
onSyncTaskStatusFromWorkspaceBoardChange={onSyncTaskStatusFromWorkspaceBoardChange}
|
||||
onRenameStatus={onRenameStatus}
|
||||
onChangeStatusColor={onChangeStatusColor}
|
||||
onChangeStatusIcon={onChangeStatusIcon}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act, type ReactNode } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WorkspaceStatusDefinition } from '../../../../shared/types'
|
||||
|
||||
const statuses: WorkspaceStatusDefinition[] = [{ id: 'todo', label: 'Todo' }]
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
vi.mock('@/components/ui/dropdown-menu', () => ({
|
||||
DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuLabel: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: ReactNode }) => <span>{children}</span>,
|
||||
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
|
||||
}))
|
||||
|
||||
import WorkspaceKanbanSettingsMenu from './WorkspaceKanbanSettingsMenu'
|
||||
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
|
||||
function renderMenu(onSyncTaskStatusFromWorkspaceBoardChange = vi.fn()): void {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
act(() => {
|
||||
root?.render(
|
||||
<WorkspaceKanbanSettingsMenu
|
||||
workspaceStatuses={statuses}
|
||||
syncTaskStatusFromWorkspaceBoard={false}
|
||||
onSyncTaskStatusFromWorkspaceBoardChange={onSyncTaskStatusFromWorkspaceBoardChange}
|
||||
onRenameStatus={vi.fn()}
|
||||
onChangeStatusColor={vi.fn()}
|
||||
onChangeStatusIcon={vi.fn()}
|
||||
onMoveStatus={vi.fn()}
|
||||
onRemoveStatus={vi.fn()}
|
||||
onAddStatus={vi.fn()}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root?.unmount()
|
||||
})
|
||||
root = null
|
||||
container?.remove()
|
||||
container = null
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('WorkspaceKanbanSettingsMenu', () => {
|
||||
it('renders the task status sync switch and forwards changes', async () => {
|
||||
const onChange = vi.fn()
|
||||
renderMenu(onChange)
|
||||
|
||||
const toggle = document.querySelector<HTMLButtonElement>(
|
||||
'button[role="switch"][aria-label="Sync board and issue status"]'
|
||||
)
|
||||
expect(toggle).not.toBeNull()
|
||||
expect(toggle?.getAttribute('aria-checked')).toBe('false')
|
||||
|
||||
await act(async () => {
|
||||
toggle?.click()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { SettingsSwitch } from '../settings/SettingsFormControls'
|
||||
import type { WorkspaceStatusDefinition } from '../../../../shared/types'
|
||||
import { getWorkspaceStatusVisualMeta } from './workspace-status'
|
||||
import WorkspaceStatusAppearancePopover from './WorkspaceStatusAppearancePopover'
|
||||
@@ -16,6 +17,8 @@ import { translate } from '@/i18n/i18n'
|
||||
|
||||
type WorkspaceKanbanSettingsMenuProps = {
|
||||
workspaceStatuses: readonly WorkspaceStatusDefinition[]
|
||||
syncTaskStatusFromWorkspaceBoard: boolean
|
||||
onSyncTaskStatusFromWorkspaceBoardChange: (enabled: boolean) => void
|
||||
onRenameStatus: (statusId: string, label: string) => void
|
||||
onChangeStatusColor: (statusId: string, color: string) => void
|
||||
onChangeStatusIcon: (statusId: string, icon: string) => void
|
||||
@@ -26,6 +29,8 @@ type WorkspaceKanbanSettingsMenuProps = {
|
||||
|
||||
export default function WorkspaceKanbanSettingsMenu({
|
||||
workspaceStatuses,
|
||||
syncTaskStatusFromWorkspaceBoard,
|
||||
onSyncTaskStatusFromWorkspaceBoardChange,
|
||||
onRenameStatus,
|
||||
onChangeStatusColor,
|
||||
onChangeStatusIcon,
|
||||
@@ -74,6 +79,34 @@ export default function WorkspaceKanbanSettingsMenu({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="px-1 pb-2">
|
||||
<div className="flex items-start justify-between gap-3 rounded-md px-1.5 py-1.5 hover:bg-worktree-sidebar-accent/70">
|
||||
<span className="min-w-0 space-y-0.5">
|
||||
<span className="block text-[12px] font-medium leading-4 text-foreground">
|
||||
{translate(
|
||||
'auto.components.sidebar.WorkspaceKanbanSettingsMenu.87d24a0c2f',
|
||||
'Sync board and issue status'
|
||||
)}
|
||||
</span>
|
||||
<span className="block text-[11px] leading-4 text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.sidebar.WorkspaceKanbanSettingsMenu.4c2eaa78cc',
|
||||
'Moving a linked workspace updates its Linear issue status when a matching workflow state exists.'
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<SettingsSwitch
|
||||
checked={syncTaskStatusFromWorkspaceBoard}
|
||||
onChange={() =>
|
||||
onSyncTaskStatusFromWorkspaceBoardChange(!syncTaskStatusFromWorkspaceBoard)
|
||||
}
|
||||
ariaLabel={translate(
|
||||
'auto.components.sidebar.WorkspaceKanbanSettingsMenu.87d24a0c2f',
|
||||
'Sync board and issue status'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuLabel>
|
||||
{translate('auto.components.sidebar.WorkspaceKanbanSettingsMenu.395e541d5d', 'Statuses')}
|
||||
</DropdownMenuLabel>
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
LinearIssue,
|
||||
LinearWorkflowState,
|
||||
WorkspaceStatusDefinition,
|
||||
Worktree
|
||||
} from '../../../../shared/types'
|
||||
import type { LinearMutationResult } from '@/runtime/runtime-linear-client'
|
||||
import {
|
||||
getWorkspaceBoardTaskStatusSyncRequest,
|
||||
syncWorkspaceBoardTaskStatuses
|
||||
} from './workspace-board-task-status-sync'
|
||||
|
||||
function issue(overrides: Partial<LinearIssue> = {}): LinearIssue {
|
||||
return {
|
||||
id: 'issue-1',
|
||||
identifier: 'ORC-1',
|
||||
title: 'Sync the board',
|
||||
description: '',
|
||||
url: 'https://linear.app/orca/issue/ORC-1/sync-the-board',
|
||||
state: { name: 'Todo', type: 'unstarted', color: '#999' },
|
||||
team: { id: 'team-1', name: 'Orca', key: 'ORC' },
|
||||
labels: [],
|
||||
labelIds: [],
|
||||
priority: 0,
|
||||
updatedAt: '2026-06-15T00:00:00.000Z',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function state(overrides: Partial<LinearWorkflowState> = {}): LinearWorkflowState {
|
||||
return {
|
||||
id: 'state-review',
|
||||
name: 'In review',
|
||||
type: 'started',
|
||||
color: '#111',
|
||||
position: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function worktree(overrides: Partial<Worktree> = {}): Worktree {
|
||||
return {
|
||||
id: 'repo::/worktree',
|
||||
linkedLinearIssue: 'ORC-1',
|
||||
linkedLinearIssueWorkspaceId: 'workspace-1',
|
||||
...overrides
|
||||
} as Worktree
|
||||
}
|
||||
|
||||
function targetStatus(
|
||||
overrides: Partial<WorkspaceStatusDefinition> = {}
|
||||
): WorkspaceStatusDefinition {
|
||||
return { id: 'in-review', label: 'In review', ...overrides }
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve
|
||||
reject = promiseReject
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
async function flushMicrotasks(count = 5): Promise<void> {
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
function setup(overrides: Partial<Worktree> = {}) {
|
||||
const target = targetStatus()
|
||||
const item = worktree(overrides)
|
||||
const getIssue = vi.fn().mockResolvedValue(issue())
|
||||
const teamStates = vi.fn().mockResolvedValue([state()])
|
||||
const updateIssue = vi.fn<() => Promise<LinearMutationResult>>().mockResolvedValue({ ok: true })
|
||||
|
||||
return {
|
||||
item,
|
||||
target,
|
||||
getIssue,
|
||||
teamStates,
|
||||
updateIssue,
|
||||
run: () =>
|
||||
syncWorkspaceBoardTaskStatuses({
|
||||
worktreeIds: [item.id],
|
||||
targetStatus: target,
|
||||
worktreesById: new Map([[item.id, item]]),
|
||||
settings: { activeRuntimeEnvironmentId: 'runtime-1' },
|
||||
getLatestWorkspaceStatus: () => target.id,
|
||||
deps: { getIssue, teamStates, updateIssue }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
describe('syncWorkspaceBoardTaskStatuses', () => {
|
||||
it('updates Linear when exactly one workflow state matches the board status', async () => {
|
||||
const { run, getIssue, teamStates, updateIssue } = setup()
|
||||
|
||||
await expect(run()).resolves.toEqual({ updated: 1, skipped: 0, failed: 0, messages: [] })
|
||||
|
||||
expect(getIssue).toHaveBeenCalledWith(
|
||||
{ activeRuntimeEnvironmentId: 'runtime-1' },
|
||||
'ORC-1',
|
||||
'workspace-1'
|
||||
)
|
||||
expect(teamStates).toHaveBeenCalledWith(
|
||||
{ activeRuntimeEnvironmentId: 'runtime-1' },
|
||||
'team-1',
|
||||
'workspace-1'
|
||||
)
|
||||
expect(updateIssue).toHaveBeenCalledWith(
|
||||
{ activeRuntimeEnvironmentId: 'runtime-1' },
|
||||
'issue-1',
|
||||
{ stateId: 'state-review' },
|
||||
'workspace-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the fetched issue workspace for state reads and writes when the link lacks one', async () => {
|
||||
const { item, target, getIssue, teamStates, updateIssue } = setup({
|
||||
linkedLinearIssueWorkspaceId: null
|
||||
})
|
||||
getIssue.mockResolvedValueOnce(issue({ workspaceId: 'issue-workspace' }))
|
||||
|
||||
await syncWorkspaceBoardTaskStatuses({
|
||||
worktreeIds: [item.id],
|
||||
targetStatus: target,
|
||||
worktreesById: new Map([[item.id, item]]),
|
||||
settings: null,
|
||||
getLatestWorkspaceStatus: () => target.id,
|
||||
deps: { getIssue, teamStates, updateIssue }
|
||||
})
|
||||
|
||||
expect(getIssue).toHaveBeenCalledWith(null, 'ORC-1', undefined)
|
||||
expect(teamStates).toHaveBeenCalledWith(null, 'team-1', 'issue-workspace')
|
||||
expect(updateIssue).toHaveBeenCalledWith(
|
||||
null,
|
||||
'issue-1',
|
||||
{ stateId: 'state-review' },
|
||||
'issue-workspace'
|
||||
)
|
||||
})
|
||||
|
||||
it('routes each Linear update through the moved worktree owner settings', async () => {
|
||||
const target = targetStatus()
|
||||
const first = worktree({ id: 'repo-a::/worktree-a', linkedLinearIssue: 'ORC-1' })
|
||||
const second = worktree({ id: 'repo-b::/worktree-b', linkedLinearIssue: 'ORC-2' })
|
||||
const getIssue = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(issue({ id: 'issue-1', identifier: 'ORC-1' }))
|
||||
.mockResolvedValueOnce(issue({ id: 'issue-2', identifier: 'ORC-2' }))
|
||||
const teamStates = vi.fn().mockResolvedValue([state()])
|
||||
const updateIssue = vi.fn<() => Promise<LinearMutationResult>>().mockResolvedValue({ ok: true })
|
||||
const getSettingsForWorktree = vi.fn((worktreeId: string) => ({
|
||||
activeRuntimeEnvironmentId: worktreeId.startsWith('repo-a') ? 'runtime-a' : 'runtime-b'
|
||||
}))
|
||||
|
||||
await syncWorkspaceBoardTaskStatuses({
|
||||
worktreeIds: [first.id, second.id],
|
||||
targetStatus: target,
|
||||
worktreesById: new Map([
|
||||
[first.id, first],
|
||||
[second.id, second]
|
||||
]),
|
||||
settings: { activeRuntimeEnvironmentId: 'focused-runtime' },
|
||||
getSettingsForWorktree,
|
||||
getLatestWorkspaceStatus: () => target.id,
|
||||
deps: { getIssue, teamStates, updateIssue }
|
||||
})
|
||||
|
||||
expect(getSettingsForWorktree).toHaveBeenCalledWith(first.id)
|
||||
expect(getSettingsForWorktree).toHaveBeenCalledWith(second.id)
|
||||
expect(getIssue).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{ activeRuntimeEnvironmentId: 'runtime-a' },
|
||||
'ORC-1',
|
||||
'workspace-1'
|
||||
)
|
||||
expect(getIssue).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{ activeRuntimeEnvironmentId: 'runtime-b' },
|
||||
'ORC-2',
|
||||
'workspace-1'
|
||||
)
|
||||
expect(updateIssue).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{ activeRuntimeEnvironmentId: 'runtime-a' },
|
||||
'issue-1',
|
||||
{ stateId: 'state-review' },
|
||||
'workspace-1'
|
||||
)
|
||||
expect(updateIssue).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{ activeRuntimeEnvironmentId: 'runtime-b' },
|
||||
'issue-2',
|
||||
{ stateId: 'state-review' },
|
||||
'workspace-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves null settings from the moved worktree resolver', async () => {
|
||||
const { item, target, getIssue, teamStates, updateIssue } = setup()
|
||||
const getSettingsForWorktree = vi.fn(() => null)
|
||||
|
||||
await syncWorkspaceBoardTaskStatuses({
|
||||
worktreeIds: [item.id],
|
||||
targetStatus: target,
|
||||
worktreesById: new Map([[item.id, item]]),
|
||||
settings: { activeRuntimeEnvironmentId: 'focused-runtime' },
|
||||
getSettingsForWorktree,
|
||||
getLatestWorkspaceStatus: () => target.id,
|
||||
deps: { getIssue, teamStates, updateIssue }
|
||||
})
|
||||
|
||||
expect(getSettingsForWorktree).toHaveBeenCalledWith(item.id)
|
||||
expect(getIssue).toHaveBeenCalledWith(null, 'ORC-1', 'workspace-1')
|
||||
expect(teamStates).toHaveBeenCalledWith(null, 'team-1', 'workspace-1')
|
||||
expect(updateIssue).toHaveBeenCalledWith(
|
||||
null,
|
||||
'issue-1',
|
||||
{ stateId: 'state-review' },
|
||||
'workspace-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('skips worktrees without linked Linear issues', async () => {
|
||||
const { item, target, getIssue, teamStates, updateIssue } = setup({ linkedLinearIssue: null })
|
||||
|
||||
const result = await syncWorkspaceBoardTaskStatuses({
|
||||
worktreeIds: [item.id],
|
||||
targetStatus: target,
|
||||
worktreesById: new Map([[item.id, item]]),
|
||||
settings: null,
|
||||
getLatestWorkspaceStatus: () => target.id,
|
||||
deps: { getIssue, teamStates, updateIssue }
|
||||
})
|
||||
|
||||
expect(result).toEqual({ updated: 0, skipped: 1, failed: 0, messages: [] })
|
||||
expect(getIssue).not.toHaveBeenCalled()
|
||||
expect(teamStates).not.toHaveBeenCalled()
|
||||
expect(updateIssue).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips when the Linear issue is already in the matching state', async () => {
|
||||
const { run, getIssue, updateIssue } = setup()
|
||||
getIssue.mockResolvedValueOnce(
|
||||
issue({ state: { name: 'In review', type: 'started', color: '#111' } })
|
||||
)
|
||||
|
||||
await expect(run()).resolves.toEqual({ updated: 0, skipped: 1, failed: 0, messages: [] })
|
||||
|
||||
expect(updateIssue).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips missing or ambiguous workflow state matches', async () => {
|
||||
const missing = setup()
|
||||
missing.teamStates.mockResolvedValueOnce([state({ name: 'Done' })])
|
||||
|
||||
await expect(missing.run()).resolves.toMatchObject({
|
||||
updated: 0,
|
||||
skipped: 1,
|
||||
failed: 0,
|
||||
messages: [{ kind: 'missing-workflow-state', statusLabel: 'In review' }]
|
||||
})
|
||||
expect(missing.updateIssue).not.toHaveBeenCalled()
|
||||
|
||||
const ambiguous = setup()
|
||||
ambiguous.teamStates.mockResolvedValueOnce([
|
||||
state({ id: 'state-1', name: 'In review' }),
|
||||
state({ id: 'state-2', name: ' in REVIEW ' })
|
||||
])
|
||||
|
||||
await expect(ambiguous.run()).resolves.toMatchObject({
|
||||
updated: 0,
|
||||
skipped: 1,
|
||||
failed: 0,
|
||||
messages: [{ kind: 'ambiguous-workflow-state', statusLabel: 'In review' }]
|
||||
})
|
||||
expect(ambiguous.updateIssue).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips stale async writes when the local workspace status changed again', async () => {
|
||||
const { item, target, getIssue, teamStates, updateIssue } = setup()
|
||||
|
||||
const result = await syncWorkspaceBoardTaskStatuses({
|
||||
worktreeIds: [item.id],
|
||||
targetStatus: target,
|
||||
worktreesById: new Map([[item.id, item]]),
|
||||
settings: null,
|
||||
getLatestWorkspaceStatus: () => 'done',
|
||||
deps: { getIssue, teamStates, updateIssue }
|
||||
})
|
||||
|
||||
expect(result).toEqual({ updated: 0, skipped: 1, failed: 0, messages: [] })
|
||||
expect(updateIssue).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('serializes repeated moves for the same worktree so the latest status wins', async () => {
|
||||
const item = worktree()
|
||||
const firstUpdate = deferred<LinearMutationResult>()
|
||||
const getIssue = vi.fn().mockResolvedValue(issue())
|
||||
const teamStates = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([state()])
|
||||
.mockResolvedValueOnce([state({ id: 'state-done', name: 'Done', type: 'completed' })])
|
||||
const updateIssue = vi
|
||||
.fn<() => Promise<LinearMutationResult>>()
|
||||
.mockReturnValueOnce(firstUpdate.promise)
|
||||
.mockResolvedValueOnce({ ok: true })
|
||||
|
||||
const firstSync = syncWorkspaceBoardTaskStatuses({
|
||||
worktreeIds: [item.id],
|
||||
targetStatus: targetStatus(),
|
||||
worktreesById: new Map([[item.id, item]]),
|
||||
settings: null,
|
||||
getLatestWorkspaceStatus: () => 'in-review',
|
||||
deps: { getIssue, teamStates, updateIssue }
|
||||
})
|
||||
await flushMicrotasks()
|
||||
expect(updateIssue).toHaveBeenCalledTimes(1)
|
||||
|
||||
const secondSync = syncWorkspaceBoardTaskStatuses({
|
||||
worktreeIds: [item.id],
|
||||
targetStatus: targetStatus({ id: 'done', label: 'Done' }),
|
||||
worktreesById: new Map([[item.id, item]]),
|
||||
settings: null,
|
||||
getLatestWorkspaceStatus: () => 'done',
|
||||
deps: { getIssue, teamStates, updateIssue }
|
||||
})
|
||||
await flushMicrotasks()
|
||||
expect(updateIssue).toHaveBeenCalledTimes(1)
|
||||
|
||||
firstUpdate.resolve({ ok: true })
|
||||
await firstSync
|
||||
await secondSync
|
||||
|
||||
expect(updateIssue).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
null,
|
||||
'issue-1',
|
||||
{ stateId: 'state-review' },
|
||||
'workspace-1'
|
||||
)
|
||||
expect(updateIssue).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
null,
|
||||
'issue-1',
|
||||
{ stateId: 'state-done' },
|
||||
'workspace-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('aggregates provider write failures without throwing', async () => {
|
||||
const { run, updateIssue } = setup()
|
||||
updateIssue.mockResolvedValueOnce({ ok: false, error: 'Linear is unavailable' })
|
||||
|
||||
await expect(run()).resolves.toEqual({
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
failed: 1,
|
||||
messages: [
|
||||
{
|
||||
kind: 'update-failed',
|
||||
issueIdentifier: 'ORC-1',
|
||||
detail: 'Linear is unavailable'
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getWorkspaceBoardTaskStatusSyncRequest', () => {
|
||||
const workspaceStatuses: WorkspaceStatusDefinition[] = [
|
||||
{ id: 'todo', label: 'Todo' },
|
||||
{ id: 'in-review', label: 'In review' }
|
||||
]
|
||||
|
||||
it('builds a sync request for enabled status moves', () => {
|
||||
const request = getWorkspaceBoardTaskStatusSyncRequest({
|
||||
enabled: true,
|
||||
worktreeIds: ['repo::/a'],
|
||||
status: 'in-review',
|
||||
worktreesById: new Map([['repo::/a', worktree({ workspaceStatus: 'todo' })]]),
|
||||
workspaceStatuses
|
||||
})
|
||||
|
||||
expect(request).toEqual({
|
||||
worktreeIds: ['repo::/a'],
|
||||
targetStatus: { id: 'in-review', label: 'In review' }
|
||||
})
|
||||
})
|
||||
|
||||
it('does not build a sync request while the board setting is disabled', () => {
|
||||
expect(
|
||||
getWorkspaceBoardTaskStatusSyncRequest({
|
||||
enabled: false,
|
||||
worktreeIds: ['repo::/a'],
|
||||
status: 'in-review',
|
||||
worktreesById: new Map([['repo::/a', worktree({ workspaceStatus: 'todo' })]]),
|
||||
workspaceStatuses
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('skips same-status and duplicate ids so manual-order-only drops do not sync', () => {
|
||||
expect(
|
||||
getWorkspaceBoardTaskStatusSyncRequest({
|
||||
enabled: true,
|
||||
worktreeIds: ['repo::/a', 'repo::/a'],
|
||||
status: 'in-review',
|
||||
worktreesById: new Map([['repo::/a', worktree({ workspaceStatus: 'in-review' })]]),
|
||||
workspaceStatuses
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not build a sync request without a board status target', () => {
|
||||
expect(
|
||||
getWorkspaceBoardTaskStatusSyncRequest({
|
||||
enabled: true,
|
||||
worktreeIds: ['repo::/a'],
|
||||
status: 'unknown-status',
|
||||
worktreesById: new Map([['repo::/a', worktree({ workspaceStatus: 'todo' })]]),
|
||||
workspaceStatuses
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,271 @@
|
||||
import {
|
||||
linearGetIssue,
|
||||
linearTeamStates,
|
||||
linearUpdateIssue,
|
||||
type LinearMutationResult,
|
||||
type RuntimeLinearSettings
|
||||
} from '@/runtime/runtime-linear-client'
|
||||
import type {
|
||||
LinearIssue,
|
||||
LinearWorkflowState,
|
||||
WorkspaceStatus,
|
||||
WorkspaceStatusDefinition,
|
||||
Worktree
|
||||
} from '../../../../shared/types'
|
||||
import { getWorkspaceStatus } from '../../../../shared/workspace-statuses'
|
||||
|
||||
export type WorkspaceBoardTaskStatusSyncResult = {
|
||||
updated: number
|
||||
skipped: number
|
||||
failed: number
|
||||
messages: WorkspaceBoardTaskStatusSyncMessage[]
|
||||
}
|
||||
|
||||
export type WorkspaceBoardTaskStatusSyncMessage =
|
||||
| { kind: 'issue-read-failed'; issueIdentifier: string }
|
||||
| { kind: 'missing-workflow-state'; statusLabel: string }
|
||||
| { kind: 'ambiguous-workflow-state'; statusLabel: string }
|
||||
| { kind: 'update-failed'; issueIdentifier: string; detail?: string }
|
||||
| { kind: 'provider-error'; issueIdentifier: string; detail?: string }
|
||||
| { kind: 'unexpected-error'; detail?: string }
|
||||
|
||||
type WorkspaceBoardTaskStatusSyncDependencies = {
|
||||
getIssue: typeof linearGetIssue
|
||||
teamStates: typeof linearTeamStates
|
||||
updateIssue: typeof linearUpdateIssue
|
||||
}
|
||||
|
||||
export type SyncWorkspaceBoardTaskStatusesArgs = {
|
||||
worktreeIds: readonly string[]
|
||||
targetStatus: WorkspaceStatusDefinition
|
||||
worktreesById: ReadonlyMap<
|
||||
string,
|
||||
Pick<Worktree, 'linkedLinearIssue' | 'linkedLinearIssueWorkspaceId'>
|
||||
>
|
||||
settings?: RuntimeLinearSettings
|
||||
getSettingsForWorktree?: (worktreeId: string) => RuntimeLinearSettings
|
||||
getLatestWorkspaceStatus: (worktreeId: string) => WorkspaceStatus | null | undefined
|
||||
deps?: Partial<WorkspaceBoardTaskStatusSyncDependencies>
|
||||
}
|
||||
|
||||
export type WorkspaceBoardTaskStatusSyncRequest = {
|
||||
worktreeIds: string[]
|
||||
targetStatus: WorkspaceStatusDefinition
|
||||
}
|
||||
|
||||
export function getWorkspaceBoardTaskStatusSyncRequest(args: {
|
||||
enabled: boolean
|
||||
worktreeIds: readonly string[]
|
||||
status: WorkspaceStatus
|
||||
worktreesById: ReadonlyMap<string, Pick<Worktree, 'workspaceStatus'>>
|
||||
workspaceStatuses: readonly WorkspaceStatusDefinition[]
|
||||
}): WorkspaceBoardTaskStatusSyncRequest | null {
|
||||
if (!args.enabled || args.worktreeIds.length === 0) {
|
||||
return null
|
||||
}
|
||||
const targetStatus = args.workspaceStatuses.find((item) => item.id === args.status)
|
||||
if (!targetStatus) {
|
||||
return null
|
||||
}
|
||||
const changedWorktreeIds = [...new Set(args.worktreeIds)].filter((worktreeId) => {
|
||||
const worktree = args.worktreesById.get(worktreeId)
|
||||
return worktree ? getWorkspaceStatus(worktree, args.workspaceStatuses) !== args.status : false
|
||||
})
|
||||
if (changedWorktreeIds.length === 0) {
|
||||
return null
|
||||
}
|
||||
return { worktreeIds: changedWorktreeIds, targetStatus }
|
||||
}
|
||||
|
||||
const defaultDeps: WorkspaceBoardTaskStatusSyncDependencies = {
|
||||
getIssue: linearGetIssue,
|
||||
teamStates: linearTeamStates,
|
||||
updateIssue: linearUpdateIssue
|
||||
}
|
||||
|
||||
const worktreeSyncQueues = new Map<string, Promise<unknown>>()
|
||||
|
||||
function normalizeStateName(name: string): string {
|
||||
return name.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function matchingWorkflowStates(
|
||||
states: readonly LinearWorkflowState[],
|
||||
targetStatus: WorkspaceStatusDefinition
|
||||
): LinearWorkflowState[] {
|
||||
const targetName = normalizeStateName(targetStatus.label)
|
||||
return states.filter((state) => normalizeStateName(state.name) === targetName)
|
||||
}
|
||||
|
||||
function getMessageKey(message: WorkspaceBoardTaskStatusSyncMessage): string {
|
||||
return JSON.stringify(message)
|
||||
}
|
||||
|
||||
function addMessage(
|
||||
result: WorkspaceBoardTaskStatusSyncResult,
|
||||
message: WorkspaceBoardTaskStatusSyncMessage
|
||||
): void {
|
||||
const key = getMessageKey(message)
|
||||
if (!result.messages.some((item) => getMessageKey(item) === key)) {
|
||||
result.messages.push(message)
|
||||
}
|
||||
}
|
||||
|
||||
function skipped(
|
||||
result: WorkspaceBoardTaskStatusSyncResult,
|
||||
message?: WorkspaceBoardTaskStatusSyncMessage
|
||||
): WorkspaceBoardTaskStatusSyncResult {
|
||||
result.skipped += 1
|
||||
if (message) {
|
||||
addMessage(result, message)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function failed(
|
||||
result: WorkspaceBoardTaskStatusSyncResult,
|
||||
message: WorkspaceBoardTaskStatusSyncMessage
|
||||
): WorkspaceBoardTaskStatusSyncResult {
|
||||
result.failed += 1
|
||||
addMessage(result, message)
|
||||
return result
|
||||
}
|
||||
|
||||
function isAlreadyInState(issue: LinearIssue, workflowState: LinearWorkflowState): boolean {
|
||||
return (
|
||||
normalizeStateName(issue.state.name) === normalizeStateName(workflowState.name) &&
|
||||
issue.state.type === workflowState.type
|
||||
)
|
||||
}
|
||||
|
||||
function mergeResult(
|
||||
aggregate: WorkspaceBoardTaskStatusSyncResult,
|
||||
item: WorkspaceBoardTaskStatusSyncResult
|
||||
): void {
|
||||
aggregate.updated += item.updated
|
||||
aggregate.skipped += item.skipped
|
||||
aggregate.failed += item.failed
|
||||
for (const message of item.messages) {
|
||||
addMessage(aggregate, message)
|
||||
}
|
||||
}
|
||||
|
||||
async function enqueueWorktreeSync(
|
||||
worktreeId: string,
|
||||
task: () => Promise<WorkspaceBoardTaskStatusSyncResult>
|
||||
): Promise<WorkspaceBoardTaskStatusSyncResult> {
|
||||
const previous = worktreeSyncQueues.get(worktreeId) ?? Promise.resolve()
|
||||
const next = previous.catch(() => undefined).then(task)
|
||||
const cleanup = next.finally(() => {
|
||||
if (worktreeSyncQueues.get(worktreeId) === cleanup) {
|
||||
worktreeSyncQueues.delete(worktreeId)
|
||||
}
|
||||
})
|
||||
worktreeSyncQueues.set(worktreeId, cleanup)
|
||||
return next
|
||||
}
|
||||
|
||||
async function syncLinearWorktreeStatus(
|
||||
args: SyncWorkspaceBoardTaskStatusesArgs,
|
||||
worktreeId: string,
|
||||
deps: WorkspaceBoardTaskStatusSyncDependencies
|
||||
): Promise<WorkspaceBoardTaskStatusSyncResult> {
|
||||
const result: WorkspaceBoardTaskStatusSyncResult = {
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
messages: []
|
||||
}
|
||||
const worktree = args.worktreesById.get(worktreeId)
|
||||
if (!worktree?.linkedLinearIssue) {
|
||||
return skipped(result)
|
||||
}
|
||||
|
||||
const settings = args.getSettingsForWorktree
|
||||
? args.getSettingsForWorktree(worktreeId)
|
||||
: args.settings
|
||||
const linkedWorkspaceId = worktree.linkedLinearIssueWorkspaceId ?? undefined
|
||||
|
||||
try {
|
||||
const issue = await deps.getIssue(settings, worktree.linkedLinearIssue, linkedWorkspaceId)
|
||||
if (!issue?.team?.id) {
|
||||
return skipped(result, {
|
||||
kind: 'issue-read-failed',
|
||||
issueIdentifier: worktree.linkedLinearIssue
|
||||
})
|
||||
}
|
||||
|
||||
const workspaceId = linkedWorkspaceId ?? issue.workspaceId
|
||||
const states = await deps.teamStates(settings, issue.team.id, workspaceId)
|
||||
const matches = matchingWorkflowStates(states, args.targetStatus)
|
||||
if (matches.length === 0) {
|
||||
return skipped(result, {
|
||||
kind: 'missing-workflow-state',
|
||||
statusLabel: args.targetStatus.label
|
||||
})
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
return skipped(result, {
|
||||
kind: 'ambiguous-workflow-state',
|
||||
statusLabel: args.targetStatus.label
|
||||
})
|
||||
}
|
||||
|
||||
const [workflowState] = matches
|
||||
if (isAlreadyInState(issue, workflowState)) {
|
||||
return skipped(result)
|
||||
}
|
||||
|
||||
// Why: board moves are local-first; slow provider reads must not let an
|
||||
// older board move overwrite a newer local status in Linear.
|
||||
if (args.getLatestWorkspaceStatus(worktreeId) !== args.targetStatus.id) {
|
||||
return skipped(result)
|
||||
}
|
||||
|
||||
const updateResult: LinearMutationResult = await deps.updateIssue(
|
||||
settings,
|
||||
issue.id,
|
||||
{ stateId: workflowState.id },
|
||||
workspaceId
|
||||
)
|
||||
if (updateResult.ok === false) {
|
||||
return failed(result, {
|
||||
kind: 'update-failed',
|
||||
issueIdentifier: issue.identifier,
|
||||
detail: updateResult.error
|
||||
})
|
||||
}
|
||||
result.updated += 1
|
||||
return result
|
||||
} catch (error) {
|
||||
return failed(result, {
|
||||
kind: 'provider-error',
|
||||
issueIdentifier: worktree.linkedLinearIssue,
|
||||
detail: error instanceof Error ? error.message : undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncWorkspaceBoardTaskStatuses(
|
||||
args: SyncWorkspaceBoardTaskStatusesArgs
|
||||
): Promise<WorkspaceBoardTaskStatusSyncResult> {
|
||||
const deps = { ...defaultDeps, ...args.deps }
|
||||
const aggregate: WorkspaceBoardTaskStatusSyncResult = {
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
messages: []
|
||||
}
|
||||
|
||||
const uniqueIds = new Set(args.worktreeIds)
|
||||
await Promise.all(
|
||||
[...uniqueIds].map(async (worktreeId) => {
|
||||
const item = await enqueueWorktreeSync(worktreeId, () =>
|
||||
syncLinearWorktreeStatus(args, worktreeId, deps)
|
||||
)
|
||||
mergeResult(aggregate, item)
|
||||
})
|
||||
)
|
||||
|
||||
return aggregate
|
||||
}
|
||||
@@ -3690,7 +3690,9 @@
|
||||
"8ce44af9a8": "Rename {{value0}}",
|
||||
"395e541d5d": "Statuses",
|
||||
"34f03eb0de": "Board settings",
|
||||
"26cbc92150": "Workspace board settings"
|
||||
"26cbc92150": "Workspace board settings",
|
||||
"87d24a0c2f": "Sync board and issue status",
|
||||
"4c2eaa78cc": "Moving a linked workspace updates its Linear issue status when a matching workflow state exists."
|
||||
},
|
||||
"WorkspaceKanbanStatusLane": {
|
||||
"8ad104642b": "Empty",
|
||||
@@ -4129,6 +4131,19 @@
|
||||
"822ff300ad": "Dismiss",
|
||||
"b7e3a91c4f": "Drag project headers to reorder, or switch to",
|
||||
"e8c1f4a2b9": "in workspace options."
|
||||
},
|
||||
"WorkspaceKanbanDrawer": {
|
||||
"1975a4e480": "Task status sync failed",
|
||||
"e02b0d92ff": "Task status sync skipped",
|
||||
"c1d2e3f4a5": "Linear issue {{value0}} could not be read.",
|
||||
"d2e3f4a5b6": "No matching Linear workflow state for {{value0}}.",
|
||||
"e3f4a5b6c7": "Multiple Linear workflow states match {{value0}}.",
|
||||
"f4a5b6c7d8": "Could not update Linear issue {{value0}}.",
|
||||
"a5b6c7d8e9": "Could not sync Linear issue {{value0}}.",
|
||||
"b6c7d8e9f0": "Task status sync could not finish.",
|
||||
"c7d8e9f0a1": "{{value0}} updated",
|
||||
"d8e9f0a1b2": "{{value0}} skipped",
|
||||
"e9f0a1b2c3": "{{value0}} failed"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
|
||||
@@ -3676,7 +3676,9 @@
|
||||
"8ce44af9a8": "Cambiar nombre {{value0}}",
|
||||
"395e541d5d": "Estados",
|
||||
"34f03eb0de": "Configuración del tablero",
|
||||
"26cbc92150": "Configuración del tablero del espacio de trabajo"
|
||||
"26cbc92150": "Configuración del tablero del espacio de trabajo",
|
||||
"87d24a0c2f": "Sincronizar el estado del tablero y de incidencias",
|
||||
"4c2eaa78cc": "Al mover un espacio de trabajo vinculado, se actualiza el estado de su incidencia de Linear cuando existe un estado de flujo de trabajo coincidente."
|
||||
},
|
||||
"WorkspaceKanbanStatusLane": {
|
||||
"8ad104642b": "Vacío",
|
||||
@@ -4129,6 +4131,19 @@
|
||||
"2d3e4f5a6b": "Check connection",
|
||||
"3c4d5e6f7a": "Manage host…",
|
||||
"6e7f8a9b0c": "Remove host…"
|
||||
},
|
||||
"WorkspaceKanbanDrawer": {
|
||||
"1975a4e480": "Error al sincronizar el estado de la tarea",
|
||||
"e02b0d92ff": "Sincronización del estado de la tarea omitida",
|
||||
"c1d2e3f4a5": "No se pudo leer la incidencia {{value0}} de Linear.",
|
||||
"d2e3f4a5b6": "No hay ningún estado de flujo de trabajo de Linear que coincida con {{value0}}.",
|
||||
"e3f4a5b6c7": "Varios estados de flujo de trabajo de Linear coinciden con {{value0}}.",
|
||||
"f4a5b6c7d8": "No se pudo actualizar la incidencia {{value0}} de Linear.",
|
||||
"a5b6c7d8e9": "No se pudo sincronizar la incidencia {{value0}} de Linear.",
|
||||
"b6c7d8e9f0": "No se pudo completar la sincronización del estado de la tarea.",
|
||||
"c7d8e9f0a1": "{{value0}} actualizadas",
|
||||
"d8e9f0a1b2": "{{value0}} omitidas",
|
||||
"e9f0a1b2c3": "{{value0}} fallidas"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
|
||||
@@ -3657,7 +3657,9 @@
|
||||
"8ce44af9a8": "{{value0}} の名前を変更",
|
||||
"395e541d5d": "ステータス",
|
||||
"34f03eb0de": "ボードの設定",
|
||||
"26cbc92150": "ワークスペースボードの設定"
|
||||
"26cbc92150": "ワークスペースボードの設定",
|
||||
"87d24a0c2f": "ボードとイシューのステータスを同期",
|
||||
"4c2eaa78cc": "リンク済みワークスペースを移動すると、一致するワークフロー状態がある場合に Linear イシューのステータスが更新されます。"
|
||||
},
|
||||
"WorkspaceKanbanStatusLane": {
|
||||
"8ad104642b": "空の",
|
||||
@@ -4129,6 +4131,19 @@
|
||||
"2d3e4f5a6b": "Check connection",
|
||||
"3c4d5e6f7a": "Manage host…",
|
||||
"6e7f8a9b0c": "Remove host…"
|
||||
},
|
||||
"WorkspaceKanbanDrawer": {
|
||||
"1975a4e480": "タスクステータスの同期に失敗しました",
|
||||
"e02b0d92ff": "タスクステータスの同期をスキップしました",
|
||||
"c1d2e3f4a5": "Linear イシュー {{value0}} を読み取れませんでした。",
|
||||
"d2e3f4a5b6": "{{value0}} に一致する Linear ワークフロー状態がありません。",
|
||||
"e3f4a5b6c7": "{{value0}} に一致する Linear ワークフロー状態が複数あります。",
|
||||
"f4a5b6c7d8": "Linear イシュー {{value0}} を更新できませんでした。",
|
||||
"a5b6c7d8e9": "Linear イシュー {{value0}} を同期できませんでした。",
|
||||
"b6c7d8e9f0": "タスクステータスの同期を完了できませんでした。",
|
||||
"c7d8e9f0a1": "{{value0}} 件更新",
|
||||
"d8e9f0a1b2": "{{value0}} 件スキップ",
|
||||
"e9f0a1b2c3": "{{value0}} 件失敗"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
|
||||
@@ -3657,7 +3657,9 @@
|
||||
"8ce44af9a8": "{{value0}} 이름 바꾸기",
|
||||
"395e541d5d": "상태",
|
||||
"34f03eb0de": "보드 설정",
|
||||
"26cbc92150": "워크스페이스 보드 설정"
|
||||
"26cbc92150": "워크스페이스 보드 설정",
|
||||
"87d24a0c2f": "보드와 이슈 상태 동기화",
|
||||
"4c2eaa78cc": "연결된 워크스페이스를 이동하면 일치하는 워크플로 상태가 있을 때 Linear 이슈 상태가 업데이트됩니다."
|
||||
},
|
||||
"WorkspaceKanbanStatusLane": {
|
||||
"8ad104642b": "비어 있는",
|
||||
@@ -4129,6 +4131,19 @@
|
||||
"2d3e4f5a6b": "Check connection",
|
||||
"3c4d5e6f7a": "Manage host…",
|
||||
"6e7f8a9b0c": "Remove host…"
|
||||
},
|
||||
"WorkspaceKanbanDrawer": {
|
||||
"1975a4e480": "작업 상태 동기화 실패",
|
||||
"e02b0d92ff": "작업 상태 동기화 건너뜀",
|
||||
"c1d2e3f4a5": "Linear 이슈 {{value0}}을(를) 읽을 수 없습니다.",
|
||||
"d2e3f4a5b6": "{{value0}}에 일치하는 Linear 워크플로 상태가 없습니다.",
|
||||
"e3f4a5b6c7": "{{value0}}에 일치하는 Linear 워크플로 상태가 여러 개입니다.",
|
||||
"f4a5b6c7d8": "Linear 이슈 {{value0}}을(를) 업데이트할 수 없습니다.",
|
||||
"a5b6c7d8e9": "Linear 이슈 {{value0}}을(를) 동기화할 수 없습니다.",
|
||||
"b6c7d8e9f0": "작업 상태 동기화를 완료할 수 없습니다.",
|
||||
"c7d8e9f0a1": "{{value0}}개 업데이트됨",
|
||||
"d8e9f0a1b2": "{{value0}}개 건너뜀",
|
||||
"e9f0a1b2c3": "{{value0}}개 실패"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
|
||||
@@ -3657,7 +3657,9 @@
|
||||
"8ce44af9a8": "重命名 {{value0}}",
|
||||
"395e541d5d": "状态",
|
||||
"34f03eb0de": "董事会设置",
|
||||
"26cbc92150": "工作区板设置"
|
||||
"26cbc92150": "工作区板设置",
|
||||
"87d24a0c2f": "同步看板和议题状态",
|
||||
"4c2eaa78cc": "移动已关联的工作区时,如果存在匹配的工作流状态,将更新其 Linear 议题状态。"
|
||||
},
|
||||
"WorkspaceKanbanStatusLane": {
|
||||
"8ad104642b": "空的",
|
||||
@@ -4129,6 +4131,19 @@
|
||||
"2d3e4f5a6b": "检查连接",
|
||||
"3c4d5e6f7a": "Manage host…",
|
||||
"6e7f8a9b0c": "删除主机..."
|
||||
},
|
||||
"WorkspaceKanbanDrawer": {
|
||||
"1975a4e480": "任务状态同步失败",
|
||||
"e02b0d92ff": "任务状态同步已跳过",
|
||||
"c1d2e3f4a5": "无法读取 Linear 议题 {{value0}}。",
|
||||
"d2e3f4a5b6": "没有与 {{value0}} 匹配的 Linear 工作流状态。",
|
||||
"e3f4a5b6c7": "有多个 Linear 工作流状态与 {{value0}} 匹配。",
|
||||
"f4a5b6c7d8": "无法更新 Linear 议题 {{value0}}。",
|
||||
"a5b6c7d8e9": "无法同步 Linear 议题 {{value0}}。",
|
||||
"b6c7d8e9f0": "无法完成任务状态同步。",
|
||||
"c7d8e9f0a1": "已更新 {{value0}} 个",
|
||||
"d8e9f0a1b2": "已跳过 {{value0}} 个",
|
||||
"e9f0a1b2c3": "失败 {{value0}} 个"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
|
||||
@@ -1101,6 +1101,26 @@ describe('createUISlice hydratePersistedUI', () => {
|
||||
expect(store.getState().workspaceBoardColumnWidth).toBe(520)
|
||||
})
|
||||
|
||||
it('defaults workspace board task status sync off and persists changes', () => {
|
||||
const setUI = vi.fn().mockResolvedValue(undefined)
|
||||
vi.stubGlobal('window', { api: { ui: { set: setUI } } })
|
||||
const store = createUIStore()
|
||||
|
||||
expect(store.getState().syncTaskStatusFromWorkspaceBoard).toBe(false)
|
||||
|
||||
store.getState().hydratePersistedUI(
|
||||
makePersistedUI({
|
||||
syncTaskStatusFromWorkspaceBoard: true
|
||||
})
|
||||
)
|
||||
expect(store.getState().syncTaskStatusFromWorkspaceBoard).toBe(true)
|
||||
|
||||
store.getState().setSyncTaskStatusFromWorkspaceBoard(false)
|
||||
|
||||
expect(store.getState().syncTaskStatusFromWorkspaceBoard).toBe(false)
|
||||
expect(setUI).toHaveBeenCalledWith({ syncTaskStatusFromWorkspaceBoard: false })
|
||||
})
|
||||
|
||||
it('hydrates a valid Kagi session link', () => {
|
||||
const store = createUIStore()
|
||||
|
||||
|
||||
@@ -811,6 +811,8 @@ export type UISlice = {
|
||||
setWorkspaceBoardOpacity: (opacity: number) => void
|
||||
workspaceBoardColumnWidth: number
|
||||
setWorkspaceBoardColumnWidth: (width: number) => void
|
||||
syncTaskStatusFromWorkspaceBoard: boolean
|
||||
setSyncTaskStatusFromWorkspaceBoard: (enabled: boolean) => void
|
||||
statusBarItems: StatusBarItem[]
|
||||
toggleStatusBarItem: (item: StatusBarItem) => void
|
||||
statusBarVisible: boolean
|
||||
@@ -1995,6 +1997,12 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
|
||||
set({ workspaceBoardColumnWidth: clamped })
|
||||
},
|
||||
|
||||
syncTaskStatusFromWorkspaceBoard: false,
|
||||
setSyncTaskStatusFromWorkspaceBoard: (enabled) => {
|
||||
window.api.ui.set({ syncTaskStatusFromWorkspaceBoard: enabled }).catch(console.error)
|
||||
set({ syncTaskStatusFromWorkspaceBoard: enabled })
|
||||
},
|
||||
|
||||
statusBarItems: [...DEFAULT_STATUS_BAR_ITEMS],
|
||||
toggleStatusBarItem: (item) =>
|
||||
set((s) => {
|
||||
@@ -2222,6 +2230,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
|
||||
workspaceStatuses: normalizeWorkspaceStatuses(ui.workspaceStatuses),
|
||||
workspaceBoardOpacity: clampWorkspaceBoardOpacity(ui.workspaceBoardOpacity),
|
||||
workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth(ui.workspaceBoardColumnWidth),
|
||||
syncTaskStatusFromWorkspaceBoard: ui.syncTaskStatusFromWorkspaceBoard === true,
|
||||
statusBarItems,
|
||||
statusBarVisible: ui.statusBarVisible ?? true,
|
||||
// Why: absent → true so existing users see the pet the first time
|
||||
|
||||
@@ -454,6 +454,7 @@ export function getDefaultUIState(): PersistedUIState {
|
||||
workspaceStatuses: cloneDefaultWorkspaceStatuses(),
|
||||
workspaceBoardOpacity: 1,
|
||||
workspaceBoardColumnWidth: 308,
|
||||
syncTaskStatusFromWorkspaceBoard: false,
|
||||
_workspaceStatusesDefaultOrderMigrated: true,
|
||||
_workspaceStatusesDefaultWorkflowMigrated: true,
|
||||
_workspaceStatusesDefaultVisualsMigrated: true,
|
||||
|
||||
@@ -3017,6 +3017,7 @@ export type PersistedUIState = {
|
||||
workspaceStatuses?: WorkspaceStatusDefinition[]
|
||||
workspaceBoardOpacity?: number
|
||||
workspaceBoardColumnWidth?: number
|
||||
syncTaskStatusFromWorkspaceBoard?: boolean
|
||||
/** One-shot migration flag for a short-lived build that persisted the
|
||||
* default workspace statuses in reverse workflow order. Once stamped,
|
||||
* user-authored status ordering is never inferred from IDs/labels again. */
|
||||
|
||||
Reference in New Issue
Block a user