mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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:
co-authored by
Orca
Jinjing
parent
e109e78ebf
commit
e218dfbf8b
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user