refactor(jira): separate worktree issue metadata

This commit is contained in:
Jinjing
2026-08-07 20:54:25 -07:00
parent ce20a109da
commit 0a72f011a4
26 changed files with 814 additions and 42 deletions
@@ -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 ?? {})
}
}
+2
View File
@@ -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',
+8
View File
@@ -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', () => {
+33 -1
View File
@@ -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) }
+77
View File
@@ -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,
+66 -20
View File
@@ -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>): 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()
}
+20
View File
@@ -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 } : {}),
@@ -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<typeof JiraIssueLinkSchema> | null
linkedJiraIssueSourceContext?: z.infer<typeof TaskSourceContextSchema> | 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'
})
}
}
@@ -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)
})
)
})
})
@@ -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
@@ -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,
+4
View File
@@ -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,
@@ -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,
@@ -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, 'linkedWorkItem'>
worktree: Pick<Worktree, 'linkedJiraIssue' | 'linkedWorkItem'>
): 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, 'linkedWorkItem'>,
worktree: Pick<Worktree, 'linkedJiraIssue' | 'linkedWorkItem'>,
properties: readonly WorktreeCardProperty[]
): WorktreeCardJiraIssueDisplay | null {
return properties.includes('jira-issue') ? getWorktreeCardJiraIssueDisplay(worktree) : null
@@ -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))
@@ -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 }
: {})
}
)
@@ -198,6 +198,8 @@ export type WorktreeSlice = {
automationProvenanceRequest?: CreateWorktreeArgs['automationProvenanceRequest']
linkedWorkItem?: WorkspaceLinkedItem | null
linkedTaskSourceContext?: TaskSourceContext | null
linkedJiraIssue?: CreateWorktreeArgs['linkedJiraIssue']
linkedJiraIssueSourceContext?: CreateWorktreeArgs['linkedJiraIssueSourceContext']
}
) => Promise<CreateWorktreeResult>
/** Register an in-flight background creation and make it the active surface. */
@@ -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<AppState>)
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<typeof createWorktree> = ['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<AppState>)
const createWorktree = store.getState().createWorktree
const args: Parameters<typeof createWorktree> = ['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<AppState>)
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({
@@ -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<AppState, [], [], WorktreeSlice>
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<AppState, [], [], WorktreeSlice>
...(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<AppState, [], [], WorktreeSlice>
...(linkedGiteaPR !== undefined ? { linkedGiteaPR } : {}),
...(linkedWorkItem !== undefined ? { linkedWorkItem } : {}),
...(linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext } : {}),
...(linkedJiraIssue !== undefined ? { linkedJiraIssue } : {}),
...(linkedJiraIssueSourceContext !== undefined
? { linkedJiraIssueSourceContext }
: {}),
...(automationProvenanceRequest ? { automationProvenanceRequest } : {}),
...(startup
? {
@@ -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', () => {
+6
View File
@@ -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,
+12
View File
@@ -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
})
+109
View File
@@ -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)
})
})
+116
View File
@@ -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<JiraIssueLink>
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()
}
+2
View File
@@ -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,
+12
View File
@@ -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