From 0a72f011a4ef44740d48d779f7d3c0a87a892b86 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:54:18 -0700 Subject: [PATCH] refactor(jira): separate worktree issue metadata --- .../ipc/worktree-linked-work-item-metadata.ts | 7 +- src/main/ipc/worktree-logic.test.ts | 2 + src/main/ipc/worktree-remote.ts | 8 ++ src/main/ipc/worktrees.ts | 34 ++++- src/main/persistence.test.ts | 77 ++++++++++++ src/main/persistence.ts | 86 ++++++++++--- src/main/runtime/orca-runtime.ts | 20 +++ .../worktree-jira-issue-link-schema.ts | 28 +++++ .../methods/worktree-jira-issue-link.test.ts | 49 ++++++++ .../rpc/methods/worktree-schemas.test.ts | 49 ++++++++ .../runtime/rpc/methods/worktree-schemas.ts | 8 ++ src/main/runtime/rpc/methods/worktree.ts | 4 + .../worktree-card-jira-issue-display.test.ts | 26 ++++ .../worktree-card-jira-issue-display.ts | 24 ++-- .../src/lib/worktree-creation-flow.test.ts | 11 +- .../src/lib/worktree-creation-flow.ts | 11 +- .../src/store/slices/worktree-helpers.ts | 2 + .../src/store/slices/worktrees.test.ts | 112 +++++++++++++++++ src/renderer/src/store/slices/worktrees.ts | 35 ++++++ src/shared/folder-workspace-worktree.test.ts | 6 + src/shared/folder-workspace-worktree.ts | 6 + src/shared/jira-issue-link-schema.ts | 12 ++ src/shared/jira-issue-link.test.ts | 109 ++++++++++++++++ src/shared/jira-issue-link.ts | 116 ++++++++++++++++++ src/shared/protocol-version.ts | 2 + src/shared/types.ts | 12 ++ 26 files changed, 814 insertions(+), 42 deletions(-) create mode 100644 src/main/runtime/rpc/methods/worktree-jira-issue-link-schema.ts create mode 100644 src/main/runtime/rpc/methods/worktree-jira-issue-link.test.ts create mode 100644 src/shared/jira-issue-link-schema.ts create mode 100644 src/shared/jira-issue-link.test.ts create mode 100644 src/shared/jira-issue-link.ts diff --git a/src/main/ipc/worktree-linked-work-item-metadata.ts b/src/main/ipc/worktree-linked-work-item-metadata.ts index e0b4259a2fc..0c1ea7dbd39 100644 --- a/src/main/ipc/worktree-linked-work-item-metadata.ts +++ b/src/main/ipc/worktree-linked-work-item-metadata.ts @@ -1,4 +1,5 @@ import type { Worktree, WorktreeMeta } from '../../shared/types' +import { resolveJiraIssueLink, resolveJiraIssueSourceContext } from '../../shared/jira-issue-link' type LinkedWorkItemMetadata = Pick< Worktree, @@ -9,6 +10,8 @@ type LinkedWorkItemMetadata = Pick< | 'linkedGiteaPR' | 'linkedWorkItem' | 'linkedTaskSourceContext' + | 'linkedJiraIssue' + | 'linkedJiraIssueSourceContext' > export function getLinkedWorkItemMetadata(meta: WorktreeMeta | undefined): LinkedWorkItemMetadata { @@ -19,6 +22,8 @@ export function getLinkedWorkItemMetadata(meta: WorktreeMeta | undefined): Linke linkedAzureDevOpsPR: meta?.linkedAzureDevOpsPR ?? null, linkedGiteaPR: meta?.linkedGiteaPR ?? null, linkedWorkItem: meta?.linkedWorkItem ?? null, - linkedTaskSourceContext: meta?.linkedTaskSourceContext ?? null + linkedTaskSourceContext: meta?.linkedTaskSourceContext ?? null, + linkedJiraIssue: resolveJiraIssueLink(meta ?? {}), + linkedJiraIssueSourceContext: resolveJiraIssueSourceContext(meta ?? {}) } } diff --git a/src/main/ipc/worktree-logic.test.ts b/src/main/ipc/worktree-logic.test.ts index ae76b28da0c..60326ec5e7e 100644 --- a/src/main/ipc/worktree-logic.test.ts +++ b/src/main/ipc/worktree-logic.test.ts @@ -513,6 +513,8 @@ describe('mergeWorktree', () => { linkedGiteaPR: null, linkedWorkItem: null, linkedTaskSourceContext: null, + linkedJiraIssue: null, + linkedJiraIssueSourceContext: null, mobileDiffReview: undefined, projectId: 'github:stablyai/orca', hostId: 'ssh:openclaw-2', diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index b09b4e71c93..d6377cb7b1e 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -1827,6 +1827,10 @@ export async function createRemoteWorktree( ...(args.linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext: args.linkedTaskSourceContext } : {}), + ...(args.linkedJiraIssue !== undefined ? { linkedJiraIssue: args.linkedJiraIssue } : {}), + ...(args.linkedJiraIssueSourceContext !== undefined + ? { linkedJiraIssueSourceContext: args.linkedJiraIssueSourceContext } + : {}), ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}) } const { worktree } = timing.timeSync('persist_metadata', () => { @@ -2435,6 +2439,10 @@ export async function createLocalWorktree( ...(args.linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext: args.linkedTaskSourceContext } : {}), + ...(args.linkedJiraIssue !== undefined ? { linkedJiraIssue: args.linkedJiraIssue } : {}), + ...(args.linkedJiraIssueSourceContext !== undefined + ? { linkedJiraIssueSourceContext: args.linkedJiraIssueSourceContext } + : {}), ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}) } const { worktree } = timing.timeSync('persist_metadata', () => { diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 457111cb652..fcfc8f8780a 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -14,6 +14,12 @@ import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-im import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection' import { TaskSourceContextSchema } from '../../shared/task-source-context-schema' import { WorkspaceLinkedItemSchema } from '../../shared/workspace-linked-item-schema' +import { + isJiraIssueLinkSourceContextMatch, + resolveJiraIssueLink, + resolveJiraIssueSourceContext +} from '../../shared/jira-issue-link' +import { JiraIssueLinkSchema } from '../../shared/jira-issue-link-schema' import { isWorkspaceLinkedItemSourceContextMatch } from '../../shared/workspace-linked-item-source-context' import { getProjectGroupSubtreeIds } from '../../shared/project-groups' import { projectResolvedWorktreeLineage } from '../../shared/resolved-worktree-lineage' @@ -253,6 +259,7 @@ import { const NullableWorkspaceLinkedItemSchema = WorkspaceLinkedItemSchema.nullable() const NullableTaskSourceContextSchema = TaskSourceContextSchema.nullable() +const NullableJiraIssueLinkSchema = JiraIssueLinkSchema.nullable() const WORKTREE_ARCHIVE_HOOK_TIMEOUT_MS = 120_000 const WORKTREE_LIST_ALL_CONCURRENCY = 8 @@ -260,6 +267,8 @@ function normalizeLinkedWorkItemFields< T extends { linkedWorkItem?: unknown linkedTaskSourceContext?: unknown + linkedJiraIssue?: unknown + linkedJiraIssueSourceContext?: unknown } >(input: T): T { const linkedWorkItem = @@ -270,6 +279,14 @@ function normalizeLinkedWorkItemFields< input.linkedTaskSourceContext === undefined ? undefined : NullableTaskSourceContextSchema.parse(input.linkedTaskSourceContext) + const linkedJiraIssue = + input.linkedJiraIssue === undefined + ? undefined + : NullableJiraIssueLinkSchema.parse(input.linkedJiraIssue) + const linkedJiraIssueSourceContext = + input.linkedJiraIssueSourceContext === undefined + ? undefined + : NullableTaskSourceContextSchema.parse(input.linkedJiraIssueSourceContext) if ( linkedWorkItem && linkedTaskSourceContext && @@ -277,10 +294,19 @@ function normalizeLinkedWorkItemFields< ) { throw new Error('Linked work item and source context identities must match') } + if ( + linkedJiraIssue && + linkedJiraIssueSourceContext && + !isJiraIssueLinkSourceContextMatch(linkedJiraIssue, linkedJiraIssueSourceContext) + ) { + throw new Error('Linked Jira issue and source context identities must match') + } return { ...input, ...(linkedWorkItem !== undefined ? { linkedWorkItem } : {}), - ...(linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext } : {}) + ...(linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext } : {}), + ...(linkedJiraIssue !== undefined ? { linkedJiraIssue } : {}), + ...(linkedJiraIssueSourceContext !== undefined ? { linkedJiraIssueSourceContext } : {}) } } @@ -980,6 +1006,8 @@ function mergeFolderWorkspace(repo: Repo, worktreeId: string, meta: WorktreeMeta linkedGiteaPR: meta.linkedGiteaPR ?? null, linkedWorkItem: meta.linkedWorkItem ?? null, linkedTaskSourceContext: meta.linkedTaskSourceContext ?? null, + linkedJiraIssue: resolveJiraIssueLink(meta), + linkedJiraIssueSourceContext: resolveJiraIssueSourceContext(meta), isArchived: meta.isArchived ?? false, isUnread: meta.isUnread ?? false, isPinned: meta.isPinned ?? false, @@ -1105,6 +1133,10 @@ function createFolderWorkspace( ...(args.linkedWorkItem !== undefined ? { linkedWorkItem: args.linkedWorkItem } : {}), ...(args.linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext: args.linkedTaskSourceContext } + : {}), + ...(args.linkedJiraIssue !== undefined ? { linkedJiraIssue: args.linkedJiraIssue } : {}), + ...(args.linkedJiraIssueSourceContext !== undefined + ? { linkedJiraIssueSourceContext: args.linkedJiraIssueSourceContext } : {}) }) return { worktree: mergeFolderWorkspace(repo, worktreeId, meta) } diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index b59ca6f5986..81abaabc8d6 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -4930,6 +4930,83 @@ describe('Store', () => { ).toBeNull() }) + it('persists dedicated Jira metadata without rewriting review or unsupported linked items', async () => { + const reviewItem = { + provider: 'gitlab', + type: 'mr', + number: 42, + title: ' Preserve review bytes ', + url: 'https://gitlab.example.com/group/repo/-/merge_requests/42?diff=1' + } + const reviewContext = { + kind: 'task-source', + provider: 'gitlab', + projectId: ' project-1 ', + hostId: 'runtime:env-1', + accountLabel: ' review@example.com ' + } + const unsupportedItem = { + provider: 'custom-review-provider', + type: 'change', + opaque: { keep: ['every', 'byte'] } + } + const unsupportedContext = { + provider: 'custom-review-provider', + opaque: ' context ' + } + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: { + review: { linkedWorkItem: reviewItem, linkedTaskSourceContext: reviewContext }, + unsupported: { + linkedWorkItem: unsupportedItem, + linkedTaskSourceContext: unsupportedContext + } + } + }) + const store = await createStore() + const issue = { + key: 'ORCA-123', + title: 'Link Jira', + url: 'https://company.atlassian.net/browse/ORCA-123' + } + const sourceContext = { + kind: 'task-source' as const, + provider: 'jira' as const, + projectId: 'project-1', + hostId: 'runtime:env-1' as const, + providerIdentity: { + provider: 'jira' as const, + siteId: 'site-1', + siteUrl: 'https://company.atlassian.net', + projectKey: 'ORCA' + } + } + + store.setWorktreeMeta('review', { + linkedJiraIssue: issue, + linkedJiraIssueSourceContext: sourceContext + }) + store.setWorktreeMeta('unsupported', { + linkedJiraIssue: issue, + linkedJiraIssueSourceContext: sourceContext + }) + + expect(store.getWorktreeMeta('review')).toMatchObject({ + linkedWorkItem: reviewItem, + linkedTaskSourceContext: reviewContext, + linkedJiraIssue: issue, + linkedJiraIssueSourceContext: sourceContext + }) + expect(store.getWorktreeMeta('unsupported')).toMatchObject({ + linkedWorkItem: unsupportedItem, + linkedTaskSourceContext: unsupportedContext, + linkedJiraIssue: issue, + linkedJiraIssueSourceContext: sourceContext + }) + }) + it('discards malformed persisted task-source metadata without aborting store load', async () => { writeDataFile({ schemaVersion: 1, diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 08697613c31..e788aa4f3ad 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -92,6 +92,11 @@ import { normalizeWorkspaceLinkedItem } from '../shared/workspace-linked-item' import { isWorkspaceLinkedItemSourceContextMatch } from '../shared/workspace-linked-item-source-context' +import { + areJiraIssueLinksEqual, + isJiraIssueLinkSourceContextMatch, + normalizeJiraIssueLink +} from '../shared/jira-issue-link' import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types' import { MOBILE_PAIRING_USERDATA_FILES } from './runtime/mobile-pairing-files' import { normalizePersistedMobileClientTabSelections } from './runtime/client-session-tab-selection-persistence' @@ -451,26 +456,52 @@ function normalizeWorktreeLinkedItemMetadata(state: PersistedState): boolean { changed = true continue } - const linkedWorkItem = normalizeWorkspaceLinkedItem(meta.linkedWorkItem) - const sourceContext = normalizeStoredTaskSourceContext(meta.linkedTaskSourceContext) - const linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch( - linkedWorkItem, - sourceContext + if (isLegacyJiraLinkedWorkItem(meta.linkedWorkItem)) { + const linkedWorkItem = normalizeWorkspaceLinkedItem(meta.linkedWorkItem) + const sourceContext = normalizeStoredTaskSourceContext(meta.linkedTaskSourceContext) + const linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch( + linkedWorkItem, + sourceContext + ) + ? sourceContext + : null + if (!areWorkspaceLinkedItemsEqual(meta.linkedWorkItem, linkedWorkItem)) { + meta.linkedWorkItem = linkedWorkItem + changed = true + } + if (!areTaskSourceContextsEqual(meta.linkedTaskSourceContext, linkedTaskSourceContext)) { + meta.linkedTaskSourceContext = linkedTaskSourceContext + changed = true + } + } + const linkedJiraIssue = normalizeJiraIssueLink(meta.linkedJiraIssue) + const jiraSourceContext = normalizeStoredTaskSourceContext(meta.linkedJiraIssueSourceContext) + const linkedJiraIssueSourceContext = isJiraIssueLinkSourceContextMatch( + linkedJiraIssue, + jiraSourceContext ) - ? sourceContext + ? jiraSourceContext : null - if (!areWorkspaceLinkedItemsEqual(meta.linkedWorkItem, linkedWorkItem)) { - meta.linkedWorkItem = linkedWorkItem + if (!areJiraIssueLinksEqual(meta.linkedJiraIssue, linkedJiraIssue)) { + meta.linkedJiraIssue = linkedJiraIssue changed = true } - if (!areTaskSourceContextsEqual(meta.linkedTaskSourceContext, linkedTaskSourceContext)) { - meta.linkedTaskSourceContext = linkedTaskSourceContext + if ( + !areTaskSourceContextsEqual(meta.linkedJiraIssueSourceContext, linkedJiraIssueSourceContext) + ) { + meta.linkedJiraIssueSourceContext = linkedJiraIssueSourceContext changed = true } } return changed } +function isLegacyJiraLinkedWorkItem(value: unknown): boolean { + return Boolean( + value && typeof value === 'object' && (value as { provider?: unknown }).provider === 'jira' + ) +} + function readGithubCacheSnapshot(dataFile: string): PersistedState['githubCache'] | null { try { const parsed = JSON.parse(readFileSync(getGithubCacheFile(dataFile), 'utf-8')) as unknown @@ -5418,16 +5449,31 @@ export class Store { setWorktreeMeta(worktreeId: string, meta: Partial): WorktreeMeta { const existing = this.state.worktreeMeta[worktreeId] || getDefaultWorktreeMeta() const updated = { ...existing, ...meta } - updated.linkedWorkItem = normalizeWorkspaceLinkedItem(updated.linkedWorkItem) - const linkedTaskSourceContext = normalizeStoredTaskSourceContext( - updated.linkedTaskSourceContext - ) - updated.linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch( - updated.linkedWorkItem, - linkedTaskSourceContext - ) - ? linkedTaskSourceContext - : null + if ( + ('linkedWorkItem' in meta || 'linkedTaskSourceContext' in meta) && + (isLegacyJiraLinkedWorkItem(updated.linkedWorkItem) || updated.linkedWorkItem == null) + ) { + updated.linkedWorkItem = normalizeWorkspaceLinkedItem(updated.linkedWorkItem) + const linkedTaskSourceContext = normalizeStoredTaskSourceContext( + updated.linkedTaskSourceContext + ) + updated.linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch( + updated.linkedWorkItem, + linkedTaskSourceContext + ) + ? linkedTaskSourceContext + : null + } + if ('linkedJiraIssue' in meta || 'linkedJiraIssueSourceContext' in meta) { + updated.linkedJiraIssue = normalizeJiraIssueLink(updated.linkedJiraIssue) + const sourceContext = normalizeStoredTaskSourceContext(updated.linkedJiraIssueSourceContext) + updated.linkedJiraIssueSourceContext = isJiraIssueLinkSourceContextMatch( + updated.linkedJiraIssue, + sourceContext + ) + ? sourceContext + : null + } if (!updated.instanceId) { updated.instanceId = randomUUID() } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index e6c7ced50a5..50caf0b82b6 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -186,6 +186,7 @@ import type { LinearCustomViewModel, JiraConnectArgs, JiraCreateIssueArgs, + JiraIssueLink, JiraIssueFilter, JiraIssueUpdate, JiraSiteSelection, @@ -225,6 +226,7 @@ import type { CodexRateLimitAccountsState } from '../../shared/types' import type { TaskSourceContext } from '../../shared/task-source-context' +import { resolveJiraIssueLink, resolveJiraIssueSourceContext } from '../../shared/jira-issue-link' import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree-removal' import { LOCAL_EXECUTION_HOST_ID, @@ -2170,6 +2172,8 @@ function mergeRuntimeFolderWorkspace(repo: Repo, worktreeId: string, meta: Workt linkedGiteaPR: meta.linkedGiteaPR ?? null, linkedWorkItem: meta.linkedWorkItem ?? null, linkedTaskSourceContext: meta.linkedTaskSourceContext ?? null, + linkedJiraIssue: resolveJiraIssueLink(meta), + linkedJiraIssueSourceContext: resolveJiraIssueSourceContext(meta), isArchived: meta.isArchived ?? false, isUnread: meta.isUnread ?? false, isPinned: meta.isPinned ?? false, @@ -21625,6 +21629,8 @@ export class OrcaRuntimeService { linkedGiteaPR?: number | null linkedWorkItem?: WorkspaceLinkedItem | null linkedTaskSourceContext?: TaskSourceContext | null + linkedJiraIssue?: JiraIssueLink | null + linkedJiraIssueSourceContext?: TaskSourceContext | null comment?: string displayName?: string telemetrySource?: WorkspaceCreateTelemetrySource @@ -21736,6 +21742,10 @@ export class OrcaRuntimeService { ...(args.linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext: args.linkedTaskSourceContext } : {}), + ...(args.linkedJiraIssue !== undefined ? { linkedJiraIssue: args.linkedJiraIssue } : {}), + ...(args.linkedJiraIssueSourceContext !== undefined + ? { linkedJiraIssueSourceContext: args.linkedJiraIssueSourceContext } + : {}), ...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}), ...(args.comment !== undefined ? { comment: args.comment } : {}), ...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}), @@ -22332,6 +22342,10 @@ export class OrcaRuntimeService { ...(args.linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext: args.linkedTaskSourceContext } : {}), + ...(args.linkedJiraIssue !== undefined ? { linkedJiraIssue: args.linkedJiraIssue } : {}), + ...(args.linkedJiraIssueSourceContext !== undefined + ? { linkedJiraIssueSourceContext: args.linkedJiraIssueSourceContext } + : {}), ...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}), ...(args.pendingFirstAgentMessageRename === true && effectiveCreatedWithAgent ? { pendingFirstAgentMessageRename: true } @@ -22732,6 +22746,8 @@ export class OrcaRuntimeService { linkedGiteaPR?: number | null linkedWorkItem?: WorkspaceLinkedItem | null linkedTaskSourceContext?: TaskSourceContext | null + linkedJiraIssue?: JiraIssueLink | null + linkedJiraIssueSourceContext?: TaskSourceContext | null comment?: string displayName?: string workspaceStatus?: string @@ -22795,6 +22811,10 @@ export class OrcaRuntimeService { ...(args.linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext: args.linkedTaskSourceContext } : {}), + ...(args.linkedJiraIssue !== undefined ? { linkedJiraIssue: args.linkedJiraIssue } : {}), + ...(args.linkedJiraIssueSourceContext !== undefined + ? { linkedJiraIssueSourceContext: args.linkedJiraIssueSourceContext } + : {}), ...(args.pushTarget ? { pushTarget: args.pushTarget } : {}), ...(args.workspaceStatus ? { workspaceStatus: args.workspaceStatus as never } : {}), ...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}), diff --git a/src/main/runtime/rpc/methods/worktree-jira-issue-link-schema.ts b/src/main/runtime/rpc/methods/worktree-jira-issue-link-schema.ts new file mode 100644 index 00000000000..f56e334027f --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree-jira-issue-link-schema.ts @@ -0,0 +1,28 @@ +import { z } from 'zod' +import { JiraIssueLinkSchema } from '../../../../shared/jira-issue-link-schema' +import { isJiraIssueLinkSourceContextMatch } from '../../../../shared/jira-issue-link' +import { TaskSourceContextSchema } from '../../../../shared/task-source-context-schema' + +export const WorktreeJiraIssueLinkFields = { + linkedJiraIssue: JiraIssueLinkSchema.nullable().optional(), + linkedJiraIssueSourceContext: TaskSourceContextSchema.nullable().optional() +} + +export function assertJiraIssueSourceContextMatch( + params: { + linkedJiraIssue?: z.infer | null + linkedJiraIssueSourceContext?: z.infer | null + }, + ctx: z.RefinementCtx +): void { + if ( + params.linkedJiraIssue && + params.linkedJiraIssueSourceContext && + !isJiraIssueLinkSourceContextMatch(params.linkedJiraIssue, params.linkedJiraIssueSourceContext) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Linked Jira issue and source context identities must match' + }) + } +} diff --git a/src/main/runtime/rpc/methods/worktree-jira-issue-link.test.ts b/src/main/runtime/rpc/methods/worktree-jira-issue-link.test.ts new file mode 100644 index 00000000000..d16b42ba100 --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree-jira-issue-link.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcRequest } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { WORKTREE_METHODS } from './worktree' + +describe('worktree Jira issue link RPC', () => { + it('forwards dedicated Jira metadata through worktree.set', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateManagedWorktreeMeta: vi.fn().mockResolvedValue({ id: 'wt-1' }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + const linkedJiraIssue = { + key: 'ORCA-123', + title: 'Link Jira', + url: 'https://company.atlassian.net/browse/ORCA-123' + } + const linkedJiraIssueSourceContext = { + 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' + } + } + const request: RpcRequest = { + id: 'req-1', + authToken: 'tok', + method: 'worktree.set', + params: { worktree: 'id:wt-1', linkedJiraIssue, linkedJiraIssueSourceContext } + } + + const response = await dispatcher.dispatch(request) + + expect(response).toMatchObject({ ok: true }) + expect(runtime.updateManagedWorktreeMeta).toHaveBeenCalledWith( + 'id:wt-1', + expect.objectContaining({ + linkedJiraIssue, + linkedJiraIssueSourceContext: expect.objectContaining(linkedJiraIssueSourceContext) + }) + ) + }) +}) diff --git a/src/main/runtime/rpc/methods/worktree-schemas.test.ts b/src/main/runtime/rpc/methods/worktree-schemas.test.ts index e1eb22f2c52..5c2bc978aea 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.test.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.test.ts @@ -102,6 +102,55 @@ describe('worktree RPC schemas', () => { ).not.toThrow() }) + it('normalizes dedicated Jira issue metadata and rejects source identity mismatches', () => { + const linkedJiraIssue = { + key: ' orca-123 ', + title: ' Link Jira ', + url: 'HTTPS://COMPANY.ATLASSIAN.NET/browse/orca-123?focusedCommentId=1' + } + const linkedJiraIssueSourceContext = { + 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' + } + } + const parsed = WorktreeCreate.parse({ + repo: 'repo-1', + name: 'jira-link', + linkedJiraIssue, + linkedJiraIssueSourceContext + }) + + expect(parsed.linkedJiraIssue).toEqual({ + key: 'ORCA-123', + title: 'Link Jira', + url: 'https://company.atlassian.net/browse/ORCA-123' + }) + expect(parsed.linkedJiraIssueSourceContext).toMatchObject({ + provider: 'jira', + projectId: 'project-1' + }) + expect( + WorktreeSet.safeParse({ + worktree: 'id:wt-1', + linkedJiraIssue, + linkedJiraIssueSourceContext: { + ...linkedJiraIssueSourceContext, + providerIdentity: { + ...linkedJiraIssueSourceContext.providerIdentity, + siteUrl: 'https://other.atlassian.net' + } + } + }).success + ).toBe(false) + }) + it('keeps a blanked display name on remote hosts instead of dropping the clear', () => { // Blanking sends displayName:'' meaning "fall back to the branch/folder name". // Coercing it to undefined made updateManagedWorktreeMeta's omitUndefinedProperties diff --git a/src/main/runtime/rpc/methods/worktree-schemas.ts b/src/main/runtime/rpc/methods/worktree-schemas.ts index 57b8a9202a5..813b05a3eb9 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.ts @@ -14,6 +14,10 @@ import { import { TaskSourceContextSchema } from '../../../../shared/task-source-context-schema' import { WorkspaceLinkedItemSchema } from '../../../../shared/workspace-linked-item-schema' import { isWorkspaceLinkedItemSourceContextMatch } from '../../../../shared/workspace-linked-item-source-context' +import { + assertJiraIssueSourceContextMatch, + WorktreeJiraIssueLinkFields +} from './worktree-jira-issue-link-schema' const OptionalTuiAgent = z .unknown() @@ -119,6 +123,7 @@ export const WorktreeCreate = z linkedGiteaPR: TriStateLinkedIssue, linkedWorkItem: WorkspaceLinkedItemSchema.nullable().optional(), linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(), + ...WorktreeJiraIssueLinkFields, comment: OptionalString, displayName: OptionalString, telemetrySource: z @@ -192,6 +197,7 @@ export const WorktreeCreate = z }) .superRefine((params, ctx) => { assertLinkedWorkItemSourceContextMatch(params, ctx) + assertJiraIssueSourceContextMatch(params, ctx) if ((params.parentWorkspace || params.parentWorktree) && params.noParent === true) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -240,6 +246,7 @@ export const WorktreeSet = WorktreeSelector.extend({ linkedGiteaPR: TriStateLinkedIssue, linkedWorkItem: WorkspaceLinkedItemSchema.nullable().optional(), linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(), + ...WorktreeJiraIssueLinkFields, isArchived: OptionalBoolean, isUnread: OptionalBoolean, isPinned: OptionalBoolean, @@ -266,6 +273,7 @@ export const WorktreeSet = WorktreeSelector.extend({ noParent: OptionalBoolean }).superRefine((params, ctx) => { assertLinkedWorkItemSourceContextMatch(params, ctx) + assertJiraIssueSourceContextMatch(params, ctx) if (params.parentWorktree && params.noParent === true) { ctx.addIssue({ code: z.ZodIssueCode.custom, diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 758e6b83891..cd8c0278c72 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -129,6 +129,8 @@ export const WORKTREE_METHODS: RpcMethod[] = [ linkedGiteaPR: params.linkedGiteaPR, linkedWorkItem: params.linkedWorkItem, linkedTaskSourceContext: params.linkedTaskSourceContext, + linkedJiraIssue: params.linkedJiraIssue, + linkedJiraIssueSourceContext: params.linkedJiraIssueSourceContext, comment: params.comment, displayName: params.displayName, telemetrySource: params.telemetrySource, @@ -211,6 +213,8 @@ export const WORKTREE_METHODS: RpcMethod[] = [ linkedGiteaPR: params.linkedGiteaPR, linkedWorkItem: params.linkedWorkItem, linkedTaskSourceContext: params.linkedTaskSourceContext, + linkedJiraIssue: params.linkedJiraIssue, + linkedJiraIssueSourceContext: params.linkedJiraIssueSourceContext, comment: params.comment, isArchived: params.isArchived, isUnread: params.isUnread, diff --git a/src/renderer/src/components/sidebar/worktree-card-jira-issue-display.test.ts b/src/renderer/src/components/sidebar/worktree-card-jira-issue-display.test.ts index 7b20053c3b6..233ee89723e 100644 --- a/src/renderer/src/components/sidebar/worktree-card-jira-issue-display.test.ts +++ b/src/renderer/src/components/sidebar/worktree-card-jira-issue-display.test.ts @@ -5,9 +5,33 @@ import { } from './worktree-card-jira-issue-display' describe('getWorktreeCardJiraIssueDisplay', () => { + it('prefers dedicated Jira issue metadata over a linked review item', () => { + expect( + getWorktreeCardJiraIssueDisplay({ + linkedJiraIssue: { + key: 'KAN-2', + title: 'Dedicated Jira metadata', + url: 'https://company.atlassian.net/browse/KAN-2' + }, + linkedWorkItem: { + provider: 'gitlab', + type: 'mr', + number: 42, + title: 'Preserved review', + url: 'https://gitlab.example.com/group/repo/-/merge_requests/42' + } + }) + ).toEqual({ + identifier: 'KAN-2', + title: 'Dedicated Jira metadata', + url: 'https://company.atlassian.net/browse/KAN-2' + }) + }) + it('projects persisted Jira linked-item metadata for the workspace card', () => { expect( getWorktreeCardJiraIssueDisplay({ + linkedJiraIssue: undefined, linkedWorkItem: { provider: 'jira', type: 'issue', @@ -27,6 +51,7 @@ describe('getWorktreeCardJiraIssueDisplay', () => { it('does not infer Jira from another provider', () => { expect( getWorktreeCardJiraIssueDisplay({ + linkedJiraIssue: undefined, linkedWorkItem: { provider: 'linear', type: 'issue', @@ -41,6 +66,7 @@ describe('getWorktreeCardJiraIssueDisplay', () => { it('shows persisted Jira metadata only when the Jira display property is enabled', () => { const worktree = { + linkedJiraIssue: undefined, linkedWorkItem: { provider: 'jira' as const, type: 'issue' as const, diff --git a/src/renderer/src/components/sidebar/worktree-card-jira-issue-display.ts b/src/renderer/src/components/sidebar/worktree-card-jira-issue-display.ts index b9373e1092a..ec460499c57 100644 --- a/src/renderer/src/components/sidebar/worktree-card-jira-issue-display.ts +++ b/src/renderer/src/components/sidebar/worktree-card-jira-issue-display.ts @@ -1,31 +1,23 @@ import type { Worktree, WorktreeCardProperty } from '../../../../shared/types' import type { WorktreeCardJiraIssueDisplay } from './worktree-card-meta-types' - -function withoutRepeatedJiraIdentifier(title: string, identifier: string): string { - const escapedIdentifier = identifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const stripped = title - .replace(new RegExp(`^${escapedIdentifier}(?:\\s*[:—-]\\s*|\\s+)`, 'i'), '') - .trim() - return stripped || title -} +import { resolveJiraIssueLink } from '../../../../shared/jira-issue-link' export function getWorktreeCardJiraIssueDisplay( - worktree: Pick + worktree: Pick ): WorktreeCardJiraIssueDisplay | null { - const item = worktree.linkedWorkItem - if (item?.provider !== 'jira' || item.type !== 'issue') { + const issue = resolveJiraIssueLink(worktree) + if (!issue) { return null } - const identifier = item.jiraIdentifier ?? String(item.number) return { - identifier, - title: withoutRepeatedJiraIdentifier(item.title, identifier), - url: item.url + identifier: issue.key, + title: issue.title, + url: issue.url } } export function getConfiguredWorktreeCardJiraIssueDisplay( - worktree: Pick, + worktree: Pick, properties: readonly WorktreeCardProperty[] ): WorktreeCardJiraIssueDisplay | null { return properties.includes('jira-issue') ? getWorktreeCardJiraIssueDisplay(worktree) : null diff --git a/src/renderer/src/lib/worktree-creation-flow.test.ts b/src/renderer/src/lib/worktree-creation-flow.test.ts index ad24a825076..b1283e3eb2d 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('preserves linked Jira context through staged creation and retry', async () => { + it('writes dedicated Jira context through staged creation and retry', async () => { const linkedWorkItem = { provider: 'jira' as const, type: 'issue' as const, @@ -471,7 +471,14 @@ describe('staged background worktree creation', () => { accountLabel: 'dev@company.test' } const request = makeRequest({ linkedWorkItem, linkedTaskSourceContext }) - const expectedOptions = { linkedWorkItem, linkedTaskSourceContext } + const expectedOptions = { + linkedJiraIssue: { + key: 'ORCA-123', + title: 'Durable Jira link', + url: 'https://company.atlassian.net/browse/ORCA-123' + }, + linkedJiraIssueSourceContext: linkedTaskSourceContext + } expect(continueBackgroundWorktreeCreation('creation-1', request)).toBe(true) await vi.waitFor(() => expect(store.createWorktree).toHaveBeenCalledTimes(1)) diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index 83451b8ce9f..d0709a0aa8c 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -26,6 +26,7 @@ import { import { createBrowserUuid } from '@/lib/browser-uuid' import { seedAgentTabStateAfterWorktreeCreate } from '@/lib/worktree-creation-agent-seeds' import { resolveBackendDraftStartup } from '@/lib/worktree-draft-startup-view-mode' +import { jiraIssueLinkFromLegacyWorkItem } from '../../../shared/jira-issue-link' import { buildWorktreeCreationStartupOpt, getInitialWorktreeCreationPhase, @@ -106,6 +107,8 @@ async function executeWorktreeCreation( let result: CreateWorktreeResult try { const backendStartup = resolveBackendDraftStartup(preparedRequest) + const linkedJiraIssue = jiraIssueLinkFromLegacyWorkItem(preparedRequest.linkedWorkItem) + const usesDedicatedJiraLink = linkedJiraIssue !== null result = await useAppStore .getState() .createWorktree( @@ -135,11 +138,15 @@ async function executeWorktreeCreation( preparedRequest.linkedGiteaPR, preparedRequest.compareBaseRef, { - ...(preparedRequest.linkedWorkItem !== undefined + ...(!usesDedicatedJiraLink && preparedRequest.linkedWorkItem !== undefined ? { linkedWorkItem: preparedRequest.linkedWorkItem } : {}), - ...(preparedRequest.linkedTaskSourceContext !== undefined + ...(!usesDedicatedJiraLink && preparedRequest.linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext: preparedRequest.linkedTaskSourceContext } + : {}), + ...(linkedJiraIssue ? { linkedJiraIssue } : {}), + ...(linkedJiraIssue && preparedRequest.linkedTaskSourceContext + ? { linkedJiraIssueSourceContext: preparedRequest.linkedTaskSourceContext } : {}) } ) diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 16a58fd985f..f8592356cbc 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -198,6 +198,8 @@ export type WorktreeSlice = { automationProvenanceRequest?: CreateWorktreeArgs['automationProvenanceRequest'] linkedWorkItem?: WorkspaceLinkedItem | null linkedTaskSourceContext?: TaskSourceContext | null + linkedJiraIssue?: CreateWorktreeArgs['linkedJiraIssue'] + linkedJiraIssueSourceContext?: CreateWorktreeArgs['linkedJiraIssueSourceContext'] } ) => Promise /** Register an in-flight background creation and make it the active surface. */ diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index b13a197b597..caff0234f82 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -5842,6 +5842,84 @@ describe('worktree remote runtime mutations', () => { expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) + it('forwards dedicated Jira metadata through remote worktree creation', async () => { + const store = createTestStore() + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-create', + ok: true, + result: { + worktree: makeWorktree({ + id: 'repo1::/path/jira-link', + repoId: 'repo1', + path: '/path/jira-link' + }) + }, + _meta: { runtimeId: 'runtime-remote' } + }) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + worktreesByRepo: { repo1: [] } + } as Partial) + const linkedJiraIssue = { + key: 'ORCA-123', + title: 'Link Jira', + url: 'https://company.atlassian.net/browse/ORCA-123' + } + const linkedJiraIssueSourceContext = { + kind: 'task-source' as const, + provider: 'jira' as const, + projectId: 'project-1', + hostId: 'runtime:env-1' as const, + providerIdentity: { + provider: 'jira' as const, + siteId: 'site-1', + siteUrl: 'https://company.atlassian.net', + projectKey: 'ORCA' + } + } + const createWorktree = store.getState().createWorktree + const args: Parameters = ['repo1', 'jira-link'] + args[25] = { linkedJiraIssue, linkedJiraIssueSourceContext } + + await createWorktree(...args) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'worktree.create', + params: expect.objectContaining({ linkedJiraIssue, linkedJiraIssueSourceContext }) + }) + ) + }) + + it('fails before remote creation when dedicated Jira metadata is unsupported', async () => { + const oldRuntimeStatus = createCompatibleRuntimeStatusResponse('runtime-old') + if (oldRuntimeStatus.ok) { + oldRuntimeStatus.result.capabilities = oldRuntimeStatus.result.capabilities?.filter( + (capability) => capability !== 'worktree.jira-issue-link.v1' + ) + } + 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 createWorktree = store.getState().createWorktree + const args: Parameters = ['repo1', 'jira-link'] + args[25] = { + linkedJiraIssue: { + key: 'ORCA-123', + title: 'Link Jira', + url: 'https://company.atlassian.net/browse/ORCA-123' + } + } + + await expect(createWorktree(...args)).rejects.toThrow('Update the remote runtime to link Jira') + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + it('passes startup commands through remote runtime worktree creation', async () => { const store = createTestStore() const wt = makeWorktree({ @@ -6366,6 +6444,40 @@ describe('worktree remote runtime mutations', () => { expect(store.getState().worktreesByRepo.repo1[0]?.comment).toBe('remote note') }) + it('fails before a remote Jira metadata update 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' + ) + } + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => + args.method === 'status.get' ? oldRuntimeStatus : runtimeEnvironmentCall(args) + ) + const store = createTestStore() + const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' }) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + worktreesByRepo: { repo1: [wt] } + } as Partial) + + await expect( + store.getState().updateWorktreeMeta(wt.id, { + linkedJiraIssue: { + key: 'ORCA-123', + title: 'Link Jira', + url: 'https://company.atlassian.net/browse/ORCA-123' + } + }) + ).resolves.toEqual({ + ok: false, + error: 'Update the remote runtime to change this workspace’s linked Jira issue' + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'worktree.set' }) + ) + }) + it('force-deletes a preserved HUB-owned SSH branch through its HUB', 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 f2efca80185..366a2bd5166 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -29,6 +29,7 @@ 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 { areTaskSourceContextsEqual } from '../../../../shared/task-source-context' import { remapClosedTerminalTabSnapshotCwds, @@ -53,6 +54,7 @@ import { } from '../../runtime/runtime-rpc-client' import { TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY, + WORKTREE_JIRA_ISSUE_LINK_RUNTIME_CAPABILITY, WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' @@ -384,6 +386,11 @@ function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): b worktree.linkedTaskSourceContext, candidate.linkedTaskSourceContext ) && + areJiraIssueLinksEqual(worktree.linkedJiraIssue, candidate.linkedJiraIssue) && + areTaskSourceContextsEqual( + worktree.linkedJiraIssueSourceContext, + candidate.linkedJiraIssueSourceContext + ) && worktree.isArchived === candidate.isArchived && worktree.isUnread === candidate.isUnread && worktree.isPinned === candidate.isPinned && @@ -1773,6 +1780,16 @@ async function persistWorktreeMeta( 'Update the remote runtime to change this workspace’s linked issue' ) } + if ( + target.kind === 'environment' && + ('linkedJiraIssue' in updates || 'linkedJiraIssueSourceContext' in updates) + ) { + await assertRuntimeEnvironmentCapability( + target.environmentId, + WORKTREE_JIRA_ISSUE_LINK_RUNTIME_CAPABILITY, + 'Update the remote runtime to change this workspace’s linked Jira issue' + ) + } // task-source-context.v1 is a sound proxy for the Linear keys: #5322 added them // to the schema and is an ancestor of the commit introducing that capability. if (target.kind === 'environment' && 'linkedLinearIssue' in updates) { @@ -3989,6 +4006,8 @@ export const createWorktreeSlice: StateCreator const automationProvenanceRequest = options?.automationProvenanceRequest const linkedWorkItem = options?.linkedWorkItem const linkedTaskSourceContext = options?.linkedTaskSourceContext + const linkedJiraIssue = options?.linkedJiraIssue + const linkedJiraIssueSourceContext = options?.linkedJiraIssueSourceContext try { for (let attempt = 0; attempt < CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS; attempt += 1) { const candidateName = getClientWorktreeCreateCandidate(name, attempt) @@ -4038,11 +4057,23 @@ export const createWorktreeSlice: StateCreator ...(linkedGiteaPR !== undefined ? { linkedGiteaPR } : {}), ...(linkedWorkItem !== undefined ? { linkedWorkItem } : {}), ...(linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext } : {}), + ...(linkedJiraIssue !== undefined ? { linkedJiraIssue } : {}), + ...(linkedJiraIssueSourceContext !== undefined ? { linkedJiraIssueSourceContext } : {}), ...(startup ? { startup } : {}), ...(creationId ? { creationId } : {}), ...(automationProvenanceRequest ? { automationProvenanceRequest } : {}) } const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), repoId)) + if ( + target.kind === 'environment' && + (linkedJiraIssue !== undefined || linkedJiraIssueSourceContext !== undefined) + ) { + await assertRuntimeEnvironmentCapability( + target.environmentId, + WORKTREE_JIRA_ISSUE_LINK_RUNTIME_CAPABILITY, + 'Update the remote runtime to link Jira' + ) + } if ( target.kind === 'environment' && (linkedWorkItem?.provider === 'jira' || linkedTaskSourceContext?.provider === 'jira') @@ -4095,6 +4126,10 @@ export const createWorktreeSlice: StateCreator ...(linkedGiteaPR !== undefined ? { linkedGiteaPR } : {}), ...(linkedWorkItem !== undefined ? { linkedWorkItem } : {}), ...(linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext } : {}), + ...(linkedJiraIssue !== undefined ? { linkedJiraIssue } : {}), + ...(linkedJiraIssueSourceContext !== undefined + ? { linkedJiraIssueSourceContext } + : {}), ...(automationProvenanceRequest ? { automationProvenanceRequest } : {}), ...(startup ? { diff --git a/src/shared/folder-workspace-worktree.test.ts b/src/shared/folder-workspace-worktree.test.ts index 0283208cd8f..23c8d61d917 100644 --- a/src/shared/folder-workspace-worktree.test.ts +++ b/src/shared/folder-workspace-worktree.test.ts @@ -114,6 +114,12 @@ describe('folderWorkspaceToWorktree', () => { jiraIdentifier: 'ORCA-123' }) expect(worktree.linkedTaskSourceContext).toEqual(linkedTaskSourceContext) + expect(worktree.linkedJiraIssue).toEqual({ + key: 'ORCA-123', + title: 'Link Jira', + url: 'https://company.atlassian.net/browse/ORCA-123' + }) + expect(worktree.linkedJiraIssueSourceContext).toEqual(linkedTaskSourceContext) }) it('projects first-message rename state for folder workspace cards', () => { diff --git a/src/shared/folder-workspace-worktree.ts b/src/shared/folder-workspace-worktree.ts index 0b427f42407..55a17d5ac39 100644 --- a/src/shared/folder-workspace-worktree.ts +++ b/src/shared/folder-workspace-worktree.ts @@ -1,6 +1,7 @@ import type { FolderWorkspace, Worktree } from './types' import { folderWorkspaceKey } from './workspace-scope' import { toSshExecutionHostId } from './execution-host' +import { resolveJiraIssueLink, resolveJiraIssueSourceContext } from './jira-issue-link' export function folderWorkspaceToWorktree(folderWorkspace: FolderWorkspace): Worktree { const linkedTask = folderWorkspace.linkedTask @@ -22,6 +23,11 @@ export function folderWorkspaceToWorktree(folderWorkspace: FolderWorkspace): Wor linkedGiteaPR: null, linkedWorkItem: linkedTask, linkedTaskSourceContext: folderWorkspace.linkedTaskSourceContext ?? null, + linkedJiraIssue: resolveJiraIssueLink({ linkedWorkItem: linkedTask }), + linkedJiraIssueSourceContext: resolveJiraIssueSourceContext({ + linkedWorkItem: linkedTask, + linkedTaskSourceContext: folderWorkspace.linkedTaskSourceContext + }), isArchived: folderWorkspace.isArchived, isUnread: folderWorkspace.isUnread, isPinned: folderWorkspace.isPinned, diff --git a/src/shared/jira-issue-link-schema.ts b/src/shared/jira-issue-link-schema.ts new file mode 100644 index 00000000000..1da0bf4c2c0 --- /dev/null +++ b/src/shared/jira-issue-link-schema.ts @@ -0,0 +1,12 @@ +import { z } from 'zod' +import type { JiraIssueLink } from './types' +import { normalizeJiraIssueLink } from './jira-issue-link' + +export const JiraIssueLinkSchema = z.unknown().transform((value, ctx): JiraIssueLink => { + const normalized = normalizeJiraIssueLink(value) + if (!normalized) { + ctx.addIssue({ code: 'custom', message: 'Invalid Jira issue link' }) + return z.NEVER + } + return normalized +}) diff --git a/src/shared/jira-issue-link.test.ts b/src/shared/jira-issue-link.test.ts new file mode 100644 index 00000000000..31ee9befa10 --- /dev/null +++ b/src/shared/jira-issue-link.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' +import { + isJiraIssueLinkSourceContextMatch, + jiraIssueLinkFromLegacyWorkItem, + normalizeJiraIssueLink, + resolveJiraIssueLink +} from './jira-issue-link' + +const sourceContext = { + kind: 'task-source' as const, + provider: 'jira' as const, + projectId: 'project-1', + hostId: 'runtime:env-1' as const, + providerIdentity: { + provider: 'jira' as const, + siteId: 'site-1', + siteUrl: 'https://company.atlassian.net', + projectKey: 'ORCA' + } +} + +describe('JiraIssueLink', () => { + it('normalizes the issue key, title, and canonical URL', () => { + expect( + normalizeJiraIssueLink({ + key: ' orca-123 ', + title: ' Link Jira ', + url: 'HTTPS://COMPANY.ATLASSIAN.NET/browse/orca-123?focusedCommentId=1#comment' + }) + ).toEqual({ + key: 'ORCA-123', + title: 'Link Jira', + url: 'https://company.atlassian.net/browse/ORCA-123' + }) + }) + + it('rejects a key that does not match the canonical URL', () => { + expect( + normalizeJiraIssueLink({ + key: 'ORCA-124', + title: 'Link Jira', + url: 'https://company.atlassian.net/browse/ORCA-123' + }) + ).toBeNull() + }) + + it('projects legacy Jira metadata without treating a review item as Jira', () => { + expect( + jiraIssueLinkFromLegacyWorkItem({ + provider: 'jira', + type: 'issue', + number: 0, + title: 'ORCA-123 Link Jira', + url: 'https://company.atlassian.net/browse/ORCA-123', + jiraIdentifier: 'ORCA-123' + }) + ).toEqual({ + key: 'ORCA-123', + title: 'Link Jira', + url: 'https://company.atlassian.net/browse/ORCA-123' + }) + expect( + jiraIssueLinkFromLegacyWorkItem({ + provider: 'gitlab', + type: 'mr', + number: 42, + title: 'Review', + url: 'https://gitlab.example.com/group/repo/-/merge_requests/42' + }) + ).toBeNull() + }) + + it('prefers an explicit link and treats explicit null as an unlink', () => { + 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' + } + expect( + resolveJiraIssueLink({ + linkedJiraIssue: { + key: 'ORCA-456', + title: 'Dedicated', + url: 'https://company.atlassian.net/browse/ORCA-456' + }, + linkedWorkItem: legacy + })?.key + ).toBe('ORCA-456') + expect(resolveJiraIssueLink({ linkedJiraIssue: null, linkedWorkItem: legacy })).toBeNull() + }) + + it('matches Jira source context by site and project', () => { + const issue = { + key: 'ORCA-123', + title: 'Link Jira', + url: 'https://company.atlassian.net/browse/ORCA-123' + } + expect(isJiraIssueLinkSourceContextMatch(issue, sourceContext)).toBe(true) + expect( + isJiraIssueLinkSourceContextMatch(issue, { + ...sourceContext, + providerIdentity: { ...sourceContext.providerIdentity, projectKey: 'OTHER' } + }) + ).toBe(false) + }) +}) diff --git a/src/shared/jira-issue-link.ts b/src/shared/jira-issue-link.ts new file mode 100644 index 00000000000..8723b12b19a --- /dev/null +++ b/src/shared/jira-issue-link.ts @@ -0,0 +1,116 @@ +import { parseJiraIssueUrl } from './jira-issue-url' +import type { TaskSourceContext } from './task-source-context' +import type { JiraIssueLink, WorkspaceLinkedItem } from './types' + +export function normalizeJiraIssueLink(value: unknown): JiraIssueLink | null { + if (!value || typeof value !== 'object') { + return null + } + const raw = value as Partial + 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) { + return null + } + return { + key, + title, + url: `${parsedUrl.origin}${parsedUrl.sitePath}/browse/${parsedUrl.issueKey}` + } +} + +export function areJiraIssueLinksEqual( + a: JiraIssueLink | null | undefined, + b: JiraIssueLink | null | undefined +): boolean { + if (a === b) { + return true + } + if (!a || !b) { + return !a && !b + } + return a.key === b.key && a.title === b.title && a.url === b.url +} + +export function jiraIssueLinkFromLegacyWorkItem( + item: WorkspaceLinkedItem | null | undefined +): JiraIssueLink | null { + if (item?.provider !== 'jira' || item.type !== 'issue') { + return null + } + const parsedUrl = parseJiraIssueUrl(item.url) + const key = item.jiraIdentifier?.trim().toUpperCase() || parsedUrl?.issueKey + if (!parsedUrl || !key || parsedUrl.issueKey !== key) { + return null + } + return normalizeJiraIssueLink({ + key, + title: withoutRepeatedJiraKey(item.title, key), + url: item.url + }) +} + +export function resolveJiraIssueLink(fields: { + linkedJiraIssue?: JiraIssueLink | null + linkedWorkItem?: WorkspaceLinkedItem | null +}): JiraIssueLink | null { + return fields.linkedJiraIssue !== undefined + ? normalizeJiraIssueLink(fields.linkedJiraIssue) + : jiraIssueLinkFromLegacyWorkItem(fields.linkedWorkItem) +} + +export function isJiraIssueLinkSourceContextMatch( + issue: JiraIssueLink | null | undefined, + context: TaskSourceContext | null | undefined +): boolean { + if (!issue || context?.provider !== 'jira') { + return false + } + const identity = context.providerIdentity + const issueUrl = parseJiraIssueUrl(issue.url) + if ( + identity?.provider !== 'jira' || + !identity.siteId || + !identity.siteUrl || + !identity.projectKey || + !issueUrl + ) { + return false + } + const siteUrl = parseJiraIssueUrl( + `${identity.siteUrl.replace(/\/+$/g, '')}/browse/${issueUrl.issueKey}` + ) + const projectKey = issueUrl.issueKey.slice(0, issueUrl.issueKey.lastIndexOf('-')) + return ( + issue.key.toUpperCase() === issueUrl.issueKey && + identity.projectKey.toUpperCase() === projectKey && + siteUrl !== null && + issueUrl.origin === siteUrl.origin && + issueUrl.sitePath === siteUrl.sitePath + ) +} + +export function resolveJiraIssueSourceContext(fields: { + linkedJiraIssue?: JiraIssueLink | null + linkedJiraIssueSourceContext?: TaskSourceContext | null + linkedWorkItem?: WorkspaceLinkedItem | null + linkedTaskSourceContext?: TaskSourceContext | null +}): TaskSourceContext | null { + if (fields.linkedJiraIssue !== undefined) { + const explicitIssue = normalizeJiraIssueLink(fields.linkedJiraIssue) + return isJiraIssueLinkSourceContextMatch(explicitIssue, fields.linkedJiraIssueSourceContext) + ? (fields.linkedJiraIssueSourceContext ?? null) + : null + } + const legacyIssue = jiraIssueLinkFromLegacyWorkItem(fields.linkedWorkItem) + return isJiraIssueLinkSourceContextMatch(legacyIssue, fields.linkedTaskSourceContext) + ? (fields.linkedTaskSourceContext ?? null) + : null +} + +function withoutRepeatedJiraKey(title: string, key: string): string { + const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const stripped = title.replace(new RegExp(`^${escapedKey}(?:\\s*[:—-]\\s*|\\s+)`, 'i'), '').trim() + return stripped || title.trim() +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index c967c66822b..af38ffb6d2e 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -28,6 +28,7 @@ export const TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY = 'task-source-context.v1' a export const WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY = 'workspace-run-context.v1' as const export const WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY = 'worktree.linked-work-item-context.v1' as const +export const WORKTREE_JIRA_ISSUE_LINK_RUNTIME_CAPABILITY = 'worktree.jira-issue-link.v1' as const export const REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY = 'remote-runtime.shared-control.v1' as const export const ORCHESTRATION_FEDERATION_RUNTIME_CAPABILITY = 'orchestration.federation.v1' as const export const ORCHESTRATION_FEDERATION_CONTROL_MAIL_RUNTIME_CAPABILITY = @@ -101,6 +102,7 @@ export const RUNTIME_CAPABILITIES = [ TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY, WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY, WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY, + WORKTREE_JIRA_ISSUE_LINK_RUNTIME_CAPABILITY, FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY, LINEAR_ISSUE_ATTRIBUTE_FILTER_RUNTIME_CAPABILITY, AI_VAULT_RUNTIME_CAPABILITY, diff --git a/src/shared/types.ts b/src/shared/types.ts index c351d699485..cd99f2abb75 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -367,6 +367,12 @@ export type WorkspaceLinkedItem = { repoId?: string } +export type JiraIssueLink = { + key: string + title: string + url: string +} + export type FolderWorkspaceLinkedTask = WorkspaceLinkedItem export type NestedRepoScanOptions = { @@ -516,6 +522,8 @@ export type Worktree = { linkedGiteaPR?: number | null linkedWorkItem?: WorkspaceLinkedItem | null linkedTaskSourceContext?: TaskSourceContext | null + linkedJiraIssue?: JiraIssueLink | null + linkedJiraIssueSourceContext?: TaskSourceContext | null isArchived: boolean isUnread: boolean isPinned: boolean @@ -641,6 +649,8 @@ export type WorktreeMeta = { linkedGiteaPR?: number | null linkedWorkItem?: WorkspaceLinkedItem | null linkedTaskSourceContext?: TaskSourceContext | null + linkedJiraIssue?: JiraIssueLink | null + linkedJiraIssueSourceContext?: TaskSourceContext | null isArchived: boolean isUnread: boolean isPinned: boolean @@ -2265,6 +2275,8 @@ export type CreateWorktreeArgs = { linkedGiteaPR?: number | null linkedWorkItem?: WorkspaceLinkedItem | null linkedTaskSourceContext?: TaskSourceContext | null + linkedJiraIssue?: JiraIssueLink | null + linkedJiraIssueSourceContext?: TaskSourceContext | null pushTarget?: GitPushTarget workspaceStatus?: WorkspaceStatus manualOrder?: number