fix:Keep Jira linked work items attached when the composer project changes (#7770)

* Keep Jira linked work items attached when the composer project changes

Repo/project switches in the new-workspace composer cleared every linked
work item except Linear, so starting a workspace from a Jira task and then
picking the actual implementation project silently dropped the ticket link.
Route all three switch paths through a shared isRepoScopedLinkedWorkItem
predicate: GitHub/GitLab sources stay repo-scoped and clear on a switch,
Linear/Jira issues stay attached.

* test(new-workspace): drop dead isLinearLinkedWorkItem, harden preserve-predicate coverage

Follow-up to the Jira-preservation fix (review findings):
- Remove isLinearLinkedWorkItem: no production consumer remains after all three
  composer switch paths route through shouldPreserveWorkspaceSourceOnRepoChange.
- Pin the clear cases in workspace-source.test.ts (GitLab explicit + inferred,
  null) that both delegating paths depend on, not just GitHub.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
hoonsbory
2026-07-20 15:05:23 -07:00
committed by GitHub
co-authored by Orca Jinjing
parent e109e78ebf
commit e218dfbf8b
4 changed files with 219 additions and 10 deletions
@@ -124,6 +124,32 @@ describe('useComposerState host-context boundaries', () => {
expect(section).toMatch(/\.catch\(\(error: unknown\) =>/)
})
it('clears only repo-scoped linked work items when the repo or project changes', () => {
// Why: Linear and Jira issues are workspace-scoped context — a repo or
// project switch must keep them attached. Jira used to be dropped because
// this path special-cased Linear only.
const repoChangeSection = sourceBetween(
HOOK_SOURCE,
'const handleRepoChange',
'const handleFolderSourceRepoChange'
)
expect(repoChangeSection).toContain(
'!shouldPreserveWorkspaceSourceOnRepoChange(linkedWorkItem)'
)
const folderSourceSection = sourceBetween(
HOOK_SOURCE,
'const handleFolderSourceRepoChange',
'const handleProjectHostSetupChange'
)
expect(folderSourceSection).toContain('!shouldPreserveWorkspaceSourceOnRepoChange(current)')
// No switch path may gate the linked-item clear on a Linear-only predicate
// again. (isLinearLinkedWorkItem itself may still appear — it drives the
// separate Linear branch-name feature — but never the preservation decision.)
expect(HOOK_SOURCE).not.toContain('if (!preserveLinearLinkedWorkItem)')
})
it('does not use local SSH gates for runtime-owned folder targets', () => {
const targetSection = sourceBetween(
HOOK_SOURCE,
@@ -568,7 +594,7 @@ describe('useComposerState host-context boundaries', () => {
'const handleProjectChange = useCallback',
'const handleSmartGitHubItemSelect'
)
expect(section).toContain("linkedProvider !== 'linear' && linkedProvider !== 'jira'")
expect(section).toContain('!shouldPreserveWorkspaceSourceOnRepoChange(linkedWorkItem)')
})
it('resolves quick-create base refs through the worktree-create precedence helper', () => {
+10 -9
View File
@@ -149,7 +149,8 @@ import type { SmartNameMode } from '@/components/new-workspace/smart-workspace-s
import { getForkPushWarning } from './fork-push-warning'
import {
buildWorkspaceSourceSelection,
shouldApplyWorkspaceSourceAutoName
shouldApplyWorkspaceSourceAutoName,
shouldPreserveWorkspaceSourceOnRepoChange
} from '../../../shared/new-workspace/workspace-source'
import { CONTEXTUAL_TOUR_ENABLE_AUTO_WORKSPACE_NAME_EVENT } from '@/components/contextual-tours/contextual-tour-composer-events'
import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
@@ -2525,8 +2526,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setLinkedPR(null)
setLinkedGitLabIssue(null)
setLinkedGitLabMR(null)
// Why: a repo change invalidates repo-scoped sources, but a Linear issue is workspace-scoped and must survive choosing the implementation project.
if (!preserveLinearLinkedWorkItem) {
// Why: a repo change invalidates repo-scoped sources, but Linear and
// Jira issues are workspace-scoped and must survive choosing the
// implementation project — not just Linear.
if (linkedWorkItem && !shouldPreserveWorkspaceSourceOnRepoChange(linkedWorkItem)) {
setLinkedWorkItem(null)
}
}
@@ -2559,10 +2562,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
}
setRepoId(value)
smartGitHubPrStartPointSelectionRef.current = null
setLinkedWorkItem((current) => {
const provider = current ? getLinkedWorkItemProvider(current) : null
return provider === 'github' || provider === 'gitlab' ? null : current
})
setLinkedWorkItem((current) =>
current && !shouldPreserveWorkspaceSourceOnRepoChange(current) ? null : current
)
setLinkedIssue('')
setLinkedPR(null)
setLinkedGitLabIssue(null)
@@ -2607,8 +2609,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setLinkedPR(null)
setLinkedGitLabIssue(null)
setLinkedGitLabMR(null)
const linkedProvider = linkedWorkItem ? getLinkedWorkItemProvider(linkedWorkItem) : null
if (linkedWorkItem && linkedProvider !== 'linear' && linkedProvider !== 'jira') {
if (linkedWorkItem && !shouldPreserveWorkspaceSourceOnRepoChange(linkedWorkItem)) {
setLinkedWorkItem(null)
}
setSparseEnabled(false)
@@ -34,6 +34,26 @@ describe('workspace source policy', () => {
it('preserves global work-item sources across repo changes', () => {
expect(shouldPreserveWorkspaceSourceOnRepoChange(linear)).toBe(true)
expect(
shouldPreserveWorkspaceSourceOnRepoChange({
provider: 'jira',
type: 'issue',
number: 0,
title: 'Workspace scoped',
url: 'https://acme.atlassian.net/browse/FUS-1'
})
).toBe(true)
// Why: Jira items picked from smart search may arrive without an explicit
// provider; preservation must still hold via URL/identifier inference.
expect(
shouldPreserveWorkspaceSourceOnRepoChange({
type: 'issue',
number: 0,
title: 'Inferred Jira',
url: 'https://acme.atlassian.net/browse/FUS-1',
jiraIdentifier: 'FUS-1'
})
).toBe(true)
expect(
shouldPreserveWorkspaceSourceOnRepoChange({
provider: 'github',
@@ -43,6 +63,27 @@ describe('workspace source policy', () => {
url: 'https://github.com/o/r/issues/1'
})
).toBe(false)
// Why: GitLab is repo-scoped; pin both the explicit MR and the
// URL-inferred shape clear, since folder-source/project-group paths delegate here.
expect(
shouldPreserveWorkspaceSourceOnRepoChange({
provider: 'gitlab',
type: 'mr',
number: 2,
title: 'Repo scoped MR',
url: 'https://gitlab.com/o/r/-/merge_requests/2'
})
).toBe(false)
expect(
shouldPreserveWorkspaceSourceOnRepoChange({
type: 'issue',
number: 3,
title: 'Inferred GitLab',
url: 'https://gitlab.example.com/g/p/-/work_items/3'
})
).toBe(false)
// Why: a null source (branch-only) has nothing to preserve; callers guard on this.
expect(shouldPreserveWorkspaceSourceOnRepoChange(null)).toBe(false)
})
it('shares provider inference, selection labels, and auto-name gates', () => {
@@ -0,0 +1,141 @@
/**
* E2E: linked work item behavior when switching the composer project.
*
* Why: Jira/Linear issues are workspace-scoped context — switching the
* implementation project must keep them attached. They used to be dropped,
* leaving only the derived name in the smart field. GitHub/GitLab sources
* are repo-scoped and must still clear on a project switch.
*
* Why E2E: the preservation logic lives in useComposerState behind the real
* ProjectCombobox interaction and main-process repo resolution — a store
* slice unit test cannot reach the combobox → handleProjectChange → smart
* field pill re-render path.
*/
import { execFileSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import type { Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import type { LinkedWorkItemSummary } from '../../src/renderer/src/lib/new-workspace'
const SECOND_PROJECT_NAME = 'linked-item-second-project'
function runGit(repoPath: string, args: string[]): void {
execFileSync('git', args, { cwd: repoPath, stdio: 'pipe' })
}
function createGitRepo(repoPath: string): void {
mkdirSync(repoPath, { recursive: true })
runGit(repoPath, ['init'])
runGit(repoPath, ['config', 'user.email', 'e2e@test.local'])
runGit(repoPath, ['config', 'user.name', 'E2E Test'])
writeFileSync(path.join(repoPath, 'README.md'), '# Linked item project switch E2E\n')
runGit(repoPath, ['add', '-A'])
runGit(repoPath, ['commit', '-m', 'Initial commit'])
}
async function addSecondProject(page: Page, repoPath: string): Promise<void> {
await page.evaluate(async (targetRepoPath) => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
const addedRepo = await store.getState().addRepoPath(targetRepoPath)
if (!addedRepo) {
throw new Error(`Failed to add repo at ${targetRepoPath}`)
}
}, repoPath)
}
async function openComposerWithLinkedWorkItem(
page: Page,
linkedWorkItem: LinkedWorkItemSummary,
prefilledName: string
): Promise<void> {
await page.evaluate(
({ linkedWorkItem, prefilledName }) => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
store.getState().openModal('new-workspace-composer', { linkedWorkItem, prefilledName })
},
{ linkedWorkItem, prefilledName }
)
}
async function switchComposerProject(page: Page, projectName: string): Promise<void> {
const composer = page.getByRole('dialog')
const combobox = composer.locator('button[data-project-combobox-root="true"]')
await combobox.click()
await page.getByRole('option', { name: new RegExp(projectName) }).click()
await expect(combobox).toContainText(projectName)
}
test.describe('New workspace composer linked item across project switches', () => {
let tempRoot: string
let secondRepoPath: string
test.beforeEach(async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
tempRoot = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-linked-item-'))
secondRepoPath = path.join(tempRoot, SECOND_PROJECT_NAME)
createGitRepo(secondRepoPath)
await addSecondProject(orcaPage, secondRepoPath)
})
test.afterEach(() => {
rmSync(tempRoot, { recursive: true, force: true })
})
test('keeps a Jira issue linked when the project changes', async ({ orcaPage }) => {
await openComposerWithLinkedWorkItem(
orcaPage,
{
type: 'issue',
provider: 'jira',
number: 0,
title: 'RDG-344 Migrate homepage from NuxtJS to NextJS',
url: 'https://example.atlassian.net/browse/RDG-344',
jiraIdentifier: 'RDG-344'
},
'rdg-344-nuxtjs-nextjs'
)
const composer = orcaPage.getByRole('dialog')
await expect(composer).toBeVisible()
const sourcePill = composer.locator('[data-workspace-source-pill="true"]')
await expect(sourcePill).toContainText('RDG-344 Migrate homepage from NuxtJS to NextJS')
await switchComposerProject(orcaPage, SECOND_PROJECT_NAME)
await expect(sourcePill).toContainText('RDG-344 Migrate homepage from NuxtJS to NextJS')
})
test('clears a repo-scoped GitHub issue when the project changes', async ({ orcaPage }) => {
await openComposerWithLinkedWorkItem(
orcaPage,
{
type: 'issue',
provider: 'github',
number: 41,
title: 'Fix crash on launch',
url: 'https://github.com/acme/app/issues/41'
},
'fix-crash-on-launch'
)
const composer = orcaPage.getByRole('dialog')
await expect(composer).toBeVisible()
const sourcePill = composer.locator('[data-workspace-source-pill="true"]')
await expect(sourcePill).toContainText('#41 Fix crash on launch')
await switchComposerProject(orcaPage, SECOND_PROJECT_NAME)
await expect(sourcePill).toHaveCount(0)
})
})