refactor(jira): separate worktree issue metadata into dedicated model

Introduce dedicated linkedJiraIssue and linkedJiraIssueSourceContext fields
to allow Jira issues to coexist with review items. Maintain backward
compatibility through fallback logic, dual-write during creation, and
runtime capability checks.
This commit is contained in:
Jinjing
2026-08-07 22:28:51 -07:00
parent 0a72f011a4
commit 7371605ba9
11 changed files with 384 additions and 36 deletions
+149 -2
View File
@@ -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
})
+63 -4
View File
@@ -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>): 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()
@@ -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',
@@ -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 } : {}),
@@ -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<AppState>)
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<typeof createWorktree> = ['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<string, unknown> }
)
.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({
+33 -10
View File
@@ -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<AppState, [], [], WorktreeSlice>
...(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<AppState, [], [], WorktreeSlice>
...(linkedGiteaPR !== undefined ? { linkedGiteaPR } : {}),
...(linkedWorkItem !== undefined ? { linkedWorkItem } : {}),
...(linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext } : {}),
...(linkedJiraIssue !== undefined ? { linkedJiraIssue } : {}),
...(linkedJiraIssueSourceContext !== undefined
...(includeDedicatedJira && linkedJiraIssue !== undefined
? { linkedJiraIssue }
: {}),
...(includeDedicatedJira && linkedJiraIssueSourceContext !== undefined
? { linkedJiraIssueSourceContext }
: {}),
...(automationProvenanceRequest ? { automationProvenanceRequest } : {}),
+1
View File
@@ -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,
+42 -1
View File
@@ -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',
+26 -14
View File
@@ -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)
+3
View File
@@ -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
@@ -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 ||