mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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'
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
+182
@@ -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)
|
||||
|
||||
+2
@@ -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 })
|
||||
}
|
||||
}
|
||||
+36
@@ -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,
|
||||
|
||||
+16
-6
@@ -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
|
||||
|
||||
@@ -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 +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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()}` : ''}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user