diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 81abaabc8d6..ab6e8799ccf 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -41,6 +41,7 @@ import { SshConnectionStore } from './ssh/ssh-connection-store' import { setSourceControlActionDefault } from '../shared/source-control-ai-actions' import { LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' import { closeTerminalTabInWorkspaceSession } from '../shared/workspace-session-terminal-tab-close' +import { resolveJiraIssueLink, resolveJiraIssueSourceContext } from '../shared/jira-issue-link' // Shared mutable state so the electron mock can reference a per-test directory const testState = { dir: '' } @@ -4930,6 +4931,147 @@ describe('Store', () => { ).toBeNull() }) + it('leaves dedicated Jira fields absent on load so legacy Jira metadata keeps its fallback', async () => { + const linkedWorkItem = { + provider: 'jira', + type: 'issue', + number: 0, + title: 'ORCA-123 Link Jira', + url: 'https://company.atlassian.net/browse/ORCA-123', + jiraIdentifier: 'ORCA-123' + } + const linkedTaskSourceContext = { + kind: 'task-source', + provider: 'jira', + projectId: 'project-1', + hostId: 'runtime:env-1', + providerIdentity: { + provider: 'jira', + siteId: 'site-1', + siteUrl: 'https://company.atlassian.net', + projectKey: 'ORCA' + } + } + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: { 'wt-legacy-jira': { linkedWorkItem, linkedTaskSourceContext } } + }) + const store = await createStore() + const meta = store.getWorktreeMeta('wt-legacy-jira') + + expect(meta).toBeDefined() + expect(meta).not.toHaveProperty('linkedJiraIssue') + expect(meta).not.toHaveProperty('linkedJiraIssueSourceContext') + expect(resolveJiraIssueLink(meta!)).toEqual({ + key: 'ORCA-123', + title: 'Link Jira', + url: 'https://company.atlassian.net/browse/ORCA-123' + }) + expect(resolveJiraIssueSourceContext(meta!)).toMatchObject(linkedTaskSourceContext) + }) + + it('drops an unreadable dedicated Jira link on load instead of masking legacy metadata', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: { + 'wt-corrupt-jira': { + linkedJiraIssue: { key: 'ORCA-123', title: 'Corrupt', url: 'not-a-url' }, + linkedWorkItem: { + provider: 'jira', + type: 'issue', + number: 0, + title: 'ORCA-123 Link Jira', + url: 'https://company.atlassian.net/browse/ORCA-123', + jiraIdentifier: 'ORCA-123' + } + } + } + }) + const store = await createStore() + const meta = store.getWorktreeMeta('wt-corrupt-jira') + + expect(meta).not.toHaveProperty('linkedJiraIssue') + expect(resolveJiraIssueLink(meta!)?.key).toBe('ORCA-123') + }) + + it('promotes legacy Jira into the dedicated pair before a review item displaces it', async () => { + const linkedWorkItem = { + provider: 'jira', + type: 'issue', + number: 0, + title: 'ORCA-123 Link Jira', + url: 'https://company.atlassian.net/browse/ORCA-123', + jiraIdentifier: 'ORCA-123' + } + const linkedTaskSourceContext = { + kind: 'task-source', + provider: 'jira', + projectId: 'project-1', + hostId: 'runtime:env-1', + providerIdentity: { + provider: 'jira', + siteId: 'site-1', + siteUrl: 'https://company.atlassian.net', + projectKey: 'ORCA' + } + } + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: { 'wt-coexist': { linkedWorkItem, linkedTaskSourceContext } } + }) + const store = await createStore() + + store.setWorktreeMeta('wt-coexist', { + linkedWorkItem: { + provider: 'gitlab', + type: 'mr', + number: 42, + title: 'Review', + url: 'https://gitlab.example.com/group/repo/-/merge_requests/42' + }, + linkedTaskSourceContext: null + }) + const meta = store.getWorktreeMeta('wt-coexist') + + expect(meta?.linkedWorkItem).toMatchObject({ provider: 'gitlab', number: 42 }) + expect(meta?.linkedJiraIssue).toEqual({ + key: 'ORCA-123', + title: 'Link Jira', + url: 'https://company.atlassian.net/browse/ORCA-123' + }) + expect(meta?.linkedJiraIssueSourceContext).toMatchObject(linkedTaskSourceContext) + }) + + it('clears the legacy Jira copy when the dedicated link is explicitly unlinked', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: { + 'wt-unlink': { + linkedWorkItem: { + provider: 'jira', + type: 'issue', + number: 0, + title: 'ORCA-123 Link Jira', + url: 'https://company.atlassian.net/browse/ORCA-123', + jiraIdentifier: 'ORCA-123' + } + } + } + }) + const store = await createStore() + + store.setWorktreeMeta('wt-unlink', { linkedJiraIssue: null }) + const meta = store.getWorktreeMeta('wt-unlink') + + expect(meta?.linkedJiraIssue).toBeNull() + expect(meta?.linkedWorkItem).toBeNull() + expect(resolveJiraIssueLink(meta!)).toBeNull() + }) + it('persists dedicated Jira metadata without rewriting review or unsupported linked items', async () => { const reviewItem = { provider: 'gitlab', @@ -4993,9 +5135,14 @@ describe('Store', () => { linkedJiraIssueSourceContext: sourceContext }) + // Known providers keep their load-time hygiene pass; only the opaque row is left verbatim. expect(store.getWorktreeMeta('review')).toMatchObject({ - linkedWorkItem: reviewItem, - linkedTaskSourceContext: reviewContext, + linkedWorkItem: { ...reviewItem, title: 'Preserve review bytes' }, + linkedTaskSourceContext: expect.objectContaining({ + provider: 'gitlab', + projectId: 'project-1', + accountLabel: 'review@example.com' + }), linkedJiraIssue: issue, linkedJiraIssueSourceContext: sourceContext }) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index e788aa4f3ad..c258c3214d5 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -95,6 +95,7 @@ import { isWorkspaceLinkedItemSourceContextMatch } from '../shared/workspace-lin import { areJiraIssueLinksEqual, isJiraIssueLinkSourceContextMatch, + jiraIssueLinkFromLegacyWorkItem, normalizeJiraIssueLink } from '../shared/jira-issue-link' import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types' @@ -456,7 +457,7 @@ function normalizeWorktreeLinkedItemMetadata(state: PersistedState): boolean { changed = true continue } - if (isLegacyJiraLinkedWorkItem(meta.linkedWorkItem)) { + if (isNormalizableWorkspaceLinkedItem(meta.linkedWorkItem)) { const linkedWorkItem = normalizeWorkspaceLinkedItem(meta.linkedWorkItem) const sourceContext = normalizeStoredTaskSourceContext(meta.linkedTaskSourceContext) const linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch( @@ -474,6 +475,13 @@ function normalizeWorktreeLinkedItemMetadata(state: PersistedState): boolean { changed = true } } + // An unreadable dedicated link is dropped, not stored as null: resolveJiraIssueLink reads + // null as an unlink, so persisting it would permanently mask a still-valid legacy Jira row. + if (meta.linkedJiraIssue != null && normalizeJiraIssueLink(meta.linkedJiraIssue) === null) { + delete meta.linkedJiraIssue + delete meta.linkedJiraIssueSourceContext + changed = true + } const linkedJiraIssue = normalizeJiraIssueLink(meta.linkedJiraIssue) const jiraSourceContext = normalizeStoredTaskSourceContext(meta.linkedJiraIssueSourceContext) const linkedJiraIssueSourceContext = isJiraIssueLinkSourceContextMatch( @@ -482,6 +490,8 @@ function normalizeWorktreeLinkedItemMetadata(state: PersistedState): boolean { ) ? jiraSourceContext : null + // Both equality checks treat undefined and null as equal, so absent fields stay absent — required + // because resolveJiraIssueLink reads a defined linkedJiraIssue (even null) as an opt out of the legacy fallback. if (!areJiraIssueLinksEqual(meta.linkedJiraIssue, linkedJiraIssue)) { meta.linkedJiraIssue = linkedJiraIssue changed = true @@ -502,6 +512,20 @@ function isLegacyJiraLinkedWorkItem(value: unknown): boolean { ) } +const KNOWN_WORKSPACE_LINKED_ITEM_PROVIDERS = new Set(['github', 'gitlab', 'linear', 'jira']) + +// Why: normalizeWorkspaceLinkedItem returns null for providers it does not know, so running it +// over an intentionally opaque item would delete it. Known providers still get their hygiene pass. +function isNormalizableWorkspaceLinkedItem(value: unknown): boolean { + return ( + value == null || + (typeof value === 'object' && + KNOWN_WORKSPACE_LINKED_ITEM_PROVIDERS.has( + (value as { provider?: unknown }).provider as string + )) + ) +} + function readGithubCacheSnapshot(dataFile: string): PersistedState['githubCache'] | null { try { const parsed = JSON.parse(readFileSync(getGithubCacheFile(dataFile), 'utf-8')) as unknown @@ -5449,9 +5473,30 @@ export class Store { setWorktreeMeta(worktreeId: string, meta: Partial): WorktreeMeta { const existing = this.state.worktreeMeta[worktreeId] || getDefaultWorktreeMeta() const updated = { ...existing, ...meta } + // Why: rows written before the dedicated model hold Jira only in linkedWorkItem, and the + // point of the split is that a review item can coexist with it. Materialise the dedicated + // pair before the non-Jira item lands, or this write drops the only copy of the Jira link. + if ( + 'linkedWorkItem' in meta && + !('linkedJiraIssue' in meta) && + existing.linkedJiraIssue === undefined && + meta.linkedWorkItem != null && + meta.linkedWorkItem.provider !== 'jira' + ) { + const promoted = jiraIssueLinkFromLegacyWorkItem(existing.linkedWorkItem) + if (promoted) { + updated.linkedJiraIssue = promoted + updated.linkedJiraIssueSourceContext = isJiraIssueLinkSourceContextMatch( + promoted, + existing.linkedTaskSourceContext + ) + ? (existing.linkedTaskSourceContext ?? null) + : null + } + } if ( ('linkedWorkItem' in meta || 'linkedTaskSourceContext' in meta) && - (isLegacyJiraLinkedWorkItem(updated.linkedWorkItem) || updated.linkedWorkItem == null) + isNormalizableWorkspaceLinkedItem(updated.linkedWorkItem) ) { updated.linkedWorkItem = normalizeWorkspaceLinkedItem(updated.linkedWorkItem) const linkedTaskSourceContext = normalizeStoredTaskSourceContext( @@ -5465,14 +5510,28 @@ export class Store { : null } if ('linkedJiraIssue' in meta || 'linkedJiraIssueSourceContext' in meta) { - updated.linkedJiraIssue = normalizeJiraIssueLink(updated.linkedJiraIssue) + // Only materialise the dedicated key when the caller set it: writing null on a + // context-only update reads back as an unlink and would mask legacy Jira. + if ('linkedJiraIssue' in meta) { + updated.linkedJiraIssue = normalizeJiraIssueLink(updated.linkedJiraIssue) + } + const dedicatedIssue = normalizeJiraIssueLink(updated.linkedJiraIssue) const sourceContext = normalizeStoredTaskSourceContext(updated.linkedJiraIssueSourceContext) updated.linkedJiraIssueSourceContext = isJiraIssueLinkSourceContextMatch( - updated.linkedJiraIssue, + dedicatedIssue, sourceContext ) ? sourceContext : null + // An explicit unlink has to clear the legacy copy too, or dual-read resurrects the issue. + if ( + 'linkedJiraIssue' in meta && + updated.linkedJiraIssue === null && + isLegacyJiraLinkedWorkItem(updated.linkedWorkItem) + ) { + updated.linkedWorkItem = null + updated.linkedTaskSourceContext = null + } } if (!updated.instanceId) { updated.instanceId = randomUUID() diff --git a/src/renderer/src/lib/worktree-creation-flow.test.ts b/src/renderer/src/lib/worktree-creation-flow.test.ts index b1283e3eb2d..8fb24212f41 100644 --- a/src/renderer/src/lib/worktree-creation-flow.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow.test.ts @@ -447,7 +447,7 @@ describe('staged background worktree creation', () => { expect(store.setSidebarOpen).toHaveBeenCalledWith(true) }) - it('writes dedicated Jira context through staged creation and retry', async () => { + it('dual-writes legacy and dedicated Jira context through staged creation and retry', async () => { const linkedWorkItem = { provider: 'jira' as const, type: 'issue' as const, @@ -471,7 +471,10 @@ describe('staged background worktree creation', () => { accountLabel: 'dev@company.test' } const request = makeRequest({ linkedWorkItem, linkedTaskSourceContext }) + // Dual-write: the legacy pair still ships so old clients and hosts keep reading the link. const expectedOptions = { + linkedWorkItem, + linkedTaskSourceContext, linkedJiraIssue: { key: 'ORCA-123', title: 'Durable Jira link', diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index d0709a0aa8c..5ad7bb2e534 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -107,8 +107,9 @@ async function executeWorktreeCreation( let result: CreateWorktreeResult try { const backendStartup = resolveBackendDraftStartup(preparedRequest) + // Dual-write while the legacy shape is still supported: old clients and hosts read Jira + // only from linkedWorkItem, and dropping it here would blank their card for new links. const linkedJiraIssue = jiraIssueLinkFromLegacyWorkItem(preparedRequest.linkedWorkItem) - const usesDedicatedJiraLink = linkedJiraIssue !== null result = await useAppStore .getState() .createWorktree( @@ -138,10 +139,10 @@ async function executeWorktreeCreation( preparedRequest.linkedGiteaPR, preparedRequest.compareBaseRef, { - ...(!usesDedicatedJiraLink && preparedRequest.linkedWorkItem !== undefined + ...(preparedRequest.linkedWorkItem !== undefined ? { linkedWorkItem: preparedRequest.linkedWorkItem } : {}), - ...(!usesDedicatedJiraLink && preparedRequest.linkedTaskSourceContext !== undefined + ...(preparedRequest.linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext: preparedRequest.linkedTaskSourceContext } : {}), ...(linkedJiraIssue ? { linkedJiraIssue } : {}), diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index caff0234f82..6e714c70b30 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -5920,6 +5920,63 @@ describe('worktree remote runtime mutations', () => { expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) + it('falls back to the legacy Jira payload when the host lacks the dedicated capability', async () => { + const oldRuntimeStatus = createCompatibleRuntimeStatusResponse('runtime-old') + if (oldRuntimeStatus.ok) { + oldRuntimeStatus.result.capabilities = oldRuntimeStatus.result.capabilities?.filter( + (capability) => capability !== 'worktree.jira-issue-link.v1' + ) + } + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-create', + ok: true, + result: { + worktree: makeWorktree({ + id: 'repo1::/path/jira-link', + repoId: 'repo1', + path: '/path/jira-link' + }) + }, + _meta: { runtimeId: 'runtime-old' } + }) + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => + args.method === 'status.get' ? oldRuntimeStatus : runtimeEnvironmentCall(args) + ) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + worktreesByRepo: { repo1: [] } + } as Partial) + const linkedWorkItem = { + provider: 'jira' as const, + type: 'issue' as const, + number: 0, + title: 'ORCA-123 Link Jira', + url: 'https://company.atlassian.net/browse/ORCA-123', + jiraIdentifier: 'ORCA-123' + } + const createWorktree = store.getState().createWorktree + const args: Parameters = ['repo1', 'jira-link'] + args[25] = { + linkedWorkItem, + linkedJiraIssue: { + key: 'ORCA-123', + title: 'Link Jira', + url: 'https://company.atlassian.net/browse/ORCA-123' + } + } + + await createWorktree(...args) + + const params = runtimeEnvironmentCall.mock.calls + .map( + (call) => call[0] as RuntimeEnvironmentCallRequest & { params?: Record } + ) + .find((call) => call.method === 'worktree.create')?.params + expect(params).toMatchObject({ linkedWorkItem }) + expect(params).not.toHaveProperty('linkedJiraIssue') + }) + it('passes startup commands through remote runtime worktree creation', async () => { const store = createTestStore() const wt = makeWorktree({ diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 366a2bd5166..770b5d0a300 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -29,7 +29,10 @@ import { import { projectWorktreeTabModelReconciliation } from './tabs' import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id' import { areWorkspaceLinkedItemsEqual } from '../../../../shared/workspace-linked-item' -import { areJiraIssueLinksEqual } from '../../../../shared/jira-issue-link' +import { + areJiraIssueLinksEqual, + jiraIssueLinkFromLegacyWorkItem +} from '../../../../shared/jira-issue-link' import { areTaskSourceContextsEqual } from '../../../../shared/task-source-context' import { remapClosedTerminalTabSnapshotCwds, @@ -50,6 +53,7 @@ import { getActiveRuntimeTarget, hasRuntimeRpcErrorCode, isRuntimeScopeForbiddenError, + runtimeEnvironmentSupportsCapability, RuntimeRpcCallError } from '../../runtime/runtime-rpc-client' import { @@ -4064,15 +4068,32 @@ export const createWorktreeSlice: StateCreator ...(automationProvenanceRequest ? { automationProvenanceRequest } : {}) } const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), repoId)) - if ( - target.kind === 'environment' && - (linkedJiraIssue !== undefined || linkedJiraIssueSourceContext !== undefined) - ) { - await assertRuntimeEnvironmentCapability( + // Why: the dedicated fields are additive over a legacy work item that names the same + // issue, so a host without the capability still records the link through the legacy + // pair. Fail only when dropping the dedicated fields would lose the link outright. + let includeDedicatedJira = + linkedJiraIssue !== undefined || linkedJiraIssueSourceContext !== undefined + if (target.kind === 'environment' && includeDedicatedJira) { + const supportsDedicatedJira = await runtimeEnvironmentSupportsCapability( target.environmentId, - WORKTREE_JIRA_ISSUE_LINK_RUNTIME_CAPABILITY, - 'Update the remote runtime to link Jira' + WORKTREE_JIRA_ISSUE_LINK_RUNTIME_CAPABILITY ) + if (!supportsDedicatedJira) { + const coveredByLegacy = + areJiraIssueLinksEqual( + jiraIssueLinkFromLegacyWorkItem(linkedWorkItem), + linkedJiraIssue ?? null + ) && + (linkedJiraIssueSourceContext == null || + areTaskSourceContextsEqual( + linkedTaskSourceContext ?? null, + linkedJiraIssueSourceContext + )) + if (!coveredByLegacy) { + throw new Error('Update the remote runtime to link Jira') + } + includeDedicatedJira = false + } } if ( target.kind === 'environment' && @@ -4126,8 +4147,10 @@ export const createWorktreeSlice: StateCreator ...(linkedGiteaPR !== undefined ? { linkedGiteaPR } : {}), ...(linkedWorkItem !== undefined ? { linkedWorkItem } : {}), ...(linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext } : {}), - ...(linkedJiraIssue !== undefined ? { linkedJiraIssue } : {}), - ...(linkedJiraIssueSourceContext !== undefined + ...(includeDedicatedJira && linkedJiraIssue !== undefined + ? { linkedJiraIssue } + : {}), + ...(includeDedicatedJira && linkedJiraIssueSourceContext !== undefined ? { linkedJiraIssueSourceContext } : {}), ...(automationProvenanceRequest ? { automationProvenanceRequest } : {}), diff --git a/src/shared/folder-workspace-worktree.ts b/src/shared/folder-workspace-worktree.ts index 55a17d5ac39..8eb84a8f4bd 100644 --- a/src/shared/folder-workspace-worktree.ts +++ b/src/shared/folder-workspace-worktree.ts @@ -23,6 +23,7 @@ export function folderWorkspaceToWorktree(folderWorkspace: FolderWorkspace): Wor linkedGiteaPR: null, linkedWorkItem: linkedTask, linkedTaskSourceContext: folderWorkspace.linkedTaskSourceContext ?? null, + // Read-only projection: FolderWorkspace stores Jira in linkedTask alone (see its type). linkedJiraIssue: resolveJiraIssueLink({ linkedWorkItem: linkedTask }), linkedJiraIssueSourceContext: resolveJiraIssueSourceContext({ linkedWorkItem: linkedTask, diff --git a/src/shared/jira-issue-link.test.ts b/src/shared/jira-issue-link.test.ts index 31ee9befa10..f1fb3538022 100644 --- a/src/shared/jira-issue-link.test.ts +++ b/src/shared/jira-issue-link.test.ts @@ -3,7 +3,8 @@ import { isJiraIssueLinkSourceContextMatch, jiraIssueLinkFromLegacyWorkItem, normalizeJiraIssueLink, - resolveJiraIssueLink + resolveJiraIssueLink, + resolveJiraIssueSourceContext } from './jira-issue-link' const sourceContext = { @@ -92,6 +93,46 @@ describe('JiraIssueLink', () => { expect(resolveJiraIssueLink({ linkedJiraIssue: null, linkedWorkItem: legacy })).toBeNull() }) + it('falls back to legacy metadata when the dedicated link is unreadable', () => { + const legacy = { + provider: 'jira' as const, + type: 'issue' as const, + number: 0, + title: 'ORCA-123 Legacy', + url: 'https://company.atlassian.net/browse/ORCA-123', + jiraIdentifier: 'ORCA-123' + } + const corrupt = { key: 'ORCA-123', title: 'Corrupt', url: 'not-a-url' } as never + + expect(resolveJiraIssueLink({ linkedJiraIssue: corrupt, linkedWorkItem: legacy })?.key).toBe( + 'ORCA-123' + ) + expect( + resolveJiraIssueSourceContext({ + linkedJiraIssue: corrupt, + linkedJiraIssueSourceContext: null, + linkedWorkItem: legacy, + linkedTaskSourceContext: sourceContext + }) + ).toEqual(sourceContext) + // An unreadable dedicated link with nothing behind it is still no link. + expect(resolveJiraIssueLink({ linkedJiraIssue: corrupt })).toBeNull() + }) + + it('keeps a key-only link usable when the issue has no summary', () => { + expect( + normalizeJiraIssueLink({ + key: 'ORCA-123', + title: ' ', + url: 'https://company.atlassian.net/browse/ORCA-123' + }) + ).toEqual({ + key: 'ORCA-123', + title: 'ORCA-123', + url: 'https://company.atlassian.net/browse/ORCA-123' + }) + }) + it('matches Jira source context by site and project', () => { const issue = { key: 'ORCA-123', diff --git a/src/shared/jira-issue-link.ts b/src/shared/jira-issue-link.ts index 8723b12b19a..d8d9911f9ff 100644 --- a/src/shared/jira-issue-link.ts +++ b/src/shared/jira-issue-link.ts @@ -10,12 +10,13 @@ export function normalizeJiraIssueLink(value: unknown): JiraIssueLink | null { const key = typeof raw.key === 'string' ? raw.key.trim().toUpperCase() : '' const title = typeof raw.title === 'string' ? raw.title.trim() : '' const parsedUrl = typeof raw.url === 'string' ? parseJiraIssueUrl(raw.url) : null - if (!key || !title || !parsedUrl || parsedUrl.issueKey !== key) { + if (!key || !parsedUrl || parsedUrl.issueKey !== key) { return null } return { key, - title, + // Identity is site + key; a blank summary must not throw the whole link away. + title: title || key, url: `${parsedUrl.origin}${parsedUrl.sitePath}/browse/${parsedUrl.issueKey}` } } @@ -55,8 +56,17 @@ export function resolveJiraIssueLink(fields: { linkedJiraIssue?: JiraIssueLink | null linkedWorkItem?: WorkspaceLinkedItem | null }): JiraIssueLink | null { - return fields.linkedJiraIssue !== undefined - ? normalizeJiraIssueLink(fields.linkedJiraIssue) + if (fields.linkedJiraIssue === undefined) { + return jiraIssueLinkFromLegacyWorkItem(fields.linkedWorkItem) + } + const dedicated = normalizeJiraIssueLink(fields.linkedJiraIssue) + if (dedicated) { + return dedicated + } + // Explicit null is a real unlink and stays one; a corrupt object is an unreadable + // opinion, so it must not permanently mask a legacy Jira link that still parses. + return fields.linkedJiraIssue === null + ? null : jiraIssueLinkFromLegacyWorkItem(fields.linkedWorkItem) } @@ -69,13 +79,9 @@ export function isJiraIssueLinkSourceContextMatch( } const identity = context.providerIdentity const issueUrl = parseJiraIssueUrl(issue.url) - if ( - identity?.provider !== 'jira' || - !identity.siteId || - !identity.siteUrl || - !identity.projectKey || - !issueUrl - ) { + // No siteId gate: identity is origin + site path + project key, and Jira Server + // contexts carry no cloud siteId to offer. + if (identity?.provider !== 'jira' || !identity.siteUrl || !identity.projectKey || !issueUrl) { return false } const siteUrl = parseJiraIssueUrl( @@ -97,12 +103,18 @@ export function resolveJiraIssueSourceContext(fields: { linkedWorkItem?: WorkspaceLinkedItem | null linkedTaskSourceContext?: TaskSourceContext | null }): TaskSourceContext | null { - if (fields.linkedJiraIssue !== undefined) { - const explicitIssue = normalizeJiraIssueLink(fields.linkedJiraIssue) - return isJiraIssueLinkSourceContextMatch(explicitIssue, fields.linkedJiraIssueSourceContext) + // The context belongs to whichever field actually supplied the issue, so a + // corrupt dedicated link falls back to the legacy pair as a unit. + const dedicatedIssue = + fields.linkedJiraIssue === undefined ? null : normalizeJiraIssueLink(fields.linkedJiraIssue) + if (dedicatedIssue) { + return isJiraIssueLinkSourceContextMatch(dedicatedIssue, fields.linkedJiraIssueSourceContext) ? (fields.linkedJiraIssueSourceContext ?? null) : null } + if (fields.linkedJiraIssue === null) { + return null + } const legacyIssue = jiraIssueLinkFromLegacyWorkItem(fields.linkedWorkItem) return isJiraIssueLinkSourceContextMatch(legacyIssue, fields.linkedTaskSourceContext) ? (fields.linkedTaskSourceContext ?? null) diff --git a/src/shared/types.ts b/src/shared/types.ts index cd99f2abb75..06e0955096a 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -338,6 +338,9 @@ export type FolderWorkspace = { connectionId?: string | null /** Renderer-owned host stamp for host-qualified folder catalogs. */ executionHostId?: ExecutionHostId | null + /** Project-group folders stay on the legacy single-slot model on purpose: they hold one linked + * task and have no review-item slot for a Jira issue to coexist with, so the dedicated + * `linkedJiraIssue` pair earns nothing here. `folderWorkspaceToWorktree` projects it on read. */ linkedTask: WorkspaceLinkedItem | null linkedTaskSourceContext?: TaskSourceContext | null comment: string diff --git a/src/shared/workspace-linked-item-source-context.ts b/src/shared/workspace-linked-item-source-context.ts index 81d29c4be05..f1422fa502f 100644 --- a/src/shared/workspace-linked-item-source-context.ts +++ b/src/shared/workspace-linked-item-source-context.ts @@ -41,11 +41,12 @@ export function isWorkspaceLinkedItemSourceContextMatch( } const identity = context.providerIdentity const itemUrl = parseJiraIssueUrl(item.url) + // Kept in lockstep with isJiraIssueLinkSourceContextMatch: dual-read resolves the + // same context through both, so a siteId gate here only would make them disagree. if ( item.type !== 'issue' || item.number !== 0 || identity?.provider !== 'jira' || - !identity.siteId || !identity.siteUrl || !identity.projectKey || !item.jiraIdentifier ||