From ef3406788d86bad9fd57a5f614ca12ee668d8d90 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 14 Oct 2026 06:20:47 -0700 Subject: [PATCH] feat(jira): remember repository project defaults (#28622) Remember an optional Jira project for each repository so its issue picker and Tasks list start with the intended project and site. Keep temporary project overrides separate from the saved default, and choose a mapped workspace destination while retaining the Jira account that supplied the issue. Older hosts that discard the optional saved field return an actionable message. Keep account ownership separate from repository execution identity. An explicit matching repository owner disambiguates repeated IDs; automatic defaults require ID uniqueness, and overrides include execution-host identity. Preserve existing preselection behavior, saved/manual query classification and linked-view choices. Reuse existing persistence, identity and query builders; check the final UTF-8 query length for searches and project-filtered presets and show a recoverable error before dispatch. Validation at reviewed 9b174ae4: 205 affected tests, node/web typechecks, seven changed-source quality checks, and 189 fresh rendered assertion invocations with 39 inspected screenshots and 1,413 stable source hashes pass. Independent source and artifact reviews found no blocker. The rendered evidence uses synthetic transports. Ubuntu 22.04 persistence CI stopped before tests because the native watcher crashed during the build; those inputs are unchanged from the preceding passing head. Full CI is not claimed green. --- src/main/persistence-jira-project.test.ts | 31 ++ .../repo-lifecycle-operations.ts | 1 + .../tracking-repos/repo-hydration.ts | 4 + .../tracking-repos/repo-sanitization.ts | 10 + .../tracking-repos/repo-update-operations.ts | 1 + .../runtime-repository-settings-controller.ts | 1 + src/preload/api/repository-api.ts | 1 + .../components/NewWorkspaceComposerModal.tsx | 3 + .../TaskPageJiraStatusFilters.test.tsx | 42 ++ .../components/TaskPageJiraStatusFilters.tsx | 18 +- .../components/jira-project-picker.test.tsx | 43 ++ .../src/components/jira-project-picker.tsx | 92 ++++ .../NewWorkspaceComposerNameSection.tsx | 3 + .../NewWorkspaceComposerProjectSection.tsx | 3 + .../RunTargetCombobox.host-identity.test.tsx | 78 ++++ .../new-workspace/RunTargetCombobox.tsx | 28 +- ...ojectLocationDialog.host-identity.test.tsx | 182 ++++++++ .../SetProjectLocationDialog.test.tsx | 6 +- .../SetProjectLocationDialog.tsx | 9 +- ...spaceNameField.jira-accessibility.test.tsx | 2 + .../new-workspace-composer-card-props.ts | 1 + .../new-workspace/run-target-options.ts | 17 +- .../smart-workspace-name-field-model.ts | 2 + .../smart-workspace-name-field-surface.tsx | 11 + .../use-jira-composer-project.test.tsx | 69 +++ .../use-jira-composer-project.ts | 42 ++ ...pace-name-field-foundation.source.test.tsx | 36 ++ .../use-smart-workspace-secondary-searches.ts | 22 +- .../use-work-item-source-foundation.ts | 14 +- .../RepositoryJiraProjectSection.test.tsx | 61 +++ .../settings/RepositoryJiraProjectSection.tsx | 97 ++++ .../components/settings/RepositoryPane.tsx | 9 + .../components/settings/repository-search.ts | 19 + .../task-page-jira-status-catalog.ts | 12 + .../jira/Filters.linked-mode.test.tsx | 168 +++++++ .../src/components/task-page/jira/Filters.tsx | 25 +- .../use-jira-project-inventory.test.tsx | 78 ++++ .../components/use-jira-project-inventory.ts | 59 +++ .../use-task-page-composer-actions.ts | 9 +- .../use-task-page-jira-list-effects.test.ts | 345 ++++++++++++++ .../use-task-page-jira-list-effects.ts | 79 ++-- ...e-task-page-jira-list-state.owner.test.tsx | 3 + .../use-task-page-jira-list-state.ts | 9 + .../use-task-page-jira-project-scope.test.tsx | 258 +++++++++++ .../use-task-page-jira-project-scope.ts | 51 +++ .../composer-state/composer-card-contract.ts | 1 + .../composer-state/composer-card-props.ts | 3 + .../composer-state/composer-decisions.ts | 22 +- .../composer-state/composer-external-sync.ts | 1 + .../composer-review-base-test-fixture.ts | 3 +- .../composer-state/composer-source-state.ts | 2 + .../composer-target-input-contracts.ts | 2 + ...omposer-target-state.jira-mapping.test.tsx | 319 +++++++++++++ .../composer-state/composer-target-state.ts | 18 +- .../composer-state/composer-target-store.ts | 4 + .../composer-state/draft-target-sync.test.ts | 1 + .../hooks/composer-state/draft-target-sync.ts | 6 +- .../composer-state/host-runtime-effects.ts | 12 +- .../composer-state/initial-target-model.ts | 8 + ...initial-target-state.jira-mapping.test.tsx | 108 +++++ .../composer-state/initial-target-state.ts | 28 +- ...ira-mapped-owner-flow.integration.test.tsx | 425 ++++++++++++++++++ .../composer-state/runtime-target-model.ts | 1 + .../runtime-target-selection.ts | 42 +- .../server-ssh-repo-connect.test.ts | 40 +- .../composer-state/source-context-state.ts | 5 +- .../composer-state/target-change-actions.ts | 19 +- .../composer-state/target-store-model.ts | 2 + ...poserState-host-context-boundaries.test.ts | 24 + .../useComposerState.integration.test.ts | 68 ++- src/renderer/src/hooks/useComposerState.ts | 2 + src/renderer/src/i18n/locales/en.json | 19 +- src/renderer/src/lib/jira-source-host.ts | 20 +- .../src/lib/project-host-setup-options.ts | 6 + ...host-workspace-target.jira-mapping.test.ts | 78 ++++ .../src/lib/project-host-workspace-target.ts | 29 +- .../src/runtime/runtime-jira-client.ts | 20 +- .../runtime-jira-project-client.test.ts | 68 +++ .../runtime/runtime-jira-project-client.ts | 29 ++ src/renderer/src/store/repos/repo-state.ts | 1 + src/renderer/src/store/repos/repo-update.ts | 9 + src/shared/jira-project-reference.test.ts | 105 +++++ src/shared/jira-project-reference.ts | 85 ++++ src/shared/jira-source-host.ts | 19 + src/shared/repo-types.ts | 3 + src/shared/rpc-contract/repo-update-params.ts | 2 + 86 files changed, 3570 insertions(+), 143 deletions(-) create mode 100644 src/main/persistence-jira-project.test.ts create mode 100644 src/renderer/src/components/jira-project-picker.test.tsx create mode 100644 src/renderer/src/components/jira-project-picker.tsx create mode 100644 src/renderer/src/components/new-workspace/RunTargetCombobox.host-identity.test.tsx create mode 100644 src/renderer/src/components/new-workspace/SetProjectLocationDialog.host-identity.test.tsx create mode 100644 src/renderer/src/components/new-workspace/use-jira-composer-project.test.tsx create mode 100644 src/renderer/src/components/new-workspace/use-jira-composer-project.ts create mode 100644 src/renderer/src/components/settings/RepositoryJiraProjectSection.test.tsx create mode 100644 src/renderer/src/components/settings/RepositoryJiraProjectSection.tsx create mode 100644 src/renderer/src/components/task-page/jira/Filters.linked-mode.test.tsx create mode 100644 src/renderer/src/components/use-jira-project-inventory.test.tsx create mode 100644 src/renderer/src/components/use-jira-project-inventory.ts create mode 100644 src/renderer/src/components/use-task-page-jira-project-scope.test.tsx create mode 100644 src/renderer/src/components/use-task-page-jira-project-scope.ts create mode 100644 src/renderer/src/hooks/composer-state/composer-target-state.jira-mapping.test.tsx create mode 100644 src/renderer/src/hooks/composer-state/initial-target-state.jira-mapping.test.tsx create mode 100644 src/renderer/src/hooks/composer-state/jira-mapped-owner-flow.integration.test.tsx create mode 100644 src/renderer/src/lib/project-host-workspace-target.jira-mapping.test.ts create mode 100644 src/renderer/src/runtime/runtime-jira-project-client.test.ts create mode 100644 src/renderer/src/runtime/runtime-jira-project-client.ts create mode 100644 src/shared/jira-project-reference.test.ts create mode 100644 src/shared/jira-project-reference.ts create mode 100644 src/shared/jira-source-host.ts 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