mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 16:02:42 +00:00
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:
@@ -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
@@ -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({
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
Reference in New Issue
Block a user