diff --git a/src/main/persistence-jira-project.test.ts b/src/main/persistence-jira-project.test.ts new file mode 100644 index 00000000000..4346e73c06a --- /dev/null +++ b/src/main/persistence-jira-project.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { closeTestStores, testState, createStore, makeRepo } from './persistence-test-harness' +vi.mock('electron', () => ({ app: { getPath: () => testState.dir } })) +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({ nth_repo_added: 2 }) })) +const project = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' } +describe('persisted repository Jira defaults', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-jira-project-')) + }) + afterEach(async () => { + await closeTestStores() + rmSync(testState.dir, { recursive: true, force: true }) + }) + it('persists and clears the project default across restart without requiring Git', () => { + const store = createStore() + store.addRepo(makeRepo({ kind: 'folder' })) + expect(store.updateRepo('r1', { defaultJiraProject: project })?.defaultJiraProject).toEqual( + project + ) + store.flush() + const reloaded = createStore() + expect(reloaded.getRepo('r1')?.defaultJiraProject).toEqual(project) + reloaded.updateRepo('r1', { defaultJiraProject: null }) + reloaded.flush() + expect(createStore().getRepo('r1')?.defaultJiraProject).toBeNull() + }) +}) diff --git a/src/main/persistence/loading-store/repo-lifecycle-operations.ts b/src/main/persistence/loading-store/repo-lifecycle-operations.ts index 29aded33564..bb22de808c4 100644 --- a/src/main/persistence/loading-store/repo-lifecycle-operations.ts +++ b/src/main/persistence/loading-store/repo-lifecycle-operations.ts @@ -141,6 +141,7 @@ export class RepoLifecycleOperations { | 'repoIcon' | 'upstream' | 'gitRemoteIdentity' + | 'defaultJiraProject' | 'hookSettings' | 'worktreeBaseRef' | 'worktreeBasePath' diff --git a/src/main/persistence/tracking-repos/repo-hydration.ts b/src/main/persistence/tracking-repos/repo-hydration.ts index e642ef50f53..110c47ec5f1 100644 --- a/src/main/persistence/tracking-repos/repo-hydration.ts +++ b/src/main/persistence/tracking-repos/repo-hydration.ts @@ -4,6 +4,7 @@ import { getDefaultRepoHookSettings } from '../../../shared/constants' import { isFolderRepo } from '../../../shared/repo-kind' import { sanitizeRepoIcon } from '../../../shared/repo-icon' import { normalizeGhAccountBinding } from '../../../shared/github/account-binding' +import { normalizeJiraProjectReference } from '../../../shared/jira-project-reference' import { normalizeRepoSourceControlAiOverrides } from '../../../shared/source-control-ai' import { sanitizeForkSyncMode, @@ -37,6 +38,7 @@ export function hydrateRepo(repo: Repo, gitUsernameCache: ReadonlyMap @@ -86,6 +88,14 @@ export function sanitizeRepoUpdatesForPersistence< } >(updates: T): T { const sanitized = { ...updates } + if ('defaultJiraProject' in sanitized) { + const reference = normalizeJiraProjectReference(sanitized.defaultJiraProject) + if (reference === undefined) { + delete sanitized.defaultJiraProject + } else { + sanitized.defaultJiraProject = reference + } + } if ('badgeColor' in sanitized) { const badgeColor = normalizeRepoBadgeColor(sanitized.badgeColor) if (!badgeColor) { diff --git a/src/main/persistence/tracking-repos/repo-update-operations.ts b/src/main/persistence/tracking-repos/repo-update-operations.ts index 79444a32a19..987e4aed5fb 100644 --- a/src/main/persistence/tracking-repos/repo-update-operations.ts +++ b/src/main/persistence/tracking-repos/repo-update-operations.ts @@ -91,6 +91,7 @@ export class RepoUpdatePersistenceOperations { | 'executionHostId' | 'symlinkPaths' | 'issueSourcePreference' + | 'defaultJiraProject' | 'forkSyncMode' | 'externalWorktreeVisibilityPromptDismissedAt' | 'externalWorktreeInboxBaselinePaths' diff --git a/src/main/runtime/runtime-repository-settings-controller.ts b/src/main/runtime/runtime-repository-settings-controller.ts index c4a02d656f4..1cc0897d566 100644 --- a/src/main/runtime/runtime-repository-settings-controller.ts +++ b/src/main/runtime/runtime-repository-settings-controller.ts @@ -28,6 +28,7 @@ type RepositoryUpdates = Partial< | 'kind' | 'symlinkPaths' | 'issueSourcePreference' + | 'defaultJiraProject' | 'externalWorktreeVisibility' | 'externalWorktreeVisibilityPromptDismissedAt' | 'externalWorktreeInboxBaselinePaths' diff --git a/src/preload/api/repository-api.ts b/src/preload/api/repository-api.ts index e2a29543dca..3d8eb6540b4 100644 --- a/src/preload/api/repository-api.ts +++ b/src/preload/api/repository-api.ts @@ -51,6 +51,7 @@ export type RepositoryApi = { | 'worktreeBasePath' | 'kind' | 'issueSourcePreference' + | 'defaultJiraProject' | 'externalWorktreeVisibilityPromptDismissedAt' | 'externalWorktreeInboxBaselinePaths' | 'importedExternalWorktreePaths' diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index 68a12418072..ca3c4603d73 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -27,6 +27,7 @@ import type { TaskSourceContext } from '../../../shared/task-source-context' import { translate } from '@/i18n/i18n' import { getWorkspaceComposerInitialFocusTarget } from '@/lib/workspace-composer-initial-focus' import { getFolderWorkspacePrimaryActionLabel } from '@/components/sidebar/folder-workspace-composer-helpers' +import type { WorkspaceCreationTargetHint } from '@/lib/project-host-workspace-target' // Why: match App-level AddRepoDialog loading — the add flow is off the hot // path for the composer, so keep its clone/SSH machinery out of the entry render. @@ -42,6 +43,7 @@ type ComposerModalData = { linkedWorkItem?: LinkedWorkItemSummary | null initialGitHubWorkItem?: GitHubWorkItem | null taskSourceContext?: TaskSourceContext | null + initialWorkspaceTarget?: WorkspaceCreationTargetHint | null initialBaseBranch?: string initialWorkspaceStatus?: WorkspaceStatus enableIssueAutomation?: boolean @@ -140,6 +142,7 @@ function QuickTabBody({ initialLinkedWorkItem: modalData.linkedWorkItem ?? null, initialGitHubWorkItem: modalData.initialGitHubWorkItem ?? null, initialTaskSourceContext: modalData.taskSourceContext ?? null, + initialWorkspaceTarget: modalData.initialWorkspaceTarget ?? null, initialRepoId: modalData.initialRepoId, initialEphemeralVmRecipeId: modalData.initialEphemeralVmRecipeId, initialProjectGroupId: modalData.initialProjectGroupId, diff --git a/src/renderer/src/components/TaskPageJiraStatusFilters.test.tsx b/src/renderer/src/components/TaskPageJiraStatusFilters.test.tsx index 782d4b411dc..1ed267a426a 100644 --- a/src/renderer/src/components/TaskPageJiraStatusFilters.test.tsx +++ b/src/renderer/src/components/TaskPageJiraStatusFilters.test.tsx @@ -2,11 +2,13 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' import { TaskPageJiraStatusFilters } from './TaskPageJiraStatusFilters' +import { getJiraStatusCatalogScopeKey } from './task-page-jira-status-catalog' import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions' afterEach(cleanup) function model(): TaskPageComposerActionsModel { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies every field read by the status-controls component. return { + jiraProjectFilter: null, jiraHasWorkspace: false, selectedJiraStatuses: ['Developed'], selectJiraStatuses: vi.fn().mockResolvedValue(undefined), @@ -93,3 +95,43 @@ it('hides statuses and query preview in linked mode without clearing selection', expect(state.selectJiraStatuses).not.toHaveBeenCalled() expect(state.selectedJiraStatuses).toEqual(['Developed']) }) + +it.each([false, true])( + 'includes the mapped project and sprint selection %s in manual editing', + async (currentSprint) => { + const state = model() + state.jiraCurrentSprint = currentSprint + state.jiraProjectFilter = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' } + render() + const sprintClause = currentSprint ? ' AND sprint in openSprints()' : '' + const query = `project = "10" AND (assignee = currentUser() AND statusCategory != Done AND status in ("Developed")${sprintClause}) ORDER BY updated DESC` + expect(screen.getByRole('textbox', { name: 'Status filter JQL' }).getAttribute('value')).toBe( + query + ) + fireEvent.click(screen.getByRole('button', { name: 'Edit as JQL' })) + await waitFor(() => expect(state.setAppliedJiraSearch).toHaveBeenCalledWith(query)) + } +) + +it('drops prior-project discovered statuses while retaining the selected status on project change', () => { + const state = model() + state.jiraProjectFilter = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' } + state.jiraStatusCatalog = { + scopeKey: getJiraStatusCatalogScopeKey( + state.jiraTaskSourceScopeKey, + state.selectedJiraSiteId, + state.activeJiraPreset, + state.jiraProjectFilter + ), + items: [{ name: 'Alpha only', categoryKey: 'indeterminate' }] + } + const { rerender } = render() + expect(screen.getByRole('button', { name: 'Alpha only' })).toBeDefined() + state.jiraProjectFilter = { id: '20', key: 'BET', name: 'Beta', siteId: 'site-b' } + rerender() + expect(screen.queryByRole('button', { name: 'Alpha only' })).toBeNull() + expect(screen.getByRole('button', { name: 'Developed' }).getAttribute('aria-pressed')).toBe( + 'true' + ) + expect(state.selectJiraStatuses).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/TaskPageJiraStatusFilters.tsx b/src/renderer/src/components/TaskPageJiraStatusFilters.tsx index 8e1f2ec7556..ab472f2a875 100644 --- a/src/renderer/src/components/TaskPageJiraStatusFilters.tsx +++ b/src/renderer/src/components/TaskPageJiraStatusFilters.tsx @@ -3,7 +3,11 @@ import { Input } from '@/components/ui/input' import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' import { translate } from '@/i18n/i18n' import { buildJiraPresetJql } from '../../../shared/jira-preset-jql' -import { includeSelectedJiraStatuses } from './task-page-jira-status-catalog' +import { + getJiraStatusCatalogScopeKey, + includeSelectedJiraStatuses +} from './task-page-jira-status-catalog' +import { restrictJiraQueryToProject } from '../../../shared/jira-project-reference' import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions' export function TaskPageJiraStatusFilters({ @@ -34,15 +38,23 @@ export function TaskPageJiraStatusFilters({ if (jiraHasWorkspace) { return null } - const scopeKey = `${jiraTaskSourceScopeKey}::${selectedJiraSiteId}::${activeJiraPreset}` + const scopeKey = getJiraStatusCatalogScopeKey( + jiraTaskSourceScopeKey, + selectedJiraSiteId, + activeJiraPreset, + model.jiraProjectFilter + ) const statuses = includeSelectedJiraStatuses( jiraStatusCatalog?.scopeKey === scopeKey ? jiraStatusCatalog.items : [], selectedJiraStatuses ) - const preview = buildJiraPresetJql(activeJiraPreset, { + const presetJql = buildJiraPresetJql(activeJiraPreset, { statuses: selectedJiraStatuses, currentSprint: jiraCurrentSprint }) + const preview = model.jiraProjectFilter + ? restrictJiraQueryToProject(presetJql, model.jiraProjectFilter) + : presetJql const disabled = !jiraQuickFiltersReady || jiraQuickFiltersLoading || diff --git a/src/renderer/src/components/jira-project-picker.test.tsx b/src/renderer/src/components/jira-project-picker.test.tsx new file mode 100644 index 00000000000..23e0d677a47 --- /dev/null +++ b/src/renderer/src/components/jira-project-picker.test.tsx @@ -0,0 +1,43 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { JiraProjectPicker } from './jira-project-picker' + +afterEach(cleanup) +const alpha = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a', siteName: 'First site' } +const otherSite = { ...alpha, siteId: 'site-b', siteName: 'Second site' } + +describe('Jira project picker', () => { + it('distinguishes projects with the same id on different sites and closes after selection', async () => { + const onChange = vi.fn() + render( + + ) + fireEvent.click(screen.getByRole('combobox', { name: 'Jira project' })) + fireEvent.click(await screen.findByRole('option', { name: /Second site/ })) + expect(onChange).toHaveBeenCalledWith({ id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-b' }) + await waitFor(() => expect(screen.queryByRole('option')).toBeNull()) + }) + + it('lets the user clear a repository default to search all projects', async () => { + const onChange = vi.fn() + render( + + ) + fireEvent.click(screen.getByRole('combobox', { name: 'Jira project' })) + fireEvent.click(await screen.findByRole('option', { name: 'All Jira projects' })) + expect(onChange).toHaveBeenCalledWith(null) + }) +}) diff --git a/src/renderer/src/components/jira-project-picker.tsx b/src/renderer/src/components/jira-project-picker.tsx new file mode 100644 index 00000000000..94bd61f9c2b --- /dev/null +++ b/src/renderer/src/components/jira-project-picker.tsx @@ -0,0 +1,92 @@ +import { useState } from 'react' +import { Check, ChevronsUpDown } from 'lucide-react' +import type { JiraProject } from '../../../shared/jira-types' +import { + normalizeJiraProjectReference, + matchesJiraProject, + type JiraProjectReference +} from '../../../shared/jira-project-reference' +import { Button } from './ui/button' +import { Popover, PopoverContent, PopoverTrigger } from './ui/popover' +import { Command, CommandInput, CommandList, CommandEmpty, CommandItem } from './ui/command' +import { getJiraProjectSelectionKey } from './task-page-jira-project-selection' +import { translate } from '@/i18n/i18n' + +export function JiraProjectPicker({ + projects, + value, + onChange, + label, + emptyLabel, + disabled +}: { + projects: readonly JiraProject[] + value: JiraProjectReference | null + onChange: (project: JiraProjectReference | null) => void + label: string + emptyLabel: string + disabled?: boolean +}): React.JSX.Element { + const [open, setOpen] = useState(false) + const select = (project: JiraProjectReference | null) => { + onChange(project) + setOpen(false) + } + return ( + + + + + + + + + + {translate('components.jiraProjectPicker.empty', 'No projects found.')} + + select(null)}> + {emptyLabel} + {!value ? : null} + + {projects.map((project) => { + const reference = normalizeJiraProjectReference(project) + return reference ? ( + select(reference)} + > + + + {project.key} · {project.name} + + {project.siteName ? ( + + {project.siteName} + + ) : null} + + {value && matchesJiraProject(project, value) ? ( + + ) : null} + + ) : null + })} + + + + + ) +} diff --git a/src/renderer/src/components/new-workspace/NewWorkspaceComposerNameSection.tsx b/src/renderer/src/components/new-workspace/NewWorkspaceComposerNameSection.tsx index 8bc581b095e..d1f08b0e490 100644 --- a/src/renderer/src/components/new-workspace/NewWorkspaceComposerNameSection.tsx +++ b/src/renderer/src/components/new-workspace/NewWorkspaceComposerNameSection.tsx @@ -11,6 +11,7 @@ type NewWorkspaceComposerNameSectionProps = Pick< NewWorkspaceComposerCardProps, | 'nameInputRef' | 'eligibleRepos' + | 'selectedRepo' | 'repoId' | 'onRepoChange' | 'name' @@ -48,6 +49,7 @@ type NewWorkspaceComposerNameSectionProps = Pick< export function NewWorkspaceComposerNameSection({ nameInputRef, eligibleRepos, + selectedRepo, repoId, onRepoChange, name, @@ -115,6 +117,7 @@ export function NewWorkspaceComposerNameSection({ ({ + Popover: ({ children }: { children: React.ReactNode }) =>
{children}
, + PopoverAnchor: ({ children }: { children: React.ReactNode }) => <>{children}, + PopoverContent: ({ children }: { children: React.ReactNode }) =>
{children}
+})) + +const localOption: ReadyProjectHostSetupOption = { + id: 'same-id', + kind: 'ready', + projectId: 'project', + hostId: 'local', + repoId: 'same-id', + label: 'Local', + detail: '/local', + path: '/local' +} +const sshOption: ReadyProjectHostSetupOption = { + ...localOption, + hostId: 'ssh:builder', + label: 'Builder', + detail: '/remote', + path: '/remote' +} +const options = [localOption, sshOption] + +afterEach(cleanup) + +describe('run target host identity', () => { + it('shows the selected SSH host when its setup ID is also used locally', () => { + render( + + ) + + const field = screen.getByRole('combobox', { name: 'Run on' }) + expect(field.parentElement?.parentElement?.textContent).toContain('Builder') + }) + + it('commits a host-qualified setup selection when the SSH row is selected', () => { + const onHostChange = vi.fn() + render( + + ) + + fireEvent.click(screen.getByRole('option', { name: /Builder/ })) + expect(onHostChange).toHaveBeenCalledWith(JSON.stringify(['same-id', 'ssh:builder'])) + }) + + it('gives duplicate setup IDs distinct keyboard navigation keys', () => { + const { rows } = buildRunTargetRows({ + hostOptions: options, + recipes: [], + query: '', + hasAddHost: false + }) + expect(new Set(rows.map((row) => row.key)).size).toBe(2) + }) +}) diff --git a/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx b/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx index 4ca978baa07..787c02d9c6c 100644 --- a/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx +++ b/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx @@ -2,10 +2,13 @@ import React, { useCallback, useMemo, useState } from 'react' import { Popover, PopoverContent } from '@/components/ui/popover' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' -import type { - NeedsSetupProjectHostOption, - ProjectHostSetupOption +import { + getProjectHostSetupSelectionKey, + type NeedsSetupProjectHostOption, + type ProjectHostSetupOption, + type ReadyProjectHostSetupOption } from '@/lib/project-host-setup-options' +import type { ExecutionHostId } from '../../../../shared/execution-host' import { isWithinComboboxRoot, useTypeAheadCombobox } from './use-type-ahead-combobox' import { COMBOBOX_POPOVER_SURFACE } from './type-ahead-combobox-styles' import { @@ -28,6 +31,7 @@ import RunTargetField from './RunTargetField' type RunTargetComboboxProps = { hostOptions: readonly ProjectHostSetupOption[] hostValue: string | null + hostExecutionHostId?: ExecutionHostId | null onHostChange?: (setupId: string) => void recipes: EphemeralVmRecipeOption[] recipeValue: string | null @@ -52,6 +56,7 @@ const ROOT_ATTRIBUTE = 'data-run-target-combobox-root' export default function RunTargetCombobox({ hostOptions, hostValue, + hostExecutionHostId, onHostChange, recipes, recipeValue, @@ -84,7 +89,12 @@ export default function RunTargetCombobox({ [hostOptions] ) const selectedHost = - readyHostOptions.find((option) => option.id === hostValue) ?? readyHostOptions[0] ?? null + readyHostOptions.find( + (option) => + option.id === hostValue && (!hostExecutionHostId || option.hostId === hostExecutionHostId) + ) ?? + readyHostOptions[0] ?? + null const selectedRecipe = recipes.find((recipe) => recipe.id === recipeValue) ?? null const armedRow = rows.find((row) => row.key === armedKey) ?? rows[0] ?? null // Only a committed selection paints the field; typing replaces it. @@ -97,8 +107,8 @@ export default function RunTargetCombobox({ }, [combobox]) const selectHost = useCallback( - (setupId: string): void => { - onHostChange?.(setupId) + (option: ReadyProjectHostSetupOption): void => { + onHostChange?.(getProjectHostSetupSelectionKey(option)) onRecipeChange?.(null) close() }, @@ -158,7 +168,7 @@ export default function RunTargetCombobox({ return } if (row.kind === 'ready') { - selectHost(row.option.id) + selectHost(row.option) return } if (row.kind === 'needs-setup') { @@ -295,13 +305,13 @@ export default function RunTargetCombobox({ label={row.option.label} detail={row.option.path} armed={isArmed} - current={selectedRecipe === null && row.option.id === selectedHost?.id} + current={selectedRecipe === null && row.option === selectedHost} optionId={optionId} onArm={() => { arm(row.key) setSubmenu(null) }} - onCommit={() => selectHost(row.option.id)} + onCommit={() => selectHost(row.option)} /> ) } diff --git a/src/renderer/src/components/new-workspace/SetProjectLocationDialog.host-identity.test.tsx b/src/renderer/src/components/new-workspace/SetProjectLocationDialog.host-identity.test.tsx new file mode 100644 index 00000000000..29a4ba1b2d9 --- /dev/null +++ b/src/renderer/src/components/new-workspace/SetProjectLocationDialog.host-identity.test.tsx @@ -0,0 +1,182 @@ +// @vitest-environment happy-dom + +import { cleanup, render, renderHook, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ExecutionHostRegistryEntry } from '../../../../shared/execution-host-registry' +import type { ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' +import { + buildProjectHostSetupOptions, + type NeedsSetupProjectHostOption +} from '@/lib/project-host-setup-options' +import { useTargetChangeActions } from '@/hooks/composer-state/target-change-actions' +import * as decisions from '@/hooks/composer-state/composer-decisions' +import { SetProjectLocationDialog } from './SetProjectLocationDialog' + +const storeMock = vi.hoisted(() => { + const repos: Repo[] = [] + const projectHostSetups: ProjectHostSetup[] = [] + return { + repos, + projectHostSetups, + setupProjectExistingFolder: vi.fn(), + setupProjectClone: vi.fn() + } +}) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign( + (selector: (state: typeof storeMock) => unknown) => selector(storeMock), + { getState: () => storeMock } + ) +})) + +vi.mock('@/components/sidebar/CreateProjectLocationField', () => ({ + CreateProjectParentBrowser: () => null +})) + +const localRepo: Repo = { + id: 'same-id', + path: '/local', + displayName: 'Orca', + badgeColor: '', + addedAt: 0, + upstream: { owner: 'stablyai', repo: 'orca' } +} +const runtimeRepo: Repo = { + ...localRepo, + path: '/runtime', + executionHostId: 'runtime:builder' +} +const hosts: ExecutionHostRegistryEntry[] = [ + { id: 'local', kind: 'local', label: 'Local', detail: '', health: 'local' }, + { + id: 'runtime:builder', + kind: 'runtime', + label: 'Builder', + detail: '', + health: 'available' + } +] +const localProjection = projectHostSetupProjectionFromRepos([localRepo]) +const runtimeProjection = projectHostSetupProjectionFromRepos([runtimeRepo]) +const option: NeedsSetupProjectHostOption = { + id: 'needs-setup:runtime:builder', + kind: 'needs-setup', + projectId: localProjection.projects[0].id, + hostId: 'runtime:builder', + label: 'Builder', + detail: 'Project location not set', + isAvailable: true, + attention: false, + canSetLocation: true +} + +function createInput(): Parameters[0] { + return { + baseBranch: undefined, + branchAutoNameRef: { current: '' }, + decisions, + folderSourceRepos: [], + hostOptions: hosts, + linkedWorkItem: { + type: 'issue', + provider: 'jira', + number: 0, + title: 'APP-1 Fix export', + jiraIdentifier: 'APP-1', + url: 'https://jira.example.invalid/browse/APP-1' + }, + projectHostSetupOptions: buildProjectHostSetupOptions({ + projectId: option.projectId, + projectHostSetups: localProjection.setups, + eligibleRepos: [localRepo], + hosts: [hosts[0]] + }), + repoId: localRepo.id, + selectedRepoProjectId: option.projectId, + setBaseBranch: vi.fn(), + setBranchNameOverride: vi.fn(), + setBranchNameOverridePreservesNameEdits: vi.fn(), + setCompareBaseRef: vi.fn(), + setForkPushWarning: vi.fn(), + setLinkedGitLabIssue: vi.fn(), + setLinkedGitLabMR: vi.fn(), + setLinkedIssue: vi.fn(), + setLinkedPR: vi.fn(), + setLinkedTaskSourceContext: vi.fn(), + setLinkedWorkItem: vi.fn(), + setProjectError: vi.fn(), + setPushTarget: vi.fn(), + setRepoId: vi.fn(), + setReuseEligibleBranch: vi.fn(), + setReuseSelectedBranch: vi.fn(), + setSelectedProjectHostSetupOverrideId: vi.fn(), + setSelectedProjectHostSetupOverrideHostId: vi.fn(), + setSparseDirectories: vi.fn(), + setSparseEnabled: vi.fn(), + setSparseSelectedPresetId: vi.fn(), + setStartFromResetHint: vi.fn(), + smartSourceSelectionRef: { current: null } + } +} + +beforeEach(() => { + storeMock.repos = [localRepo] + storeMock.projectHostSetups = [...localProjection.setups] + storeMock.setupProjectExistingFolder.mockReset() + storeMock.setupProjectClone.mockReset() + const publishRuntimeSetup = async () => { + storeMock.repos = [localRepo, runtimeRepo] + storeMock.projectHostSetups = [...localProjection.setups, ...runtimeProjection.setups] + return { + project: runtimeProjection.projects[0], + repo: runtimeRepo, + setup: runtimeProjection.setups[0] + } + } + storeMock.setupProjectExistingFolder.mockImplementation(publishRuntimeSetup) + storeMock.setupProjectClone.mockImplementation(publishRuntimeSetup) +}) + +afterEach(cleanup) + +describe('set-location host identity', () => { + it.each(['existing folder', 'clone'])( + 'selects the newly published runtime setup after %s when a stale local option has the same ID', + async (method) => { + const input = createInput() + const { result } = renderHook(() => useTargetChangeActions(input)) + render( + result.current.handleProjectHostSetupChange(selection)} + /> + ) + const user = userEvent.setup() + if (method === 'clone') { + await user.click(screen.getByRole('button', { name: /Clone from URL/ })) + await user.type(screen.getByPlaceholderText('/destination/on/host'), '/runtime') + await user.click(screen.getByRole('button', { name: 'Clone' })) + } else { + await user.click(screen.getByRole('button', { name: /Browse folder/ })) + await user.type(screen.getByPlaceholderText('/path/to/project/on/host'), '/runtime') + await user.click(screen.getByRole('button', { name: 'Set location' })) + } + + expect(input.setSelectedProjectHostSetupOverrideHostId).toHaveBeenCalledWith( + 'runtime:builder' + ) + expect(input.setSelectedProjectHostSetupOverrideHostId).not.toHaveBeenCalledWith('local') + expect(input.setSelectedProjectHostSetupOverrideId).toHaveBeenCalledWith('same-id') + expect(input.setLinkedTaskSourceContext).not.toHaveBeenCalled() + expect(input.setLinkedWorkItem).not.toHaveBeenCalled() + } + ) +}) diff --git a/src/renderer/src/components/new-workspace/SetProjectLocationDialog.test.tsx b/src/renderer/src/components/new-workspace/SetProjectLocationDialog.test.tsx index bca91bf2e9f..c824349ed01 100644 --- a/src/renderer/src/components/new-workspace/SetProjectLocationDialog.test.tsx +++ b/src/renderer/src/components/new-workspace/SetProjectLocationDialog.test.tsx @@ -105,7 +105,7 @@ describe('SetProjectLocationDialog', () => { kind: 'git', displayName: 'orca' }) - expect(onReady).toHaveBeenCalledWith('setup-openclaw') + expect(onReady).toHaveBeenCalledWith(JSON.stringify(['setup-openclaw', 'ssh:openclaw'])) expect(onClose).not.toHaveBeenCalled() }) @@ -125,7 +125,7 @@ describe('SetProjectLocationDialog', () => { destination: '/remote/orca', displayName: 'orca' }) - expect(onReady).toHaveBeenCalledWith('setup-openclaw-clone') + expect(onReady).toHaveBeenCalledWith(JSON.stringify(['setup-openclaw-clone', 'ssh:openclaw'])) }) it('hides clone for folder projects', () => { @@ -230,7 +230,7 @@ describe('SetProjectLocationDialog', () => { await user.type(screen.getByPlaceholderText('/path/to/project/on/host'), '/remote/orca') await user.click(screen.getByRole('button', { name: 'Set location' })) - expect(onReady).toHaveBeenCalledWith('setup-openclaw') + expect(onReady).toHaveBeenCalledWith(JSON.stringify(['setup-openclaw', 'ssh:openclaw'])) }) it('notifies the parent when dismissed', async () => { diff --git a/src/renderer/src/components/new-workspace/SetProjectLocationDialog.tsx b/src/renderer/src/components/new-workspace/SetProjectLocationDialog.tsx index 3b06ebd716d..e041467e49e 100644 --- a/src/renderer/src/components/new-workspace/SetProjectLocationDialog.tsx +++ b/src/renderer/src/components/new-workspace/SetProjectLocationDialog.tsx @@ -10,7 +10,10 @@ import { import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import { CreateProjectParentBrowser } from '@/components/sidebar/CreateProjectLocationField' -import type { NeedsSetupProjectHostOption } from '@/lib/project-host-setup-options' +import { + getProjectHostSetupSelectionKey, + type NeedsSetupProjectHostOption +} from '@/lib/project-host-setup-options' import { parseExecutionHostId } from '../../../../shared/execution-host' import type { RepoKind } from '../../../../shared/repo-types' import { pickLocalProjectLocationFolder } from './pick-local-project-folder' @@ -168,7 +171,7 @@ function SetProjectLocationDialogBody({ displayName: projectName }) if (result && !abandoned.current) { - onReady(result.setup.id) + onReady(getProjectHostSetupSelectionKey({ id: result.setup.id, hostId: option.hostId })) } } finally { setIsSubmitting(false) @@ -189,7 +192,7 @@ function SetProjectLocationDialogBody({ displayName: projectName }) if (result && !abandoned.current) { - onReady(result.setup.id) + onReady(getProjectHostSetupSelectionKey({ id: result.setup.id, hostId: option.hostId })) } } finally { setIsSubmitting(false) diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.jira-accessibility.test.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.jira-accessibility.test.tsx index 51018fb8c44..aa00d98f9c1 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.jira-accessibility.test.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.jira-accessibility.test.tsx @@ -44,6 +44,8 @@ const jiraConnectionMock = vi.hoisted(() => ({ })) const originalWindowApi = window.api +vi.mock('../jira-project-picker', () => ({ JiraProjectPicker: () => null })) + vi.mock('./use-jira-url-source', () => ({ useJiraUrlSource: () => ({ ...jiraMock.state, diff --git a/src/renderer/src/components/new-workspace/new-workspace-composer-card-props.ts b/src/renderer/src/components/new-workspace/new-workspace-composer-card-props.ts index 699318b3ea6..5895d6b7053 100644 --- a/src/renderer/src/components/new-workspace/new-workspace-composer-card-props.ts +++ b/src/renderer/src/components/new-workspace/new-workspace-composer-card-props.ts @@ -42,6 +42,7 @@ export type NewWorkspaceComposerCardProps = { quickSetupOnly?: boolean onQuickSetupOnlySelect?: () => void eligibleRepos: readonly RepoOption[] + selectedRepo?: RepoOption | null repoId: string projectOptions?: NewWorkspaceProjectOption[] selectedProjectId?: string | null diff --git a/src/renderer/src/components/new-workspace/run-target-options.ts b/src/renderer/src/components/new-workspace/run-target-options.ts index b95d128831d..394d3cd2cdc 100644 --- a/src/renderer/src/components/new-workspace/run-target-options.ts +++ b/src/renderer/src/components/new-workspace/run-target-options.ts @@ -1,8 +1,9 @@ import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types' -import type { - NeedsSetupProjectHostOption, - ProjectHostSetupOption, - ReadyProjectHostSetupOption +import { + getProjectHostSetupSelectionKey, + type NeedsSetupProjectHostOption, + type ProjectHostSetupOption, + type ReadyProjectHostSetupOption } from '@/lib/project-host-setup-options' import { translate } from '@/i18n/i18n' @@ -93,9 +94,13 @@ export function buildRunTargetRows({ ) const rows: RunTargetRowModel[] = [ - ...ready.map((option) => ({ key: `host:${option.id}`, kind: 'ready' as const, option })), + ...ready.map((option) => ({ + key: `host:${getProjectHostSetupSelectionKey(option)}`, + kind: 'ready' as const, + option + })), ...needsSetup.map((option) => ({ - key: `needs:${option.id}`, + key: `needs:${getProjectHostSetupSelectionKey(option)}`, kind: 'needs-setup' as const, option })) diff --git a/src/renderer/src/components/new-workspace/smart-workspace-name-field-model.ts b/src/renderer/src/components/new-workspace/smart-workspace-name-field-model.ts index 52f7b892f84..ee724b4ca23 100644 --- a/src/renderer/src/components/new-workspace/smart-workspace-name-field-model.ts +++ b/src/renderer/src/components/new-workspace/smart-workspace-name-field-model.ts @@ -16,6 +16,7 @@ export type RepoOption = ReturnType['repos'][number export type SmartWorkspaceNameFieldProps = { repos: readonly RepoOption[] + selectedRepo?: RepoOption | null repoId: string onRepoChange: (repoId: string) => void value: string @@ -91,6 +92,7 @@ export const RESULT_LIMIT = 12 export type WorkItemSourceSearchProps = Pick< SmartWorkspaceNameFieldProps, | 'repos' + | 'selectedRepo' | 'repoId' | 'value' | 'disabled' diff --git a/src/renderer/src/components/new-workspace/smart-workspace-name-field-surface.tsx b/src/renderer/src/components/new-workspace/smart-workspace-name-field-surface.tsx index 64dc62e65af..cde98aea5d1 100644 --- a/src/renderer/src/components/new-workspace/smart-workspace-name-field-surface.tsx +++ b/src/renderer/src/components/new-workspace/smart-workspace-name-field-surface.tsx @@ -11,6 +11,7 @@ import { renderSmartWorkspaceNameInput } from './smart-workspace-name-input-surf import { renderSmartWorkspaceSourceResults } from './smart-workspace-source-results-surface' import { getJiraSourceStatusMessage } from './jira-source-status-message' import type { SmartWorkspaceNameFieldController } from './use-smart-workspace-name-field-controller' +import { JiraProjectPicker } from '../jira-project-picker' export function renderSmartWorkspaceNameField( controller: SmartWorkspaceNameFieldController @@ -48,6 +49,16 @@ export function renderSmartWorkspaceNameField( tabsListRef={controller.tabsListRef} /> )} + {mode === 'jira' && selectedSource === null ? ( + + ) : null} ({ + useJiraProjectInventory: () => ({ projects: [], connected: true, error: null, loading: false }) +})) +const project = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' } +const other = { ...project, id: '20', key: 'BRV', name: 'Bravo' } +const repo: Repo = { + id: 'repo', + path: '/repo', + displayName: 'Alpha', + badgeColor: '', + addedAt: 0, + defaultJiraProject: project +} +const source = normalizeTaskSourceContext({ provider: 'jira', projectId: 'repo', hostId: 'local' }) +afterEach(cleanup) +describe('Jira composer project defaults', () => { + it('defaults from the repository and allows another project or All without changing the repository', () => { + const { result } = renderHook(() => useJiraComposerProject(repo, source, true)) + expect(result.current.jiraProject).toEqual(project) + act(() => result.current.setJiraProject(other)) + expect(result.current.jiraProject).toEqual(other) + act(() => result.current.setJiraProject(null)) + expect(result.current.jiraProject).toBeNull() + expect(repo.defaultJiraProject).toEqual(project) + }) + it('uses a new repository default when the composer changes repositories', () => { + const { result, rerender } = renderHook( + ({ current }) => useJiraComposerProject(current, source, true), + { initialProps: { current: repo } } + ) + act(() => result.current.setJiraProject(null)) + rerender({ current: { ...repo, id: 'other', defaultJiraProject: other } }) + expect(result.current.jiraProject).toEqual(other) + }) + it('resets All when switching between same-ID local and SSH repositories', () => { + const { result, rerender } = renderHook( + ({ current }) => useJiraComposerProject(current, source, true), + { initialProps: { current: repo } } + ) + act(() => result.current.setJiraProject(null)) + rerender({ current: { ...repo, executionHostId: 'ssh:target', defaultJiraProject: other } }) + expect(result.current.jiraProject).toEqual(other) + }) + it('resets All when switching between distinct same-host repository locations', () => { + const { result, rerender } = renderHook( + ({ current }) => useJiraComposerProject(current, source, true), + { initialProps: { current: repo } } + ) + act(() => result.current.setJiraProject(null)) + rerender({ current: { ...repo, path: '/other-location', defaultJiraProject: other } }) + expect(result.current.jiraProject).toEqual(other) + }) + it('keeps remote accounts separate and supports direct SSH with the existing local Jira account', () => { + const remoteRepo: Repo = { ...repo, executionHostId: 'runtime:remote' } + const { result, rerender } = renderHook( + ({ current }) => useJiraComposerProject(current, source, true), + { initialProps: { current: remoteRepo } } + ) + expect(result.current.jiraProject).toBeNull() + rerender({ current: { ...repo, executionHostId: 'ssh:target' } }) + expect(result.current.jiraProject).toEqual(project) + }) +}) diff --git a/src/renderer/src/components/new-workspace/use-jira-composer-project.ts b/src/renderer/src/components/new-workspace/use-jira-composer-project.ts new file mode 100644 index 00000000000..43d08f2310a --- /dev/null +++ b/src/renderer/src/components/new-workspace/use-jira-composer-project.ts @@ -0,0 +1,42 @@ +import { useState } from 'react' +import type { JiraProjectReference } from '../../../../shared/jira-project-reference' +import type { Repo } from '../../../../shared/repo-types' +import type { TaskSourceContext } from '../../../../shared/task-source-context' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { resolveJiraSourceHostId } from '../../../../shared/jira-source-host' +import { useJiraProjectInventory } from '../use-jira-project-inventory' + +const LOCAL_SOURCE = { kind: 'local' } as const + +export function useJiraComposerProject( + repo: Repo | null, + source: TaskSourceContext | null, + enabled: boolean +) { + const inventory = useJiraProjectInventory(source ?? LOCAL_SOURCE, enabled && source !== null) + const scopeKey = JSON.stringify([ + repo ? getRepoExecutionHostId(repo) : null, + repo?.id ?? null, + repo?.path ?? null, + source?.hostId ?? null, + source?.projectId ?? null, + source?.projectHostSetupId ?? null + ]) + const [override, setOverride] = useState<{ + scopeKey: string + project: JiraProjectReference | null + } | null>(null) + const project = + override?.scopeKey === scopeKey + ? override.project + : repo && + resolveJiraSourceHostId({ workspaceHostId: getRepoExecutionHostId(repo) }) === + source?.hostId + ? (repo.defaultJiraProject ?? null) + : null + return { + jiraProject: project, + jiraProjectInventory: inventory, + setJiraProject: (next: JiraProjectReference | null) => setOverride({ scopeKey, project: next }) + } +} diff --git a/src/renderer/src/components/new-workspace/use-smart-workspace-name-field-foundation.source.test.tsx b/src/renderer/src/components/new-workspace/use-smart-workspace-name-field-foundation.source.test.tsx index c473858ad2f..545e215a0c8 100644 --- a/src/renderer/src/components/new-workspace/use-smart-workspace-name-field-foundation.source.test.tsx +++ b/src/renderer/src/components/new-workspace/use-smart-workspace-name-field-foundation.source.test.tsx @@ -2,6 +2,8 @@ import { act, cleanup, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import { normalizeTaskSourceContext } from '../../../../shared/task-source-context' import type { LinearIssue } from '../../../../shared/linear/issue-types' import type { TaskSourceContext } from '../../../../shared/task-source-context' import type { NormalizedWorkItemSourceSearchProps } from './smart-workspace-name-field-model' @@ -41,6 +43,9 @@ vi.mock('./use-jira-source-connection', () => ({ useJiraSourceConnection: () => ({ status: null, loaded: false }) })) vi.mock('./use-jira-url-source', () => ({ useJiraUrlSource: mocks.readJiraSource })) +vi.mock('../use-jira-project-inventory', () => ({ + useJiraProjectInventory: () => ({ projects: [], connected: true, error: null, loading: false }) +})) const source: TaskSourceContext = { kind: 'task-source', @@ -80,6 +85,37 @@ describe('smart workspace explicit source connection', () => { } }) + it('uses the resolved runtime repository default when a local repository has the same ID', () => { + const localRepo: Repo = { + id: 'same-id', + path: '/local', + displayName: 'Local', + badgeColor: '', + addedAt: 0, + defaultJiraProject: { id: '10', key: 'LOC', name: 'Local', siteId: 'site-a' } + } + const selectedRepo: Repo = { + ...localRepo, + path: '/runtime', + executionHostId: 'runtime:remote', + defaultJiraProject: { id: '20', key: 'REM', name: 'Remote', siteId: 'site-b' } + } + const fieldProps = { + ...props(), + repos: [localRepo, selectedRepo], + repoId: 'same-id', + selectedRepo, + jiraSourceContext: normalizeTaskSourceContext({ + provider: 'jira', + projectId: 'repo:same-id', + hostId: 'runtime:remote' + }) + } + const { result } = renderHook(() => useWorkItemSourceFoundation(fieldProps)) + expect(result.current.selectedRepo).toBe(selectedRepo) + expect(result.current.jiraProject).toEqual(selectedRepo.defaultJiraProject) + }) + it('exposes Linear on a connected saved host while the focused runtime is disconnected', async () => { mocks.readStatus.mockResolvedValue({ connected: true, diff --git a/src/renderer/src/components/new-workspace/use-smart-workspace-secondary-searches.ts b/src/renderer/src/components/new-workspace/use-smart-workspace-secondary-searches.ts index 1d9a48af476..efa54264880 100644 --- a/src/renderer/src/components/new-workspace/use-smart-workspace-secondary-searches.ts +++ b/src/renderer/src/components/new-workspace/use-smart-workspace-secondary-searches.ts @@ -7,6 +7,7 @@ import { linearWorkspaceScopeSignature } from '../../../../shared/linear/workspa import { getSmartWorkspaceLinearSearchQuery } from '../../../../shared/new-workspace/smart-workspace-linear-intent' import type { parseBoundedSmartWorkspaceLinearIssueUrlIntent } from '../../../../shared/new-workspace/smart-workspace-linear-intent' import { RESULT_LIMIT } from './smart-workspace-name-field-model' +import { restrictJiraQueryToProject } from '../../../../shared/jira-project-reference' import { getBranchSearchRequest } from '../../../../shared/new-workspace/smart-workspace-source-results' import type { useWorkItemSourceFoundation } from './use-work-item-source-foundation' @@ -52,6 +53,7 @@ export function useSmartWorkspaceSecondarySearches({ setSettledLinearUrlQuery, jiraSourceContext, jiraConnectionStatus, + jiraProject, searchJiraIssues, setJiraIssues, setJiraLoading @@ -236,13 +238,20 @@ export function useSmartWorkspaceSecondarySearches({ const controller = new AbortController() setJiraLoading(true) const siteId = - jiraConnectionStatus?.selectedSiteId ?? jiraConnectionStatus?.activeSiteId ?? null + jiraProject?.siteId ?? + jiraConnectionStatus?.selectedSiteId ?? + jiraConnectionStatus?.activeSiteId ?? + null void searchJiraIssuesByKeyOrText(jiraSearchQuery, (jql) => - searchJiraIssues(jql, RESULT_LIMIT, { - sourceContext: jiraSourceContext, - siteId, - signal: controller.signal - }) + searchJiraIssues( + jiraProject ? restrictJiraQueryToProject(jql, jiraProject) : jql, + RESULT_LIMIT, + { + sourceContext: jiraSourceContext, + siteId, + signal: controller.signal + } + ) ) .then((issues) => { if (!stale) { @@ -267,6 +276,7 @@ export function useSmartWorkspaceSecondarySearches({ jiraConnectionStatus?.activeSiteId, jiraConnectionStatus?.selectedSiteId, jiraSearchQuery, + jiraProject, jiraSourceContext, searchJiraIssues, setJiraIssues, diff --git a/src/renderer/src/components/new-workspace/use-work-item-source-foundation.ts b/src/renderer/src/components/new-workspace/use-work-item-source-foundation.ts index 44856ede9af..080739baa46 100644 --- a/src/renderer/src/components/new-workspace/use-work-item-source-foundation.ts +++ b/src/renderer/src/components/new-workspace/use-work-item-source-foundation.ts @@ -12,6 +12,7 @@ import { useJiraUrlSource } from './use-jira-url-source' import { useLinearSourceConnection } from './use-linear-source-connection' import { useSmartWorkspaceFieldAvailability } from './use-smart-workspace-field-availability' import { useWorkItemSourceState } from './use-work-item-source-state' +import { useJiraComposerProject } from './use-jira-composer-project' export function useWorkItemSourceFoundation(props: NormalizedWorkItemSourceSearchProps) { const { @@ -61,8 +62,11 @@ export function useWorkItemSourceFoundation(props: NormalizedWorkItemSourceSearc })) ) const selectedRepo = useMemo( - () => repos.find((repo) => repo.id === repoId) ?? null, - [repoId, repos] + () => + props.selectedRepo !== undefined + ? props.selectedRepo + : (repos.find((repo) => repo.id === repoId) ?? null), + [props.selectedRepo, repoId, repos] ) const githubSourceContext = useMemo(() => { if (githubSourceContextOverride?.provider === 'github') { @@ -158,6 +162,11 @@ export function useWorkItemSourceFoundation(props: NormalizedWorkItemSourceSearc connection: jiraConnection }) const jiraSourceConnected = jiraConnectionStatus?.connected === true + const jiraProject = useJiraComposerProject( + selectedRepo, + jiraSourceContext, + !disabled && !textOnly && state.mode === 'jira' + ) const showJiraSiteContext = state.mode === 'jira' && jiraConnectionStatus?.selectedSiteId === 'all' const jiraStatusId = React.useId() @@ -202,6 +211,7 @@ export function useWorkItemSourceFoundation(props: NormalizedWorkItemSourceSearc jiraConnectionStatus, jiraSource, jiraSourceConnected, + ...jiraProject, showJiraSiteContext, jiraStatusId, linearStatusId diff --git a/src/renderer/src/components/settings/RepositoryJiraProjectSection.test.tsx b/src/renderer/src/components/settings/RepositoryJiraProjectSection.test.tsx new file mode 100644 index 00000000000..f0362e31067 --- /dev/null +++ b/src/renderer/src/components/settings/RepositoryJiraProjectSection.test.tsx @@ -0,0 +1,61 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import type { JiraProjectReference } from '../../../../shared/jira-project-reference' +import { RepositoryJiraProjectSection } from './RepositoryJiraProjectSection' +const project = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' } +const { state, inventory } = vi.hoisted(() => { + const repos: Repo[] = [] + return { state: { repos, settingsSearchQuery: '' }, inventory: vi.fn() } +}) +vi.mock('@/store', () => { + const useAppStore = (selector: (store: typeof state) => unknown) => selector(state) + useAppStore.getState = () => state + return { useAppStore } +}) +vi.mock('../use-jira-project-inventory', () => ({ useJiraProjectInventory: inventory })) +vi.mock('../jira-project-picker', () => ({ + JiraProjectPicker: ({ onChange }: { onChange: (next: JiraProjectReference | null) => void }) => ( + + ) +})) +afterEach(cleanup) +beforeEach(() => { + state.repos = [ + { + id: 'repo', + path: '/repo', + displayName: 'Alpha', + badgeColor: '', + addedAt: 0, + executionHostId: 'runtime:owner', + kind: 'folder' + } + ] + inventory.mockReturnValue({ connected: true, projects: [project], error: null, loading: false }) +}) +describe('repository Jira setting', () => { + it('loads project choices from the owning runtime and accepts its persisted echo', async () => { + const update = vi.fn(async () => { + state.repos[0] = { ...state.repos[0], defaultJiraProject: project } + return true + }) + render() + expect(inventory).toHaveBeenCalledWith({ kind: 'environment', environmentId: 'owner' }, true) + fireEvent.click(screen.getByRole('button', { name: 'Choose Alpha' })) + await waitFor(() => + expect(update).toHaveBeenCalledWith('repo', { defaultJiraProject: project }) + ) + expect(screen.queryByRole('alert')).toBeNull() + }) + it('explains an old runtime ignoring the optional field instead of claiming it saved', async () => { + const update = vi.fn(async () => true) + render() + fireEvent.click(screen.getByRole('button', { name: 'Choose Alpha' })) + await waitFor(() => + expect(screen.getByRole('alert').textContent).toContain('Update this runtime') + ) + expect(state.repos[0].defaultJiraProject).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/settings/RepositoryJiraProjectSection.tsx b/src/renderer/src/components/settings/RepositoryJiraProjectSection.tsx new file mode 100644 index 00000000000..f1946091741 --- /dev/null +++ b/src/renderer/src/components/settings/RepositoryJiraProjectSection.tsx @@ -0,0 +1,97 @@ +import { useMemo, useState } from 'react' +import type { Repo } from '../../../../shared/repo-types' +import type { JiraProjectReference } from '../../../../shared/jira-project-reference' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { taskSourceRuntimeTarget } from '@/lib/task-source-runtime-target' +import { useAppStore } from '@/store' +import { useJiraProjectInventory } from '../use-jira-project-inventory' +import { JiraProjectPicker } from '../jira-project-picker' +import { SearchableSetting } from './SearchableSetting' +import { translate } from '@/i18n/i18n' + +export function RepositoryJiraProjectSection({ + repo, + updateRepo, + forceVisible +}: { + repo: Repo + updateRepo: (id: string, updates: { defaultJiraProject: JiraProjectReference | null }) => unknown + forceVisible?: boolean +}): React.JSX.Element { + const hostId = getRepoExecutionHostId(repo) + const source = useMemo(() => taskSourceRuntimeTarget({ hostId }), [hostId]) + const inventory = useJiraProjectInventory(source, true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const applyProject = async (project: JiraProjectReference | null) => { + setSaving(true) + setError(null) + try { + const result = await updateRepo(repo.id, { defaultJiraProject: project }) + if (result === false) { + throw new Error( + translate( + 'components.RepositoryJiraProjectSection.saveFailed', + 'Could not save the default Jira project.' + ) + ) + } + const echoed = useAppStore + .getState() + .repos.find( + (entry) => entry.id === repo.id && getRepoExecutionHostId(entry) === hostId + )?.defaultJiraProject + if ( + (echoed?.id ?? null) !== (project?.id ?? null) || + (echoed?.siteId ?? null) !== (project?.siteId ?? null) + ) { + throw new Error( + translate( + 'components.RepositoryJiraProjectSection.runtimeUpdate', + 'Update this runtime to save a default Jira project.' + ) + ) + } + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)) + } finally { + setSaving(false) + } + } + const label = translate('components.RepositoryJiraProjectSection.title', 'Default Jira project') + return ( + +

{label}

+ void applyProject(project)} + label={label} + emptyLabel={translate('components.RepositoryJiraProjectSection.none', 'No default project')} + disabled={inventory.loading || saving} + /> + {!inventory.loading && !inventory.connected && !inventory.error ? ( +

+ {translate( + 'components.RepositoryJiraProjectSection.connect', + 'Connect Jira in Integrations to choose a project.' + )} +

+ ) : null} + {error || inventory.error ? ( +

+ {error ?? inventory.error} +

+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/RepositoryPane.tsx b/src/renderer/src/components/settings/RepositoryPane.tsx index c978335153a..04d94b62a01 100644 --- a/src/renderer/src/components/settings/RepositoryPane.tsx +++ b/src/renderer/src/components/settings/RepositoryPane.tsx @@ -29,6 +29,7 @@ import { translate } from '@/i18n/i18n' import { RepositoryWindowsRuntimeSection } from './RepositoryWindowsRuntimeSection' import { matchesRepositoryIdentitySearch } from './repository-identity-search' import { RepositoryWorktreeDefaultsSection } from './RepositoryWorktreeDefaultsSection' +import { RepositoryJiraProjectSection } from './RepositoryJiraProjectSection' import { getProjectRuntimeSessionSummary } from './repository-runtime-session-summary' import { getRepoOwnerWorktreeVisibilityDefaults } from '../../store/worktree-visibility-defaults-by-host' export { getRepositoryPaneSearchEntries } @@ -176,6 +177,7 @@ export function RepositoryPane({ const identityEntryTitles = new Set([ translate('auto.components.settings.repository.search.7e1e456a95', 'Display Name'), translate('auto.components.settings.repository.search.b24f00294a', 'Project Icon'), + translate('components.RepositoryJiraProjectSection.title', 'Default Jira project'), translate('auto.components.settings.repository.search.githubAccount', 'GitHub Account'), translate( 'auto.components.settings.repository.search.keepForkUpToDate', @@ -305,6 +307,13 @@ export function RepositoryPane({ + + {!isFolder ? ( <> ({ loading: false })) +vi.mock('../Surface', () => ({ + TaskPageSurface: ({ model }: { model: TaskPageComposerActionsModel }) => ( + + ) +})) + +const project = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' } +const setJiraProjectFilter = vi.fn() +const initialStore = useAppStore.getState() +const initialApiDescriptor = Object.getOwnPropertyDescriptor(window, 'api') + +function FiltersFixture({ + baseModel, + loading +}: { + baseModel: TaskPageComposerActionsModel + loading: boolean +}) { + const [jiraHasWorkspace, setJiraHasWorkspace] = useState(false) + const [jiraProjectFilter, setProject] = useState(project) + const model = { + ...baseModel, + jiraHasWorkspace, + setJiraHasWorkspace, + jiraProjectFilter, + setJiraProjectFilter: (value: JiraProjectReference | null) => { + setJiraProjectFilter(value) + setProject(value) + }, + setTaskResumeState: vi.fn(), + jiraPresets: [{ id: 'all', label: 'All' }], + jiraLoading: false, + jiraSearchInput: '', + setJiraSearchInput: vi.fn(), + setAppliedJiraSearch: vi.fn(), + activeJiraPreset: 'all', + setActiveJiraPreset: vi.fn(), + setJiraRefreshNonce: vi.fn(), + jiraSavedFilters: [], + jiraActiveSavedFilterId: null, + jiraSavedFiltersReady: true, + jiraSavedFiltersLoading: false, + jiraSavedFiltersPending: false, + jiraSavedFiltersError: null, + retryJiraSavedFilters: vi.fn(), + applyJiraSavedFilter: vi.fn().mockResolvedValue(true), + createJiraSavedFilter: vi.fn().mockResolvedValue(undefined), + updateJiraSavedFilter: vi.fn().mockResolvedValue(undefined), + deleteJiraSavedFilter: vi.fn().mockResolvedValue(undefined), + clearJiraSavedFilterSelection: vi.fn().mockResolvedValue(undefined), + selectedJiraStatuses: [], + selectJiraStatuses: vi.fn().mockResolvedValue(undefined), + jiraQuickFiltersReady: true, + jiraQuickFiltersLoading: false, + jiraQuickFiltersPending: false, + jiraQuickFiltersError: null, + retryJiraQuickFilters: vi.fn(), + jiraStatusCatalog: null, + jiraTaskSourceScopeKey: 'fixture', + selectedJiraSiteId: 'all', + jiraProjectsLoading: loading, + setNewJiraIssueOpen: vi.fn(), + setNewJiraIssueTitle: vi.fn(), + setNewJiraIssueBody: vi.fn(), + setNewJiraIssueProjectId: vi.fn(), + setNewJiraIssueProjectQuery: vi.fn(), + setNewJiraIssueProjectCommandValue: vi.fn(), + setNewJiraIssueTypeId: vi.fn(), + sortedAvailableJiraProjects: [project] + } satisfies TaskPageComposerActionsModel + + return ( + + + + ) +} + +beforeEach(() => { + fixture.loading = false + useAppStore.setState( + { + ...initialStore, + settings: null, + persistedUIReady: false, + repos: [], + taskPageData: {}, + checkLinearConnection: vi + .fn() + .mockResolvedValue(undefined), + checkJiraConnection: vi + .fn() + .mockResolvedValue(undefined), + refreshPreflightStatus: vi + .fn() + .mockResolvedValue(undefined) + }, + true + ) + Object.defineProperty(window, 'api', { + configurable: true, + value: { + gh: { viewer: vi.fn().mockResolvedValue(null) } + } + }) +}) + +afterEach(() => { + cleanup() + useAppStore.setState(initialStore, true) + if (initialApiDescriptor) { + Object.defineProperty(window, 'api', initialApiDescriptor) + } else { + Reflect.deleteProperty(window, 'api') + } + vi.clearAllMocks() +}) + +describe('Jira project filters in Has Workspace mode', () => { + it('disables project changes while linked and restores the existing choice on a preset', async () => { + render() + const picker = screen.getByRole('combobox', { name: 'Jira project' }) + expect(picker).toBeEnabled() + expect(picker).toHaveTextContent('ALP · Alpha') + + fireEvent.click(screen.getByRole('button', { name: 'Has Workspace' })) + expect(picker).toBeDisabled() + fireEvent.click(picker) + expect(screen.queryByRole('option')).not.toBeInTheDocument() + expect(setJiraProjectFilter).not.toHaveBeenCalled() + expect(picker).toHaveTextContent('ALP · Alpha') + + fireEvent.click(screen.getByRole('button', { name: 'All' })) + expect(picker).toBeEnabled() + expect(picker).toHaveTextContent('ALP · Alpha') + fireEvent.click(picker) + fireEvent.click(await screen.findByRole('option', { name: 'All Jira projects' })) + expect(picker).toHaveTextContent('All Jira projects') + + fireEvent.click(screen.getByRole('button', { name: 'Has Workspace' })) + expect(picker).toBeDisabled() + expect(picker).toHaveTextContent('All Jira projects') + fireEvent.click(screen.getByRole('button', { name: 'All' })) + expect(picker).toBeEnabled() + expect(picker).toHaveTextContent('All Jira projects') + expect(setJiraProjectFilter).toHaveBeenCalledExactlyOnceWith(null) + }) + + it('keeps project changes disabled during inventory loading', () => { + fixture.loading = true + render() + expect(screen.getByRole('combobox', { name: 'Jira project' })).toBeDisabled() + }) +}) diff --git a/src/renderer/src/components/task-page/jira/Filters.tsx b/src/renderer/src/components/task-page/jira/Filters.tsx index 1ba65cbc300..20896600f01 100644 --- a/src/renderer/src/components/task-page/jira/Filters.tsx +++ b/src/renderer/src/components/task-page/jira/Filters.tsx @@ -10,6 +10,8 @@ import { translate } from '@/i18n/i18n' import { LoaderCircle, Plus, RefreshCw, Search, X } from 'lucide-react' import { Input } from '@/components/ui/input' import { shouldSuppressEnterSubmit } from '@/lib/new-workspace-enter-guard' +import { JiraProjectPicker } from '@/components/jira-project-picker' +import { matchesJiraProject } from '../../../../../shared/jira-project-reference' export function TaskPageJiraFilters({ model }: { @@ -53,6 +55,16 @@ export function TaskPageJiraFilters({
+
+ +
{jiraPresets.map((preset) => { const active = !jiraHasWorkspace && !jiraSearchInput && activeJiraPreset === preset.id return ( @@ -125,11 +137,14 @@ export function TaskPageJiraFilters({ const draft = useAppStore.getState().newJiraIssueDraft setNewJiraIssueTitle(draft?.title ?? '') setNewJiraIssueBody(draft?.body ?? '') - setNewJiraIssueProjectId( - sortedAvailableJiraProjects[0] - ? getJiraProjectSelectionKey(sortedAvailableJiraProjects[0]) - : null - ) + const projectFilter = model.jiraProjectFilter + const defaultProject = projectFilter + ? sortedAvailableJiraProjects.find((project) => + matchesJiraProject(project, projectFilter) + ) + : null + const project = defaultProject ?? sortedAvailableJiraProjects[0] + setNewJiraIssueProjectId(project ? getJiraProjectSelectionKey(project) : null) setNewJiraIssueProjectQuery('') setNewJiraIssueProjectCommandValue('') setNewJiraIssueTypeId(null) diff --git a/src/renderer/src/components/use-jira-project-inventory.test.tsx b/src/renderer/src/components/use-jira-project-inventory.test.tsx new file mode 100644 index 00000000000..a340085ba63 --- /dev/null +++ b/src/renderer/src/components/use-jira-project-inventory.test.tsx @@ -0,0 +1,78 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { JiraProject } from '../../../shared/jira-types' +import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import { normalizeTaskSourceContext } from '../../../shared/task-source-context' +import { useJiraProjectInventory } from './use-jira-project-inventory' + +const { status, projects } = vi.hoisted(() => ({ status: vi.fn(), projects: vi.fn() })) +vi.mock('@/runtime/runtime-jira-project-client', () => ({ + jiraStatusForTarget: status, + jiraListProjectsForTarget: projects +})) +afterEach(cleanup) +beforeEach(() => { + status.mockReset() + projects.mockReset() + status.mockResolvedValue({ connected: true }) +}) + +describe('Jira project inventory ownership', () => { + it('ignores a late result from the previous account host', async () => { + const local: RuntimeClientTarget = { kind: 'local' } + const remote: RuntimeClientTarget = { kind: 'environment', environmentId: 'owner' } + const localProject = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'local-site' } + const remoteProject = { ...localProject, siteId: 'remote-site' } + let finishLocal: (value: JiraProject[]) => void = () => { + throw new Error('Local request has not started') + } + projects.mockImplementation((source: RuntimeClientTarget) => + source === local + ? new Promise((resolve) => { + finishLocal = resolve + }) + : Promise.resolve([remoteProject]) + ) + const initialProps: { source: RuntimeClientTarget } = { source: local } + const { result, rerender } = renderHook(({ source }) => useJiraProjectInventory(source, true), { + initialProps + }) + await waitFor(() => expect(projects).toHaveBeenCalledWith(local, 'all')) + rerender({ source: remote }) + await waitFor(() => expect(result.current.projects).toEqual([remoteProject])) + await act(async () => finishLocal([localProject])) + expect(result.current.projects).toEqual([remoteProject]) + }) + + it('does not discover projects when the account is disconnected', async () => { + status.mockResolvedValue({ connected: false }) + const local: RuntimeClientTarget = { kind: 'local' } + const { result } = renderHook(() => useJiraProjectInventory(local, true)) + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.connected).toBe(false) + expect(projects).not.toHaveBeenCalled() + }) + + it.each([ + ['local', { kind: 'local' }], + ['ssh:builder', { kind: 'local' }], + ['runtime:owner', { kind: 'environment', environmentId: 'owner' }] + ] as const)('routes the %s task source through its account owner', async (hostId, target) => { + const source = normalizeTaskSourceContext({ provider: 'jira', projectId: 'project', hostId }) + if (!source) { + throw new Error('Expected a valid Jira task source') + } + projects.mockResolvedValue([]) + const { result } = renderHook(() => useJiraProjectInventory(source, true)) + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(status).toHaveBeenCalledWith(target) + expect(projects).toHaveBeenCalledWith(target, 'all') + }) + + it('does not read any owner when inventory is disabled', () => { + renderHook(() => useJiraProjectInventory({ kind: 'local' }, false)) + expect(status).not.toHaveBeenCalled() + expect(projects).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/use-jira-project-inventory.ts b/src/renderer/src/components/use-jira-project-inventory.ts new file mode 100644 index 00000000000..275aa140fcb --- /dev/null +++ b/src/renderer/src/components/use-jira-project-inventory.ts @@ -0,0 +1,59 @@ +import { useEffect, useState } from 'react' +import type { JiraProject } from '../../../shared/jira-types' +import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import type { TaskSourceContext } from '../../../shared/task-source-context' +import { taskSourceRuntimeTarget } from '@/lib/task-source-runtime-target' +import { + jiraListProjectsForTarget, + jiraStatusForTarget +} from '@/runtime/runtime-jira-project-client' + +type JiraProjectInventorySource = RuntimeClientTarget | TaskSourceContext + +export function useJiraProjectInventory(source: JiraProjectInventorySource, enabled: boolean) { + const [inventory, setInventory] = useState<{ + source: JiraProjectInventorySource + projects: JiraProject[] + connected: boolean + error: string | null + } | null>(null) + useEffect(() => { + if (!enabled) { + return + } + let cancelled = false + const target = source.kind === 'task-source' ? taskSourceRuntimeTarget(source) : source + void jiraStatusForTarget(target) + .then(async (status) => ({ + source, + connected: status.connected, + projects: status.connected ? await jiraListProjectsForTarget(target, 'all') : [], + error: null + })) + .then((next) => { + if (!cancelled) { + setInventory(next) + } + }) + .catch((error: unknown) => { + if (!cancelled) { + setInventory({ + source, + connected: false, + projects: [], + error: error instanceof Error ? error.message : String(error) + }) + } + }) + return () => { + cancelled = true + } + }, [source, enabled]) + const current = inventory?.source === source ? inventory : null + return { + projects: current?.projects ?? [], + connected: current?.connected ?? false, + error: current?.error ?? null, + loading: enabled && current === null + } +} diff --git a/src/renderer/src/components/use-task-page-composer-actions.ts b/src/renderer/src/components/use-task-page-composer-actions.ts index 75d104c0353..16a732508a5 100644 --- a/src/renderer/src/components/use-task-page-composer-actions.ts +++ b/src/renderer/src/components/use-task-page-composer-actions.ts @@ -13,6 +13,8 @@ import { bindTaskPageJiraItemSourceContext } from './task-page-jira-item-source- import type { LinkedWorkItemSummary } from '@/lib/new-workspace' import { shouldHideTaskPageListChrome } from '@/components/task-page-list-chrome-visibility' import { getJiraIssueWorkspaceSeed } from './task-page-source-context' +import { findJiraIssueDefaultRepo } from '../../../shared/jira-project-reference' +import { getRepoWorkspaceCreationTargetHint } from '@/lib/project-host-workspace-target' export function useTaskPageComposerActions(model: TaskPageJiraListEffectsModel) { const { setTaskResumeState, @@ -212,14 +214,19 @@ export function useTaskPageComposerActions(model: TaskPageJiraListEffectsModel) url: issue.url, jiraIdentifier: issue.key } + const mappedRepo = findJiraIssueDefaultRepo(model.repos, issue, taskSourceContext.hostId) openModal('new-workspace-composer', { linkedWorkItem, taskSourceContext, + initialRepoId: mappedRepo?.id, + initialWorkspaceTarget: mappedRepo + ? getRepoWorkspaceCreationTargetHint(mappedRepo, useAppStore.getState().projectHostSetups) + : undefined, prefilledName: getJiraIssueWorkspaceSeed(issue), telemetrySource: 'sidebar' }) }, - [jiraSites, jiraTaskSourceContext, openModal] + [jiraSites, jiraTaskSourceContext, model.repos, openModal] ) const handleUseJiraItem = useCallback( (issue: JiraIssue): void => { diff --git a/src/renderer/src/components/use-task-page-jira-list-effects.test.ts b/src/renderer/src/components/use-task-page-jira-list-effects.test.ts index 7bcd61c74f0..eb130b4e503 100644 --- a/src/renderer/src/components/use-task-page-jira-list-effects.test.ts +++ b/src/renderer/src/components/use-task-page-jira-list-effects.test.ts @@ -3,6 +3,9 @@ import { act, cleanup, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { TaskPageLinearCollectionEffectsModel } from './use-task-page-linear-collection-effects' import { useTaskPageJiraListEffects } from './use-task-page-jira-list-effects' +import { restrictJiraQueryToProject } from '../../../shared/jira-project-reference' +import { buildJiraPresetJql } from '../../../shared/jira-preset-jql' +import { buildJiraTextMatchJql } from '../../../shared/jira-search-input-jql' const listJiraIssues = vi.fn().mockResolvedValue([]) const searchJiraIssues = vi.fn().mockResolvedValue([]) @@ -37,6 +40,7 @@ function createModel(): TaskPageLinearCollectionEffectsModel { setAppliedJiraSearch: vi.fn(), activeJiraPreset: 'assigned', jiraCurrentSprint: false, + jiraProjectFilter: null, jiraRefreshNonce: 0, jiraActiveSavedFilterId: null, jiraSavedFilters: [], @@ -109,6 +113,46 @@ describe('useTaskPageJiraListEffects refresh wiring', () => { expect(listJiraIssues).not.toHaveBeenCalled() }) + it('keeps mapped saved JQL exact with sprint filtering and ignores persisted statuses', async () => { + const model = createModel() + model.appliedJiraSearch = 'filter = 12345 ORDER BY created ASC' + model.jiraActiveSavedFilterId = 'saved' + model.jiraSavedFilters = [{ id: 'saved', name: 'Saved', jql: model.appliedJiraSearch }] + model.jiraCurrentSprint = true + model.selectedJiraStatuses = ['Blocked'] + model.jiraProjectFilter = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' } + searchJiraIssues.mockRejectedValue(new Error('Error 400: Invalid saved query')) + renderHook(() => useTaskPageJiraListEffects(model)) + await waitFor(() => + expect(model.setJiraError).toHaveBeenCalledWith( + expect.objectContaining({ details: 'Invalid saved query' }) + ) + ) + expect(searchJiraIssues).toHaveBeenCalledExactlyOnceWith( + 'project = "10" AND ((filter = 12345) AND sprint in openSprints()) ORDER BY created ASC', + 50, + { sourceContext: null, siteId: 'site-a', force: false } + ) + expect(listJiraIssues).not.toHaveBeenCalled() + }) + + it('classifies mapped text before adding sprint filtering without persisted statuses', async () => { + const model = createModel() + model.appliedJiraSearch = 'fix login' + model.jiraCurrentSprint = true + model.selectedJiraStatuses = ['Blocked'] + model.jiraProjectFilter = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' } + renderHook(() => useTaskPageJiraListEffects(model)) + await waitFor(() => + expect(searchJiraIssues).toHaveBeenCalledExactlyOnceWith( + 'project = "10" AND ((text ~ "fix login*") AND sprint in openSprints())', + 50, + { sourceContext: null, siteId: 'site-a', force: false } + ) + ) + expect(listJiraIssues).not.toHaveBeenCalled() + }) + it('reports a saved query that exceeds the request limit after sprint composition', async () => { const model = createModel() model.appliedJiraSearch = `key in (${Array.from({ length: 1030 }, (_, index) => `APP-${index + 1}`).join(',')})` @@ -125,6 +169,165 @@ describe('useTaskPageJiraListEffects refresh wiring', () => { expect(listJiraIssues).not.toHaveBeenCalled() }) + it('checks the saved query limit after both sprint and project restrictions', async () => { + const model = createModel() + model.appliedJiraSearch = `summary ~ "${'x'.repeat(8140)}"` + model.jiraActiveSavedFilterId = 'large' + model.jiraSavedFilters = [{ id: 'large', name: 'Large', jql: model.appliedJiraSearch }] + model.jiraCurrentSprint = true + model.jiraProjectFilter = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' } + renderHook(() => useTaskPageJiraListEffects(model)) + await waitFor(() => + expect(model.setJiraError).toHaveBeenCalledWith( + expect.objectContaining({ title: expect.stringContaining('too long') }) + ) + ) + expect(searchJiraIssues).not.toHaveBeenCalled() + expect(listJiraIssues).not.toHaveBeenCalled() + }) + + it.each([ + { + name: 'saved JQL', + query: `summary ~ "${'x'.repeat(8160)}"`, + validQuery: `summary ~ "${'x'.repeat(8159)}"`, + saved: true + }, + { + name: 'manual JQL', + query: `summary ~ "${'x'.repeat(8160)}"`, + validQuery: `summary ~ "${'x'.repeat(8159)}"`, + saved: false + }, + { name: 'plain text', query: 'x'.repeat(8162), validQuery: 'x'.repeat(8161), saved: false }, + { + name: 'UTF-8 JQL', + query: `summary ~ "${'界'.repeat(2720)}"`, + validQuery: `summary ~ "${'界'.repeat(2719)}xx"`, + saved: false + } + ])( + 'rejects project-only overflow for $name and recovers at 8192 bytes', + async ({ query, validQuery, saved }) => { + const model = createModel() + const project = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' } + model.appliedJiraSearch = query + model.jiraProjectFilter = project + if (saved) { + model.jiraActiveSavedFilterId = 'large' + model.jiraSavedFilters = [{ id: 'large', name: 'Large', jql: query }] + } + const jql = query.includes('~') ? query : buildJiraTextMatchJql(query) + expect(new TextEncoder().encode(query).length).toBeLessThan(8192) + expect(new TextEncoder().encode(restrictJiraQueryToProject(jql, project)).length).toBe(8193) + const { rerender } = renderHook(() => useTaskPageJiraListEffects(model)) + await waitFor(() => + expect(model.setJiraError).toHaveBeenLastCalledWith({ + title: 'The Jira query is too long. Shorten the query or clear filters.', + details: null + }) + ) + expect(model.setJiraLoading).toHaveBeenLastCalledWith(false) + expect(model.setJiraIssues).toHaveBeenLastCalledWith([]) + expect(model.setJiraJqlRejection).toHaveBeenCalledExactlyOnceWith(null) + expect(searchJiraIssues).not.toHaveBeenCalled() + expect(listJiraIssues).not.toHaveBeenCalled() + + const validJql = query.includes('~') ? validQuery : buildJiraTextMatchJql(validQuery) + const scopedValidJql = restrictJiraQueryToProject(validJql, project) + expect(new TextEncoder().encode(scopedValidJql).length).toBe(8192) + model.appliedJiraSearch = validQuery + if (saved) { + model.jiraSavedFilters = [{ id: 'large', name: 'Large', jql: validQuery }] + } + rerender() + await waitFor(() => expect(model.setJiraLoading).toHaveBeenLastCalledWith(false)) + expect(searchJiraIssues).toHaveBeenCalledExactlyOnceWith(scopedValidJql, 50, { + sourceContext: null, + siteId: 'site-a', + force: false + }) + expect(model.setJiraError).toHaveBeenLastCalledWith(null) + } + ) + + it.each([ + { + name: 'escaped', + statuses: Array.from({ length: 50 }, (_, index) => `${index}${'"'.repeat(124)}`) + }, + { + name: 'UTF-8', + statuses: Array.from({ length: 50 }, (_, index) => `${index}${'界'.repeat(126)}`) + } + ])( + 'rejects an oversized mapped preset with $name status names and recovers', + async ({ statuses }) => { + const model = createModel() + const project = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' } + model.activeJiraPreset = 'all' + model.jiraProjectFilter = project + model.selectedJiraStatuses = statuses + expect(statuses.every((status) => status.length <= 128)).toBe(true) + const query = restrictJiraQueryToProject(buildJiraPresetJql('all', { statuses }), project) + expect(new TextEncoder().encode(query).length).toBeGreaterThan(8192) + const { rerender } = renderHook(() => useTaskPageJiraListEffects(model)) + await waitFor(() => + expect(model.setJiraError).toHaveBeenLastCalledWith({ + title: 'The Jira query is too long. Shorten the query or clear filters.', + details: null + }) + ) + expect(model.setJiraLoading).toHaveBeenLastCalledWith(false) + expect(searchJiraIssues).not.toHaveBeenCalled() + expect(listJiraIssues).not.toHaveBeenCalled() + model.selectedJiraStatuses = ['Blocked'] + rerender() + await waitFor(() => expect(searchJiraIssues).toHaveBeenCalledTimes(1)) + expect(searchJiraIssues).toHaveBeenLastCalledWith( + 'project = "10" AND (statusCategory != Done AND status in ("Blocked")) ORDER BY updated DESC', + 50, + { sourceContext: null, siteId: 'site-a', force: false } + ) + await waitFor(() => expect(model.setJiraError).toHaveBeenLastCalledWith(null)) + expect(model.setJiraLoading).toHaveBeenLastCalledWith(false) + } + ) + + it('reports an oversized manual query without project or sprint filters', async () => { + const model = createModel() + model.appliedJiraSearch = `summary ~ "${'x'.repeat(8181)}"` + renderHook(() => useTaskPageJiraListEffects(model)) + await waitFor(() => + expect(model.setJiraError).toHaveBeenLastCalledWith({ + title: 'The Jira query is too long. Shorten the query or clear filters.', + details: null + }) + ) + expect(searchJiraIssues).not.toHaveBeenCalled() + expect(listJiraIssues).not.toHaveBeenCalled() + expect(model.setJiraLoading).toHaveBeenLastCalledWith(false) + }) + + it('ignores a mapped preset limit rejection after changing the source', async () => { + const model = createModel() + model.jiraProjectFilter = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' } + model.selectedJiraStatuses = Array.from( + { length: 50 }, + (_, index) => `${index}${'"'.repeat(124)}` + ) + const { rerender } = renderHook(() => useTaskPageJiraListEffects(model)) + model.taskSource = 'linear' + rerender() + await act(async () => { + await Promise.resolve() + }) + expect(model.setJiraError).toHaveBeenCalledExactlyOnceWith(null) + expect(model.setJiraIssues).not.toHaveBeenCalled() + expect(searchJiraIssues).not.toHaveBeenCalled() + expect(listJiraIssues).not.toHaveBeenCalled() + }) + it('loads linked keys directly and does not reread them for local search text', async () => { const model = createModel() model.jiraHasWorkspace = true @@ -260,6 +463,148 @@ describe('useTaskPageJiraListEffects refresh wiring', () => { }) }) + it.each([ + { + statuses: [], + currentSprint: false, + query: 'project = "10" AND (statusCategory != Done) ORDER BY updated DESC' + }, + { + statuses: ['In Progress', 'Blocked'], + currentSprint: false, + query: + 'project = "10" AND (statusCategory != Done AND status in ("In Progress", "Blocked")) ORDER BY updated DESC' + }, + { + statuses: [], + currentSprint: true, + query: + 'project = "10" AND (statusCategory != Done AND sprint in openSprints()) ORDER BY updated DESC' + }, + { + statuses: ['Blocked'], + currentSprint: true, + query: + 'project = "10" AND (statusCategory != Done AND status in ("Blocked") AND sprint in openSprints()) ORDER BY updated DESC' + } + ])( + 'scopes statuses $statuses and sprint $currentSprint to the mapped project and owner', + async ({ statuses, currentSprint, query }) => { + const model = createModel() + model.jiraTaskSourceContext = { + kind: 'task-source', + provider: 'jira', + projectId: 'source-project', + hostId: 'runtime:owner' + } + model.activeJiraPreset = 'all' + model.selectedJiraStatuses = statuses + model.jiraCurrentSprint = currentSprint + model.jiraProjectFilter = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' } + renderHook(() => useTaskPageJiraListEffects(model)) + await waitFor(() => + expect(searchJiraIssues).toHaveBeenCalledWith(query, 50, { + sourceContext: model.jiraTaskSourceContext, + siteId: 'site-a', + force: false + }) + ) + expect(listJiraIssues).not.toHaveBeenCalled() + await waitFor(() => expect(model.setJiraLoading).toHaveBeenLastCalledWith(false)) + if (statuses.length === 0) { + expect(model.setJiraStatusCatalog).toHaveBeenCalledWith({ + scopeKey: 'fixture::site-1::all::site-a:10', + items: [] + }) + } else { + expect(model.setJiraStatusCatalog).not.toHaveBeenCalled() + } + } + ) + + it('ignores the project mapping for linked issues and restores it when leaving linked mode', async () => { + const model = createModel() + model.activeJiraPreset = 'all' + model.jiraProjectFilter = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' } + model.selectedJiraStatuses = ['Blocked'] + model.jiraCurrentSprint = true + model.jiraHasWorkspace = true + model.linkedJiraIssueRefs = [{ key: 'APP-1', siteId: 'site-1' }] + model.linkedJiraIssueRefsSignature = JSON.stringify(model.linkedJiraIssueRefs) + const { rerender } = renderHook(() => useTaskPageJiraListEffects(model)) + await waitFor(() => expect(searchJiraIssues).toHaveBeenCalledTimes(1)) + expect(searchJiraIssues).toHaveBeenLastCalledWith('key = APP-1', 1, { + sourceContext: null, + siteId: 'site-1', + force: false + }) + expect(listJiraIssues).not.toHaveBeenCalled() + expect(model.setJiraStatusCatalog).not.toHaveBeenCalled() + expect(model.selectedJiraStatuses).toEqual(['Blocked']) + expect(model.jiraCurrentSprint).toBe(true) + model.jiraHasWorkspace = false + rerender() + await waitFor(() => expect(searchJiraIssues).toHaveBeenCalledTimes(2)) + expect(searchJiraIssues).toHaveBeenLastCalledWith( + 'project = "10" AND (statusCategory != Done AND status in ("Blocked") AND sprint in openSprints()) ORDER BY updated DESC', + 50, + { sourceContext: null, siteId: 'site-a', force: false } + ) + expect(model.setJiraError).toHaveBeenLastCalledWith(null) + expect(model.setJiraStatusCatalog).not.toHaveBeenCalled() + }) + + it.each([ + ['release candidate', 'project = "10" AND (release candidate)'], + [ + 'filter = 12345 ORDER BY created ASC', + 'project = "10" AND (filter = 12345) ORDER BY created ASC' + ] + ])( + 'keeps saved JQL exact in the mapped project and restores its preset after exit: %s', + async (query, scopedQuery) => { + const model = createModel() + const filter = { id: 'saved', name: 'Saved work', jql: query } + model.jiraTaskSourceContext = { + kind: 'task-source', + provider: 'jira', + projectId: 'source-project', + hostId: 'runtime:owner' + } + model.activeJiraPreset = 'all' + model.jiraProjectFilter = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' } + model.selectedJiraStatuses = ['Blocked'] + model.jiraSavedFilters = [filter] + model.jiraActiveSavedFilterId = filter.id + model.appliedJiraSearch = query + searchJiraIssues.mockRejectedValueOnce(new Error('Error 400: JQL syntax invalid')) + const { rerender } = renderHook(() => useTaskPageJiraListEffects(model)) + await waitFor(() => + expect(model.setJiraError).toHaveBeenLastCalledWith( + expect.objectContaining({ details: 'JQL syntax invalid' }) + ) + ) + expect(searchJiraIssues).toHaveBeenCalledExactlyOnceWith(scopedQuery, 50, { + sourceContext: model.jiraTaskSourceContext, + siteId: 'site-a', + force: false + }) + expect(model.setJiraJqlRejection).toHaveBeenCalledExactlyOnceWith(null) + + model.jiraActiveSavedFilterId = null + model.appliedJiraSearch = '' + rerender() + await waitFor(() => expect(searchJiraIssues).toHaveBeenCalledTimes(2)) + expect(searchJiraIssues).toHaveBeenLastCalledWith( + 'project = "10" AND (statusCategory != Done AND status in ("Blocked")) ORDER BY updated DESC', + 50, + { sourceContext: model.jiraTaskSourceContext, siteId: 'site-a', force: false } + ) + expect(listJiraIssues).not.toHaveBeenCalled() + expect(model.setJiraError).toHaveBeenLastCalledWith(null) + } + ) + it('shows the JQL rejection with its text matches and clears it for the next search', async () => { const model = createModel() model.appliedJiraSearch = 'project = NOPE' diff --git a/src/renderer/src/components/use-task-page-jira-list-effects.ts b/src/renderer/src/components/use-task-page-jira-list-effects.ts index c2da274362f..517b17ddea0 100644 --- a/src/renderer/src/components/use-task-page-jira-list-effects.ts +++ b/src/renderer/src/components/use-task-page-jira-list-effects.ts @@ -1,7 +1,10 @@ import { translate } from '@/i18n/i18n' import { isRuntimeProviderSearchQueryWithinLimit } from '@/runtime/runtime-provider-search-bounds' import { withCurrentSprintClause } from '../../../shared/jira-jql-predicate' -import { buildJiraStatusCatalog } from './task-page-jira-status-catalog' +import { + buildJiraStatusCatalog, + getJiraStatusCatalogScopeKey +} from './task-page-jira-status-catalog' import type { TaskPageLinearCollectionEffectsModel } from './use-task-page-linear-collection-effects' import { useEffect, useMemo, useRef } from 'react' import { @@ -15,6 +18,8 @@ import { searchTaskPageJiraIssues } from '@/components/task-page-jira-search' import { JIRA_ITEM_LIMIT, TASK_SEARCH_DEBOUNCE_MS } from './task-page-source-context' import { readLinkedJiraIssues } from './task-page-jira-linked-issues' import { ipcOrRuntimeTargetForHostId } from '@/runtime/runtime-client-target' +import { buildJiraPresetJql } from '../../../shared/jira-preset-jql' +import { restrictJiraQueryToProject } from '../../../shared/jira-project-reference' export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffectsModel) { const { accountBackedTaskSourceHostId, @@ -45,6 +50,7 @@ export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffect setAppliedJiraSearch, activeJiraPreset, jiraCurrentSprint, + jiraProjectFilter, jiraRefreshNonce, jiraActiveSavedFilterId, jiraSavedFilters, @@ -100,7 +106,7 @@ export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffect const force = previousJiraRefreshNonceRef.current !== jiraRefreshNonce previousJiraRefreshNonceRef.current = jiraRefreshNonce let cancelled = false - let sprintQueryTooLong = false + let queryTooLong = false setJiraLoading(true) setJiraError(null) setJiraJqlRejection(null) @@ -108,36 +114,53 @@ export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffect const trimmed = jiraRemoteSearch.trim() const savedFilter = jiraSavedFilters.find((filter) => filter.id === jiraActiveSavedFilterId) const exactSavedJql = savedFilter?.jql === trimmed + const searchWithinLimit = async (query: string) => { + if (!isRuntimeProviderSearchQueryWithinLimit(query)) { + queryTooLong = true + throw new Error('Query exceeds the request limit.') + } + return searchJiraIssues(query, JIRA_ITEM_LIMIT, { + sourceContext: jiraTaskSourceContext, + ...(jiraProjectFilter ? { siteId: jiraProjectFilter.siteId } : {}), + force + }) + } const request = jiraHasWorkspace ? readLinkedJiraIssues(linkedJiraIssueRefs, searchJiraIssues, jiraTaskSourceContext, force) : trimmed.length > 0 ? searchTaskPageJiraIssues( trimmed, async (jql) => { - const query = jiraCurrentSprint ? withCurrentSprintClause(jql) : jql - if (jiraCurrentSprint && !isRuntimeProviderSearchQueryWithinLimit(query)) { - sprintQueryTooLong = true - throw new Error('Query exceeds the request limit after sprint filtering.') - } - return searchJiraIssues(query, JIRA_ITEM_LIMIT, { - sourceContext: jiraTaskSourceContext, - force - }) + const sprintJql = jiraCurrentSprint ? withCurrentSprintClause(jql) : jql + const query = jiraProjectFilter + ? restrictJiraQueryToProject(sprintJql, jiraProjectFilter) + : sprintJql + return searchWithinLimit(query) }, exactSavedJql ? 'jql' : 'input' ).then((result) => ({ ...result, error: null })) - : listJiraIssues(activeJiraPreset, JIRA_ITEM_LIMIT, { - sourceContext: jiraTaskSourceContext, - force, - ...(selectedJiraStatuses.length || jiraCurrentSprint - ? { - filters: { - ...(selectedJiraStatuses.length ? { statuses: selectedJiraStatuses } : {}), - ...(jiraCurrentSprint ? { currentSprint: true } : {}) + : jiraProjectFilter + ? searchWithinLimit( + restrictJiraQueryToProject( + buildJiraPresetJql(activeJiraPreset, { + statuses: selectedJiraStatuses, + currentSprint: jiraCurrentSprint + }), + jiraProjectFilter + ) + ).then((issues) => ({ issues, jqlRejection: null, error: null })) + : listJiraIssues(activeJiraPreset, JIRA_ITEM_LIMIT, { + sourceContext: jiraTaskSourceContext, + force, + ...(selectedJiraStatuses.length || jiraCurrentSprint + ? { + filters: { + ...(selectedJiraStatuses.length ? { statuses: selectedJiraStatuses } : {}), + ...(jiraCurrentSprint ? { currentSprint: true } : {}) + } } - } - : {}) - }).then((issues) => ({ issues, jqlRejection: null, error: null })) + : {}) + }).then((issues) => ({ issues, jqlRejection: null, error: null })) void request .then((result) => { const { issues, jqlRejection } = result @@ -147,7 +170,12 @@ export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffect setJiraError(result.error) if (!jiraHasWorkspace && !trimmed && selectedJiraStatuses.length === 0) { setJiraStatusCatalog({ - scopeKey: `${jiraTaskSourceScopeKey}::${selectedJiraSiteId}::${activeJiraPreset}`, + scopeKey: getJiraStatusCatalogScopeKey( + jiraTaskSourceScopeKey, + selectedJiraSiteId, + activeJiraPreset, + jiraProjectFilter + ), items: buildJiraStatusCatalog(issues) }) } @@ -182,11 +210,11 @@ export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffect const failureState = createTaskPageJiraLoadFailureState(err) setJiraIssues(failureState.issues) setJiraError( - sprintQueryTooLong + queryTooLong ? { title: translate( 'jira.board.queryTooLong', - 'The Jira query is too long with Current sprint selected. Shorten the query or turn off Current sprint.' + 'The Jira query is too long. Shorten the query or clear filters.' ), details: null } @@ -207,6 +235,7 @@ export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffect jiraHasWorkspace, linkedJiraIssueRefsSignature, jiraCurrentSprint, + jiraProjectFilter, jiraRefreshNonce, jiraActiveSavedFilterId, jiraSavedFilters, diff --git a/src/renderer/src/components/use-task-page-jira-list-state.owner.test.tsx b/src/renderer/src/components/use-task-page-jira-list-state.owner.test.tsx index 39c155aafd7..39cb9f6ce23 100644 --- a/src/renderer/src/components/use-task-page-jira-list-state.owner.test.tsx +++ b/src/renderer/src/components/use-task-page-jira-list-state.owner.test.tsx @@ -23,6 +23,9 @@ function fixture( jiraConnected: true, selectedJiraSiteId: 'site-1', taskSource: 'jira', + repos: [], + selectedRepos: [], + pageData: {}, jiraTaskSourceContext: sourceContext } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This isolated hook reads only the listed pipeline fields. diff --git a/src/renderer/src/components/use-task-page-jira-list-state.ts b/src/renderer/src/components/use-task-page-jira-list-state.ts index 5215f58144f..74b3d5149ad 100644 --- a/src/renderer/src/components/use-task-page-jira-list-state.ts +++ b/src/renderer/src/components/use-task-page-jira-list-state.ts @@ -27,6 +27,7 @@ import { } from '@/runtime/runtime-client-target' import { getEnvironmentRuntimeContextKey } from '@/lib/provider-runtime-context' import { getTaskSourceCacheScope } from '../../../shared/task-source-context' +import { useTaskPageJiraProjectScope } from './use-task-page-jira-project-scope' const EMPTY_JIRA_PRIORITIES: JiraPrioritiesBySite = new Map() export function useTaskPageJiraListState(model: TaskPageLinearViewStateModel) { @@ -42,6 +43,13 @@ export function useTaskPageJiraListState(model: TaskPageLinearViewStateModel) { ipcOrRuntimeTargetForHostId(jiraTaskSourceContext?.hostId ?? accountBackedTaskSourceHostId), [accountBackedTaskSourceHostId, jiraTaskSourceContext] ) + const jiraProjectScope = useTaskPageJiraProjectScope( + model.repos, + model.selectedRepos, + model.pageData.preselectedRepoId, + model.accountBackedTaskSourceHostId, + selectedJiraSiteId + ) // Jira tab state const [jiraIssues, setJiraIssues] = useState([]) const [jiraLoading, setJiraLoading] = useState(false) @@ -169,6 +177,7 @@ export function useTaskPageJiraListState(model: TaskPageLinearViewStateModel) { ...quickFilters, jiraStatusCatalog, setJiraStatusCatalog, + ...jiraProjectScope, jiraIssues, setJiraIssues, jiraLoading, diff --git a/src/renderer/src/components/use-task-page-jira-project-scope.test.tsx b/src/renderer/src/components/use-task-page-jira-project-scope.test.tsx new file mode 100644 index 00000000000..fd8c34925e2 --- /dev/null +++ b/src/renderer/src/components/use-task-page-jira-project-scope.test.tsx @@ -0,0 +1,258 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { Repo } from '../../../shared/repo-types' +import { useTaskPageJiraProjectScope } from './use-task-page-jira-project-scope' +import { useTaskPageJiraListState } from './use-task-page-jira-list-state' +import { useTaskPageStoreBindings } from './use-task-page-store-bindings' +import { useTaskPageRepoSelection } from './use-task-page-repo-selection' +import { useTaskPageRuntimeHosts } from './use-task-page-runtime-hosts' +import { useTaskPageSourceAvailability } from './use-task-page-source-availability' +import { useTaskPageProviderState } from './use-task-page-provider-state' +import { useTaskPageGitHubListState } from './use-task-page-github-list-state' +import { useTaskPageGitHubDetail } from './use-task-page-github-detail' +import { useTaskPageGitHubCacheReconciliation } from './use-task-page-github-cache-reconciliation' +import { useTaskPageGitHubIssueDraft } from './use-task-page-github-issue-draft' +import { useTaskPageDetailRouting } from './use-task-page-detail-routing' +import { useTaskPageLinearViewState } from './use-task-page-linear-view-state' +const { readSavedFilters, mutateSavedFilters, readQuickFilters, writeQuickFilters } = vi.hoisted( + () => ({ + readSavedFilters: vi.fn(), + mutateSavedFilters: vi.fn(), + readQuickFilters: vi.fn(), + writeQuickFilters: vi.fn() + }) +) +vi.mock('@/runtime/runtime-jira-saved-filter-state', () => ({ + readJiraSavedFilterState: readSavedFilters, + mutateJiraSavedFilters: mutateSavedFilters +})) +vi.mock('@/runtime/runtime-jira-quick-filter-state', () => ({ + readJiraQuickFilterState: readQuickFilters, + writeJiraQuickFilterState: writeQuickFilters +})) +const originalState = useAppStore.getState() +const project = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' } +const filter = { id: 'saved', name: 'Saved work', jql: 'filter = 12345' } +const repo: Repo = { + id: 'repo', + path: '/repo', + displayName: 'Alpha', + badgeColor: '', + addedAt: 0, + defaultJiraProject: project, + kind: 'folder' +} +const sshProject = { ...project, id: '20', key: 'BRV', name: 'Bravo' } +const sshRepo: Repo = { + ...repo, + path: '/ssh/repo', + connectionId: 'builder', + defaultJiraProject: sshProject +} + +function useComposedJiraListState() { + const store = useTaskPageStoreBindings() + const selection = useTaskPageRepoSelection(store) + const hosts = useTaskPageRuntimeHosts(selection) + const availability = useTaskPageSourceAvailability(hosts) + const provider = useTaskPageProviderState(availability) + const githubList = useTaskPageGitHubListState(provider) + const githubDetail = useTaskPageGitHubDetail(githubList) + const reconciled = useTaskPageGitHubCacheReconciliation(githubDetail) + const draft = useTaskPageGitHubIssueDraft(reconciled) + const routing = useTaskPageDetailRouting(draft) + const linear = useTaskPageLinearViewState(routing) + return useTaskPageJiraListState(linear) +} +afterEach(() => { + cleanup() + useAppStore.setState(originalState, true) + localStorage.removeItem('orca.jira.has-workspace.v1') +}) +beforeEach(() => { + vi.resetAllMocks() + readSavedFilters.mockResolvedValue({ filters: [filter], activeFilterId: filter.id }) + mutateSavedFilters.mockResolvedValue({ filters: [filter], activeFilterId: null }) + readQuickFilters.mockResolvedValue({ statuses: ['Blocked'] }) + writeQuickFilters.mockResolvedValue({ statuses: [] }) +}) +describe('repository-scoped Jira Tasks', () => { + it('uses an explicit folder repository default and allows the All-project override', () => { + const { result } = renderHook(() => + useTaskPageJiraProjectScope([repo], [], 'repo', 'local', 'all') + ) + expect(result.current.jiraProjectFilter).toEqual(project) + act(() => result.current.setJiraProjectFilter(null)) + expect(result.current.jiraProjectFilter).toBeNull() + expect(repo.defaultJiraProject).toEqual(project) + }) + it('does not apply another site or execution host’s project mapping', () => { + const initialProps: { host: ExecutionHostId; site: string } = { host: 'local', site: 'site-b' } + const { result, rerender } = renderHook( + ({ host, site }) => useTaskPageJiraProjectScope([repo], [repo], 'repo', host, site), + { initialProps } + ) + expect(result.current.jiraProjectFilter).toBeNull() + rerender({ host: 'runtime:remote', site: 'site-a' }) + expect(result.current.jiraProjectFilter).toBeNull() + rerender({ host: 'local', site: 'site-a' }) + expect(result.current.jiraProjectFilter).toEqual(project) + }) + + it('uses the explicitly selected SSH owner before a duplicate preselected repository ID', () => { + const { result } = renderHook(() => + useTaskPageJiraProjectScope([repo, sshRepo], [sshRepo], repo.id, 'local', project.siteId) + ) + expect(result.current.jiraProjectFilter).toEqual(sshProject) + }) + + it('keeps a unique preselection ahead of a different previously selected repository', () => { + const previouslySelected: Repo = { ...repo, id: 'previous', defaultJiraProject: sshProject } + const { result } = renderHook(() => + useTaskPageJiraProjectScope( + [repo, previouslySelected], + [previouslySelected], + repo.id, + 'local', + project.siteId + ) + ) + expect(result.current.jiraProjectFilter).toEqual(project) + }) + + it('falls back to the single selected repository when the preselected ID is missing', () => { + const { result } = renderHook(() => + useTaskPageJiraProjectScope([repo], [repo], 'missing', 'local', project.siteId) + ) + expect(result.current.jiraProjectFilter).toEqual(project) + }) + + it('falls back when a unique different-ID preselection belongs to another Jira account', () => { + const runtimeRepo: Repo = { ...repo, id: 'remote', executionHostId: 'runtime:remote' } + const { result } = renderHook(() => + useTaskPageJiraProjectScope( + [repo, runtimeRepo], + [repo], + runtimeRepo.id, + 'local', + project.siteId + ) + ) + expect(result.current.jiraProjectFilter).toEqual(project) + }) + + it('does not borrow a compatible default when the explicit matching owner uses another account', () => { + const runtimeRepo: Repo = { ...repo, executionHostId: 'runtime:remote' } + const { result } = renderHook(() => + useTaskPageJiraProjectScope( + [repo, runtimeRepo], + [runtimeRepo], + repo.id, + 'local', + project.siteId + ) + ) + expect(result.current.jiraProjectFilter).toBeNull() + }) + + it('does not use a different selected repository to resolve ambiguous preselection', () => { + const previouslySelected: Repo = { ...repo, id: 'previous' } + const { result } = renderHook(() => + useTaskPageJiraProjectScope( + [repo, sshRepo, previouslySelected], + [previouslySelected], + repo.id, + 'local', + project.siteId + ) + ) + expect(result.current.jiraProjectFilter).toBeNull() + }) + + it('resets the All-project override when the same repository ID changes execution owner', () => { + const initialProps: { selected: readonly Repo[] } = { selected: [repo] } + const { result, rerender } = renderHook( + ({ selected }) => + useTaskPageJiraProjectScope([repo, sshRepo], selected, undefined, 'local', project.siteId), + { initialProps } + ) + expect(result.current.jiraProjectFilter).toEqual(project) + act(() => result.current.setJiraProjectFilter(null)) + expect(result.current.jiraProjectFilter).toBeNull() + rerender({ selected: [sshRepo] }) + expect(result.current.jiraProjectFilter).toEqual(sshProject) + }) + + it.each([{ selected: [] }, { selected: [repo, sshRepo] }])( + 'refuses an ambiguous preselected repository ID without a single selected owner (%#)', + ({ selected }) => { + const { result } = renderHook(() => + useTaskPageJiraProjectScope([repo, sshRepo], selected, repo.id, 'local', project.siteId) + ) + expect(result.current.jiraProjectFilter).toBeNull() + } + ) + + it('does not use the Jira account owner to choose between local and runtime repositories', () => { + const runtimeRepo: Repo = { ...repo, executionHostId: 'runtime:remote' } + const { result } = renderHook(() => + useTaskPageJiraProjectScope([repo, runtimeRepo], [], repo.id, 'local', project.siteId) + ) + expect(result.current.jiraProjectFilter).toBeNull() + }) + + it('keeps duplicate execution owners ambiguous through the actual Tasks list-state pipeline', () => { + const localGitRepo: Repo = { ...repo, kind: 'git' } + const sshGitRepo: Repo = { ...sshRepo, kind: 'git' } + useAppStore.setState({ + settings: null, + repos: [localGitRepo, sshGitRepo], + taskPageData: { taskSource: 'jira', preselectedRepoId: repo.id }, + jiraStatus: { connected: true, viewer: null, selectedSiteId: 'all' }, + jiraStatusChecked: true, + jiraStatusContextKey: getProviderRuntimeContextKey(null) + }) + const { result } = renderHook(useComposedJiraListState) + expect(result.current.selectedRepos).toEqual([localGitRepo, sshGitRepo]) + expect(result.current.accountBackedTaskSourceHostId).toBe('local') + expect(result.current.jiraProjectFilter).toBeNull() + }) + + it('keeps linked mode, saved filters, and the project override in the composed list state', async () => { + localStorage.removeItem('orca.jira.has-workspace.v1') + useAppStore.setState({ + settings: null, + repos: [repo], + taskPageData: { taskSource: 'jira', preselectedRepoId: repo.id }, + jiraStatus: { connected: true, viewer: null, selectedSiteId: 'all' }, + jiraStatusChecked: true, + jiraStatusContextKey: getProviderRuntimeContextKey(null) + }) + const { result } = renderHook(useComposedJiraListState) + act(() => result.current.setTaskResumeApplied(true)) + await waitFor(() => expect(result.current.jiraSavedFiltersReady).toBe(true)) + await waitFor(() => expect(result.current.jiraQuickFiltersReady).toBe(true)) + expect(readSavedFilters).toHaveBeenCalledWith({ kind: 'local' }) + expect(result.current.jiraActiveSavedFilterId).toBe(filter.id) + expect(result.current.appliedJiraSearch).toBe(filter.jql) + expect(result.current.selectedJiraStatuses).toEqual(['Blocked']) + expect(result.current.jiraProjectFilter).toEqual(project) + act(() => result.current.setJiraProjectFilter(null)) + act(() => result.current.setJiraHasWorkspace(true)) + expect(result.current.jiraHasWorkspace).toBe(true) + expect(result.current.jiraProjectFilter).toBeNull() + await act(async () => result.current.clearJiraSavedFilterSelection()) + expect(mutateSavedFilters).toHaveBeenCalledWith({ kind: 'local' }, { kind: 'select', id: null }) + expect(result.current.jiraActiveSavedFilterId).toBeNull() + expect(result.current.jiraProjectFilter).toBeNull() + act(() => result.current.setJiraHasWorkspace(false)) + expect(result.current.jiraHasWorkspace).toBe(false) + expect(result.current.jiraProjectFilter).toBeNull() + expect(repo.defaultJiraProject).toEqual(project) + expect(result.current.selectedJiraStatuses).toEqual(['Blocked']) + }) +}) diff --git a/src/renderer/src/components/use-task-page-jira-project-scope.ts b/src/renderer/src/components/use-task-page-jira-project-scope.ts new file mode 100644 index 00000000000..582d80e487d --- /dev/null +++ b/src/renderer/src/components/use-task-page-jira-project-scope.ts @@ -0,0 +1,51 @@ +import { useCallback, useState } from 'react' +import type { Repo } from '../../../shared/repo-types' +import type { JiraProjectReference } from '../../../shared/jira-project-reference' +import { getRepoExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' +import { resolveJiraSourceHostId } from '../../../shared/jira-source-host' +import { getRepoHostIdentity } from '@/store/slices/repo-host-identity' + +export function useTaskPageJiraProjectScope( + repos: readonly Repo[], + selectedRepos: readonly Repo[], + preselectedRepoId: string | undefined, + hostId: ExecutionHostId, + siteId: string | null | undefined +) { + const preselectedRepos = preselectedRepoId + ? repos.filter((entry) => entry.id === preselectedRepoId) + : [] + const selectedRepo = selectedRepos.length === 1 ? selectedRepos[0] : null + const preselectedRepo = preselectedRepos.length === 1 ? preselectedRepos[0] : null + const repo = + !preselectedRepoId || selectedRepo?.id === preselectedRepoId + ? selectedRepo + : preselectedRepos.length > 1 + ? null + : preselectedRepo && + resolveJiraSourceHostId({ + workspaceHostId: getRepoExecutionHostId(preselectedRepo) + }) === hostId + ? preselectedRepo + : selectedRepo + const scopeKey = JSON.stringify([hostId, repo ? getRepoHostIdentity(repo) : null, siteId ?? null]) + const [override, setOverride] = useState<{ + scopeKey: string + project: JiraProjectReference | null + } | null>(null) + const defaultProject = + repo && resolveJiraSourceHostId({ workspaceHostId: getRepoExecutionHostId(repo) }) === hostId + ? (repo.defaultJiraProject ?? null) + : null + const jiraProjectFilter = + override?.scopeKey === scopeKey + ? override.project + : defaultProject && (!siteId || siteId === 'all' || defaultProject.siteId === siteId) + ? defaultProject + : null + const setJiraProjectFilter = useCallback( + (project: JiraProjectReference | null) => setOverride({ scopeKey, project }), + [scopeKey] + ) + return { jiraProjectFilter, setJiraProjectFilter } +} diff --git a/src/renderer/src/hooks/composer-state/composer-card-contract.ts b/src/renderer/src/hooks/composer-state/composer-card-contract.ts index bbd084be6bf..89ff82d54b9 100644 --- a/src/renderer/src/hooks/composer-state/composer-card-contract.ts +++ b/src/renderer/src/hooks/composer-state/composer-card-contract.ts @@ -57,6 +57,7 @@ export type ComposerCardSourceProps = Pick< > export type ComposerCardActionProps = { + selectedRepo?: ComposerModel['selectedRepo'] | null onRepoChange: ComposerModel['handleRepoChange'] onProjectChange: ComposerModel['handleProjectChange'] onProjectHostSetupChange: ComposerModel['handleProjectHostSetupChange'] diff --git a/src/renderer/src/hooks/composer-state/composer-card-props.ts b/src/renderer/src/hooks/composer-state/composer-card-props.ts index 4584bec2a5f..f199e509913 100644 --- a/src/renderer/src/hooks/composer-state/composer-card-props.ts +++ b/src/renderer/src/hooks/composer-state/composer-card-props.ts @@ -128,6 +128,9 @@ export function buildComposerCardProps(state: ComposerModel) { const createDisabled = isProjectGroupTarget ? folderCreateDisabled : repoCreateDisabled const cardProps: ComposerCardSourceProps & ComposerCardActionProps = { eligibleRepos: isProjectGroupTarget ? folderSourceRepos : eligibleRepos, + selectedRepo: isProjectGroupTarget + ? (folderSourceRepos.find((repo) => repo.id === repoId) ?? null) + : (selectedRepo ?? null), repoId, projectOptions, selectedProjectId, diff --git a/src/renderer/src/hooks/composer-state/composer-decisions.ts b/src/renderer/src/hooks/composer-state/composer-decisions.ts index 1e252b419fd..1a68a705568 100644 --- a/src/renderer/src/hooks/composer-state/composer-decisions.ts +++ b/src/renderer/src/hooks/composer-state/composer-decisions.ts @@ -13,6 +13,7 @@ import type { ExecutionHostId } from '../../../../shared/execution-host' import type { JiraIssueKeyCasing } from '../../../../shared/ui-chrome-types' import type { LinkedWorkItemSummary } from '@/lib/new-workspace' import type { SmartGitHubPrStartPointSelection } from './source-selection-decisions' +import type { WorkspaceCreationTargetHint } from '@/lib/project-host-workspace-target' export type ComposerDecisions = { canResolveFolderSmartGitHubSubmit: (input: { hasFolderSourceRepos: boolean }) => boolean @@ -37,15 +38,7 @@ export type ComposerDecisions = { lastAutoName: string lookupTextIsQuery?: boolean }) => boolean - resolveInitialWorkspaceRunSeed: (input: { - draftProjectId?: string | null - draftHostId?: string | null - draftProjectHostSetupId?: string | null - initialTaskSourceContext?: Pick< - TaskSourceContext, - 'projectId' | 'hostId' | 'projectHostSetupId' - > | null - }) => { + resolveInitialWorkspaceRunSeed: (input: InitialWorkspaceRunSeedInput) => { projectId: string | null hostId: ExecutionHostId | null projectHostSetupId: string | null @@ -142,6 +135,7 @@ export type InitialWorkspaceRunSeedInput = { draftProjectId?: string | null draftHostId?: string | null draftProjectHostSetupId?: string | null + initialWorkspaceTarget?: WorkspaceCreationTargetHint | null initialTaskSourceContext?: Pick< TaskSourceContext, 'projectId' | 'hostId' | 'projectHostSetupId' @@ -152,17 +146,19 @@ export function resolveInitialWorkspaceRunSeed({ draftProjectId, draftHostId, draftProjectHostSetupId, + initialWorkspaceTarget, initialTaskSourceContext }: InitialWorkspaceRunSeedInput): { projectId: string | null hostId: ExecutionHostId | null projectHostSetupId: string | null } { + // Why: the linked provider account can belong to a different project or host than creation. + const initialRunTarget = initialWorkspaceTarget ?? initialTaskSourceContext return { - projectId: draftProjectId ?? initialTaskSourceContext?.projectId ?? null, - hostId: normalizeExecutionHostId(draftHostId ?? initialTaskSourceContext?.hostId), - projectHostSetupId: - draftProjectHostSetupId ?? initialTaskSourceContext?.projectHostSetupId ?? null + projectId: draftProjectId ?? initialRunTarget?.projectId ?? null, + hostId: normalizeExecutionHostId(draftHostId ?? initialRunTarget?.hostId), + projectHostSetupId: draftProjectHostSetupId ?? initialRunTarget?.projectHostSetupId ?? null } } diff --git a/src/renderer/src/hooks/composer-state/composer-external-sync.ts b/src/renderer/src/hooks/composer-state/composer-external-sync.ts index 799619e3874..8ab3bd9aa86 100644 --- a/src/renderer/src/hooks/composer-state/composer-external-sync.ts +++ b/src/renderer/src/hooks/composer-state/composer-external-sync.ts @@ -22,6 +22,7 @@ export function useComposerExternalSync(target: ComposerTargetState): ComposerEx repoIdRef: target.runtimeTargetSelection.repoIdRef, runtimeEnvironmentId: target.workspaceIdentityState.runtimeEnvironmentId, selectedRepoConnectionIdRef: target.asyncComposerState.selectedRepoConnectionIdRef, + selectedRepoExecutionHostId: target.runtimeTargetSelection.selectedRepoExecutionHostId, selectedRepoHookContextKey: target.runtimeTargetSelection.selectedRepoHookContextKey, selectedRepoIsGit: target.runtimeTargetSelection.selectedRepoIsGit, selectedRepoSshStatus: target.runtimeTargetSelection.selectedRepoSshStatus, diff --git a/src/renderer/src/hooks/composer-state/composer-review-base-test-fixture.ts b/src/renderer/src/hooks/composer-state/composer-review-base-test-fixture.ts index 662da6b8f2d..dd42f5c0a63 100644 --- a/src/renderer/src/hooks/composer-state/composer-review-base-test-fixture.ts +++ b/src/renderer/src/hooks/composer-state/composer-review-base-test-fixture.ts @@ -149,7 +149,8 @@ export function useReviewBaseSelectionFixture(initialReview?: GitHubWorkItem) { hostOptions: [], projectHostSetupOptions: [], selectedRepoProjectId: null, - setSelectedProjectHostSetupOverrideId: vi.fn() + setSelectedProjectHostSetupOverrideId: vi.fn(), + setSelectedProjectHostSetupOverrideHostId: vi.fn() }) const project = useProjectTargetActions({ ...common, diff --git a/src/renderer/src/hooks/composer-state/composer-source-state.ts b/src/renderer/src/hooks/composer-state/composer-source-state.ts index a24afe1a876..df2e8fc96de 100644 --- a/src/renderer/src/hooks/composer-state/composer-source-state.ts +++ b/src/renderer/src/hooks/composer-state/composer-source-state.ts @@ -78,6 +78,8 @@ export function useComposerSourceState( setReuseSelectedBranch: target.workspaceIdentityState.setReuseSelectedBranch, setSelectedProjectHostSetupOverrideId: target.initialTargetState.setSelectedProjectHostSetupOverrideId, + setSelectedProjectHostSetupOverrideHostId: + target.initialTargetState.setSelectedProjectHostSetupOverrideHostId, setSparseDirectories: target.asyncComposerState.setSparseDirectories, setSparseEnabled: target.asyncComposerState.setSparseEnabled, setSparseSelectedPresetId: target.asyncComposerState.setSparseSelectedPresetId, diff --git a/src/renderer/src/hooks/composer-state/composer-target-input-contracts.ts b/src/renderer/src/hooks/composer-state/composer-target-input-contracts.ts index a874d311a6f..a38c6685bfb 100644 --- a/src/renderer/src/hooks/composer-state/composer-target-input-contracts.ts +++ b/src/renderer/src/hooks/composer-state/composer-target-input-contracts.ts @@ -7,6 +7,7 @@ export type ComposerRuntimeTargetSelectionInput = Pick< | 'eligibleRepos' | 'hostOptions' | 'initialEphemeralVmRecipeId' + | 'pendingInitialWorkspaceTarget' | 'projectGroups' | 'projectHostSetups' | 'projects' @@ -14,6 +15,7 @@ export type ComposerRuntimeTargetSelectionInput = Pick< | 'repos' | 'selectedProjectGroup' | 'selectedProjectHostSetupOverrideId' + | 'selectedProjectHostSetupOverrideHostId' | 'settings' | 'sshConnectionStates' | 'workspaceHostScope' diff --git a/src/renderer/src/hooks/composer-state/composer-target-state.jira-mapping.test.tsx b/src/renderer/src/hooks/composer-state/composer-target-state.jira-mapping.test.tsx new file mode 100644 index 00000000000..c163ae97179 --- /dev/null +++ b/src/renderer/src/hooks/composer-state/composer-target-state.jira-mapping.test.tsx @@ -0,0 +1,319 @@ +// @vitest-environment happy-dom + +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { Repo } from '../../../../shared/repo-types' +import { EMPTY_RETIRED_NAME_REGISTRY } from '../../../../shared/worktree/retired-name-registry' +import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' +import { normalizeTaskSourceContext } from '../../../../shared/task-source-context' +import { + getRepoWorkspaceCreationTargetHint, + type WorkspaceCreationTargetHint +} from '@/lib/project-host-workspace-target' +import * as decisions from './composer-decisions' +import { useComposerTargetState } from './composer-target-state' +import { useComposerExternalSync } from './composer-external-sync' +import { useComposerSourceState } from './composer-source-state' + +const originalState = useAppStore.getState() +let originalApi: PropertyDescriptor | undefined +const currentRepo: Repo = { + id: 'current', + path: '/current', + displayName: 'Current', + kind: 'folder', + badgeColor: '', + addedAt: 0 +} +const jiraSource = normalizeTaskSourceContext({ + provider: 'jira', + projectId: 'repo:current', + hostId: 'local', + providerIdentity: { + provider: 'jira', + siteId: 'site', + siteUrl: 'https://jira.example.invalid', + projectKey: 'APP' + } +}) + +beforeEach(() => { + originalApi = Object.getOwnPropertyDescriptor(window, 'api') + Object.defineProperty(window, 'api', { + configurable: true, + value: { + preflight: { + detectAgents: vi.fn().mockResolvedValue([]) + }, + worktrees: { + listRetiredNames: vi + .fn() + .mockResolvedValue(EMPTY_RETIRED_NAME_REGISTRY) + } + } + }) +}) + +afterEach(() => { + cleanup() + useAppStore.setState(originalState, true) + if (originalApi) { + Object.defineProperty(window, 'api', originalApi) + } else { + Reflect.deleteProperty(window, 'api') + } +}) + +describe('composer mapped Jira creation ownership', () => { + it.each([ + [false, false], + [true, false], + [true, true] + ])( + 'uses the mapped target while preserving Jira linkage (SSH %s, repeated ID %s)', + (useSsh, repeatedId) => { + const mappedRepo: Repo = { + ...currentRepo, + id: repeatedId ? currentRepo.id : 'mapped', + path: '/mapped', + ...(useSsh ? { connectionId: 'builder' } : {}) + } + const repos = [currentRepo, mappedRepo] + const projection = projectHostSetupProjectionFromRepos(repos) + useAppStore.setState({ + settings: null, + repos, + projects: [...projection.projects], + projectHostSetups: [...projection.setups], + projectGroups: [], + newWorkspaceDraft: null, + worktreesByRepo: {}, + sparsePresetsByRepo: {}, + workspaceHostScope: 'all', + sshTargetLabels: new Map([['builder', 'Builder']]), + detectedAgentIds: [], + remoteDetectedAgentIds: { builder: [] } + }) + const { result } = renderHook(() => { + const target = useComposerTargetState( + { + persistDraft: false, + initialRepoId: mappedRepo.id, + initialWorkspaceTarget: getRepoWorkspaceCreationTargetHint( + mappedRepo, + projection.setups + ), + initialTaskSourceContext: jiraSource, + initialLinkedWorkItem: { + provider: 'jira', + type: 'issue', + number: 0, + title: 'APP-1 Fix export', + jiraIdentifier: 'APP-1', + url: 'https://jira.example.invalid/browse/APP-1' + } + }, + decisions + ) + const external = useComposerExternalSync(target) + const source = useComposerSourceState(target, external) + return { ...target, source } + }) + + expect(result.current.initialTargetState.resolvedInitialWorkspaceTarget).toMatchObject({ + status: 'ready', + target: { hostId: useSsh ? 'ssh:builder' : 'local', repo: mappedRepo } + }) + expect(result.current.runtimeTargetSelection.selectedRepo).toBe(mappedRepo) + expect(result.current.runtimeTargetSelection.selectedRepoExecutionHostId).toBe( + useSsh ? 'ssh:builder' : 'local' + ) + expect(result.current.sourceContextState.taskSourceContext).toEqual(jiraSource) + expect(result.current.sourceContextState.linkedWorkItem?.jiraIdentifier).toBe('APP-1') + if (repeatedId) { + act(() => + result.current.source.targetChangeActions.handleProjectHostSetupChange( + JSON.stringify([mappedRepo.id, 'local']) + ) + ) + expect(result.current.runtimeTargetSelection.selectedRepo).toBe(currentRepo) + act(() => + result.current.source.targetChangeActions.handleProjectHostSetupChange( + JSON.stringify([mappedRepo.id, 'ssh:builder']) + ) + ) + expect(result.current.runtimeTargetSelection.selectedRepo).toBe(mappedRepo) + expect(result.current.sourceContextState.taskSourceContext).toEqual(jiraSource) + expect(result.current.sourceContextState.linkedWorkItem?.jiraIdentifier).toBe('APP-1') + } + } + ) +}) + +function renderUnavailableMappedComposer( + options: { + repeatedId?: boolean + configured?: boolean + includeSetupHint?: boolean + emptyRepoOverride?: boolean + missingSshSetup?: boolean + canonicalSameHostSetup?: boolean + } = {} +) { + const mappedRepo: Repo = { + ...currentRepo, + id: options.repeatedId ? currentRepo.id : 'mapped', + path: '/mapped', + connectionId: 'builder', + ...(options.canonicalSameHostSetup ? { upstream: { owner: 'fixture', repo: 'app' } } : {}) + } + const canonicalRepo: Repo = { ...mappedRepo, id: 'canonical', path: '/canonical' } + const repos = options.canonicalSameHostSetup + ? [currentRepo, canonicalRepo, mappedRepo] + : [currentRepo, mappedRepo] + const projection = projectHostSetupProjectionFromRepos(repos) + const initialWorkspaceTarget: WorkspaceCreationTargetHint = { + ...getRepoWorkspaceCreationTargetHint(mappedRepo, projection.setups), + ...(options.includeSetupHint === false ? { projectHostSetupId: null } : {}) + } + useAppStore.setState({ + settings: null, + repos, + projects: [...projection.projects], + projectHostSetups: projection.setups.filter( + (setup) => !options.missingSshSetup || setup.hostId !== 'ssh:builder' + ), + projectGroups: [], + newWorkspaceDraft: null, + worktreesByRepo: {}, + sparsePresetsByRepo: {}, + workspaceHostScope: 'all', + sshTargetLabels: options.configured ? new Map([['builder', 'Builder']]) : new Map(), + detectedAgentIds: [], + remoteDetectedAgentIds: { builder: [] } + }) + const hook = renderHook(() => { + const target = useComposerTargetState( + { + persistDraft: false, + initialRepoId: mappedRepo.id, + initialWorkspaceTarget, + initialTaskSourceContext: jiraSource, + initialLinkedWorkItem: { + provider: 'jira', + type: 'issue', + number: 0, + title: 'APP-1 Fix export', + jiraIdentifier: 'APP-1', + url: 'https://jira.example.invalid/browse/APP-1' + }, + ...(options.emptyRepoOverride ? { repoIdOverride: '' } : {}) + }, + decisions + ) + const external = useComposerExternalSync(target) + const source = useComposerSourceState(target, external) + return { ...target, source } + }) + return { ...hook, mappedRepo, canonicalRepo } +} + +describe('unavailable mapped Jira destinations', () => { + it.each([true, false])( + 'retains the unavailable SSH mapping (setup hint %s)', + (includeSetupHint) => { + const { result } = renderUnavailableMappedComposer({ includeSetupHint }) + + expect(result.current.initialTargetState.resolvedInitialWorkspaceTarget.status).toBe( + 'unavailable' + ) + expect(result.current.runtimeTargetSelection.selectedWorkspaceTarget.status).toBe( + 'unavailable' + ) + expect(result.current.runtimeTargetSelection.selectedRepo).toBeUndefined() + expect(result.current.initialTargetState.repoId).toBe('mapped') + } + ) + + it('does not auto-pick a local repo when an explicit mapping has an empty repo override', () => { + const { result } = renderUnavailableMappedComposer({ emptyRepoOverride: true }) + + expect(result.current.initialTargetState.internalRepoId).toBe('mapped') + expect(result.current.runtimeTargetSelection.selectedWorkspaceTarget.status).toBe('unavailable') + expect(result.current.runtimeTargetSelection.selectedRepo).toBeUndefined() + }) + + it('recovers the intended host when it becomes configured', () => { + const { result, mappedRepo } = renderUnavailableMappedComposer() + act(() => useAppStore.setState({ sshTargetLabels: new Map([['builder', 'Builder']]) })) + + expect(result.current.runtimeTargetSelection.selectedWorkspaceTarget).toMatchObject({ + status: 'ready', + target: { hostId: 'ssh:builder', repo: mappedRepo } + }) + expect(result.current.runtimeTargetSelection.selectedRepo).toBe(mappedRepo) + }) + + it('allows the user to replace an unavailable mapped target with a local project', () => { + const { result } = renderUnavailableMappedComposer() + act(() => result.current.source.projectTargetActions.handleProjectChange('repo:current')) + + expect(result.current.runtimeTargetSelection.selectedRepo).toBe(currentRepo) + expect(result.current.runtimeTargetSelection.selectedWorkspaceTarget).toMatchObject({ + status: 'ready', + target: { hostId: 'local', repo: currentRepo } + }) + }) + + it('withholds a same-ID local repo when the selected SSH setup disappears', () => { + const { result, mappedRepo } = renderUnavailableMappedComposer({ + repeatedId: true, + configured: true + }) + expect(result.current.runtimeTargetSelection.selectedRepo).toBe(mappedRepo) + + act(() => + useAppStore.setState((state) => ({ + projectHostSetups: state.projectHostSetups.filter((setup) => setup.hostId !== 'ssh:builder') + })) + ) + + expect(result.current.runtimeTargetSelection.selectedWorkspaceTarget.status).toBe('unavailable') + expect(result.current.runtimeTargetSelection.selectedRepo).toBeUndefined() + expect(result.current.runtimeTargetSelection.selectedRepoExecutionHostId).toBeNull() + }) + + it('retains a hinted SSH setup missing before initialization despite a same-ID local setup', () => { + const { result } = renderUnavailableMappedComposer({ + repeatedId: true, + configured: true, + missingSshSetup: true + }) + + expect(result.current.runtimeTargetSelection.selectedWorkspaceTarget).toEqual({ + status: 'unavailable', + reason: 'setup-not-found' + }) + expect(result.current.runtimeTargetSelection.selectedRepo).toBeUndefined() + expect(result.current.initialTargetState.repoId).toBe(currentRepo.id) + }) + + it('aligns the repo identity with the canonical same-host setup after recovery', () => { + const { result, canonicalRepo } = renderUnavailableMappedComposer({ + canonicalSameHostSetup: true + }) + expect(result.current.runtimeTargetSelection.selectedWorkspaceTarget.status).toBe('unavailable') + act(() => useAppStore.setState({ sshTargetLabels: new Map([['builder', 'Builder']]) })) + + expect(result.current.runtimeTargetSelection.selectedWorkspaceTarget).toMatchObject({ + status: 'ready', + target: { repo: canonicalRepo, hostId: 'ssh:builder' } + }) + expect(result.current.runtimeTargetSelection.selectedRepo).toBe(canonicalRepo) + expect(result.current.initialTargetState.repoId).toBe(canonicalRepo.id) + expect(result.current.runtimeTargetSelection.repoIdRef.current).toBe(canonicalRepo.id) + expect(result.current.sourceContextState.taskSourceContext).toEqual(jiraSource) + expect(result.current.sourceContextState.linkedWorkItem?.jiraIdentifier).toBe('APP-1') + }) +}) diff --git a/src/renderer/src/hooks/composer-state/composer-target-state.ts b/src/renderer/src/hooks/composer-state/composer-target-state.ts index 1f82e27ddd0..c8599df9a4d 100644 --- a/src/renderer/src/hooks/composer-state/composer-target-state.ts +++ b/src/renderer/src/hooks/composer-state/composer-target-state.ts @@ -16,13 +16,14 @@ export function useComposerTargetState( decisions: ComposerDecisions ): ComposerTargetState { const composerTargetStore = useComposerTargetStore(options, decisions) - const initialTargetState = useComposerInitialTargetState({ + const initialTargetSeedState = useComposerInitialTargetState({ actionableHostIds: composerTargetStore.actionableHostIds, decisions: composerTargetStore.decisions, eligibleRepos: composerTargetStore.eligibleRepos, initialProjectGroupId: composerTargetStore.initialProjectGroupId, initialRepoId: composerTargetStore.initialRepoId, initialTaskSourceContext: composerTargetStore.initialTaskSourceContext, + initialWorkspaceTarget: composerTargetStore.initialWorkspaceTarget, initialWorkspaceStatus: composerTargetStore.initialWorkspaceStatus, newWorkspaceDraft: composerTargetStore.newWorkspaceDraft, persistDraft: composerTargetStore.persistDraft, @@ -40,18 +41,25 @@ export function useComposerTargetState( eligibleRepos: composerTargetStore.eligibleRepos, hostOptions: composerTargetStore.hostOptions, initialEphemeralVmRecipeId: composerTargetStore.initialEphemeralVmRecipeId, + pendingInitialWorkspaceTarget: initialTargetSeedState.pendingInitialWorkspaceTarget, projectGroups: composerTargetStore.projectGroups, projectHostSetups: composerTargetStore.projectHostSetups, projects: composerTargetStore.projects, - repoId: initialTargetState.repoId, + repoId: initialTargetSeedState.repoId, repos: composerTargetStore.repos, - selectedProjectGroup: initialTargetState.selectedProjectGroup, - selectedProjectHostSetupOverrideId: initialTargetState.selectedProjectHostSetupOverrideId, + selectedProjectGroup: initialTargetSeedState.selectedProjectGroup, + selectedProjectHostSetupOverrideId: initialTargetSeedState.selectedProjectHostSetupOverrideId, + selectedProjectHostSetupOverrideHostId: + initialTargetSeedState.selectedProjectHostSetupOverrideHostId, settings: composerTargetStore.settings, sshConnectionStates: composerTargetStore.sshConnectionStates, workspaceHostScope: composerTargetStore.workspaceHostScope, worktreesByRepo: composerTargetStore.worktreesByRepo }) + const initialTargetState = { + ...initialTargetSeedState, + repoId: runtimeTargetSelection.resolvedRepoId + } const sourceContextState = useComposerSourceContextState({ folderSourceRepos: runtimeTargetSelection.folderSourceRepos, decisions: composerTargetStore.decisions, @@ -71,6 +79,7 @@ export function useComposerTargetState( selectedRepo: runtimeTargetSelection.selectedRepo, selectedRepoIsGit: runtimeTargetSelection.selectedRepoIsGit, setInternalRepoId: initialTargetState.setInternalRepoId, + setPendingInitialWorkspaceTarget: initialTargetState.setPendingInitialWorkspaceTarget, selectedWorkspaceTarget: runtimeTargetSelection.selectedWorkspaceTarget }) const workspaceIdentityState = useWorkspaceIdentityState({ @@ -169,6 +178,7 @@ export function useComposerTargetState( note: sourceContextState.note, persistDraft: composerTargetStore.persistDraft, repoId: initialTargetState.repoId, + pendingInitialWorkspaceTarget: initialTargetState.pendingInitialWorkspaceTarget, selectedProjectGroup: initialTargetState.selectedProjectGroup, selectedRepo: runtimeTargetSelection.selectedRepo, selectedRepoIsGit: runtimeTargetSelection.selectedRepoIsGit, diff --git a/src/renderer/src/hooks/composer-state/composer-target-store.ts b/src/renderer/src/hooks/composer-state/composer-target-store.ts index c3495835d72..5219afee063 100644 --- a/src/renderer/src/hooks/composer-state/composer-target-store.ts +++ b/src/renderer/src/hooks/composer-state/composer-target-store.ts @@ -13,6 +13,7 @@ import type { WorkspaceStatus } from '../../../../shared/worktree/types' import type { WorkspaceSource as WorkspaceCreateTelemetrySource } from '../../../../shared/workspace-source' import type { LinkedWorkItemSummary } from '@/lib/new-workspace' import type { ComposerDecisions } from './composer-decisions' +import type { WorkspaceCreationTargetHint } from '@/lib/project-host-workspace-target' export type ComposerStateInput = { initialRepoId?: string @@ -23,6 +24,7 @@ export type ComposerStateInput = { initialLinkedWorkItem?: LinkedWorkItemSummary | null initialGitHubWorkItem?: GitHubWorkItem | null initialTaskSourceContext?: TaskSourceContext | null + initialWorkspaceTarget?: WorkspaceCreationTargetHint | null initialWorkspaceStatus?: WorkspaceStatus initialBaseBranch?: string persistDraft: boolean @@ -45,6 +47,7 @@ export function useComposerTargetStore(options: ComposerStateInput, decisions: C initialLinkedWorkItem = null, initialGitHubWorkItem = null, initialTaskSourceContext = null, + initialWorkspaceTarget = null, initialWorkspaceStatus, initialBaseBranch, persistDraft, @@ -161,6 +164,7 @@ export function useComposerTargetStore(options: ComposerStateInput, decisions: C initialLinkedWorkItem, initialGitHubWorkItem, initialTaskSourceContext, + initialWorkspaceTarget, initialWorkspaceStatus, initialBaseBranch, persistDraft, diff --git a/src/renderer/src/hooks/composer-state/draft-target-sync.test.ts b/src/renderer/src/hooks/composer-state/draft-target-sync.test.ts index 1f74d15e699..858efa77385 100644 --- a/src/renderer/src/hooks/composer-state/draft-target-sync.test.ts +++ b/src/renderer/src/hooks/composer-state/draft-target-sync.test.ts @@ -33,6 +33,7 @@ function createState(overrides: Partial = {}): DraftTarget name: 'workspace-name', note: 'note', persistDraft: true, + pendingInitialWorkspaceTarget: null, repoId: '', selectedProjectGroup: null, selectedRepo: undefined, diff --git a/src/renderer/src/hooks/composer-state/draft-target-sync.ts b/src/renderer/src/hooks/composer-state/draft-target-sync.ts index dcfc90b53bc..51eb6eae301 100644 --- a/src/renderer/src/hooks/composer-state/draft-target-sync.ts +++ b/src/renderer/src/hooks/composer-state/draft-target-sync.ts @@ -20,6 +20,7 @@ export type DraftTargetSyncInput = Pick< | 'name' | 'note' | 'persistDraft' + | 'pendingInitialWorkspaceTarget' | 'repoId' | 'selectedProjectGroup' | 'selectedRepo' @@ -51,6 +52,7 @@ export function useDraftTargetSync(state: DraftTargetSyncInput): void { name, note, persistDraft, + pendingInitialWorkspaceTarget, repoId, selectedProjectGroup, selectedRepo, @@ -127,13 +129,13 @@ export function useDraftTargetSync(state: DraftTargetSyncInput): void { // Auto-pick the first eligible repo if we somehow start with none selected. useEffect(() => { - if (isProjectGroupTarget) { + if (isProjectGroupTarget || pendingInitialWorkspaceTarget) { return } if (!repoId && eligibleRepos[0]?.id) { setRepoId(eligibleRepos[0].id) } - }, [eligibleRepos, isProjectGroupTarget, repoId, setRepoId]) + }, [eligibleRepos, isProjectGroupTarget, pendingInitialWorkspaceTarget, repoId, setRepoId]) useEffect(() => { if (!selectedProjectGroup) { diff --git a/src/renderer/src/hooks/composer-state/host-runtime-effects.ts b/src/renderer/src/hooks/composer-state/host-runtime-effects.ts index 3679e79b982..3d338d1aeb0 100644 --- a/src/renderer/src/hooks/composer-state/host-runtime-effects.ts +++ b/src/renderer/src/hooks/composer-state/host-runtime-effects.ts @@ -17,6 +17,7 @@ type HostRuntimeEffectsInput = Pick< | 'repoIdRef' | 'runtimeEnvironmentId' | 'selectedRepoConnectionIdRef' + | 'selectedRepoExecutionHostId' | 'selectedRepoHookContextKey' | 'selectedRepoIsGit' | 'selectedRepoSshStatus' @@ -37,6 +38,7 @@ import { } from '@/lib/repo-ssh-connection' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' export function useHostRuntimeEffects(input: HostRuntimeEffectsInput) { const { @@ -55,6 +57,7 @@ export function useHostRuntimeEffects(input: HostRuntimeEffectsInput) { repoIdRef, runtimeEnvironmentId, selectedRepoConnectionIdRef, + selectedRepoExecutionHostId, selectedRepoHookContextKey, selectedRepoIsGit, selectedRepoSshStatus, @@ -141,7 +144,12 @@ export function useHostRuntimeEffects(input: HostRuntimeEffectsInput) { return } const liveState = useAppStore.getState() - const liveRepo = liveState.repos.find((repo) => repo.id === repoIdRef.current) + const liveRepo = liveState.repos.find( + (repo) => + repo.id === repoIdRef.current && + getRepoExecutionHostId(repo) === selectedRepoExecutionHostId && + getRepoSshConnection(repo)?.targetId === targetId + ) const connection = liveRepo ? getRepoSshConnection(liveRepo) : null if (!liveRepo || connection?.targetId !== targetId) { return @@ -160,7 +168,7 @@ export function useHostRuntimeEffects(input: HostRuntimeEffectsInput) { : translate('auto.hooks.useComposerState.ba6cb77082', 'Failed to connect to project.') ) } - }, [repoIdRef, selectedRepoConnectionIdRef]) + }, [repoIdRef, selectedRepoConnectionIdRef, selectedRepoExecutionHostId]) const onConnectSelectedProjectGroup = useCallback(async (): Promise => { if (!folderTargetConnectionId) { diff --git a/src/renderer/src/hooks/composer-state/initial-target-model.ts b/src/renderer/src/hooks/composer-state/initial-target-model.ts index 3e9342f28b9..880e583608d 100644 --- a/src/renderer/src/hooks/composer-state/initial-target-model.ts +++ b/src/renderer/src/hooks/composer-state/initial-target-model.ts @@ -16,6 +16,10 @@ export type ComposerInitialTargetModel = { hostId: ExecutionHostId | null projectHostSetupId: string | null } + pendingInitialWorkspaceTarget: ComposerInitialTargetModel['initialRunSeed'] | null + setPendingInitialWorkspaceTarget: React.Dispatch< + React.SetStateAction + > resolvedInitialWorkspaceStatus: WorkspaceStatus | undefined resolvedInitialWorkspaceTarget: WorkspaceCreationTargetResolution resolvedInitialRepoId: string @@ -23,6 +27,10 @@ export type ComposerInitialTargetModel = { setInternalRepoId: React.Dispatch> selectedProjectHostSetupOverrideId: string | null setSelectedProjectHostSetupOverrideId: React.Dispatch> + selectedProjectHostSetupOverrideHostId: ExecutionHostId | null + setSelectedProjectHostSetupOverrideHostId: React.Dispatch< + React.SetStateAction + > initialFolderProjectGroupId: string | null initialFolderProjectGroup: ProjectGroup | null selectedProjectGroupId: string | null diff --git a/src/renderer/src/hooks/composer-state/initial-target-state.jira-mapping.test.tsx b/src/renderer/src/hooks/composer-state/initial-target-state.jira-mapping.test.tsx new file mode 100644 index 00000000000..c942e6f5998 --- /dev/null +++ b/src/renderer/src/hooks/composer-state/initial-target-state.jira-mapping.test.tsx @@ -0,0 +1,108 @@ +// @vitest-environment happy-dom + +import { cleanup, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' +import { resolveWorkspaceCreationTarget } from '@/lib/project-host-workspace-target' +import * as decisions from './composer-decisions' +import { useComposerInitialTargetState } from './initial-target-state' + +const currentRepo: Repo = { + id: 'current', + path: '/current', + displayName: 'Current', + badgeColor: '', + addedAt: 0 +} +const mappedRepo: Repo = { ...currentRepo, id: 'mapped', path: '/mapped' } +const jiraSourceContext = { + kind: 'task-source' as const, + provider: 'jira' as const, + projectId: 'repo:current', + hostId: 'local' as const +} + +function createInput(repos: Repo[]): Parameters[0] { + const projection = projectHostSetupProjectionFromRepos(repos) + return { + actionableHostIds: new Set(['local', 'ssh:builder']), + decisions, + eligibleRepos: repos, + initialProjectGroupId: undefined, + initialRepoId: 'mapped', + initialTaskSourceContext: jiraSourceContext, + initialWorkspaceTarget: null, + initialWorkspaceStatus: undefined, + newWorkspaceDraft: null, + persistDraft: false, + projectGroups: [], + projectHostSetups: projection.setups, + projects: projection.projects, + repoIdOverride: undefined, + seedActiveRepoId: 'current', + workspaceHostScope: 'all', + workspaceStatuses: [] + } +} + +afterEach(cleanup) + +describe('Jira mapped workspace creation target', () => { + it('selects the mapped repository while retaining the unrelated Tasks source context', () => { + const input = createInput([currentRepo, mappedRepo]) + const { result } = renderHook(() => + useComposerInitialTargetState({ + ...input, + initialWorkspaceTarget: { projectId: 'repo:mapped', hostId: 'local' } + }) + ) + + expect(result.current.resolvedInitialWorkspaceTarget).toMatchObject({ + status: 'ready', + target: { repoId: 'mapped', hostId: 'local' } + }) + expect(input.initialTaskSourceContext).toEqual(jiraSourceContext) + }) + + it('uses the mapped SSH execution host while Jira account ownership remains local', () => { + const sshRepo: Repo = { ...mappedRepo, connectionId: 'builder' } + const input = createInput([currentRepo, sshRepo]) + const { result } = renderHook(() => + useComposerInitialTargetState({ + ...input, + initialWorkspaceTarget: { projectId: 'repo:mapped', hostId: 'ssh:builder' } + }) + ) + + expect(result.current.resolvedInitialWorkspaceTarget).toMatchObject({ + status: 'ready', + target: { repoId: 'mapped', hostId: 'ssh:builder', repo: sshRepo } + }) + expect(input.initialTaskSourceContext?.hostId).toBe('local') + }) + + it('preserves a selected execution host when setup IDs repeat across hosts', () => { + const sshRepo: Repo = { ...currentRepo, connectionId: 'builder', path: '/remote/current' } + const input = createInput([currentRepo, sshRepo]) + const { result } = renderHook(() => + useComposerInitialTargetState({ + ...input, + initialWorkspaceTarget: { projectId: 'repo:current', hostId: 'ssh:builder' } + }) + ) + + const runtimeTarget = resolveWorkspaceCreationTarget({ + eligibleRepos: input.eligibleRepos, + projects: input.projects, + projectHostSetups: input.projectHostSetups, + draftRepoId: result.current.repoId, + projectHostSetupId: result.current.selectedProjectHostSetupOverrideId, + projectHostSetupHostId: result.current.selectedProjectHostSetupOverrideHostId + }) + expect(runtimeTarget).toMatchObject({ + status: 'ready', + target: { hostId: 'ssh:builder', repo: sshRepo } + }) + }) +}) diff --git a/src/renderer/src/hooks/composer-state/initial-target-state.ts b/src/renderer/src/hooks/composer-state/initial-target-state.ts index 407bb2cd0df..e0e8a9d41da 100644 --- a/src/renderer/src/hooks/composer-state/initial-target-state.ts +++ b/src/renderer/src/hooks/composer-state/initial-target-state.ts @@ -1,4 +1,5 @@ import type { ComposerModel } from './composer-model' +import type { ComposerInitialTargetModel } from './initial-target-model' type ComposerInitialTargetStateInput = Pick< ComposerModel, @@ -8,6 +9,7 @@ type ComposerInitialTargetStateInput = Pick< | 'initialProjectGroupId' | 'initialRepoId' | 'initialTaskSourceContext' + | 'initialWorkspaceTarget' | 'initialWorkspaceStatus' | 'newWorkspaceDraft' | 'persistDraft' @@ -34,6 +36,7 @@ export function useComposerInitialTargetState(input: ComposerInitialTargetStateI initialProjectGroupId, initialRepoId, initialTaskSourceContext, + initialWorkspaceTarget, initialWorkspaceStatus, newWorkspaceDraft, persistDraft, @@ -63,6 +66,7 @@ export function useComposerInitialTargetState(input: ComposerInitialTargetStateI draftProjectId, draftHostId, draftProjectHostSetupId, + initialWorkspaceTarget, initialTaskSourceContext }) @@ -84,6 +88,7 @@ export function useComposerInitialTargetState(input: ComposerInitialTargetStateI projectId: initialRunSeed.projectId, hostId: initialRunSeed.hostId, projectHostSetupId: initialRunSeed.projectHostSetupId, + projectHostSetupHostId: initialWorkspaceTarget ? initialRunSeed.hostId : null, focusedHostScope: workspaceHostScope, actionableHostIds }) @@ -91,7 +96,17 @@ export function useComposerInitialTargetState(input: ComposerInitialTargetStateI const resolvedInitialRepoId = resolvedInitialWorkspaceTarget.status === 'ready' ? resolvedInitialWorkspaceTarget.target.repoId - : '' + : initialWorkspaceTarget + ? (draftRepoId ?? initialRepoId ?? '') + : '' + + const [pendingInitialWorkspaceTarget, setPendingInitialWorkspaceTarget] = useState< + ComposerInitialTargetModel['initialRunSeed'] | null + >( + initialWorkspaceTarget && resolvedInitialWorkspaceTarget.status === 'unavailable' + ? initialRunSeed + : null + ) const [internalRepoId, setInternalRepoId] = useState(resolvedInitialRepoId) @@ -103,6 +118,13 @@ export function useComposerInitialTargetState(input: ComposerInitialTargetStateI : null ) + const [selectedProjectHostSetupOverrideHostId, setSelectedProjectHostSetupOverrideHostId] = + useState( + resolvedInitialWorkspaceTarget.status === 'ready' + ? resolvedInitialWorkspaceTarget.target.hostId + : null + ) + const initialFolderProjectGroupId = initialProjectGroupId ?? draftProjectGroupId const initialFolderProjectGroup = findActionableFolderProjectGroup({ @@ -163,6 +185,8 @@ export function useComposerInitialTargetState(input: ComposerInitialTargetStateI draftHostId, draftProjectHostSetupId, initialRunSeed, + pendingInitialWorkspaceTarget, + setPendingInitialWorkspaceTarget, resolvedInitialWorkspaceStatus, resolvedInitialWorkspaceTarget, resolvedInitialRepoId, @@ -170,6 +194,8 @@ export function useComposerInitialTargetState(input: ComposerInitialTargetStateI setInternalRepoId, selectedProjectHostSetupOverrideId, setSelectedProjectHostSetupOverrideId, + selectedProjectHostSetupOverrideHostId, + setSelectedProjectHostSetupOverrideHostId, initialFolderProjectGroupId, initialFolderProjectGroup, selectedProjectGroupId, diff --git a/src/renderer/src/hooks/composer-state/jira-mapped-owner-flow.integration.test.tsx b/src/renderer/src/hooks/composer-state/jira-mapped-owner-flow.integration.test.tsx new file mode 100644 index 00000000000..2ab6b76c411 --- /dev/null +++ b/src/renderer/src/hooks/composer-state/jira-mapped-owner-flow.integration.test.tsx @@ -0,0 +1,425 @@ +// @vitest-environment happy-dom + +import { + act, + cleanup, + fireEvent, + render, + renderHook, + screen, + waitFor +} from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { RepositoryJiraProjectSection } from '@/components/settings/RepositoryJiraProjectSection' +import { useWorkItemSourceSearch } from '@/components/new-workspace/use-work-item-source-search' +import { useJiraProjectInventory } from '@/components/use-jira-project-inventory' +import { getRepoWorkspaceCreationTargetHint } from '@/lib/project-host-workspace-target' +import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' +import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' +import type { JiraIssue, JiraProject } from '../../../../shared/jira-types' +import type { Repo } from '../../../../shared/repo-types' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import type { CreateWorktreeResult } from '../../../../shared/worktree/create-types' +import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' +import { EMPTY_RETIRED_NAME_REGISTRY } from '../../../../shared/worktree/retired-name-registry' +import { + RUNTIME_PROTOCOL_VERSION, + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION +} from '../../../../shared/protocol-version' +import * as decisions from './composer-decisions' +import { useComposerTargetState } from './composer-target-state' +import { useComposerExternalSync } from './composer-external-sync' +import { useComposerSourceState } from './composer-source-state' +import { useComposerSubmitOrchestration } from './composer-submit-orchestration' + +const originalState = useAppStore.getState() +let originalApi: PropertyDescriptor | undefined +const alpha = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' } +const beta = { ...alpha, key: 'BET', name: 'Beta', siteId: 'site-b' } +const localRepo: Repo = { + id: 'local-repo', + path: '/local-repo', + displayName: 'Local project', + badgeColor: '', + addedAt: 0, + kind: 'folder' +} +const executionRepo: Repo = { + ...localRepo, + id: 'ssh-repo', + path: '/ssh-repo', + displayName: 'SSH project', + connectionId: 'execution-b' +} +const issue: JiraIssue = { + id: 'issue-a', + key: 'ALP-1', + siteId: alpha.siteId, + title: 'Fix export', + url: 'https://jira-a.example.invalid/browse/ALP-1', + project: alpha, + issueType: { id: 'task', name: 'Task' }, + status: { + id: 'open', + name: 'Open', + categoryKey: 'new', + categoryName: 'To do', + colorName: 'blue-gray' + }, + labels: [], + updatedAt: '2026-01-01T00:00:00Z', + createdAt: '2026-01-01T00:00:00Z' +} +const status = vi.fn() +const projects = vi.fn() +const search = vi.fn() +const update = vi.fn() +const create = vi.fn() +const runtimeCall = vi.fn() + +function seedRepos(repos: readonly Repo[] = [localRepo, executionRepo], configured = true) { + const projection = projectHostSetupProjectionFromRepos(repos) + useAppStore.setState({ + settings: null, + repos, + projects: [...projection.projects], + projectHostSetups: [...projection.setups], + projectGroups: [], + newWorkspaceDraft: null, + worktreesByRepo: {}, + sparsePresetsByRepo: {}, + workspaceHostScope: 'all', + sshTargetLabels: configured ? new Map([['execution-b', 'Execution B']]) : new Map(), + sshConnectionStates: new Map([ + [ + 'execution-b', + { + targetId: 'execution-b', + status: 'connected', + connectionGeneration: 1, + reconnectAttempt: 0, + error: null + } + ] + ]), + detectedAgentIds: [], + remoteDetectedAgentIds: { 'execution-b': [] }, + jiraSearchCache: {}, + jiraConnectionRevisions: {}, + jiraStatus: { connected: false, viewer: null }, + jiraStatusChecked: true, + linearStatus: { connected: false, viewer: null }, + linearStatusChecked: true, + pendingWorktreeCreations: {}, + activePendingCreationId: null, + refreshPreflightStatus: vi.fn(async () => {}), + ensureDetectedAgents: vi.fn(async () => []), + ensureRemoteDetectedAgents: vi.fn(async () => []) + }) + return projection +} + +function MappedRepositorySetting() { + const repo = useAppStore((state) => state.repos.find((row) => row.id === executionRepo.id)) + if (!repo) { + throw new Error('Expected execution repository') + } + return ( + + useAppStore.getState().updateRepo(id, updates, { hostId: 'ssh:execution-b' }) + } + forceVisible + /> + ) +} + +function renderMappedComposer(configured = true) { + const projection = seedRepos(useAppStore.getState().repos, configured) + const mapped = useAppStore.getState().repos.find((repo) => repo.id === executionRepo.id) + if (!mapped) { + throw new Error('Expected mapped repository') + } + return renderHook(() => { + const target = useComposerTargetState( + { + persistDraft: false, + initialRepoId: mapped.id, + initialWorkspaceTarget: getRepoWorkspaceCreationTargetHint(mapped, projection.setups) + }, + decisions + ) + const external = useComposerExternalSync(target) + const source = useComposerSourceState(target, external) + const submit = useComposerSubmitOrchestration(target, external, source) + const lookup = useWorkItemSourceSearch({ + repos: target.composerTargetStore.repos, + repoId: target.initialTargetState.repoId, + selectedRepo: target.runtimeTargetSelection.selectedRepo ?? null, + jiraSourceContext: target.sourceContextState.smartNameJiraSourceContext, + value: 'export', + branchesEnabled: false, + repoBackedSourcesDisabled: true + }) + return { target, source, submit, lookup } + }) +} + +beforeEach(() => { + vi.resetAllMocks() + clearRuntimeCompatibilityCacheForTests() + originalApi = Object.getOwnPropertyDescriptor(window, 'api') + status.mockResolvedValue({ + connected: true, + viewer: { accountId: 'account-a', displayName: 'Jira A', email: null }, + selectedSiteId: 'all', + sites: [ + { + id: alpha.siteId, + siteUrl: 'https://jira-a.example.invalid', + email: '', + displayName: 'Jira A', + accountId: 'account-a' + } + ] + }) + projects.mockResolvedValue([alpha, beta]) + search.mockResolvedValue([issue]) + update.mockImplementation(async ({ repoId, updates }) => { + const repo = useAppStore.getState().repos.find((row) => row.id === repoId) + if (!repo) { + throw new Error('Unknown synthetic execution repository') + } + return { ...repo, defaultJiraProject: updates.defaultJiraProject } + }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { + jira: { + status, + readStatus: status, + listProjects: projects, + searchIssues: search, + cancelSearchIssues: vi + .fn() + .mockResolvedValue(undefined) + }, + repos: { update }, + worktrees: { + create, + listRetiredNames: vi + .fn() + .mockResolvedValue(EMPTY_RETIRED_NAME_REGISTRY) + }, + runtimeEnvironments: { call: runtimeCall } + } + }) + seedRepos() +}) + +afterEach(() => { + cleanup() + clearRuntimeCompatibilityCacheForTests() + useAppStore.setState(originalState, true) + if (originalApi) { + Object.defineProperty(window, 'api', originalApi) + } else { + Reflect.deleteProperty(window, 'api') + } +}) + +describe('mapped Jira owner A to SSH execution owner B', () => { + it('discovers A, verifies B save echo, queries A and starts B with the bound Jira link', async () => { + render() + const picker = screen.getByRole('combobox', { name: 'Default Jira project' }) + await waitFor(() => expect(picker.hasAttribute('disabled')).toBe(false)) + expect(projects).toHaveBeenCalledWith({ siteId: 'all' }) + fireEvent.click(picker) + fireEvent.click(await screen.findByRole('option', { name: 'ALP · Alpha' })) + await waitFor(() => expect(picker.textContent).toContain('ALP · Alpha')) + expect(update).toHaveBeenCalledExactlyOnceWith({ + repoId: executionRepo.id, + hostId: 'ssh:execution-b', + updates: { defaultJiraProject: alpha } + }) + expect(screen.queryByRole('alert')).toBeNull() + expect(useAppStore.getState().repos[0].defaultJiraProject).toBeUndefined() + + const { result } = renderMappedComposer() + await waitFor(() => expect(result.current.lookup.jiraSourceConnected).toBe(true)) + expect(result.current.lookup.jiraConnectionStatus?.viewer?.accountId).toBe('account-a') + expect(result.current.lookup.jiraSourceContext?.hostId).toBe('local') + expect(result.current.target.runtimeTargetSelection.selectedRepoExecutionHostId).toBe( + 'ssh:execution-b' + ) + act(() => result.current.lookup.setMode('jira')) + await waitFor(() => expect(result.current.lookup.jiraIssues).toEqual([issue])) + expect(search).toHaveBeenCalledWith( + expect.objectContaining({ + jql: 'project = "10" AND (text ~ "export*")', + siteId: alpha.siteId, + limit: 12 + }) + ) + const row = result.current.lookup.rows.find((entry) => entry.kind === 'jira') + if (!row || row.kind !== 'jira') { + throw new Error('Expected discovered Jira row') + } + const linked = result.current.lookup.resolveRow(row) + const boundSource = linked?.taskSourceContext + if (!boundSource) { + throw new Error('Expected account-bound Jira row') + } + act(() => + result.current.source.issueSourceActions.handleSmartJiraIssueSelect(row.issue, boundSource) + ) + act(() => result.current.source.projectTargetActions.handleProjectChange('repo:local-repo')) + expect(result.current.target.runtimeTargetSelection.selectedRepo).toBe(localRepo) + act(() => result.current.source.projectTargetActions.handleProjectChange('repo:ssh-repo')) + expect(result.current.target.runtimeTargetSelection.selectedRepo?.connectionId).toBe( + 'execution-b' + ) + expect(result.current.target.sourceContextState.taskSourceContext).toMatchObject({ + hostId: 'local', + providerIdentity: { provider: 'jira', siteId: alpha.siteId, projectKey: alpha.key } + }) + const response = Promise.withResolvers() + create.mockReturnValueOnce(response.promise) + await act(async () => result.current.submit.quickSubmitAction.submitQuick(null)) + await waitFor(() => expect(create).toHaveBeenCalledOnce()) + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + repoId: executionRepo.id, + setupDecision: 'skip', + linkedWorkItem: expect.objectContaining({ provider: 'jira', jiraIdentifier: issue.key }), + linkedTaskSourceContext: boundSource + }) + ) + const pending = Object.values(useAppStore.getState().pendingWorktreeCreations)[0] + expect(pending.request.workspaceRunContext).toMatchObject({ + hostId: 'ssh:execution-b', + repoId: executionRepo.id, + path: executionRepo.path + }) + expect(runtimeCall).not.toHaveBeenCalled() + // Cancel the presentation so this transport test does not seed or activate terminals. + act(() => useAppStore.getState().removePendingWorktreeCreation(pending.creationId)) + await act(async () => + response.resolve({ worktree: makeWorktree({ id: 'created-b', repoId: executionRepo.id }) }) + ) + await waitFor(() => + expect(useAppStore.getState().worktreesByRepo[executionRepo.id][0]).toMatchObject({ + id: 'created-b', + hostId: 'ssh:execution-b' + }) + ) + }) + + it('rejects an ignored save echo before exposing a default to the composer', async () => { + update.mockResolvedValue(executionRepo) + render() + const picker = screen.getByRole('combobox', { name: 'Default Jira project' }) + await waitFor(() => expect(picker.hasAttribute('disabled')).toBe(false)) + fireEvent.click(picker) + fireEvent.click(await screen.findByRole('option', { name: 'ALP · Alpha' })) + await waitFor(() => + expect(screen.getByRole('alert').textContent).toContain('Update this runtime') + ) + const { result } = renderMappedComposer() + expect(result.current.lookup.jiraProject).toBeNull() + expect(create).not.toHaveBeenCalled() + }) + + it('restores B mapping after target changes but refuses an unavailable execution host', async () => { + seedRepos([localRepo, { ...executionRepo, defaultJiraProject: alpha }]) + const { result } = renderMappedComposer() + expect(result.current.lookup.jiraProject).toEqual(alpha) + act(() => result.current.source.projectTargetActions.handleProjectChange('repo:local-repo')) + expect(result.current.lookup.jiraProject).toBeNull() + act(() => result.current.source.projectTargetActions.handleProjectChange('repo:ssh-repo')) + expect(result.current.lookup.jiraProject).toEqual(alpha) + act(() => useAppStore.setState({ sshTargetLabels: new Map() })) + expect(result.current.target.runtimeTargetSelection.selectedWorkspaceTarget.status).toBe( + 'unavailable' + ) + await act(async () => result.current.submit.quickSubmitAction.submitQuick(null)) + expect(create).not.toHaveBeenCalled() + expect(runtimeCall).not.toHaveBeenCalled() + expect(useAppStore.getState().pendingWorktreeCreations).toEqual({}) + }) + + it('drops the first A inventory after account selection changes A → B → A', async () => { + const firstA = Promise.withResolvers() + projects.mockReturnValueOnce(firstA.promise).mockResolvedValueOnce([beta]) + const accountA: RuntimeClientTarget = { kind: 'local' } + const accountB: RuntimeClientTarget = { kind: 'environment', environmentId: 'jira-b' } + runtimeCall.mockImplementation(async ({ method }) => { + if (method === 'status.get') { + return { + id: 'compatibility-b', + ok: true, + _meta: { runtimeId: 'jira-b' }, + result: { + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + } + } + } + if (method === 'jira.status') { + return { + id: 'status-b', + ok: true, + _meta: { runtimeId: 'jira-b' }, + result: { connected: true, viewer: null } + } + } + if (method === 'jira.listProjects') { + return { id: 'inventory-b', ok: true, _meta: { runtimeId: 'jira-b' }, result: [alpha] } + } + throw new Error(`Unexpected synthetic Jira B method: ${method}`) + }) + const initialProps: { source: RuntimeClientTarget } = { source: accountA } + const { result, rerender } = renderHook(({ source }) => useJiraProjectInventory(source, true), { + initialProps + }) + await waitFor(() => expect(projects).toHaveBeenCalledOnce()) + rerender({ source: accountB }) + await waitFor(() => expect(result.current.projects).toEqual([alpha])) + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'jira-b', + method: 'jira.listProjects', + params: { siteId: 'all' } + }) + ) + rerender({ source: accountA }) + await waitFor(() => expect(result.current.projects).toEqual([beta])) + await act(async () => firstA.resolve([alpha])) + expect(result.current.projects).toEqual([beta]) + }) + + it('refuses an unresolved captured execution owner at the store transport boundary', async () => { + const { result } = renderMappedComposer() + const jiraSource = result.current.target.sourceContextState.smartNameJiraSourceContext + if (!jiraSource) { + throw new Error('Expected Jira source') + } + const createWorktree = useAppStore.getState().createWorktree + const args: Parameters = [executionRepo.id, 'export'] + args[25] = { + executionHostId: 'runtime:unresolved-owner', + linkedTaskSourceContext: jiraSource + } + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + await expect(createWorktree(...args)).rejects.toThrow('The workspace host is unresolved') + expect(create).not.toHaveBeenCalled() + expect(runtimeCall).not.toHaveBeenCalled() + expect(useAppStore.getState().worktreesByRepo).toEqual({}) + } finally { + error.mockRestore() + } + }) +}) diff --git a/src/renderer/src/hooks/composer-state/runtime-target-model.ts b/src/renderer/src/hooks/composer-state/runtime-target-model.ts index 5d7ea6afeaa..13fb875223b 100644 --- a/src/renderer/src/hooks/composer-state/runtime-target-model.ts +++ b/src/renderer/src/hooks/composer-state/runtime-target-model.ts @@ -32,6 +32,7 @@ export type ComposerRuntimeTargetModel = { folderDetectedIds: TuiAgent[] | null folderDetectedAgentIds: Set | null selectedWorkspaceTarget: WorkspaceCreationTargetResolution + resolvedRepoId: string selectedRepo: Repo | undefined selectedRepoIsGit: boolean selectedRepoExecutionHostId: ExecutionHostId | null diff --git a/src/renderer/src/hooks/composer-state/runtime-target-selection.ts b/src/renderer/src/hooks/composer-state/runtime-target-selection.ts index 65dbc84bd39..0a2c8f325da 100644 --- a/src/renderer/src/hooks/composer-state/runtime-target-selection.ts +++ b/src/renderer/src/hooks/composer-state/runtime-target-selection.ts @@ -32,13 +32,15 @@ export function useComposerRuntimeTargetSelection(input: ComposerRuntimeTargetSe eligibleRepos, hostOptions, initialEphemeralVmRecipeId, + pendingInitialWorkspaceTarget, projectGroups, projectHostSetups, projects, - repoId, + repoId: requestedRepoId, repos, selectedProjectGroup, selectedProjectHostSetupOverrideId, + selectedProjectHostSetupOverrideHostId, settings, sshConnectionStates, workspaceHostScope, @@ -104,33 +106,50 @@ export function useComposerRuntimeTargetSelection(input: ComposerRuntimeTargetSe eligibleRepos, projects, projectHostSetups, - draftRepoId: repoId, - projectHostSetupId: selectedProjectHostSetupOverrideId, + draftRepoId: requestedRepoId, + projectId: pendingInitialWorkspaceTarget?.projectId, + hostId: pendingInitialWorkspaceTarget?.hostId, + projectHostSetupId: + pendingInitialWorkspaceTarget?.projectHostSetupId ?? selectedProjectHostSetupOverrideId, + projectHostSetupHostId: + pendingInitialWorkspaceTarget?.hostId ?? + (selectedProjectHostSetupOverrideId ? selectedProjectHostSetupOverrideHostId : null), focusedHostScope: workspaceHostScope, actionableHostIds }), [ actionableHostIds, eligibleRepos, + pendingInitialWorkspaceTarget, projectHostSetups, projects, - repoId, + requestedRepoId, selectedProjectHostSetupOverrideId, + selectedProjectHostSetupOverrideHostId, workspaceHostScope ] ) - const selectedRepo = - selectedWorkspaceTarget.status === 'ready' && selectedWorkspaceTarget.target.repoId === repoId + const resolvedRepoId = + !isProjectGroupTarget && + pendingInitialWorkspaceTarget && + selectedWorkspaceTarget.status === 'ready' + ? selectedWorkspaceTarget.target.repoId + : requestedRepoId + + const selectedRepo = isProjectGroupTarget + ? eligibleRepos.find((repo) => repo.id === resolvedRepoId) + : selectedWorkspaceTarget.status === 'ready' && + selectedWorkspaceTarget.target.repoId === resolvedRepoId ? selectedWorkspaceTarget.target.repo - : eligibleRepos.find((repo) => repo.id === repoId) + : undefined const selectedRepoIsGit = selectedRepo ? isGitRepoKind(selectedRepo) : false const selectedRepoExecutionHostId = selectedRepo ? getRepoExecutionHostId(selectedRepo) : null const selectedRepoHookContextKey = selectedRepo - ? JSON.stringify([selectedRepoExecutionHostId ?? 'local', repoId]) + ? JSON.stringify([selectedRepoExecutionHostId ?? 'local', resolvedRepoId]) : null const sshStateByEnvironment = useAppStore((s) => s.sshStateByEnvironment) @@ -266,11 +285,11 @@ export function useComposerRuntimeTargetSelection(input: ComposerRuntimeTargetSe status: selectedRepoSshGateInput?.status ?? null }) - const repoIdRef = useRef(repoId) + const repoIdRef = useRef(resolvedRepoId) useEffect(() => { - repoIdRef.current = repoId - }, [repoId]) + repoIdRef.current = resolvedRepoId + }, [resolvedRepoId]) return { isProjectGroupTarget, @@ -289,6 +308,7 @@ export function useComposerRuntimeTargetSelection(input: ComposerRuntimeTargetSe folderDetectedIds, folderDetectedAgentIds, selectedWorkspaceTarget, + resolvedRepoId, selectedRepo, selectedRepoIsGit, selectedRepoExecutionHostId, diff --git a/src/renderer/src/hooks/composer-state/server-ssh-repo-connect.test.ts b/src/renderer/src/hooks/composer-state/server-ssh-repo-connect.test.ts index 79926ba46d6..1dbef34f53a 100644 --- a/src/renderer/src/hooks/composer-state/server-ssh-repo-connect.test.ts +++ b/src/renderer/src/hooks/composer-state/server-ssh-repo-connect.test.ts @@ -2,6 +2,7 @@ // #25887: a workspace on a paired server's own SSH target must be connected by that server. import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ExecutionHostId } from '../../../../shared/execution-host' const mocks = vi.hoisted(() => { const state: Record = {} @@ -23,7 +24,7 @@ const { useHostRuntimeEffects } = await import('./host-runtime-effects') const sshConnect = vi.fn() -function renderEffects(repoId: string, targetId: string) { +function renderEffects(repoId: string, targetId: string, hostId: ExecutionHostId = 'local') { return renderHook(() => // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the connect path reads only the refs and store set here; the rest are inert stubs. useHostRuntimeEffects({ @@ -42,6 +43,7 @@ function renderEffects(repoId: string, targetId: string) { repoIdRef: { current: repoId }, runtimeEnvironmentId: null, selectedRepoConnectionIdRef: { current: targetId }, + selectedRepoExecutionHostId: hostId, selectedRepoHookContextKey: null, selectedRepoIsGit: false, selectedRepoSshStatus: null, @@ -69,7 +71,7 @@ describe('connecting the SSH target of the selected project', () => { ], sshConnectionStates: new Map() } - const { result } = renderEffects('repo-dike', 'ssh-dike') + const { result } = renderEffects('repo-dike', 'ssh-dike', 'runtime:env-1') await act(() => result.current.onConnectSelectedRepo()) @@ -82,13 +84,45 @@ describe('connecting the SSH target of the selected project', () => { repos: [{ id: 'repo-local', connectionId: 'ssh-mine', executionHostId: 'ssh:ssh-mine' }], sshConnectionStates: new Map() } - const { result } = renderEffects('repo-local', 'ssh-mine') + const { result } = renderEffects('repo-local', 'ssh-mine', 'ssh:ssh-mine') await act(() => result.current.onConnectSelectedRepo()) expect(sshConnect).toHaveBeenCalledWith({ targetId: 'ssh-mine' }) expect(mocks.connectRuntimeEnvironmentSshTarget).not.toHaveBeenCalled() }) + + it('connects the selected SSH repo when a same-ID local repo appears first', async () => { + mocks.state = { + repos: [{ id: 'repeated' }, { id: 'repeated', connectionId: 'builder' }], + sshConnectionStates: new Map() + } + const { result } = renderEffects('repeated', 'builder', 'ssh:builder') + + await act(() => result.current.onConnectSelectedRepo()) + + expect(sshConnect).toHaveBeenCalledWith({ targetId: 'builder' }) + expect(mocks.connectRuntimeEnvironmentSshTarget).not.toHaveBeenCalled() + }) + + it.each(['ssh:builder', 'runtime:env-2'])( + 'does not connect the same target ID on sibling host %s', + async (siblingHost) => { + mocks.state = { + repos: [ + { id: 'repeated', connectionId: 'builder', executionHostId: siblingHost }, + { id: 'repeated', connectionId: 'builder', executionHostId: 'runtime:env-1' } + ], + sshConnectionStates: new Map() + } + const { result } = renderEffects('repeated', 'builder', 'runtime:env-1') + + await act(() => result.current.onConnectSelectedRepo()) + + expect(mocks.connectRuntimeEnvironmentSshTarget).toHaveBeenCalledWith('env-1', 'builder') + expect(sshConnect).not.toHaveBeenCalled() + } + ) }) describe("agents for a project on a paired server's SSH host", () => { diff --git a/src/renderer/src/hooks/composer-state/source-context-state.ts b/src/renderer/src/hooks/composer-state/source-context-state.ts index 0ab4f494422..168e997098e 100644 --- a/src/renderer/src/hooks/composer-state/source-context-state.ts +++ b/src/renderer/src/hooks/composer-state/source-context-state.ts @@ -20,6 +20,7 @@ type ComposerSourceContextStateInput = Pick< | 'selectedRepo' | 'selectedRepoIsGit' | 'setInternalRepoId' + | 'setPendingInitialWorkspaceTarget' | 'selectedWorkspaceTarget' > @@ -57,18 +58,20 @@ export function useComposerSourceContextState(input: ComposerSourceContextStateI selectedRepo, selectedRepoIsGit, setInternalRepoId, + setPendingInitialWorkspaceTarget, selectedWorkspaceTarget } = input const { getMatchingLinkedTaskSourceContext } = decisions const setRepoId = useCallback( (value: string) => { + setPendingInitialWorkspaceTarget(null) if (onRepoIdOverrideChange) { onRepoIdOverrideChange(value) } else { setInternalRepoId(value) } }, - [onRepoIdOverrideChange, setInternalRepoId] + [onRepoIdOverrideChange, setInternalRepoId, setPendingInitialWorkspaceTarget] ) const [name, setName] = useState( diff --git a/src/renderer/src/hooks/composer-state/target-change-actions.ts b/src/renderer/src/hooks/composer-state/target-change-actions.ts index 596ed006ebd..d1c06c7709c 100644 --- a/src/renderer/src/hooks/composer-state/target-change-actions.ts +++ b/src/renderer/src/hooks/composer-state/target-change-actions.ts @@ -28,6 +28,7 @@ type TargetChangeActionsInput = Pick< | 'setReuseEligibleBranch' | 'setReuseSelectedBranch' | 'setSelectedProjectHostSetupOverrideId' + | 'setSelectedProjectHostSetupOverrideHostId' | 'setSparseDirectories' | 'setSparseEnabled' | 'setSparseSelectedPresetId' @@ -43,6 +44,8 @@ import { import { shouldPreserveWorkspaceSourceOnRepoChange } from '../../../../shared/new-workspace/workspace-source' import { buildProjectHostSetupOptions, + getProjectHostSetupSelectionKey, + type ProjectHostSetupOption, type ReadyProjectHostSetupOption } from '@/lib/project-host-setup-options' import { useAppStore } from '@/store' @@ -76,6 +79,7 @@ export function useTargetChangeActions(input: TargetChangeActionsInput) { setReuseEligibleBranch, setReuseSelectedBranch, setSelectedProjectHostSetupOverrideId, + setSelectedProjectHostSetupOverrideHostId, setSparseDirectories, setSparseEnabled, setSparseSelectedPresetId, @@ -93,6 +97,7 @@ export function useTargetChangeActions(input: TargetChangeActionsInput) { if (value === repoId && !options.forceResetStartFrom) { if (!options.preserveStartFrom) { setSelectedProjectHostSetupOverrideId(null) + setSelectedProjectHostSetupOverrideHostId(null) } setRepoId(value) return @@ -116,6 +121,7 @@ export function useTargetChangeActions(input: TargetChangeActionsInput) { setRepoId(value) if (!options.preserveStartFrom) { setSelectedProjectHostSetupOverrideId(null) + setSelectedProjectHostSetupOverrideHostId(null) } // Why: only a GitHub PR re-resolves on the new host; other review bases are kept as before. if (options.preserveStartFrom && smartSourceSelectionRef.current?.kind === 'github-pr') { @@ -188,6 +194,7 @@ export function useTargetChangeActions(input: TargetChangeActionsInput) { setReuseEligibleBranch, setReuseSelectedBranch, setSelectedProjectHostSetupOverrideId, + setSelectedProjectHostSetupOverrideHostId, setSparseDirectories, setSparseEnabled, setSparseSelectedPresetId, @@ -230,7 +237,11 @@ export function useTargetChangeActions(input: TargetChangeActionsInput) { const handleProjectHostSetupChange = useCallback( (setupId: string): void => { - const option = projectHostSetupOptions.find((candidate) => candidate.id === setupId) + const matchesSelection = ( + candidate: Pick + ): boolean => + candidate.id === setupId || getProjectHostSetupSelectionKey(candidate) === setupId + const option = projectHostSetupOptions.find(matchesSelection) // Why: a just-created setup lands in the store before the memoized picker // options refresh. Rebuild through the same builder rather than reading the // raw record — repo eligibility, ephemeral-VM/runtime-owned host exclusion, @@ -246,13 +257,14 @@ export function useTargetChangeActions(input: TargetChangeActionsInput) { hosts: hostOptions }).find( (candidate): candidate is ReadyProjectHostSetupOption => - candidate.id === setupId && candidate.kind === 'ready' + matchesSelection(candidate) && candidate.kind === 'ready' ) if (!target) { return } // Why: switching run host for the same project must not erase the task/PR source the user is starting from. setSelectedProjectHostSetupOverrideId(target.id) + setSelectedProjectHostSetupOverrideHostId(target.hostId) handleRepoChange(target.repoId, { preserveStartFrom: true, forceResetStartFrom: true @@ -263,7 +275,8 @@ export function useTargetChangeActions(input: TargetChangeActionsInput) { hostOptions, projectHostSetupOptions, selectedRepoProjectId, - setSelectedProjectHostSetupOverrideId + setSelectedProjectHostSetupOverrideId, + setSelectedProjectHostSetupOverrideHostId ] ) diff --git a/src/renderer/src/hooks/composer-state/target-store-model.ts b/src/renderer/src/hooks/composer-state/target-store-model.ts index 431e33c1ca1..17f96ab69e6 100644 --- a/src/renderer/src/hooks/composer-state/target-store-model.ts +++ b/src/renderer/src/hooks/composer-state/target-store-model.ts @@ -21,6 +21,7 @@ import type { RuntimeEnvironmentStatus } from '../../store/slices/runtime-status import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { WorkspaceHostScope } from '../../../../shared/ui-chrome-types' import type { WorkspaceSource as WorkspaceCreateTelemetrySource } from '../../../../shared/workspace-source' +import type { WorkspaceCreationTargetHint } from '@/lib/project-host-workspace-target' export type ComposerTargetStoreModel = { initialRepoId: string | undefined @@ -30,6 +31,7 @@ export type ComposerTargetStoreModel = { initialLinkedWorkItem: LinkedWorkItemSummary | null initialGitHubWorkItem: GitHubWorkItem | null initialTaskSourceContext: TaskSourceContext | null + initialWorkspaceTarget: WorkspaceCreationTargetHint | null initialWorkspaceStatus: WorkspaceStatus | undefined initialBaseBranch: string | undefined persistDraft: boolean diff --git a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts index 50b3af3c0d1..dcadae5cd3c 100644 --- a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts +++ b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts @@ -177,6 +177,30 @@ describe('useComposerState host-context boundaries', () => { }) }) + it('uses an explicit creation target without borrowing the linked Jira account host', () => { + expect( + resolveInitialWorkspaceRunSeed({ + initialTaskSourceContext: { + projectId: 'account-backed-task-source', + hostId: 'local', + projectHostSetupId: 'unrelated-setup' + }, + initialWorkspaceTarget: { projectId: 'mapped-project', hostId: 'ssh:builder' } + }) + ).toEqual({ projectId: 'mapped-project', hostId: 'ssh:builder', projectHostSetupId: null }) + }) + + it('keeps a restored draft target ahead of an explicit creation target', () => { + expect( + resolveInitialWorkspaceRunSeed({ + draftProjectId: 'draft-project', + draftHostId: 'local', + draftProjectHostSetupId: 'draft-setup', + initialWorkspaceTarget: { projectId: 'mapped-project', hostId: 'ssh:builder' } + }) + ).toEqual({ projectId: 'draft-project', hostId: 'local', projectHostSetupId: 'draft-setup' }) + }) + it('restores Jira draft context only when its site and issue identity agree', () => { const item = { provider: 'jira' as const, diff --git a/src/renderer/src/hooks/useComposerState.integration.test.ts b/src/renderer/src/hooks/useComposerState.integration.test.ts index 6b1103c67a0..0c4b738953e 100644 --- a/src/renderer/src/hooks/useComposerState.integration.test.ts +++ b/src/renderer/src/hooks/useComposerState.integration.test.ts @@ -3,6 +3,10 @@ import { act, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' +import { EMPTY_RETIRED_NAME_REGISTRY } from '../../../shared/worktree/retired-name-registry' +import type { Repo } from '../../../shared/repo-types' +import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' +import { normalizeTaskSourceContext } from '../../../shared/task-source-context' import { useComposerState } from './useComposerState' import * as publicComposerDecisions from './useComposerState' import * as composerDecisions from './composer-state/composer-decisions' @@ -14,9 +18,14 @@ beforeEach(() => { const preflight = { detectAgents: vi.fn().mockResolvedValue([]) } satisfies Pick + const worktrees = { + listRetiredNames: vi + .fn() + .mockResolvedValue(EMPTY_RETIRED_NAME_REGISTRY) + } satisfies Pick Object.defineProperty(window, 'api', { configurable: true, - value: { preflight } + value: { preflight, worktrees } }) }) @@ -57,6 +66,63 @@ describe('useComposerState integrated lifecycle', () => { ) }) + it('passes the selected runtime repository to the name field when IDs repeat across hosts', () => { + const localRepo: Repo = { + id: 'same-id', + path: '/local', + displayName: 'Local', + badgeColor: '', + addedAt: 0, + defaultJiraProject: { id: '10', key: 'LOC', name: 'Local', siteId: 'site-a' } + } + const runtimeRepo: Repo = { + ...localRepo, + path: '/remote', + executionHostId: 'runtime:remote', + defaultJiraProject: { id: '20', key: 'REM', name: 'Remote', siteId: 'site-b' } + } + const projection = projectHostSetupProjectionFromRepos([localRepo, runtimeRepo]) + useAppStore.setState({ + repos: [localRepo, runtimeRepo], + projects: [...projection.projects], + projectHostSetups: projection.setups.map((setup) => ({ + ...setup, + id: `${setup.hostId}:setup` + })), + projectGroups: [], + newWorkspaceDraft: null, + worktreesByRepo: {}, + sparsePresetsByRepo: {}, + runtimeEnvironments: [ + { + id: 'remote', + name: 'Remote', + createdAt: 0, + updatedAt: 0, + lastUsedAt: null, + runtimeId: null, + endpoints: [], + preferredEndpointId: 'endpoint' + } + ] + }) + const composer = renderHook(() => + useComposerState({ + persistDraft: false, + initialTaskSourceContext: normalizeTaskSourceContext({ + provider: 'jira', + projectId: 'repo:same-id', + hostId: 'runtime:remote' + }) + }) + ) + expect(composer.result.current.cardProps.selectedRepo).toBe(runtimeRepo) + expect(composer.result.current.cardProps.smartNameJiraSourceContext?.hostId).toBe( + 'runtime:remote' + ) + composer.unmount() + }) + it('composes two live composers and exposes the parent-worktree control state', () => { useAppStore.setState({ repos: [], diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 5461b4c21a0..eb6762580d3 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -7,6 +7,7 @@ import type { QuickSubmitOptions } from './composer-state/quick-creation-request import type { TaskSourceContext } from '../../../shared/task-source-context' import type { WorkspaceSource as WorkspaceCreateTelemetrySource } from '../../../shared/workspace-source' import type { WorkspaceStatus } from '../../../shared/worktree/types' +import type { WorkspaceCreationTargetHint } from '@/lib/project-host-workspace-target' import { buildComposerCardProps } from './composer-state/composer-card-props' import { canResolveFolderSmartGitHubSubmit, @@ -50,6 +51,7 @@ export type UseComposerStateOptions = { initialLinkedWorkItem?: LinkedWorkItemSummary | null initialGitHubWorkItem?: GitHubWorkItem | null initialTaskSourceContext?: TaskSourceContext | null + initialWorkspaceTarget?: WorkspaceCreationTargetHint | null initialWorkspaceStatus?: WorkspaceStatus initialBaseBranch?: string persistDraft: boolean diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index b1049f8f7a9..64f4492b979 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -19303,6 +19303,23 @@ }, "JiraIssueWorkspace": { "pendingChange": "Another issue change is in progress." + }, + "jiraProjectPicker": { + "search": "Search Jira projects", + "empty": "No projects found.", + "label": "Jira project", + "all": "All Jira projects" + }, + "RepositoryJiraProjectSection": { + "saveFailed": "Could not save the default Jira project.", + "runtimeUpdate": "Update this runtime to save a default Jira project.", + "title": "Default Jira project", + "description": "Default the Jira issue picker and Tasks list to this project. You can change it for each worktree.", + "none": "No default project", + "connect": "Connect Jira in Integrations to choose a project.", + "keywordJira": "jira", + "keywordDefaultProject": "default project", + "keywordIssuePicker": "issue picker" } }, "dashboardPopout": { @@ -20094,7 +20111,7 @@ "chooseTransition": "Choose a status change:", "cancel": "Cancel", "noTransition": "No status change to {{column}} is available for {{key}}.", - "queryTooLong": "The Jira query is too long with Current sprint selected. Shorten the query or turn off Current sprint." + "queryTooLong": "The Jira query is too long. Shorten the query or clear filters." } } } diff --git a/src/renderer/src/lib/jira-source-host.ts b/src/renderer/src/lib/jira-source-host.ts index a52e5c2e2c1..bfe29266def 100644 --- a/src/renderer/src/lib/jira-source-host.ts +++ b/src/renderer/src/lib/jira-source-host.ts @@ -1,19 +1 @@ -import { - getConnectionExecutionHostId, - LOCAL_EXECUTION_HOST_ID, - normalizeExecutionHostId, - parseExecutionHostId, - type ExecutionHostId -} from '../../../shared/execution-host' - -export function resolveJiraSourceHostId(args: { - workspaceHostId?: string | null - groupExecutionHostId?: string | null - groupConnectionId?: string | null -}): ExecutionHostId { - const groupHostId = - normalizeExecutionHostId(args.groupExecutionHostId) ?? - getConnectionExecutionHostId(args.groupConnectionId) - const hostId = normalizeExecutionHostId(args.workspaceHostId) ?? groupHostId - return parseExecutionHostId(hostId)?.kind === 'ssh' ? LOCAL_EXECUTION_HOST_ID : hostId -} +export { resolveJiraSourceHostId } from '../../../shared/jira-source-host' diff --git a/src/renderer/src/lib/project-host-setup-options.ts b/src/renderer/src/lib/project-host-setup-options.ts index 8776a50c935..a8aa1158ee6 100644 --- a/src/renderer/src/lib/project-host-setup-options.ts +++ b/src/renderer/src/lib/project-host-setup-options.ts @@ -49,6 +49,12 @@ export type ReadyProjectHostSetupOption = Extract +export function getProjectHostSetupSelectionKey( + option: Pick +): string { + return JSON.stringify([option.id, option.hostId]) +} + type BuildReadySetupOptionsInput = { projectId: string projectHostSetups: readonly ProjectHostSetup[] diff --git a/src/renderer/src/lib/project-host-workspace-target.jira-mapping.test.ts b/src/renderer/src/lib/project-host-workspace-target.jira-mapping.test.ts new file mode 100644 index 00000000000..63eddff6d45 --- /dev/null +++ b/src/renderer/src/lib/project-host-workspace-target.jira-mapping.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' +import type { Repo } from '../../../shared/repo-types' +import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' +import { + getRepoWorkspaceCreationTargetHint, + resolveWorkspaceCreationTarget +} from './project-host-workspace-target' + +const localRepo: Repo = { + id: 'same-id', + path: '/local', + displayName: 'Local', + badgeColor: '', + addedAt: 0 +} +const sshRepo: Repo = { ...localRepo, connectionId: 'builder', path: '/remote' } + +describe('mapped repository creation target hints', () => { + it('uses the execution host and matching catalog project when repository IDs repeat', () => { + const projection = projectHostSetupProjectionFromRepos([localRepo, sshRepo]) + const setups = projection.setups.map((setup) => ({ + ...setup, + projectId: setup.hostId === 'local' ? 'local-project' : 'ssh-project' + })) + + expect(getRepoWorkspaceCreationTargetHint(sshRepo, setups)).toEqual({ + projectId: 'ssh-project', + hostId: 'ssh:builder', + projectHostSetupId: 'same-id' + }) + }) + + it('supports repositories whose catalog still comes from the legacy projection', () => { + expect(getRepoWorkspaceCreationTargetHint(sshRepo, [])).toEqual({ + projectId: 'repo:same-id', + hostId: 'ssh:builder' + }) + }) + + it('retains unique setup precedence when a linked Jira account is on another host', () => { + const projection = projectHostSetupProjectionFromRepos([sshRepo]) + expect( + resolveWorkspaceCreationTarget({ + eligibleRepos: [sshRepo], + projects: projection.projects, + projectHostSetups: projection.setups, + projectHostSetupId: 'same-id', + hostId: 'local' + }) + ).toMatchObject({ status: 'ready', target: { hostId: 'ssh:builder', repo: sshRepo } }) + }) + + it('does not select another host when an ambiguous setup lacks the requested host', () => { + const projection = projectHostSetupProjectionFromRepos([localRepo, sshRepo]) + expect( + resolveWorkspaceCreationTarget({ + eligibleRepos: [localRepo, sshRepo], + projects: projection.projects, + projectHostSetups: projection.setups, + projectHostSetupId: 'same-id', + hostId: 'ssh:missing' + }) + ).toEqual({ status: 'unavailable', reason: 'setup-not-found' }) + }) + + it('keeps an explicit host selection unavailable when only a same-ID setup on another host remains', () => { + const projection = projectHostSetupProjectionFromRepos([localRepo]) + expect( + resolveWorkspaceCreationTarget({ + eligibleRepos: [localRepo], + projects: projection.projects, + projectHostSetups: projection.setups, + projectHostSetupId: 'same-id', + projectHostSetupHostId: 'ssh:builder' + }) + ).toEqual({ status: 'unavailable', reason: 'setup-not-found' }) + }) +}) diff --git a/src/renderer/src/lib/project-host-workspace-target.ts b/src/renderer/src/lib/project-host-workspace-target.ts index 37e0ddf4217..75233a91bdc 100644 --- a/src/renderer/src/lib/project-host-workspace-target.ts +++ b/src/renderer/src/lib/project-host-workspace-target.ts @@ -4,7 +4,10 @@ import { type ExecutionHostId, type ExecutionHostScope } from '../../../shared/execution-host' -import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' +import { + getProjectIdentityKey, + projectHostSetupProjectionFromRepos +} from '../../../shared/project-host-setup-projection' import type { Project, ProjectHostSetup } from '../../../shared/project-types' import type { Repo } from '../../../shared/repo-types' import { resolveComposerRepoId } from './new-workspace-composer-repo' @@ -18,6 +21,25 @@ export type WorkspaceCreationTarget = { setup: ProjectHostSetup } +export type WorkspaceCreationTargetHint = Pick & { + projectHostSetupId?: string | null +} + +export function getRepoWorkspaceCreationTargetHint( + repo: Repo, + projectHostSetups: readonly ProjectHostSetup[] +): WorkspaceCreationTargetHint { + const hostId = getRepoExecutionHostId(repo) + const setup = projectHostSetups.find( + (candidate) => candidate.repoId === repo.id && candidate.hostId === hostId + ) + return { + projectId: setup?.projectId ?? getProjectIdentityKey(repo), + hostId, + ...(setup ? { projectHostSetupId: setup.id } : {}) + } +} + export type WorkspaceCreationTargetResolution = | { status: 'ready'; target: WorkspaceCreationTarget } | { @@ -41,6 +63,7 @@ type ProjectHostWorkspaceTargetInput = { projectId?: string | null hostId?: ExecutionHostId | null projectHostSetupId?: string | null + projectHostSetupHostId?: ExecutionHostId | null focusedHostScope?: ExecutionHostScope | null actionableHostIds?: ReadonlySet } @@ -138,7 +161,9 @@ export function resolveWorkspaceCreationTarget( : allSetups if (projectHostSetupId) { - const setup = allSetups.find((entry) => entry.id === projectHostSetupId) + const matchingSetups = allSetups.filter((entry) => entry.id === projectHostSetupId) + const setupHostId = input.projectHostSetupHostId ?? (matchingSetups.length > 1 ? hostId : null) + const setup = matchingSetups.find((entry) => !setupHostId || entry.hostId === setupHostId) if (!setup) { return { status: 'unavailable', reason: 'setup-not-found' } } diff --git a/src/renderer/src/runtime/runtime-jira-client.ts b/src/renderer/src/runtime/runtime-jira-client.ts index 02c1ae8eefb..d2768737db2 100644 --- a/src/renderer/src/runtime/runtime-jira-client.ts +++ b/src/renderer/src/runtime/runtime-jira-client.ts @@ -27,6 +27,8 @@ import { clearJiraImageCache } from './runtime-jira-image-cache' import { isRuntimeProviderSearchQueryWithinLimit } from './runtime-provider-search-bounds' import { getJiraRuntimeTarget, type RuntimeJiraSettings } from './runtime-jira-target' import { parseJiraConnectionStatus } from './runtime-jira-connection-status' +import { jiraListProjectsForTarget, jiraStatusForTarget } from './runtime-jira-project-client' + import { jiraGetIssueForTarget, jiraUpdateIssueForTarget, @@ -52,6 +54,7 @@ export { jiraListAssignableUsersForProject, jiraSearchUsers } from './runtime-jira-user-fields-client' +export { jiraStatusForTarget } from './runtime-jira-project-client' export type { RuntimeJiraSettings } from './runtime-jira-target' export type JiraConnectResult = { ok: true; viewer: JiraViewer } | { ok: false; error: string } @@ -62,16 +65,6 @@ export async function jiraStatus(settings: RuntimeJiraSettings): Promise { - return parseJiraConnectionStatus( - target.kind === 'environment' - ? await callRuntimeRpc(target, 'jira.status', undefined, { timeoutMs: 15_000 }) - : await window.api.jira.status() - ) -} - export async function jiraConnect( settings: RuntimeJiraSettings, args: { siteUrl: string; email: string; apiToken: string; authType?: JiraAuthType } @@ -231,12 +224,7 @@ export async function jiraListProjects( settings: RuntimeJiraSettings, siteId?: JiraSiteSelection | null ): Promise { - const target = getJiraRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc(target, 'jira.listProjects', siteId ? { siteId } : undefined, { - timeoutMs: 30_000 - }) - : window.api.jira.listProjects(siteId ? { siteId } : undefined) + return jiraListProjectsForTarget(getJiraRuntimeTarget(settings), siteId) } export async function jiraListIssueTypes( diff --git a/src/renderer/src/runtime/runtime-jira-project-client.test.ts b/src/renderer/src/runtime/runtime-jira-project-client.test.ts new file mode 100644 index 00000000000..89f2289dce8 --- /dev/null +++ b/src/renderer/src/runtime/runtime-jira-project-client.test.ts @@ -0,0 +1,68 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { jiraListProjectsForTarget, jiraStatusForTarget } from './runtime-jira-project-client' + +const { callRuntimeRpc, rowLessSourceTarget } = vi.hoisted(() => ({ + callRuntimeRpc: vi.fn(), + rowLessSourceTarget: vi.fn(() => { + throw new Error('Project inventory must not resolve the default host') + }) +})) +vi.mock('./runtime-rpc-client', () => ({ callRuntimeRpc })) +vi.mock('@/lib/default-creation-host', () => ({ rowLessSourceTarget })) + +const status = vi.fn() +const listProjects = vi.fn() +const project = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site' } + +beforeEach(() => { + vi.clearAllMocks() + status.mockResolvedValue({ connected: true, viewer: null }) + listProjects.mockResolvedValue([project]) + vi.stubGlobal('window', { api: { jira: { status, listProjects } } }) +}) +afterEach(() => vi.unstubAllGlobals()) + +describe('Jira project inventory target transport', () => { + it('reads local status and every site through IPC without a default-host lookup', async () => { + await expect(jiraStatusForTarget({ kind: 'local' })).resolves.toMatchObject({ connected: true }) + await expect(jiraListProjectsForTarget({ kind: 'local' }, 'all')).resolves.toEqual([project]) + expect(status).toHaveBeenCalledExactlyOnceWith() + expect(listProjects).toHaveBeenCalledExactlyOnceWith({ siteId: 'all' }) + expect(callRuntimeRpc).not.toHaveBeenCalled() + expect(rowLessSourceTarget).not.toHaveBeenCalled() + }) + + it('sends status and project reads to the explicit paired owner', async () => { + const target = { kind: 'environment', environmentId: 'owner' } as const + callRuntimeRpc.mockImplementation(async (_target, method) => + method === 'jira.status' ? { connected: true, viewer: null } : [project] + ) + await expect(jiraStatusForTarget(target)).resolves.toMatchObject({ connected: true }) + await expect(jiraListProjectsForTarget(target, 'all')).resolves.toEqual([project]) + expect(callRuntimeRpc).toHaveBeenNthCalledWith(1, target, 'jira.status', undefined, { + timeoutMs: 15_000 + }) + expect(callRuntimeRpc).toHaveBeenNthCalledWith( + 2, + target, + 'jira.listProjects', + { siteId: 'all' }, + { timeoutMs: 30_000 } + ) + expect(status).not.toHaveBeenCalled() + expect(listProjects).not.toHaveBeenCalled() + expect(rowLessSourceTarget).not.toHaveBeenCalled() + }) + + it('keeps the optional project selector absent and normalizes a malformed status', async () => { + status.mockResolvedValue(undefined) + await expect(jiraStatusForTarget({ kind: 'local' })).resolves.toEqual({ + connected: false, + viewer: null + }) + await jiraListProjectsForTarget({ kind: 'local' }) + expect(listProjects).toHaveBeenCalledExactlyOnceWith(undefined) + }) +}) diff --git a/src/renderer/src/runtime/runtime-jira-project-client.ts b/src/renderer/src/runtime/runtime-jira-project-client.ts new file mode 100644 index 00000000000..d024cdd8295 --- /dev/null +++ b/src/renderer/src/runtime/runtime-jira-project-client.ts @@ -0,0 +1,29 @@ +import type { + JiraConnectionStatus, + JiraProject, + JiraSiteSelection +} from '../../../shared/jira-types' +import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import { callRuntimeRpc } from './runtime-rpc-client' +import { parseJiraConnectionStatus } from './runtime-jira-connection-status' + +export async function jiraStatusForTarget( + target: RuntimeClientTarget +): Promise { + return parseJiraConnectionStatus( + target.kind === 'environment' + ? await callRuntimeRpc(target, 'jira.status', undefined, { timeoutMs: 15_000 }) + : await window.api.jira.status() + ) +} + +export async function jiraListProjectsForTarget( + target: RuntimeClientTarget, + siteId?: JiraSiteSelection | null +): Promise { + return target.kind === 'environment' + ? callRuntimeRpc(target, 'jira.listProjects', siteId ? { siteId } : undefined, { + timeoutMs: 30_000 + }) + : window.api.jira.listProjects(siteId ? { siteId } : undefined) +} diff --git a/src/renderer/src/store/repos/repo-state.ts b/src/renderer/src/store/repos/repo-state.ts index 2809893b889..3e59e7c0cc8 100644 --- a/src/renderer/src/store/repos/repo-state.ts +++ b/src/renderer/src/store/repos/repo-state.ts @@ -43,6 +43,7 @@ export type RepoUpdate = Partial< | 'kind' | 'symlinkPaths' | 'issueSourcePreference' + | 'defaultJiraProject' | 'forkSyncMode' | 'externalWorktreeVisibilityPromptDismissedAt' | 'externalWorktreeInboxBaselinePaths' diff --git a/src/renderer/src/store/repos/repo-update.ts b/src/renderer/src/store/repos/repo-update.ts index 7b3fa975697..8eae71827fb 100644 --- a/src/renderer/src/store/repos/repo-update.ts +++ b/src/renderer/src/store/repos/repo-update.ts @@ -4,6 +4,7 @@ import type { Repo } from '../../../../shared/repo-types' import { sanitizeRepoIcon } from '../../../../shared/repo-icon' import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color' import { normalizeGhAccountBinding } from '../../../../shared/github/account-binding' +import { normalizeJiraProjectReference } from '../../../../shared/jira-project-reference' import { findRepoForHost, getRepoHostIdentityForParts, @@ -22,6 +23,14 @@ import { mergeProjectCompatibilityForHostRepoChange } from './repo-catalog-ident export function sanitizeRepoUpdate(updates: RepoUpdate): RepoUpdate { const sanitized = { ...updates } + if ('defaultJiraProject' in sanitized) { + const reference = normalizeJiraProjectReference(sanitized.defaultJiraProject) + if (reference === undefined) { + delete sanitized.defaultJiraProject + } else { + sanitized.defaultJiraProject = reference + } + } if ('badgeColor' in sanitized) { const badgeColor = normalizeRepoBadgeColor(sanitized.badgeColor) if (!badgeColor) { diff --git a/src/shared/jira-project-reference.test.ts b/src/shared/jira-project-reference.test.ts new file mode 100644 index 00000000000..aa07f8c6943 --- /dev/null +++ b/src/shared/jira-project-reference.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import type { Repo } from './repo-types' +import type { JiraIssue } from './jira-types' +import { + findJiraIssueDefaultRepo, + normalizeJiraProjectReference, + restrictJiraQueryToProject +} from './jira-project-reference' +import { createRepoUpdateSchema } from './rpc-contract/repo-update-params' +import { z } from 'zod' + +const project = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' } +const issue: JiraIssue = { + id: 'issue', + key: 'ALP-1', + title: 'Fix', + siteId: 'site-a', + url: '', + project, + issueType: { id: '1', name: 'Bug' }, + status: { id: '1', name: 'Open', categoryKey: 'new', categoryName: 'To Do' }, + labels: [], + updatedAt: '', + createdAt: '' +} +const repo: Repo = { + id: 'repo', + path: '/repo', + displayName: 'Alpha', + badgeColor: '', + addedAt: 0, + defaultJiraProject: project +} + +describe('Jira project mappings', () => { + it('normalizes persisted references and drops malformed shapes', () => { + expect( + normalizeJiraProjectReference({ ...project, id: ' 10 ', token: 'must not persist' }) + ).toEqual(project) + expect(normalizeJiraProjectReference({ ...project, siteId: '' })).toBeUndefined() + expect(normalizeJiraProjectReference({ id: '10' })).toBeUndefined() + expect(normalizeJiraProjectReference(null)).toBeNull() + }) + + it('validates the optional field for native and remote repository updates', () => { + const schema = createRepoUpdateSchema({ repo: z.string() }) + expect( + schema.parse({ repo: 'repo', updates: { defaultJiraProject: project } }).updates + .defaultJiraProject + ).toEqual(project) + expect( + schema.parse({ repo: 'repo', updates: { defaultJiraProject: null } }).updates + .defaultJiraProject + ).toBeNull() + expect( + schema.parse({ repo: 'repo', updates: { defaultJiraProject: { id: 'bad' } } }).updates + .defaultJiraProject + ).toBeUndefined() + }) + + it('chooses only a unique repository with the matching project, account, and host', () => { + expect(findJiraIssueDefaultRepo([repo], issue, 'local')).toBe(repo) + expect(findJiraIssueDefaultRepo([{ ...repo, kind: 'folder' }], issue, 'local')?.id).toBe('repo') + expect( + findJiraIssueDefaultRepo([repo, { ...repo, id: 'ambiguous' }], issue, 'local') + ).toBeNull() + expect(findJiraIssueDefaultRepo([repo], { ...issue, siteId: 'site-b' }, 'local')).toBeNull() + expect( + findJiraIssueDefaultRepo([{ ...repo, executionHostId: 'runtime:remote' }], issue, 'local') + ).toBeNull() + expect( + findJiraIssueDefaultRepo( + [{ ...repo, executionHostId: 'runtime:remote' }], + issue, + 'runtime:remote' + )?.id + ).toBe('repo') + expect( + findJiraIssueDefaultRepo([{ ...repo, executionHostId: 'ssh:target' }], issue, 'local')?.id + ).toBe('repo') + }) + + it.each([ + ['ORDER BY updated DESC', 'project = "10" ORDER BY updated DESC'], + [ + 'statusCategory != Done ORDER BY updated DESC', + 'project = "10" AND (statusCategory != Done) ORDER BY updated DESC' + ], + [ + 'text ~ "order by speed" ORDER BY created ASC', + 'project = "10" AND (text ~ "order by speed") ORDER BY created ASC' + ], + ['key = "ALP-1" OR key = "ALP-2"', 'project = "10" AND (key = "ALP-1" OR key = "ALP-2")'], + [ + "summary ~ 'order by' order by updated DESC", + 'project = "10" AND (summary ~ \'order by\') order by updated DESC' + ], + [ + 'summary ~ "say \\"order by\\"" ORDER BY updated DESC', + 'project = "10" AND (summary ~ "say \\"order by\\"") ORDER BY updated DESC' + ] + ])('preserves query meaning and ordering when scoping %s', (jql, expected) => { + expect(restrictJiraQueryToProject(jql, project)).toBe(expected) + }) +}) diff --git a/src/shared/jira-project-reference.ts b/src/shared/jira-project-reference.ts new file mode 100644 index 00000000000..43ba9c02b9d --- /dev/null +++ b/src/shared/jira-project-reference.ts @@ -0,0 +1,85 @@ +import type { JiraIssue, JiraProject } from './jira-types' +import type { Repo } from './repo-types' +import { getRepoExecutionHostId } from './execution-host' +import type { ExecutionHostId } from './execution-host' +import { resolveJiraSourceHostId } from './jira-source-host' + +export type JiraProjectReference = Pick & { siteId: string } + +export function normalizeJiraProjectReference( + value: unknown +): JiraProjectReference | null | undefined { + if (value === null) { + return null + } + if ( + !value || + typeof value !== 'object' || + !('id' in value) || + !('key' in value) || + !('name' in value) || + !('siteId' in value) + ) { + return undefined + } + const { id, key, name, siteId } = value + if ( + typeof id !== 'string' || + !id.trim() || + typeof key !== 'string' || + !key.trim() || + typeof name !== 'string' || + !name.trim() || + typeof siteId !== 'string' || + !siteId.trim() + ) { + return undefined + } + return { id: id.trim(), key: key.trim(), name: name.trim(), siteId: siteId.trim() } +} + +export function matchesJiraProject(project: JiraProject, reference: JiraProjectReference): boolean { + return project.siteId === reference.siteId && project.id === reference.id +} + +export function findJiraIssueDefaultRepo( + repos: readonly Repo[], + issue: JiraIssue, + hostId: ExecutionHostId +): Repo | null { + const matches = repos.filter( + (repo) => + resolveJiraSourceHostId({ workspaceHostId: getRepoExecutionHostId(repo) }) === hostId && + repo.defaultJiraProject && + matchesJiraProject( + { ...issue.project, siteId: issue.siteId ?? issue.project.siteId }, + repo.defaultJiraProject + ) + ) + return matches.length === 1 ? matches[0] : null +} + +export function restrictJiraQueryToProject(jql: string, project: JiraProjectReference): string { + let quote: string | null = null + let orderIndex = -1 + for (let index = 0; index < jql.length; index += 1) { + const character = jql[index] + if (quote && character === '\\') { + index += 1 + } else if (character === quote) { + quote = null + } else if (!quote && (character === '"' || character === "'")) { + quote = character + } else if ( + !quote && + (index === 0 || /\s/.test(character)) && + /^\s*ORDER\s+BY\s+/i.test(jql.slice(index)) + ) { + orderIndex = index + break + } + } + const clause = orderIndex >= 0 ? jql.slice(0, orderIndex) : jql + const ordering = orderIndex >= 0 ? jql.slice(orderIndex) : '' + return `project = ${JSON.stringify(project.id)}${clause.trim() ? ` AND (${clause})` : ''}${ordering.trim() ? ` ${ordering.trim()}` : ''}` +} diff --git a/src/shared/jira-source-host.ts b/src/shared/jira-source-host.ts new file mode 100644 index 00000000000..17f09d24bc0 --- /dev/null +++ b/src/shared/jira-source-host.ts @@ -0,0 +1,19 @@ +import { + getConnectionExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + parseExecutionHostId, + type ExecutionHostId +} from './execution-host' + +export function resolveJiraSourceHostId(args: { + workspaceHostId?: string | null + groupExecutionHostId?: string | null + groupConnectionId?: string | null +}): ExecutionHostId { + const groupHostId = + normalizeExecutionHostId(args.groupExecutionHostId) ?? + getConnectionExecutionHostId(args.groupConnectionId) + const hostId = normalizeExecutionHostId(args.workspaceHostId) ?? groupHostId + return parseExecutionHostId(hostId)?.kind === 'ssh' ? LOCAL_EXECUTION_HOST_ID : hostId +} diff --git a/src/shared/repo-types.ts b/src/shared/repo-types.ts index 757e8892aae..db66e5b40e0 100644 --- a/src/shared/repo-types.ts +++ b/src/shared/repo-types.ts @@ -6,6 +6,7 @@ import type { ForkSyncMode } from './git-fork-sync' import type { GitRemoteIdentity } from './git-remote-identity' import type { RepoSourceControlAiOverrides } from './source-control-ai-types' import type { RepoProjectHostSetupMethod } from './project-types' +import type { JiraProjectReference } from './jira-project-reference' // ─── Repo ──────────────────────────────────────────────────────────── export type RepoKind = 'git' | 'folder' @@ -80,6 +81,8 @@ export type Repo = { * identically to `'auto'`; writers leave it undefined on creation so * existing persisted records stay forward-compatible. */ issueSourcePreference?: IssueSourcePreference + /** Host-owned Jira project default; users can override it in each composer. */ + defaultJiraProject?: JiraProjectReference | null /** * Per-project gh account binding (host + login only — never a token). * At spawn, Orca resolves a short-lived token via `gh auth token --user` diff --git a/src/shared/rpc-contract/repo-update-params.ts b/src/shared/rpc-contract/repo-update-params.ts index d4bf49e7992..012da5a9527 100644 --- a/src/shared/rpc-contract/repo-update-params.ts +++ b/src/shared/rpc-contract/repo-update-params.ts @@ -3,6 +3,7 @@ import { normalizeRepoSourceControlAiOverrides } from '../source-control-ai' import { normalizeRepoBadgeColor } from '../repo-badge-color' import { sanitizeRepoIcon } from '../repo-icon' import { normalizeGhAccountBinding } from '../github/account-binding' +import { normalizeJiraProjectReference } from '../jira-project-reference' import { normalizeCustomWorktreeVisibilitySources, normalizeWorktreeVisibilitySourcePreferences @@ -57,6 +58,7 @@ export function createRepoUpdateSchema