mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(composer): preserve explicit workspace names after selecting tasks (#26937)
Preserve names entered after selecting a work item in desktop, mobile and folder creation. Reuse the existing source and last-generated-name state, and avoid repeating source naming when a delayed base lookup finishes. Verified the current-main merge composition: 70 focused tests and renderer typecheck pass; newer execution-host routing and folder launch checks remain intact. The reviewed PR head has no failed or pending CI checks and no unresolved review threads.
This commit is contained in:
@@ -50,7 +50,11 @@ export function buildLinearLinkedWorkItem(issue: {
|
||||
// Faithful port of desktop applyLinkedWorkItem's name gate: the derived name
|
||||
// replaces the current field only when it's empty, still the last auto-name, or
|
||||
// a lookup query — never a name the user deliberately typed.
|
||||
export function shouldApplyAutoName(args: { currentName: string; lastAutoName: string }): boolean {
|
||||
export function shouldApplyAutoName(args: {
|
||||
currentName: string
|
||||
lastAutoName: string
|
||||
lookupTextIsQuery?: boolean
|
||||
}): boolean {
|
||||
return shouldApplyWorkspaceSourceAutoName(args)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { GitHubWorkItem } from '../../../src/shared/github/work-item-types'
|
||||
import type { LinearIssue } from '../../../src/shared/linear/issue-types'
|
||||
import { useMobileComposerSource } from './use-mobile-composer-source'
|
||||
import { buildTaskWorkspaceCreateParams } from './workspace-create-params'
|
||||
|
||||
const mounted: ReactTestRenderer[] = []
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
for (const renderer of mounted.splice(0)) {
|
||||
renderer.unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function renderMobileComposerSource() {
|
||||
const holder: { value?: ReturnType<typeof useMobileComposerSource> } = {}
|
||||
function Probe() {
|
||||
holder.value = useMobileComposerSource({ client: null, selectedRepoId: 'fixture-repo' })
|
||||
return null
|
||||
}
|
||||
act(() => {
|
||||
mounted.push(create(createElement(Probe)))
|
||||
})
|
||||
return {
|
||||
get current() {
|
||||
if (!holder.value) {
|
||||
throw new Error('Composer source did not render')
|
||||
}
|
||||
return holder.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function issue(number: number): GitHubWorkItem {
|
||||
return {
|
||||
id: `issue-${number}`,
|
||||
type: 'issue',
|
||||
number,
|
||||
title: 'Fix export',
|
||||
state: 'open',
|
||||
url: `https://github.com/fixture/repo/issues/${number}`,
|
||||
repoId: 'fixture-repo',
|
||||
labels: [],
|
||||
updatedAt: '',
|
||||
author: null
|
||||
}
|
||||
}
|
||||
|
||||
describe('mobile workspace name ownership', () => {
|
||||
const linearIssue: LinearIssue = {
|
||||
id: 'linear-9',
|
||||
identifier: 'ENG-9',
|
||||
title: 'Fix export',
|
||||
url: 'https://linear.app/fixture/issue/ENG-9',
|
||||
state: { name: 'Open', type: 'unstarted', color: '' },
|
||||
team: { id: 'engineering', name: 'Engineering', key: 'ENG' },
|
||||
labels: [],
|
||||
labelIds: [],
|
||||
priority: 0,
|
||||
updatedAt: ''
|
||||
}
|
||||
|
||||
it('keeps a manual Linear identifier name when its source refreshes', () => {
|
||||
const result = renderMobileComposerSource()
|
||||
act(() => result.current.handleSmartLinearIssueSelect(linearIssue))
|
||||
act(() => result.current.setName('ENG-9'))
|
||||
act(() =>
|
||||
result.current.handleSmartLinearIssueSelect({ ...linearIssue, title: 'Fix refreshed export' })
|
||||
)
|
||||
|
||||
expect(result.current.name).toBe('ENG-9')
|
||||
expect(result.current.isNameAutoManaged).toBe(false)
|
||||
})
|
||||
|
||||
it('still replaces an unselected Linear identifier query', () => {
|
||||
const result = renderMobileComposerSource()
|
||||
act(() => result.current.setName('ENG-9'))
|
||||
act(() => result.current.handleSmartLinearIssueSelect(linearIssue))
|
||||
|
||||
expect(result.current.name).toBe('eng-9-fix-export')
|
||||
expect(result.current.isNameAutoManaged).toBe(true)
|
||||
})
|
||||
|
||||
it.each([2, 347])('keeps an edited name when issue #%s replaces the source', (number) => {
|
||||
const result = renderMobileComposerSource()
|
||||
act(() => result.current.handleSmartGitHubItemSelect(issue(2)))
|
||||
act(() => result.current.setName('002'))
|
||||
act(() => result.current.handleSmartGitHubItemSelect(issue(number)))
|
||||
|
||||
expect(result.current.name).toBe('002')
|
||||
expect(result.current.isNameAutoManaged).toBe(false)
|
||||
expect(
|
||||
buildTaskWorkspaceCreateParams({
|
||||
item: {
|
||||
provider: 'github',
|
||||
source: { ...issue(number), type: 'issue', repoId: 'fixture-repo' }
|
||||
},
|
||||
targetRepoId: 'fixture-repo',
|
||||
setupDecision: 'skip',
|
||||
agent: 'blank',
|
||||
workspaceName: result.current.name,
|
||||
nameIsAutoManaged: result.current.isNameAutoManaged
|
||||
})
|
||||
).toMatchObject({
|
||||
name: '002',
|
||||
displayName: '002',
|
||||
displayNameKind: 'user',
|
||||
linkedIssue: number
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['002', '347', '#347'])('still generates a name when picking query %s', (query) => {
|
||||
const result = renderMobileComposerSource()
|
||||
act(() => result.current.setName(query))
|
||||
act(() => result.current.handleSmartGitHubItemSelect(issue(query === '002' ? 2 : 347)))
|
||||
|
||||
expect(result.current.name).toBe('fix-export')
|
||||
expect(result.current.isNameAutoManaged).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { shouldApplyAutoName } from './composer-linked-work-item'
|
||||
import type { MobileLinkedWorkItem } from './mobile-composer-source-types'
|
||||
|
||||
export function useMobileComposerName(linkedWorkItem: MobileLinkedWorkItem | null) {
|
||||
const [name, setNameState] = useState('')
|
||||
const lastAutoNameRef = useRef('')
|
||||
const setName = useCallback((value: string) => setNameState(value), [])
|
||||
const applyAutoName = useCallback(
|
||||
(suggested: string, currentName: string) => {
|
||||
if (
|
||||
suggested &&
|
||||
shouldApplyAutoName({
|
||||
currentName,
|
||||
lastAutoName: lastAutoNameRef.current,
|
||||
lookupTextIsQuery: !linkedWorkItem
|
||||
})
|
||||
) {
|
||||
setNameState(suggested)
|
||||
lastAutoNameRef.current = suggested
|
||||
}
|
||||
},
|
||||
[linkedWorkItem]
|
||||
)
|
||||
const isNameAutoManaged = !name.trim() || name === lastAutoNameRef.current
|
||||
return { name, setName, setNameState, lastAutoNameRef, applyAutoName, isNameAutoManaged }
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
MobileLinkedWorkItem,
|
||||
SmartNameSelection
|
||||
} from './mobile-composer-source-types'
|
||||
import { useMobileComposerName } from './use-mobile-composer-name'
|
||||
const EMPTY_BASE: ComposerBaseState = {}
|
||||
|
||||
export type UseMobileComposerSourceArgs = {
|
||||
@@ -39,7 +40,6 @@ export type UseMobileComposerSourceArgs = {
|
||||
|
||||
export function useMobileComposerSource(args: UseMobileComposerSourceArgs) {
|
||||
const { client, selectedRepoId, worktreeBranches = [], onError } = args
|
||||
const [name, setNameState] = useState('')
|
||||
const [linkedWorkItem, setLinkedWorkItem] = useState<MobileLinkedWorkItem | null>(null)
|
||||
const [base, setBase] = useState<ComposerBaseState>(EMPTY_BASE)
|
||||
const [reuseEligibleBranch, setReuseEligibleBranch] = useState<string | null>(null)
|
||||
@@ -50,19 +50,12 @@ export function useMobileComposerSource(args: UseMobileComposerSourceArgs) {
|
||||
// may contain slashes) is kept verbatim as the git branch (folder is sanitized).
|
||||
const [branchCreateIntent, setBranchCreateIntent] = useState(false)
|
||||
|
||||
const lastAutoNameRef = useRef('')
|
||||
const branchSelectionRef = useRef<{ refName: string; localBranchName: string } | null>(null)
|
||||
// Guards async base resolution: only the latest selection applies its result.
|
||||
const resolveTokenRef = useRef(0)
|
||||
|
||||
const setName = useCallback((value: string) => setNameState(value), [])
|
||||
|
||||
const applyAutoName = useCallback((suggested: string, currentName: string) => {
|
||||
if (suggested && shouldApplyAutoName({ currentName, lastAutoName: lastAutoNameRef.current })) {
|
||||
setNameState(suggested)
|
||||
lastAutoNameRef.current = suggested
|
||||
}
|
||||
}, [])
|
||||
const { name, setName, setNameState, lastAutoNameRef, applyAutoName, isNameAutoManaged } =
|
||||
useMobileComposerName(linkedWorkItem)
|
||||
|
||||
const clearBaseAndBranch = useCallback(() => {
|
||||
branchSelectionRef.current = null
|
||||
@@ -201,18 +194,23 @@ export function useMobileComposerSource(args: UseMobileComposerSourceArgs) {
|
||||
resolveTokenRef.current += 1
|
||||
setLinkedWorkItem(buildLinearLinkedWorkItem(issue))
|
||||
const suggested = resolveLinearAutoName(issue)
|
||||
const identifierTyped = name.trim().toLowerCase() === issue.identifier.toLowerCase()
|
||||
const identifierTyped =
|
||||
!linkedWorkItem && name.trim().toLowerCase() === issue.identifier.toLowerCase()
|
||||
if (
|
||||
suggested &&
|
||||
(identifierTyped ||
|
||||
shouldApplyAutoName({ currentName: name, lastAutoName: lastAutoNameRef.current }))
|
||||
shouldApplyAutoName({
|
||||
currentName: name,
|
||||
lastAutoName: lastAutoNameRef.current,
|
||||
lookupTextIsQuery: !linkedWorkItem
|
||||
}))
|
||||
) {
|
||||
setNameState(suggested)
|
||||
lastAutoNameRef.current = suggested
|
||||
}
|
||||
clearBaseAndBranch()
|
||||
},
|
||||
[clearBaseAndBranch, name]
|
||||
[clearBaseAndBranch, linkedWorkItem, name]
|
||||
)
|
||||
|
||||
const handleSmartBranchSelect = useCallback(
|
||||
@@ -302,10 +300,6 @@ export function useMobileComposerSource(args: UseMobileComposerSourceArgs) {
|
||||
[base, branchCreateIntent, linkedWorkItem, name, reuseEligibleBranch, reuseSelectedBranch]
|
||||
)
|
||||
|
||||
// Auto-managed until the user edits the name away from the last derived value;
|
||||
// desktop suppresses the workspace displayName once the name is user-edited.
|
||||
const isNameAutoManaged = !name.trim() || name === lastAutoNameRef.current
|
||||
|
||||
return {
|
||||
name,
|
||||
setName,
|
||||
|
||||
@@ -65,6 +65,36 @@ function makeFolderWorkspace(overrides: Partial<FolderWorkspace> = {}): FolderWo
|
||||
}
|
||||
|
||||
describe('submitFolderWorkspaceCreate', () => {
|
||||
it.each(['347', '002'])('keeps manual folder name %s with its linked source', async (name) => {
|
||||
const linkedWorkItem = {
|
||||
provider: 'github' as const,
|
||||
type: 'issue' as const,
|
||||
number: Number(name),
|
||||
title: 'Fix export',
|
||||
url: `https://github.com/fixture/repo/issues/${Number(name)}`
|
||||
}
|
||||
const createFolderWorkspace = vi.fn(async () => null)
|
||||
await submitFolderWorkspaceCreate({
|
||||
projectGroup: makeProjectGroup(),
|
||||
name,
|
||||
lastAutoName: 'Fix export',
|
||||
linkedWorkItem,
|
||||
note: '',
|
||||
quickAgent: null,
|
||||
autoRenameBranchFromWork: false,
|
||||
agentCmdOverrides: {},
|
||||
createFolderWorkspace,
|
||||
onOpenChange: vi.fn()
|
||||
})
|
||||
|
||||
expect(createFolderWorkspace).toHaveBeenCalledWith({
|
||||
projectGroupId: 'group-1',
|
||||
name,
|
||||
connectionId: null,
|
||||
linkedTask: linkedWorkItem
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.activateAndRevealFolderWorkspace.mockReturnValue({ primaryTabId: 'tab-1' })
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { buildAgentStartupPlan } from '@/lib/tui-agent-startup'
|
||||
import { tuiAgentToAgentKind } from '@/lib/telemetry'
|
||||
import { activateAndRevealFolderWorkspace } from '@/lib/worktree-activation'
|
||||
import { isWorkItemLookupText } from '@/lib/work-item-lookup-text'
|
||||
import { shouldApplyWorkspaceSourceAutoName } from '../../../../shared/new-workspace/workspace-source'
|
||||
import type { FolderWorkspace } from '../../../../shared/folder-workspace-types'
|
||||
import type { ProjectGroup } from '../../../../shared/project-group-types'
|
||||
import type { TuiAgent } from '../../../../shared/tui-agent'
|
||||
@@ -82,7 +82,11 @@ export async function submitFolderWorkspaceCreate({
|
||||
onOpenChange
|
||||
}: SubmitFolderWorkspaceCreateParams): Promise<boolean> {
|
||||
const linkedName = linkedWorkItem ? getLinkedItemDisplayName(linkedWorkItem) : null
|
||||
const nameIsAutoManaged = !name.trim() || name === lastAutoName || isWorkItemLookupText(name)
|
||||
const nameIsAutoManaged = shouldApplyWorkspaceSourceAutoName({
|
||||
currentName: name,
|
||||
lastAutoName,
|
||||
lookupTextIsQuery: !linkedWorkItem
|
||||
})
|
||||
const workspaceName =
|
||||
nameIsAutoManaged && linkedName
|
||||
? linkedName
|
||||
|
||||
@@ -135,7 +135,8 @@ export function useBranchStartPointActions(input: BranchStartPointActionsInput)
|
||||
item: GitHubWorkItem,
|
||||
nextPushTarget?: GitPushTarget,
|
||||
nextBranchNameOverride?: string,
|
||||
nextCompareBaseRef?: string
|
||||
nextCompareBaseRef?: string,
|
||||
options: { sourceAlreadyLinked?: boolean } = {}
|
||||
): void => {
|
||||
setBaseBranch(nextBaseBranch)
|
||||
setBaseBranchNamesWorkspace(true)
|
||||
@@ -146,7 +147,10 @@ export function useBranchStartPointActions(input: BranchStartPointActionsInput)
|
||||
branchAutoNameRef.current = ''
|
||||
setStartFromResetHint(null)
|
||||
// Why: a Start-from PR pick is also a linkedWorkItem assignment; reuse applyLinkedWorkItem so auto-name and linkedPR stay one code path.
|
||||
applyLinkedWorkItem(item, { preserveBranchNameOverride: Boolean(nextBranchNameOverride) })
|
||||
// Provider picks already linked the source; a delayed base result must not replay its name.
|
||||
if (!options.sourceAlreadyLinked) {
|
||||
applyLinkedWorkItem(item, { preserveBranchNameOverride: Boolean(nextBranchNameOverride) })
|
||||
}
|
||||
// Why: prefill the note from the PR (only when empty or still an auto-fill) so the sidebar surfaces it without clobbering user text.
|
||||
const identity = resolveGitHubWorkItemIdentity(item)
|
||||
if (identity.type === 'pr') {
|
||||
@@ -180,7 +184,8 @@ export function useBranchStartPointActions(input: BranchStartPointActionsInput)
|
||||
nextBaseBranch: string,
|
||||
item: GitLabWorkItem,
|
||||
nextPushTarget?: GitPushTarget,
|
||||
nextCompareBaseRef?: string
|
||||
nextCompareBaseRef?: string,
|
||||
options: { sourceAlreadyLinked?: boolean } = {}
|
||||
): void => {
|
||||
setBaseBranch(nextBaseBranch)
|
||||
setBaseBranchNamesWorkspace(true)
|
||||
@@ -189,7 +194,9 @@ export function useBranchStartPointActions(input: BranchStartPointActionsInput)
|
||||
setBranchNameOverride(undefined)
|
||||
branchAutoNameRef.current = ''
|
||||
setStartFromResetHint(null)
|
||||
applyLinkedGitLabWorkItem(item)
|
||||
if (!options.sourceAlreadyLinked) {
|
||||
applyLinkedGitLabWorkItem(item)
|
||||
}
|
||||
if (item.type === 'mr') {
|
||||
const suggestedNote = `MR !${item.number} — ${item.title}`
|
||||
const currentNote = noteRef.current
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
getLinkedWorkItemWorkspaceName
|
||||
} from '../../../../shared/workspace-name'
|
||||
import { normalizeExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { isWorkItemLookupText } from '@/lib/work-item-lookup-text'
|
||||
import { shouldApplyWorkspaceSourceAutoName } from '../../../../shared/new-workspace/workspace-source'
|
||||
import { resolveGitHubWorkItemIdentity } from '@/lib/github-work-item-identity'
|
||||
import { isWorkspaceLinkedItemSourceContextMatch } from '../../../../shared/workspace-linked-item-source-context'
|
||||
import { getGitHubLinkedWorkItemIdentity } from './source-selection-decisions'
|
||||
@@ -30,7 +30,11 @@ export type ComposerDecisions = {
|
||||
item: LinkedWorkItemSummary | null | undefined,
|
||||
context: TaskSourceContext | null | undefined
|
||||
) => TaskSourceContext | null
|
||||
isExplicitWorkspaceNameInput: (input: { name: string; lastAutoName: string }) => boolean
|
||||
isExplicitWorkspaceNameInput: (input: {
|
||||
name: string
|
||||
lastAutoName: string
|
||||
lookupTextIsQuery?: boolean
|
||||
}) => boolean
|
||||
resolveInitialWorkspaceRunSeed: (input: {
|
||||
draftProjectId?: string | null
|
||||
draftHostId?: string | null
|
||||
@@ -67,13 +71,15 @@ export function canResolveFolderSmartGitHubSubmit({
|
||||
|
||||
export function isExplicitWorkspaceNameInput({
|
||||
name,
|
||||
lastAutoName
|
||||
lastAutoName,
|
||||
lookupTextIsQuery
|
||||
}: {
|
||||
name: string
|
||||
lastAutoName: string
|
||||
lookupTextIsQuery?: boolean
|
||||
}): boolean {
|
||||
// Why: a user-authored name must win over linked-item and first-message AI naming.
|
||||
return Boolean(name.trim()) && name !== lastAutoName && !isWorkItemLookupText(name)
|
||||
return !shouldApplyWorkspaceSourceAutoName({ currentName: name, lastAutoName, lookupTextIsQuery })
|
||||
}
|
||||
|
||||
export function resolveSmartGitHubCreateNames({
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { LinkedWorkItemSummary } from '@/lib/new-workspace'
|
||||
import type { GitHubWorkItem } from '../../../../shared/github/work-item-types'
|
||||
import type { GitLabWorkItem } from '../../../../shared/gitlab-types'
|
||||
import type { GitHubPrStartPoint } from '../../../../shared/worktree/types'
|
||||
import type { SmartGitHubPrStartPointSelection } from './source-selection-decisions'
|
||||
import { useGitHubSourceApplication } from './github-source-application'
|
||||
import { useSourceIdentityActions } from './source-identity-actions'
|
||||
import { useBranchStartPointActions } from './branch-start-point-actions'
|
||||
import { useGitHubProviderSelection } from './github-provider-selection'
|
||||
import { useGitLabProviderSelection } from './gitlab-provider-selection'
|
||||
|
||||
const repo = {
|
||||
id: 'fixture-repo',
|
||||
path: '/repos/fixture',
|
||||
displayName: 'Fixture',
|
||||
badgeColor: '',
|
||||
addedAt: 0
|
||||
}
|
||||
const pr: GitHubWorkItem = {
|
||||
id: 'pr-2',
|
||||
type: 'pr',
|
||||
number: 2,
|
||||
title: 'Fix export',
|
||||
state: 'open',
|
||||
url: 'https://github.com/fixture/repo/pull/2',
|
||||
labels: [],
|
||||
updatedAt: '',
|
||||
author: null,
|
||||
repoId: repo.id
|
||||
}
|
||||
const mr: GitLabWorkItem = {
|
||||
id: 'mr-2',
|
||||
number: 2,
|
||||
title: 'Fix export',
|
||||
labels: [],
|
||||
updatedAt: '',
|
||||
author: null,
|
||||
repoId: repo.id,
|
||||
type: 'mr',
|
||||
state: 'opened',
|
||||
url: 'https://gitlab.com/fixture/repo/-/merge_requests/2'
|
||||
}
|
||||
let originalApi: PropertyDescriptor | undefined
|
||||
const resolvePrBase = vi.fn<Window['api']['worktrees']['resolvePrBase']>()
|
||||
const resolveMrBase = vi.fn<Window['api']['worktrees']['resolveMrBase']>()
|
||||
|
||||
beforeEach(() => {
|
||||
originalApi = Object.getOwnPropertyDescriptor(window, 'api')
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { worktrees: { resolvePrBase, resolveMrBase } }
|
||||
})
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks()
|
||||
if (originalApi) {
|
||||
Object.defineProperty(window, 'api', originalApi)
|
||||
} else {
|
||||
Reflect.deleteProperty(window, 'api')
|
||||
}
|
||||
})
|
||||
|
||||
function deferred() {
|
||||
let resolve!: (value: GitHubPrStartPoint) => void
|
||||
const promise = new Promise<GitHubPrStartPoint>((next) => {
|
||||
resolve = next
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function useDeferredSource() {
|
||||
const [name, setName] = useState('')
|
||||
const [linkedWorkItem, setLinkedWorkItem] = useState<LinkedWorkItemSummary | null>(null)
|
||||
const [baseBranch, setBaseBranch] = useState<string | undefined>()
|
||||
const common = {
|
||||
name,
|
||||
setName,
|
||||
linkedWorkItem,
|
||||
setLinkedWorkItem,
|
||||
baseBranch,
|
||||
setBaseBranch,
|
||||
lastAutoNameRef: useRef(''),
|
||||
branchAutoNameRef: useRef(''),
|
||||
smartGitHubPrStartPointSelectionRef: useRef<SmartGitHubPrStartPointSelection | null>(null),
|
||||
lastAutoNoteRef: useRef(''),
|
||||
noteRef: useRef(''),
|
||||
initialProjectGroupAppliedRef: useRef(false),
|
||||
selectedRepoGitHubSourceContext: null,
|
||||
branchNameOverride: undefined,
|
||||
branchNameOverridePreservesNameEdits: false,
|
||||
forkPushWarning: null,
|
||||
pushTarget: undefined,
|
||||
baseBranchNamesWorkspace: false,
|
||||
isProjectGroupTarget: false,
|
||||
settings: null,
|
||||
eligibleRepos: [repo],
|
||||
selectedRepo: repo,
|
||||
setBranchNameOverride: vi.fn(),
|
||||
setBranchNameOverridePreservesNameEdits: vi.fn(),
|
||||
setCreateError: vi.fn(),
|
||||
setForkPushWarning: vi.fn(),
|
||||
setLinkDebouncedQuery: vi.fn(),
|
||||
setLinkDirectItem: vi.fn(),
|
||||
setLinkPopoverOpen: vi.fn(),
|
||||
setLinkQuery: vi.fn(),
|
||||
setLinkedGitLabIssue: vi.fn(),
|
||||
setLinkedGitLabMR: vi.fn(),
|
||||
setLinkedIssue: vi.fn(),
|
||||
setLinkedPR: vi.fn(),
|
||||
setLinkedTaskSourceContext: vi.fn(),
|
||||
setPushTarget: vi.fn(),
|
||||
setReuseEligibleBranch: vi.fn(),
|
||||
setReuseSelectedBranch: vi.fn(),
|
||||
setCompareBaseRef: vi.fn(),
|
||||
setStartFromResetHint: vi.fn(),
|
||||
setBaseBranchNamesWorkspace: vi.fn(),
|
||||
setNote: vi.fn(),
|
||||
setProjectError: vi.fn(),
|
||||
setSelectedProjectGroupId: vi.fn(),
|
||||
setSparseDirectories: vi.fn(),
|
||||
setSparseEnabled: vi.fn(),
|
||||
setSparseSelectedPresetId: vi.fn(),
|
||||
handleRepoChange: vi.fn()
|
||||
}
|
||||
const application = useGitHubSourceApplication(common)
|
||||
const identity = useSourceIdentityActions({ ...common, ...application })
|
||||
const branch = useBranchStartPointActions({ ...common, ...application, ...identity })
|
||||
const github = useGitHubProviderSelection({ ...common, ...application, ...branch })
|
||||
const gitlab = useGitLabProviderSelection({ ...common, ...identity, ...branch })
|
||||
return { ...github, ...gitlab, ...identity, ...branch, name, linkedWorkItem, baseBranch }
|
||||
}
|
||||
|
||||
describe('name ownership while resolving review bases', () => {
|
||||
it.each(['github', 'gitlab'])(
|
||||
'keeps an Advanced name while %s resolves its base',
|
||||
async (provider) => {
|
||||
const pending = deferred()
|
||||
resolvePrBase.mockReturnValue(pending.promise)
|
||||
resolveMrBase.mockReturnValue(pending.promise)
|
||||
const { result } = renderHook(useDeferredSource)
|
||||
act(() => {
|
||||
if (provider === 'github') {
|
||||
result.current.handleSmartGitHubItemSelect(pr)
|
||||
} else {
|
||||
result.current.handleSmartGitLabItemSelect(mr)
|
||||
}
|
||||
})
|
||||
act(() => result.current.handleNameValueChange('002'))
|
||||
await act(async () => {
|
||||
pending.resolve({ baseBranch: 'feature/export' })
|
||||
await pending.promise
|
||||
})
|
||||
|
||||
expect(result.current.name).toBe('002')
|
||||
expect(result.current.linkedWorkItem).toMatchObject({ provider, number: 2 })
|
||||
expect(result.current.baseBranch).toBe('feature/export')
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['github', 'gitlab'])(
|
||||
'still links and auto-names a direct %s Start-from selection',
|
||||
(provider) => {
|
||||
const { result } = renderHook(useDeferredSource)
|
||||
act(() => {
|
||||
if (provider === 'github') {
|
||||
result.current.handleBaseBranchPrSelect('feature/export', pr)
|
||||
} else {
|
||||
result.current.handleBaseBranchMrSelect('feature/export', mr)
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.current.name).toBe('fix-export')
|
||||
expect(result.current.linkedWorkItem).toMatchObject({ provider, number: 2 })
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -56,6 +56,7 @@ export function useComposerExternalSync(target: ComposerTargetState): ComposerEx
|
||||
const githubSourceApplication = useGitHubSourceApplication({
|
||||
branchAutoNameRef: target.asyncComposerState.branchAutoNameRef,
|
||||
lastAutoNameRef: target.asyncComposerState.lastAutoNameRef,
|
||||
linkedWorkItem: target.sourceContextState.linkedWorkItem,
|
||||
name: target.sourceContextState.name,
|
||||
selectedRepoGitHubSourceContext: target.sourceContextState.selectedRepoGitHubSourceContext,
|
||||
setBranchNameOverride: target.workspaceIdentityState.setBranchNameOverride,
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { GitHubWorkItem } from '../../../../shared/github/work-item-types'
|
||||
import type { LinkedWorkItemSummary } from '@/lib/new-workspace'
|
||||
import { getSmartGitHubSubmitResolution } from '@/lib/smart-github-submit'
|
||||
import { useGitHubSourceApplication } from './github-source-application'
|
||||
import { useSourceIdentityActions } from './source-identity-actions'
|
||||
import { useQuickSubmitSourcePreparation } from './quick-submit-source-preparation'
|
||||
import * as decisions from './composer-decisions'
|
||||
import type {
|
||||
PendingSmartGitHubSubmitResolution,
|
||||
SmartGitHubPrStartPointSelection
|
||||
} from './source-selection-decisions'
|
||||
|
||||
function issue(number: number): GitHubWorkItem {
|
||||
return {
|
||||
id: `issue-${number}`,
|
||||
type: 'issue',
|
||||
number,
|
||||
title: 'Fix export',
|
||||
state: 'open',
|
||||
url: `https://github.com/fixture/repo/issues/${number}`,
|
||||
labels: [],
|
||||
updatedAt: '',
|
||||
author: null,
|
||||
repoId: 'fixture-repo'
|
||||
}
|
||||
}
|
||||
|
||||
function useNameOwnership(draft?: { name: string; linkedWorkItem: LinkedWorkItemSummary }) {
|
||||
const [name, setName] = useState(draft?.name ?? '')
|
||||
const [linkedWorkItem, setLinkedWorkItem] = useState<LinkedWorkItemSummary | null>(
|
||||
draft?.linkedWorkItem ?? null
|
||||
)
|
||||
const lastAutoNameRef = useRef(
|
||||
decisions.getInitialAutoManagedWorkspaceName({
|
||||
initialName: '',
|
||||
draftName: draft?.name,
|
||||
draftLinkedWorkItem: draft?.linkedWorkItem
|
||||
})
|
||||
)
|
||||
const common = {
|
||||
name,
|
||||
setName,
|
||||
linkedWorkItem,
|
||||
setLinkedWorkItem,
|
||||
lastAutoNameRef,
|
||||
branchAutoNameRef: useRef(''),
|
||||
smartGitHubPrStartPointSelectionRef: useRef<SmartGitHubPrStartPointSelection | null>(null),
|
||||
selectedRepoGitHubSourceContext: null,
|
||||
branchNameOverride: undefined,
|
||||
branchNameOverridePreservesNameEdits: false,
|
||||
forkPushWarning: null,
|
||||
pushTarget: undefined,
|
||||
setBranchNameOverride: vi.fn(),
|
||||
setBranchNameOverridePreservesNameEdits: vi.fn(),
|
||||
setCreateError: vi.fn(),
|
||||
setForkPushWarning: vi.fn(),
|
||||
setLinkDebouncedQuery: vi.fn(),
|
||||
setLinkDirectItem: vi.fn(),
|
||||
setLinkPopoverOpen: vi.fn(),
|
||||
setLinkQuery: vi.fn(),
|
||||
setLinkedGitLabIssue: vi.fn(),
|
||||
setLinkedGitLabMR: vi.fn(),
|
||||
setLinkedIssue: vi.fn(),
|
||||
setLinkedPR: vi.fn(),
|
||||
setLinkedTaskSourceContext: vi.fn(),
|
||||
setPushTarget: vi.fn(),
|
||||
setReuseEligibleBranch: vi.fn(),
|
||||
setReuseSelectedBranch: vi.fn()
|
||||
}
|
||||
const application = useGitHubSourceApplication(common)
|
||||
const identity = useSourceIdentityActions({ ...common, ...application })
|
||||
const source = useQuickSubmitSourcePreparation({
|
||||
...common,
|
||||
baseBranch: 'main',
|
||||
compareBaseRef: undefined,
|
||||
disabledTuiAgents: [],
|
||||
effectiveLinkedPR: null,
|
||||
decisions,
|
||||
fallbackCreatureName: 'otter',
|
||||
linkedGitLabMR: null,
|
||||
parsedLinkedIssueNumber: linkedWorkItem?.number ?? null
|
||||
})
|
||||
return {
|
||||
...application,
|
||||
...identity,
|
||||
name,
|
||||
linkedWorkItem,
|
||||
prepare: (resolution: PendingSmartGitHubSubmitResolution = { kind: 'none' }) =>
|
||||
source.prepareQuickSubmitSource(resolution, null, name || 'otter')
|
||||
}
|
||||
}
|
||||
|
||||
describe('workspace name ownership after linking a source', () => {
|
||||
it.each(['347', '002'])('submits Advanced name %s as user input', (name) => {
|
||||
const { result } = renderHook(() => useNameOwnership())
|
||||
act(() => result.current.applyLinkedWorkItem(issue(Number(name))))
|
||||
act(() => result.current.handleNameValueChange(name))
|
||||
|
||||
expect(result.current.prepare()).toMatchObject({
|
||||
workspaceName: name,
|
||||
nameIsAutoManaged: false,
|
||||
submitLinkedWorkItem: { number: Number(name), url: issue(Number(name)).url }
|
||||
})
|
||||
})
|
||||
|
||||
it.each([2, 347])('preserves an edited name when source #%s replaces the source', (number) => {
|
||||
const { result } = renderHook(() => useNameOwnership())
|
||||
act(() => result.current.applyLinkedWorkItem(issue(2)))
|
||||
act(() => result.current.handleNameValueChange('002'))
|
||||
act(() => result.current.applyLinkedWorkItem(issue(number)))
|
||||
|
||||
expect(result.current.name).toBe('002')
|
||||
expect(result.current.prepare()).toMatchObject({
|
||||
workspaceName: '002',
|
||||
nameIsAutoManaged: false,
|
||||
submitLinkedWorkItem: { number }
|
||||
})
|
||||
})
|
||||
|
||||
it('restores manual ownership from an existing draft without a new persisted flag', () => {
|
||||
const { result } = renderHook(() => useNameOwnership({ name: '002', linkedWorkItem: issue(2) }))
|
||||
|
||||
expect(result.current.prepare()).toMatchObject({
|
||||
workspaceName: '002',
|
||||
nameIsAutoManaged: false
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps manual ownership when the linked source is removed', () => {
|
||||
const { result } = renderHook(() => useNameOwnership())
|
||||
act(() => result.current.applyLinkedWorkItem(issue(2)))
|
||||
act(() => result.current.handleNameValueChange('002'))
|
||||
act(() => result.current.handleRemoveLinkedWorkItem())
|
||||
|
||||
expect(result.current.prepare()).toMatchObject({
|
||||
workspaceName: '002',
|
||||
nameIsAutoManaged: false,
|
||||
submitLinkedWorkItem: null
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['347', '002', '#347'])('still auto-names a source picked from query %s', (query) => {
|
||||
const { result } = renderHook(() => useNameOwnership())
|
||||
act(() => result.current.handleNameValueChange(query))
|
||||
act(() => result.current.applyLinkedWorkItem(issue(query === '002' ? 2 : 347)))
|
||||
|
||||
expect(result.current.prepare()).toMatchObject({
|
||||
workspaceName: 'fix-export',
|
||||
nameIsAutoManaged: true
|
||||
})
|
||||
})
|
||||
|
||||
it('restores automatic naming when the Advanced name is cleared', () => {
|
||||
const { result } = renderHook(() => useNameOwnership())
|
||||
act(() => result.current.applyLinkedWorkItem(issue(2)))
|
||||
act(() => result.current.handleNameValueChange('002'))
|
||||
act(() => result.current.handleNameValueChange(''))
|
||||
|
||||
expect(result.current.prepare()).toMatchObject({
|
||||
workspaceName: 'fix-export',
|
||||
nameIsAutoManaged: true
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps automatic ownership when a generated name is restored or refreshed', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNameOwnership({ name: 'fix-export', linkedWorkItem: issue(2) })
|
||||
)
|
||||
act(() => result.current.applyLinkedWorkItem({ ...issue(2), title: 'Fix refreshed export' }))
|
||||
|
||||
expect(result.current.prepare()).toMatchObject({
|
||||
workspaceName: 'fix-refreshed-export',
|
||||
nameIsAutoManaged: true
|
||||
})
|
||||
})
|
||||
|
||||
it('still resolves a pasted lookup at submit time as an automatic name', () => {
|
||||
const { result } = renderHook(() => useNameOwnership())
|
||||
act(() => result.current.handleNameValueChange(issue(2).url))
|
||||
|
||||
expect(
|
||||
result.current.prepare({
|
||||
...getSmartGitHubSubmitResolution(issue(2)),
|
||||
kind: 'metadata-only'
|
||||
})
|
||||
).toMatchObject({ workspaceName: 'fix-export', nameIsAutoManaged: true })
|
||||
})
|
||||
|
||||
it('keeps an Advanced name when the selected pull request resolves its base at submit time', () => {
|
||||
const pr: GitHubWorkItem = {
|
||||
...issue(2),
|
||||
type: 'pr',
|
||||
url: 'https://github.com/fixture/repo/pull/2'
|
||||
}
|
||||
const { result } = renderHook(() => useNameOwnership())
|
||||
act(() => result.current.applyLinkedWorkItem(pr))
|
||||
act(() => result.current.handleNameValueChange('002'))
|
||||
|
||||
expect(
|
||||
result.current.prepare({
|
||||
...getSmartGitHubSubmitResolution(pr),
|
||||
kind: 'pr-start-point',
|
||||
baseBranch: 'feature/export'
|
||||
})
|
||||
).toMatchObject({ workspaceName: '002', nameIsAutoManaged: false, submitLinkedPR: 2 })
|
||||
})
|
||||
})
|
||||
@@ -5,9 +5,23 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { useIssueSourceActions } from './issue-source-actions'
|
||||
import { resolveDraftBaseBranchNamesWorkspace } from './workspace-identity-state'
|
||||
import { useGitHubProviderSelection } from './github-provider-selection'
|
||||
import type { LinearIssue } from '../../../../shared/linear/issue-types'
|
||||
|
||||
type Input = Parameters<typeof useIssueSourceActions>[0]
|
||||
|
||||
const linearIssue: LinearIssue = {
|
||||
id: 'linear-9',
|
||||
identifier: 'ENG-9',
|
||||
title: 'Fix refreshed export',
|
||||
url: 'https://linear.app/fixture/issue/ENG-9',
|
||||
state: { name: 'Open', type: 'unstarted', color: '' },
|
||||
team: { id: 'engineering', name: 'Engineering', key: 'ENG' },
|
||||
labels: [],
|
||||
labelIds: [],
|
||||
priority: 0,
|
||||
updatedAt: ''
|
||||
}
|
||||
|
||||
function createInput(overrides: Partial<Input> = {}): Input {
|
||||
return {
|
||||
baseBranch: undefined,
|
||||
@@ -42,6 +56,43 @@ function createInput(overrides: Partial<Input> = {}): Input {
|
||||
}
|
||||
|
||||
describe('composer name source selection', () => {
|
||||
it.each([false, true])(
|
||||
'keeps an Advanced Linear identifier name on source refresh (folder %s)',
|
||||
(isProjectGroupTarget) => {
|
||||
const input = createInput({
|
||||
isProjectGroupTarget,
|
||||
name: 'ENG-9',
|
||||
lastAutoNameRef: { current: 'eng-9-fix-export' },
|
||||
linkedWorkItem: {
|
||||
provider: 'linear',
|
||||
type: 'issue',
|
||||
number: 0,
|
||||
title: 'Fix export',
|
||||
url: linearIssue.url,
|
||||
linearIdentifier: 'ENG-9'
|
||||
}
|
||||
})
|
||||
const { result } = renderHook(() => useIssueSourceActions(input))
|
||||
act(() => result.current.handleSmartLinearIssueSelect(linearIssue))
|
||||
|
||||
expect(input.setName).not.toHaveBeenCalled()
|
||||
expect(input.setLinkedWorkItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ linearIdentifier: 'ENG-9' })
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([false, true])(
|
||||
'still replaces an unselected Linear identifier query (folder %s)',
|
||||
(isProjectGroupTarget) => {
|
||||
const input = createInput({ isProjectGroupTarget, name: 'ENG-9' })
|
||||
const { result } = renderHook(() => useIssueSourceActions(input))
|
||||
act(() => result.current.handleSmartLinearIssueSelect(linearIssue))
|
||||
|
||||
expect(input.setName).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([false, true])('clears only source-owned bases when selecting Jira (%s)', (owned) => {
|
||||
const input = createInput({ baseBranch: 'release/1.2', baseBranchNamesWorkspace: owned })
|
||||
const { result } = renderHook(() => useIssueSourceActions(input))
|
||||
|
||||
@@ -25,14 +25,16 @@ export type ComposerSourceModel = {
|
||||
nextBaseBranch: string,
|
||||
item: GitLabWorkItem,
|
||||
nextPushTarget?: GitPushTarget | undefined,
|
||||
nextCompareBaseRef?: string | undefined
|
||||
nextCompareBaseRef?: string | undefined,
|
||||
options?: { sourceAlreadyLinked?: boolean }
|
||||
) => void
|
||||
handleBaseBranchPrSelect: (
|
||||
nextBaseBranch: string,
|
||||
item: GitHubWorkItem,
|
||||
nextPushTarget?: GitPushTarget | undefined,
|
||||
nextBranchNameOverride?: string | undefined,
|
||||
nextCompareBaseRef?: string | undefined
|
||||
nextCompareBaseRef?: string | undefined,
|
||||
options?: { sourceAlreadyLinked?: boolean }
|
||||
) => void
|
||||
handleBranchNameOverrideChange: (value: string | undefined) => void
|
||||
handleClearSmartNameSelection: () => void
|
||||
|
||||
@@ -159,6 +159,7 @@ export function useComposerSourceState(
|
||||
handleBaseBranchPrSelect: branchStartPointActions.handleBaseBranchPrSelect,
|
||||
isProjectGroupTarget: target.runtimeTargetSelection.isProjectGroupTarget,
|
||||
lastAutoNameRef: target.asyncComposerState.lastAutoNameRef,
|
||||
linkedWorkItem: target.sourceContextState.linkedWorkItem,
|
||||
name: target.sourceContextState.name,
|
||||
selectedRepo: target.runtimeTargetSelection.selectedRepo,
|
||||
selectedRepoGitHubSourceContext: target.sourceContextState.selectedRepoGitHubSourceContext,
|
||||
@@ -188,6 +189,7 @@ export function useComposerSourceState(
|
||||
handleBaseBranchMrSelect: branchStartPointActions.handleBaseBranchMrSelect,
|
||||
isProjectGroupTarget: target.runtimeTargetSelection.isProjectGroupTarget,
|
||||
lastAutoNameRef: target.asyncComposerState.lastAutoNameRef,
|
||||
linkedWorkItem: target.sourceContextState.linkedWorkItem,
|
||||
name: target.sourceContextState.name,
|
||||
selectedRepo: target.runtimeTargetSelection.selectedRepo,
|
||||
setBaseBranch: target.workspaceIdentityState.setBaseBranch,
|
||||
|
||||
@@ -9,6 +9,7 @@ type GitHubProviderSelectionInput = Pick<
|
||||
| 'handleBaseBranchPrSelect'
|
||||
| 'isProjectGroupTarget'
|
||||
| 'lastAutoNameRef'
|
||||
| 'linkedWorkItem'
|
||||
| 'name'
|
||||
| 'selectedRepo'
|
||||
| 'selectedRepoGitHubSourceContext'
|
||||
@@ -55,6 +56,7 @@ export function useGitHubProviderSelection(input: GitHubProviderSelectionInput)
|
||||
handleBaseBranchPrSelect,
|
||||
isProjectGroupTarget,
|
||||
lastAutoNameRef,
|
||||
linkedWorkItem,
|
||||
name,
|
||||
selectedRepo,
|
||||
selectedRepoGitHubSourceContext,
|
||||
@@ -96,7 +98,8 @@ export function useGitHubProviderSelection(input: GitHubProviderSelectionInput)
|
||||
nextName &&
|
||||
shouldApplyWorkspaceSourceAutoName({
|
||||
currentName: name,
|
||||
lastAutoName: lastAutoNameRef.current
|
||||
lastAutoName: lastAutoNameRef.current,
|
||||
lookupTextIsQuery: !linkedWorkItem
|
||||
})
|
||||
) {
|
||||
setName(nextName)
|
||||
@@ -150,7 +153,8 @@ export function useGitHubProviderSelection(input: GitHubProviderSelectionInput)
|
||||
normalizedItem,
|
||||
result.pushTarget,
|
||||
result.branchNameOverride,
|
||||
result.compareBaseRef
|
||||
result.compareBaseRef,
|
||||
{ sourceAlreadyLinked: true }
|
||||
)
|
||||
// Why: a fork PR push lands on the contributor's fork; without maintainer-edits allowed GitHub rejects it, so warn up front.
|
||||
setForkPushWarning(getForkPushWarning(result))
|
||||
@@ -176,6 +180,7 @@ export function useGitHubProviderSelection(input: GitHubProviderSelectionInput)
|
||||
handleBaseBranchPrSelect,
|
||||
isProjectGroupTarget,
|
||||
name,
|
||||
linkedWorkItem,
|
||||
selectedRepo,
|
||||
selectedRepoGitHubSourceContext,
|
||||
branchAutoNameRef,
|
||||
|
||||
@@ -4,6 +4,7 @@ type GitHubSourceApplicationInput = Pick<
|
||||
ComposerModel,
|
||||
| 'branchAutoNameRef'
|
||||
| 'lastAutoNameRef'
|
||||
| 'linkedWorkItem'
|
||||
| 'name'
|
||||
| 'selectedRepoGitHubSourceContext'
|
||||
| 'setBranchNameOverride'
|
||||
@@ -27,6 +28,7 @@ export function useGitHubSourceApplication(input: GitHubSourceApplicationInput)
|
||||
const {
|
||||
branchAutoNameRef,
|
||||
lastAutoNameRef,
|
||||
linkedWorkItem,
|
||||
name,
|
||||
selectedRepoGitHubSourceContext,
|
||||
setBranchNameOverride,
|
||||
@@ -73,7 +75,8 @@ export function useGitHubSourceApplication(input: GitHubSourceApplicationInput)
|
||||
suggestedName &&
|
||||
shouldApplyWorkspaceSourceAutoName({
|
||||
currentName: name,
|
||||
lastAutoName: lastAutoNameRef.current
|
||||
lastAutoName: lastAutoNameRef.current,
|
||||
lookupTextIsQuery: !linkedWorkItem
|
||||
})
|
||||
) {
|
||||
setName(suggestedName)
|
||||
@@ -87,6 +90,7 @@ export function useGitHubSourceApplication(input: GitHubSourceApplicationInput)
|
||||
},
|
||||
[
|
||||
name,
|
||||
linkedWorkItem,
|
||||
selectedRepoGitHubSourceContext,
|
||||
branchAutoNameRef,
|
||||
lastAutoNameRef,
|
||||
|
||||
@@ -8,6 +8,7 @@ type GitLabProviderSelectionInput = Pick<
|
||||
| 'handleBaseBranchMrSelect'
|
||||
| 'isProjectGroupTarget'
|
||||
| 'lastAutoNameRef'
|
||||
| 'linkedWorkItem'
|
||||
| 'name'
|
||||
| 'selectedRepo'
|
||||
| 'setBaseBranch'
|
||||
@@ -47,6 +48,7 @@ export function useGitLabProviderSelection(input: GitLabProviderSelectionInput)
|
||||
handleBaseBranchMrSelect,
|
||||
isProjectGroupTarget,
|
||||
lastAutoNameRef,
|
||||
linkedWorkItem,
|
||||
name,
|
||||
selectedRepo,
|
||||
setBaseBranch,
|
||||
@@ -81,7 +83,8 @@ export function useGitLabProviderSelection(input: GitLabProviderSelectionInput)
|
||||
nextName &&
|
||||
shouldApplyWorkspaceSourceAutoName({
|
||||
currentName: name,
|
||||
lastAutoName: lastAutoNameRef.current
|
||||
lastAutoName: lastAutoNameRef.current,
|
||||
lookupTextIsQuery: !linkedWorkItem
|
||||
})
|
||||
) {
|
||||
setName(nextName)
|
||||
@@ -149,7 +152,8 @@ export function useGitLabProviderSelection(input: GitLabProviderSelectionInput)
|
||||
result.baseBranch,
|
||||
item,
|
||||
result.pushTarget,
|
||||
result.compareBaseRef
|
||||
result.compareBaseRef,
|
||||
{ sourceAlreadyLinked: true }
|
||||
)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
@@ -169,6 +173,7 @@ export function useGitLabProviderSelection(input: GitLabProviderSelectionInput)
|
||||
handleBaseBranchMrSelect,
|
||||
isProjectGroupTarget,
|
||||
name,
|
||||
linkedWorkItem,
|
||||
selectedRepo,
|
||||
branchAutoNameRef,
|
||||
lastAutoNameRef,
|
||||
|
||||
@@ -103,9 +103,10 @@ export function useIssueSourceActions(input: IssueSourceActionsInput) {
|
||||
if (
|
||||
shouldApplyWorkspaceSourceAutoName({
|
||||
currentName: name,
|
||||
lastAutoName: lastAutoNameRef.current
|
||||
lastAutoName: lastAutoNameRef.current,
|
||||
lookupTextIsQuery: !linkedWorkItem
|
||||
}) ||
|
||||
name.trim().toLowerCase() === issue.identifier.toLowerCase()
|
||||
(!linkedWorkItem && name.trim().toLowerCase() === issue.identifier.toLowerCase())
|
||||
) {
|
||||
setName(suggestedName)
|
||||
lastAutoNameRef.current = suggestedName
|
||||
@@ -124,9 +125,10 @@ export function useIssueSourceActions(input: IssueSourceActionsInput) {
|
||||
if (
|
||||
shouldApplyWorkspaceSourceAutoName({
|
||||
currentName: name,
|
||||
lastAutoName: lastAutoNameRef.current
|
||||
lastAutoName: lastAutoNameRef.current,
|
||||
lookupTextIsQuery: !linkedWorkItem
|
||||
}) ||
|
||||
name.trim().toLowerCase() === issue.identifier.toLowerCase()
|
||||
(!linkedWorkItem && name.trim().toLowerCase() === issue.identifier.toLowerCase())
|
||||
) {
|
||||
setName(suggestedName)
|
||||
lastAutoNameRef.current = suggestedName
|
||||
@@ -141,6 +143,7 @@ export function useIssueSourceActions(input: IssueSourceActionsInput) {
|
||||
[
|
||||
isProjectGroupTarget,
|
||||
name,
|
||||
linkedWorkItem,
|
||||
branchAutoNameRef,
|
||||
lastAutoNameRef,
|
||||
setBranchNameOverride,
|
||||
@@ -182,7 +185,8 @@ export function useIssueSourceActions(input: IssueSourceActionsInput) {
|
||||
suggestedName &&
|
||||
shouldApplyWorkspaceSourceAutoName({
|
||||
currentName: name,
|
||||
lastAutoName: lastAutoNameRef.current
|
||||
lastAutoName: lastAutoNameRef.current,
|
||||
lookupTextIsQuery: !linkedWorkItem
|
||||
})
|
||||
) {
|
||||
setName(suggestedName)
|
||||
@@ -191,6 +195,7 @@ export function useIssueSourceActions(input: IssueSourceActionsInput) {
|
||||
},
|
||||
[
|
||||
name,
|
||||
linkedWorkItem,
|
||||
baseBranchNamesWorkspace,
|
||||
branchAutoNameRef,
|
||||
lastAutoNameRef,
|
||||
|
||||
@@ -71,7 +71,8 @@ export function useQuickSubmitSourcePreparation(input: QuickSubmitSourcePreparat
|
||||
|
||||
const nameIsAutoManaged = !isExplicitWorkspaceNameInput({
|
||||
name,
|
||||
lastAutoName: lastAutoNameRef.current
|
||||
lastAutoName: lastAutoNameRef.current,
|
||||
lookupTextIsQuery: !linkedWorkItem && smartGitHubResolution.kind !== 'none'
|
||||
})
|
||||
|
||||
const smartGitHubCreateNames =
|
||||
|
||||
@@ -111,7 +111,8 @@ export function useSourceIdentityActions(input: SourceIdentityActionsInput) {
|
||||
nextName &&
|
||||
shouldApplyWorkspaceSourceAutoName({
|
||||
currentName: name,
|
||||
lastAutoName: lastAutoNameRef.current
|
||||
lastAutoName: lastAutoNameRef.current,
|
||||
lookupTextIsQuery: !linkedWorkItem
|
||||
})
|
||||
) {
|
||||
setName(nextName)
|
||||
@@ -123,6 +124,7 @@ export function useSourceIdentityActions(input: SourceIdentityActionsInput) {
|
||||
},
|
||||
[
|
||||
name,
|
||||
linkedWorkItem,
|
||||
branchAutoNameRef,
|
||||
lastAutoNameRef,
|
||||
setBranchNameOverride,
|
||||
|
||||
@@ -10,6 +10,24 @@ import {
|
||||
} from './workspace-source'
|
||||
|
||||
describe('workspace source policy', () => {
|
||||
it.each(['347', '002', '#347', 'https://github.com/fixture/repo/issues/347'])(
|
||||
'keeps manual name %s after the query field becomes a selected source',
|
||||
(currentName) => {
|
||||
expect(
|
||||
shouldApplyWorkspaceSourceAutoName({
|
||||
currentName,
|
||||
lastAutoName: 'fix-export',
|
||||
lookupTextIsQuery: false
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldApplyWorkspaceSourceAutoName({
|
||||
currentName,
|
||||
lastAutoName: 'fix-export'
|
||||
})
|
||||
).toBe(true)
|
||||
}
|
||||
)
|
||||
const linear = buildLinearWorkspaceSource({
|
||||
identifier: 'ENG-42',
|
||||
title: 'Ship mobile parity',
|
||||
|
||||
@@ -160,11 +160,13 @@ export function buildJiraWorkspaceSource(
|
||||
export function shouldApplyWorkspaceSourceAutoName(args: {
|
||||
currentName: string
|
||||
lastAutoName: string
|
||||
lookupTextIsQuery?: boolean
|
||||
}): boolean {
|
||||
return (
|
||||
!args.currentName.trim() ||
|
||||
args.currentName === args.lastAutoName ||
|
||||
isWorkItemLookupText(args.currentName)
|
||||
// A selected source exposes a name field; its manual edits are no longer lookup queries.
|
||||
(args.lookupTextIsQuery !== false && isWorkItemLookupText(args.currentName))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user