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.
This commit is contained in:
Neil
2026-10-14 06:20:47 -07:00
committed by GitHub
parent a0c46b405f
commit ef3406788d
86 changed files with 3570 additions and 143 deletions
+31
View File
@@ -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()
})
})
@@ -141,6 +141,7 @@ export class RepoLifecycleOperations {
| 'repoIcon'
| 'upstream'
| 'gitRemoteIdentity'
| 'defaultJiraProject'
| 'hookSettings'
| 'worktreeBaseRef'
| 'worktreeBasePath'
@@ -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<string, st
projectHostSetupMethod: rawProjectHostSetupMethod,
forkSyncMode: rawForkSyncMode,
ghAccount: rawGhAccount,
defaultJiraProject: rawDefaultJiraProject,
customWorktreeVisibilitySources: rawCustomWorktreeVisibilitySources,
worktreeVisibilitySourcePreferences: rawWorktreeVisibilitySourcePreferences,
...repoWithoutIcon
@@ -48,6 +50,7 @@ export function hydrateRepo(repo: Repo, gitUsernameCache: ReadonlyMap<string, st
const projectHostSetupMethod = sanitizeRepoProjectHostSetupMethod(rawProjectHostSetupMethod)
const forkSyncMode = sanitizeForkSyncMode(rawForkSyncMode)
const ghAccount = normalizeGhAccountBinding(rawGhAccount)
const defaultJiraProject = normalizeJiraProjectReference(rawDefaultJiraProject)
const customWorktreeVisibilitySources = normalizeCustomWorktreeVisibilitySources(
rawCustomWorktreeVisibilitySources
)
@@ -71,6 +74,7 @@ export function hydrateRepo(repo: Repo, gitUsernameCache: ReadonlyMap<string, st
...(projectHostSetupMethod !== undefined ? { projectHostSetupMethod } : {}),
...(forkSyncMode !== undefined ? { forkSyncMode } : {}),
...(ghAccount ? { ghAccount } : {}),
...(defaultJiraProject !== undefined ? { defaultJiraProject } : {}),
...(customWorktreeVisibilitySources !== undefined ? { customWorktreeVisibilitySources } : {}),
...(worktreeVisibilitySourcePreferences !== undefined
? { worktreeVisibilitySourcePreferences }
@@ -4,6 +4,7 @@ import type { GitRemoteIdentity } from '../../../shared/git-remote-identity'
import { normalizeRepoBadgeColor } from '../../../shared/repo-badge-color'
import { sanitizeRepoIcon } from '../../../shared/repo-icon'
import { normalizeGhAccountBinding } from '../../../shared/github/account-binding'
import { normalizeJiraProjectReference } from '../../../shared/jira-project-reference'
import type { GhAccountBinding } from '../../../shared/github/account-binding'
import {
normalizeCustomWorktreeVisibilitySources,
@@ -78,6 +79,7 @@ export function sanitizeRepoUpdatesForPersistence<
| 'worktreeBasePath'
| 'projectHostSetupMethod'
| 'forkSyncMode'
| 'defaultJiraProject'
| 'customWorktreeVisibilitySources'
| 'worktreeVisibilitySourcePreferences'
>
@@ -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) {
@@ -91,6 +91,7 @@ export class RepoUpdatePersistenceOperations {
| 'executionHostId'
| 'symlinkPaths'
| 'issueSourcePreference'
| 'defaultJiraProject'
| 'forkSyncMode'
| 'externalWorktreeVisibilityPromptDismissedAt'
| 'externalWorktreeInboxBaselinePaths'
@@ -28,6 +28,7 @@ type RepositoryUpdates = Partial<
| 'kind'
| 'symlinkPaths'
| 'issueSourcePreference'
| 'defaultJiraProject'
| 'externalWorktreeVisibility'
| 'externalWorktreeVisibilityPromptDismissedAt'
| 'externalWorktreeInboxBaselinePaths'
+1
View File
@@ -51,6 +51,7 @@ export type RepositoryApi = {
| 'worktreeBasePath'
| 'kind'
| 'issueSourcePreference'
| 'defaultJiraProject'
| 'externalWorktreeVisibilityPromptDismissedAt'
| 'externalWorktreeInboxBaselinePaths'
| 'importedExternalWorktreePaths'
@@ -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,
@@ -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(<TaskPageJiraStatusFilters model={state} />)
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(<TaskPageJiraStatusFilters model={state} />)
expect(screen.getByRole('button', { name: 'Alpha only' })).toBeDefined()
state.jiraProjectFilter = { id: '20', key: 'BET', name: 'Beta', siteId: 'site-b' }
rerender(<TaskPageJiraStatusFilters model={state} />)
expect(screen.queryByRole('button', { name: 'Alpha only' })).toBeNull()
expect(screen.getByRole('button', { name: 'Developed' }).getAttribute('aria-pressed')).toBe(
'true'
)
expect(state.selectJiraStatuses).not.toHaveBeenCalled()
})
@@ -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 ||
@@ -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(
<JiraProjectPicker
projects={[alpha, otherSite]}
value={alpha}
onChange={onChange}
label="Jira project"
emptyLabel="All Jira projects"
/>
)
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(
<JiraProjectPicker
projects={[alpha]}
value={alpha}
onChange={onChange}
label="Jira project"
emptyLabel="All Jira projects"
/>
)
fireEvent.click(screen.getByRole('combobox', { name: 'Jira project' }))
fireEvent.click(await screen.findByRole('option', { name: 'All Jira projects' }))
expect(onChange).toHaveBeenCalledWith(null)
})
})
@@ -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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-label={label}
aria-expanded={open}
disabled={disabled}
className="w-full justify-between"
>
<span className="truncate">{value ? `${value.key} · ${value.name}` : emptyLabel}</span>
<ChevronsUpDown className="size-4 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent align="start">
<Command>
<CommandInput
placeholder={translate('components.jiraProjectPicker.search', 'Search Jira projects')}
/>
<CommandList>
<CommandEmpty>
{translate('components.jiraProjectPicker.empty', 'No projects found.')}
</CommandEmpty>
<CommandItem value={emptyLabel} onSelect={() => select(null)}>
<span className="flex-1">{emptyLabel}</span>
{!value ? <Check className="size-4" /> : null}
</CommandItem>
{projects.map((project) => {
const reference = normalizeJiraProjectReference(project)
return reference ? (
<CommandItem
key={getJiraProjectSelectionKey(project)}
value={`${project.key} ${project.name} ${project.siteName ?? ''} ${project.siteId}`}
onSelect={() => select(reference)}
>
<span className="min-w-0 flex-1">
<span className="block truncate">
{project.key} · {project.name}
</span>
{project.siteName ? (
<span className="block truncate text-xs text-muted-foreground">
{project.siteName}
</span>
) : null}
</span>
{value && matchesJiraProject(project, value) ? (
<Check className="size-4" />
) : null}
</CommandItem>
) : null
})}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
@@ -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({
<SmartWorkspaceNameField
inputRef={nameInputRef}
repos={eligibleRepos}
selectedRepo={selectedRepo}
repoId={repoId}
onRepoChange={onRepoChange}
value={name}
@@ -27,6 +27,7 @@ type NewWorkspaceComposerProjectSectionProps = Pick<
| 'selectedRepoConnectInProgress'
| 'onConnectSelectedRepo'
| 'selectedProjectHostSetupId'
| 'selectedRepoExecutionHostId'
| 'onEphemeralVmRecipeChange'
| 'selectedEphemeralVmRecipeId'
| 'ephemeralVmRecipeError'
@@ -64,6 +65,7 @@ export function NewWorkspaceComposerProjectSection({
shouldShowRunTargetPicker,
projectHostSetupOptions,
selectedProjectHostSetupId,
selectedRepoExecutionHostId,
handleProjectHostSetupChange,
ephemeralVmRecipes,
selectedEphemeralVmRecipeId = null,
@@ -150,6 +152,7 @@ export function NewWorkspaceComposerProjectSection({
<RunTargetCombobox
hostOptions={projectHostSetupOptions ?? []}
hostValue={selectedProjectHostSetupId ?? null}
hostExecutionHostId={selectedRepoExecutionHostId}
onHostChange={handleProjectHostSetupChange}
recipes={ephemeralVmRecipes}
recipeValue={selectedEphemeralVmRecipeId}
@@ -0,0 +1,78 @@
// @vitest-environment happy-dom
import React from 'react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ReadyProjectHostSetupOption } from '@/lib/project-host-setup-options'
import RunTargetCombobox from './RunTargetCombobox'
import { buildRunTargetRows } from './run-target-options'
vi.mock('@/components/ui/popover', () => ({
Popover: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
PopoverAnchor: ({ children }: { children: React.ReactNode }) => <>{children}</>,
PopoverContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>
}))
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(
<RunTargetCombobox
hostOptions={options}
hostValue="same-id"
hostExecutionHostId="ssh:builder"
recipes={[]}
recipeValue={null}
/>
)
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(
<RunTargetCombobox
hostOptions={options}
hostValue="same-id"
onHostChange={onHostChange}
recipes={[]}
recipeValue={null}
/>
)
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)
})
})
@@ -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)}
/>
)
}
@@ -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<typeof useTargetChangeActions>[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(
<SetProjectLocationDialog
option={option}
projectName="Orca"
projectKind="git"
defaultCloneUrl="https://github.com/stablyai/orca.git"
onClose={vi.fn()}
onReady={(selection) => 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()
}
)
})
@@ -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 () => {
@@ -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)
@@ -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,
@@ -42,6 +42,7 @@ export type NewWorkspaceComposerCardProps = {
quickSetupOnly?: boolean
onQuickSetupOnlySelect?: () => void
eligibleRepos: readonly RepoOption[]
selectedRepo?: RepoOption | null
repoId: string
projectOptions?: NewWorkspaceProjectOption[]
selectedProjectId?: string | null
@@ -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
}))
@@ -16,6 +16,7 @@ export type RepoOption = ReturnType<typeof useAppStore.getState>['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'
@@ -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 ? (
<JiraProjectPicker
projects={controller.jiraProjectInventory.projects}
value={controller.jiraProject}
onChange={controller.setJiraProject}
label={translate('components.jiraProjectPicker.label', 'Jira project')}
emptyLabel={translate('components.jiraProjectPicker.all', 'All Jira projects')}
disabled={disabled || controller.jiraProjectInventory.loading}
/>
) : null}
<Popover
open={!disabled && open && mode !== 'text' && selectedSource === null}
onOpenChange={handleSourcePopoverOpenChange}
@@ -0,0 +1,69 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../../../shared/repo-types'
import { normalizeTaskSourceContext } from '../../../../shared/task-source-context'
import { useJiraComposerProject } from './use-jira-composer-project'
vi.mock('../use-jira-project-inventory', () => ({
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)
})
})
@@ -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 })
}
}
@@ -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,
@@ -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,
@@ -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
@@ -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 }) => (
<button onClick={() => onChange(project)}>Choose Alpha</button>
)
}))
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(<RepositoryJiraProjectSection repo={state.repos[0]} updateRepo={update} />)
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(<RepositoryJiraProjectSection repo={state.repos[0]} updateRepo={update} />)
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()
})
})
@@ -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<string | null>(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 (
<SearchableSetting
title={label}
description={translate(
'components.RepositoryJiraProjectSection.description',
'Default the Jira issue picker and Tasks list to this project. You can change it for each worktree.'
)}
keywords={['jira', 'project', repo.displayName]}
forceVisible={forceVisible}
className="space-y-2"
>
<p className="text-sm font-semibold">{label}</p>
<JiraProjectPicker
projects={inventory.projects}
value={repo.defaultJiraProject ?? null}
onChange={(project) => void applyProject(project)}
label={label}
emptyLabel={translate('components.RepositoryJiraProjectSection.none', 'No default project')}
disabled={inventory.loading || saving}
/>
{!inventory.loading && !inventory.connected && !inventory.error ? (
<p className="text-xs text-muted-foreground">
{translate(
'components.RepositoryJiraProjectSection.connect',
'Connect Jira in Integrations to choose a project.'
)}
</p>
) : null}
{error || inventory.error ? (
<p role="alert" className="text-xs text-destructive">
{error ?? inventory.error}
</p>
) : null}
</SearchableSetting>
)
}
@@ -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({
<RepositoryIconPicker repo={repo} updateRepo={updateSelectedRepo} />
</SearchableSetting>
<RepositoryJiraProjectSection
key={`${selectedHostId}:${repo.id}`}
repo={repo}
updateRepo={updateSelectedRepo}
forceVisible={forceFullPaneForRepoMatch}
/>
{!isFolder ? (
<>
<RepositoryHostSetupsSection
@@ -23,6 +23,25 @@ export function getRepositoryPaneSearchEntries(
(Boolean(options.windowsRuntimeSupported) &&
getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID)
return [
{
title: translate('components.RepositoryJiraProjectSection.title', 'Default Jira project'),
description: translate(
'components.RepositoryJiraProjectSection.description',
'Default the Jira issue picker and Tasks list to this project. You can change it for each worktree.'
),
keywords: [
repo.displayName,
...translateSearchKeyword('components.RepositoryJiraProjectSection.keywordJira', 'jira'),
...translateSearchKeyword(
'components.RepositoryJiraProjectSection.keywordDefaultProject',
'default project'
),
...translateSearchKeyword(
'components.RepositoryJiraProjectSection.keywordIssuePicker',
'issue picker'
)
]
},
{
title: translate('auto.components.settings.repository.search.7e1e456a95', 'Display Name'),
description: translate(
@@ -1,4 +1,16 @@
import type { JiraIssue } from '../../../shared/jira-types'
import type { JiraProjectReference } from '../../../shared/jira-project-reference'
import { getJiraProjectSelectionKey } from './task-page-jira-project-selection'
export function getJiraStatusCatalogScopeKey(
sourceScopeKey: string,
selectedSiteId: string | null | undefined,
preset: string,
project?: JiraProjectReference | null
): string {
const scopeKey = `${sourceScopeKey}::${selectedSiteId}::${preset}`
return project ? `${scopeKey}::${getJiraProjectSelectionKey(project)}` : scopeKey
}
export type JiraStatusCatalogItem = { name: string; categoryKey: string }
const STATUS_NAME_COLLATOR = new Intl.Collator('en', { sensitivity: 'base' })
@@ -0,0 +1,168 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { useState } from 'react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { useAppStore } from '@/store'
import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions'
import type { JiraProjectReference } from '../../../../../shared/jira-project-reference'
import TaskPage from '../TaskPage'
import { TaskPageJiraFilters } from './Filters'
const fixture = vi.hoisted(() => ({ loading: false }))
vi.mock('../Surface', () => ({
TaskPageSurface: ({ model }: { model: TaskPageComposerActionsModel }) => (
<FiltersFixture baseModel={model} loading={fixture.loading} />
)
}))
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<JiraProjectReference | null>(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 (
<TooltipProvider>
<TaskPageJiraFilters model={model} />
</TooltipProvider>
)
}
beforeEach(() => {
fixture.loading = false
useAppStore.setState(
{
...initialStore,
settings: null,
persistedUIReady: false,
repos: [],
taskPageData: {},
checkLinearConnection: vi
.fn<typeof initialStore.checkLinearConnection>()
.mockResolvedValue(undefined),
checkJiraConnection: vi
.fn<typeof initialStore.checkJiraConnection>()
.mockResolvedValue(undefined),
refreshPreflightStatus: vi
.fn<typeof initialStore.refreshPreflightStatus>()
.mockResolvedValue(undefined)
},
true
)
Object.defineProperty(window, 'api', {
configurable: true,
value: {
gh: { viewer: vi.fn<typeof window.api.gh.viewer>().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(<TaskPage />)
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(<TaskPage />)
expect(screen.getByRole('combobox', { name: 'Jira project' })).toBeDisabled()
})
})
@@ -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({
<div className="rounded-md rounded-b-none border border-border/50 bg-muted/50 px-3 pt-2 pb-0 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
<div className="min-w-0 max-w-64">
<JiraProjectPicker
projects={sortedAvailableJiraProjects}
value={model.jiraProjectFilter}
onChange={model.setJiraProjectFilter}
label={translate('components.jiraProjectPicker.label', 'Jira project')}
emptyLabel={translate('components.jiraProjectPicker.all', 'All Jira projects')}
disabled={jiraProjectsLoading || jiraHasWorkspace}
/>
</div>
{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)
@@ -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<JiraProject[]>((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()
})
})
@@ -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
}
}
@@ -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 => {
@@ -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'
@@ -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,
@@ -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.
@@ -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<JiraIssue[]>([])
const [jiraLoading, setJiraLoading] = useState(false)
@@ -169,6 +177,7 @@ export function useTaskPageJiraListState(model: TaskPageLinearViewStateModel) {
...quickFilters,
jiraStatusCatalog,
setJiraStatusCatalog,
...jiraProjectScope,
jiraIssues,
setJiraIssues,
jiraLoading,
@@ -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'])
})
})
@@ -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 }
}
@@ -57,6 +57,7 @@ export type ComposerCardSourceProps = Pick<
>
export type ComposerCardActionProps = {
selectedRepo?: ComposerModel['selectedRepo'] | null
onRepoChange: ComposerModel['handleRepoChange']
onProjectChange: ComposerModel['handleProjectChange']
onProjectHostSetupChange: ComposerModel['handleProjectHostSetupChange']
@@ -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,
@@ -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
}
}
@@ -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,
@@ -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,
@@ -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,
@@ -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'
@@ -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<Window['api']['preflight']['detectAgents']>().mockResolvedValue([])
},
worktrees: {
listRetiredNames: vi
.fn<Window['api']['worktrees']['listRetiredNames']>()
.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')
})
})
@@ -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,
@@ -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,
@@ -33,6 +33,7 @@ function createState(overrides: Partial<DraftTargetSyncInput> = {}): DraftTarget
name: 'workspace-name',
note: 'note',
persistDraft: true,
pendingInitialWorkspaceTarget: null,
repoId: '',
selectedProjectGroup: null,
selectedRepo: undefined,
@@ -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) {
@@ -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<void> => {
if (!folderTargetConnectionId) {
@@ -16,6 +16,10 @@ export type ComposerInitialTargetModel = {
hostId: ExecutionHostId | null
projectHostSetupId: string | null
}
pendingInitialWorkspaceTarget: ComposerInitialTargetModel['initialRunSeed'] | null
setPendingInitialWorkspaceTarget: React.Dispatch<
React.SetStateAction<ComposerInitialTargetModel['initialRunSeed'] | null>
>
resolvedInitialWorkspaceStatus: WorkspaceStatus | undefined
resolvedInitialWorkspaceTarget: WorkspaceCreationTargetResolution
resolvedInitialRepoId: string
@@ -23,6 +27,10 @@ export type ComposerInitialTargetModel = {
setInternalRepoId: React.Dispatch<React.SetStateAction<string>>
selectedProjectHostSetupOverrideId: string | null
setSelectedProjectHostSetupOverrideId: React.Dispatch<React.SetStateAction<string | null>>
selectedProjectHostSetupOverrideHostId: ExecutionHostId | null
setSelectedProjectHostSetupOverrideHostId: React.Dispatch<
React.SetStateAction<ExecutionHostId | null>
>
initialFolderProjectGroupId: string | null
initialFolderProjectGroup: ProjectGroup | null
selectedProjectGroupId: string | null
@@ -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<typeof useComposerInitialTargetState>[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 }
})
})
})
@@ -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<string>(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,
@@ -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<Window['api']['jira']['status']>()
const projects = vi.fn<Window['api']['jira']['listProjects']>()
const search = vi.fn<Window['api']['jira']['searchIssues']>()
const update = vi.fn<Window['api']['repos']['update']>()
const create = vi.fn<Window['api']['worktrees']['create']>()
const runtimeCall = vi.fn<Window['api']['runtimeEnvironments']['call']>()
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 (
<RepositoryJiraProjectSection
repo={repo}
updateRepo={(id, updates) =>
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<Window['api']['jira']['cancelSearchIssues']>()
.mockResolvedValue(undefined)
},
repos: { update },
worktrees: {
create,
listRetiredNames: vi
.fn<Window['api']['worktrees']['listRetiredNames']>()
.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(<MappedRepositorySetting />)
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<CreateWorktreeResult>()
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(<MappedRepositorySetting />)
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<JiraProject[]>()
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<typeof createWorktree> = [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()
}
})
})
@@ -32,6 +32,7 @@ export type ComposerRuntimeTargetModel = {
folderDetectedIds: TuiAgent[] | null
folderDetectedAgentIds: Set<TuiAgent> | null
selectedWorkspaceTarget: WorkspaceCreationTargetResolution
resolvedRepoId: string
selectedRepo: Repo | undefined
selectedRepoIsGit: boolean
selectedRepoExecutionHostId: ExecutionHostId | null
@@ -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,
@@ -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<string, unknown> = {}
@@ -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", () => {
@@ -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<string>(
@@ -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<ProjectHostSetupOption, 'id' | 'hostId'>
): 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
]
)
@@ -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
@@ -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,
@@ -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<Window['api']['preflight']['detectAgents']>().mockResolvedValue([])
} satisfies Pick<Window['api']['preflight'], 'detectAgents'>
const worktrees = {
listRetiredNames: vi
.fn<Window['api']['worktrees']['listRetiredNames']>()
.mockResolvedValue(EMPTY_RETIRED_NAME_REGISTRY)
} satisfies Pick<Window['api']['worktrees'], 'listRetiredNames'>
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: [],
@@ -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
+18 -1
View File
@@ -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."
}
}
}
+1 -19
View File
@@ -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'
@@ -49,6 +49,12 @@ export type ReadyProjectHostSetupOption = Extract<ProjectHostSetupOption, { kind
export type NeedsSetupProjectHostOption = Extract<ProjectHostSetupOption, { kind: 'needs-setup' }>
export function getProjectHostSetupSelectionKey(
option: Pick<ProjectHostSetupOption, 'id' | 'hostId'>
): string {
return JSON.stringify([option.id, option.hostId])
}
type BuildReadySetupOptionsInput = {
projectId: string
projectHostSetups: readonly ProjectHostSetup[]
@@ -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' })
})
})
@@ -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<WorkspaceCreationTarget, 'projectId' | 'hostId'> & {
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<ExecutionHostId>
}
@@ -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' }
}
@@ -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<JiraCon
return jiraStatusForTarget(getJiraRuntimeTarget(settings))
}
export async function jiraStatusForTarget(
target: RuntimeClientTarget
): Promise<JiraConnectionStatus> {
return parseJiraConnectionStatus(
target.kind === 'environment'
? await callRuntimeRpc<unknown>(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<JiraProject[]> {
const target = getJiraRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<JiraProject[]>(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(
@@ -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)
})
})
@@ -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<JiraConnectionStatus> {
return parseJiraConnectionStatus(
target.kind === 'environment'
? await callRuntimeRpc<unknown>(target, 'jira.status', undefined, { timeoutMs: 15_000 })
: await window.api.jira.status()
)
}
export async function jiraListProjectsForTarget(
target: RuntimeClientTarget,
siteId?: JiraSiteSelection | null
): Promise<JiraProject[]> {
return target.kind === 'environment'
? callRuntimeRpc<JiraProject[]>(target, 'jira.listProjects', siteId ? { siteId } : undefined, {
timeoutMs: 30_000
})
: window.api.jira.listProjects(siteId ? { siteId } : undefined)
}
@@ -43,6 +43,7 @@ export type RepoUpdate = Partial<
| 'kind'
| 'symlinkPaths'
| 'issueSourcePreference'
| 'defaultJiraProject'
| 'forkSyncMode'
| 'externalWorktreeVisibilityPromptDismissedAt'
| 'externalWorktreeInboxBaselinePaths'
@@ -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) {
+105
View File
@@ -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)
})
})
+85
View File
@@ -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<JiraProject, 'id' | 'key' | 'name'> & { 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()}` : ''}`
}
+19
View File
@@ -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
}
+3
View File
@@ -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`
@@ -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<T extends Readonly<Record<string, z.ZodTy
kind: z.enum(['git', 'folder']).optional(),
symlinkPaths: z.array(z.string()).optional(),
issueSourcePreference: z.enum(['auto', 'upstream', 'origin']).optional(),
defaultJiraProject: z.unknown().optional().transform(normalizeJiraProjectReference),
ghAccount: z
.unknown()
.optional()