mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
refactor(jira): separate worktree issue metadata
This commit is contained in:
@@ -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 ?? {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user