diff --git a/src/renderer/src/components/cmd-j/task-search-activation.test.ts b/src/renderer/src/components/cmd-j/task-search-activation.test.ts index 3c70fcdcb4e..a74dbbd6119 100644 --- a/src/renderer/src/components/cmd-j/task-search-activation.test.ts +++ b/src/renderer/src/components/cmd-j/task-search-activation.test.ts @@ -27,7 +27,16 @@ it('uses the attachment source owner when a workspace executes on a different ho it('rejects source host/account/site collisions even when the issue URL matches', () => { for (const source of [ { ...jiraTaskSource, hostId: 'runtime:other' as const }, - { ...jiraTaskSource, accountLabel: 'someone@example.com' }, + // Another login on the same site has its own siteId. + { + ...jiraTaskSource, + providerIdentity: { + provider: 'jira' as const, + siteId: 'other-site-second-login', + siteUrl: 'https://other.atlassian.net', + projectKey: 'DEMO' + } + }, { ...jiraTaskSource, providerIdentity: { @@ -45,6 +54,15 @@ it('rejects source host/account/site collisions even when the issue URL matches' } }) +it('treats the Jira account label as display-only', () => { + const workspace = makeWorktree('labeled', 'Display name label', { + linkedItems: [ + { ...attachment, taskSourceContext: { ...jiraTaskSource, accountLabel: 'Other' } } + ] + }) + expect(findCmdJTaskWorkspace(jiraTaskResult, [workspace])).toBe(workspace) +}) + it('rejects bare legacy identifiers and other Jira origins', () => { const bare = makeWorktree('bare', 'Unscoped', { linkedWorkItem: { diff --git a/src/renderer/src/components/cmd-j/task-search-activation.ts b/src/renderer/src/components/cmd-j/task-search-activation.ts index 6a11b89f259..f6a431327f0 100644 --- a/src/renderer/src/components/cmd-j/task-search-activation.ts +++ b/src/renderer/src/components/cmd-j/task-search-activation.ts @@ -13,7 +13,10 @@ import { } from '@/lib/new-workspace' import { buildLinearIssueLinkedWorkItem } from '@/lib/linear-linked-work-item' import type { Worktree } from '../../../../shared/worktree/types' -import type { TaskSourceContext } from '../../../../shared/task-source-context' +import { + haveConflictingTaskSourceAccounts, + type TaskSourceContext +} from '../../../../shared/task-source-context' import type { CmdJTaskSearchResult } from './task-search-results' import { getJiraIssueWorkspaceSeed } from '../task-page-source-context' @@ -21,30 +24,18 @@ function sourceMatches(source: TaskSourceContext, target: TaskSourceContext): bo if (source.provider !== target.provider || source.hostId !== target.hostId) { return false } - if (source.accountLabel && target.accountLabel && source.accountLabel !== target.accountLabel) { + if (haveConflictingTaskSourceAccounts(source, target)) { return false } const left = source.providerIdentity const right = target.providerIdentity - if ( + return !( left?.provider === 'linear' && right?.provider === 'linear' && left.workspaceId && right.workspaceId && left.workspaceId !== right.workspaceId - ) { - return false - } - if ( - left?.provider === 'jira' && - right?.provider === 'jira' && - left.siteId && - right.siteId && - left.siteId !== right.siteId - ) { - return false - } - return true + ) } export function findCmdJTaskWorkspace( diff --git a/src/renderer/src/components/cmd-j/task-search-requests.ts b/src/renderer/src/components/cmd-j/task-search-requests.ts index 4718e6c1fac..130c34bc068 100644 --- a/src/renderer/src/components/cmd-j/task-search-requests.ts +++ b/src/renderer/src/components/cmd-j/task-search-requests.ts @@ -4,6 +4,7 @@ import { jiraSearchIssuesForTarget, jiraStatusForTarget } from '@/runtime/runtim import { ipcOrRuntimeTargetForHostId } from '@/runtime/runtime-client-target' import { gitLabApiFor } from '@/runtime/gitlab-owner-api' import { resolveJiraSourceHostId } from '@/lib/jira-source-host' +import { getJiraAccountLabel } from '@/lib/jira-account-label' import { getRepoExecutionHostId, parseExecutionHostId, @@ -257,7 +258,7 @@ async function searchJira( normalizeTaskSourceContext({ ...source, providerIdentity: { provider: 'jira', siteId: issue.siteId ?? null, siteUrl: null }, - accountLabel: status.viewer?.email ?? null + accountLabel: getJiraAccountLabel(status.viewer) }) return itemSource ? [ diff --git a/src/renderer/src/components/new-workspace/use-jira-url-source.ts b/src/renderer/src/components/new-workspace/use-jira-url-source.ts index f3a922a7171..7973a4dfc87 100644 --- a/src/renderer/src/components/new-workspace/use-jira-url-source.ts +++ b/src/renderer/src/components/new-workspace/use-jira-url-source.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useAppStore } from '@/store' +import { getJiraAccountLabel } from '@/lib/jira-account-label' import { assertRuntimeEnvironmentCapability } from '@/runtime/runtime-rpc-client' import { getMatchingJiraSites, @@ -88,7 +89,7 @@ export function bindJiraIssueSourceContext( siteUrl: site.siteUrl, projectKey: issue.project.key }, - accountLabel: site.email || site.displayName + accountLabel: getJiraAccountLabel(site) }) } diff --git a/src/renderer/src/components/sidebar/workspace-attachment-source-result.test.ts b/src/renderer/src/components/sidebar/workspace-attachment-source-result.test.ts index 6a8482eb31b..b97540a5749 100644 --- a/src/renderer/src/components/sidebar/workspace-attachment-source-result.test.ts +++ b/src/renderer/src/components/sidebar/workspace-attachment-source-result.test.ts @@ -159,6 +159,27 @@ describe('attachment source conversion', () => { ).toBe(false) } ) + it('detects a duplicate Jira link by site identity, not account label', () => { + const jira = (accountLabel: string, siteId: string): WorkspaceAttachment => ({ + provider: 'jira', + type: 'issue', + number: 0, + jiraIdentifier: 'APP-1', + // Board URLs carry no provable issue identity, so source context decides. + url: 'https://acme.atlassian.net/jira/software/projects/APP/boards/1?selectedIssue=APP-1', + taskSourceContext: + normalizeTaskSourceContext({ + provider: 'jira', + projectId: 'account-backed-task-source', + hostId: 'local', + providerIdentity: { provider: 'jira', siteId, siteUrl: 'https://acme.atlassian.net' }, + accountLabel + }) ?? undefined + }) + const saved = jira('dev@acme.example', 'site-a') + expect(isWorkspaceAttachmentLinked([saved], jira('Acme', 'site-a'))).toBe(true) + expect(isWorkspaceAttachmentLinked([saved], jira('dev@acme.example', 'site-b'))).toBe(false) + }) it('only enriches the same symbolic identifier and URL source', () => { const task: WorkspaceAttachment = { provider: 'linear', diff --git a/src/renderer/src/components/sidebar/workspace-attachment-source-result.ts b/src/renderer/src/components/sidebar/workspace-attachment-source-result.ts index 7612bdd3557..382ca24262f 100644 --- a/src/renderer/src/components/sidebar/workspace-attachment-source-result.ts +++ b/src/renderer/src/components/sidebar/workspace-attachment-source-result.ts @@ -3,6 +3,7 @@ import type { Repo } from '../../../../shared/repo-types' import type { WorkspaceAttachment, Worktree } from '../../../../shared/worktree/types' import { buildTaskSourceContextFromRepo, + haveConflictingTaskSourceAccounts, normalizeTaskSourceContext, type TaskSourceContext, type TaskProvider @@ -91,9 +92,7 @@ export function isWorkspaceAttachmentLinked( !newContext || (oldContext.hostId === newContext.hostId && oldContext.projectId === newContext.projectId && - (!oldContext.accountLabel || - !newContext.accountLabel || - oldContext.accountLabel === newContext.accountLabel)) + !haveConflictingTaskSourceAccounts(oldContext, newContext)) ) }) } diff --git a/src/renderer/src/components/task-page-jira-item-source-context.ts b/src/renderer/src/components/task-page-jira-item-source-context.ts index be8ec6d4006..bdee8a35844 100644 --- a/src/renderer/src/components/task-page-jira-item-source-context.ts +++ b/src/renderer/src/components/task-page-jira-item-source-context.ts @@ -1,4 +1,5 @@ import type { JiraIssue, JiraSite } from '../../../shared/jira-types' +import { getJiraAccountLabel } from '@/lib/jira-account-label' import { normalizeTaskSourceContext, type TaskSourceContext @@ -24,6 +25,6 @@ export function bindTaskPageJiraItemSourceContext(args: { siteUrl: site.siteUrl, projectKey: args.issue.project.key }, - accountLabel: site.email || site.displayName || site.siteUrl + accountLabel: getJiraAccountLabel(site) }) } diff --git a/src/renderer/src/components/use-task-page-source-availability.ts b/src/renderer/src/components/use-task-page-source-availability.ts index bee09bdb1e5..53c3ba38186 100644 --- a/src/renderer/src/components/use-task-page-source-availability.ts +++ b/src/renderer/src/components/use-task-page-source-availability.ts @@ -1,5 +1,6 @@ import type { TaskPageRuntimeHostsModel } from './use-task-page-runtime-hosts' import { useCallback, useMemo } from 'react' +import { getJiraAccountLabel } from '@/lib/jira-account-label' import type { Repo } from '../../../shared/repo-types' import { getTaskPageRepoSourceContext, @@ -129,7 +130,7 @@ export function useTaskPageSourceAvailabilityPrelude(model: TaskPageRuntimeHosts siteId: selectedJiraSiteId && selectedJiraSiteId !== 'all' ? selectedJiraSiteId : null, siteUrl: selectedJiraSite?.siteUrl ?? null }, - accountLabel: selectedJiraSite?.displayName ?? selectedJiraSite?.siteUrl ?? null + accountLabel: getJiraAccountLabel(selectedJiraSite) }), [ accountBackedTaskSourceHostId, diff --git a/src/renderer/src/lib/jira-account-label.ts b/src/renderer/src/lib/jira-account-label.ts new file mode 100644 index 00000000000..8da69ddaa52 --- /dev/null +++ b/src/renderer/src/lib/jira-account-label.ts @@ -0,0 +1,9 @@ +/** Display label for a Jira login; its identity is the host plus providerIdentity.siteId. */ +export function getJiraAccountLabel( + account: + | { email?: string | null; displayName?: string | null; siteUrl?: string | null } + | null + | undefined +): string | null { + return account?.email?.trim() || account?.displayName?.trim() || account?.siteUrl?.trim() || null +} diff --git a/src/renderer/src/lib/jira-issue-workspace-attachment.test.ts b/src/renderer/src/lib/jira-issue-workspace-attachment.test.ts index 93c4cf245b4..dc38a603e27 100644 --- a/src/renderer/src/lib/jira-issue-workspace-attachment.test.ts +++ b/src/renderer/src/lib/jira-issue-workspace-attachment.test.ts @@ -112,7 +112,16 @@ describe('Jira workspace attachment identity', () => { it('rejects another execution host or Jira account but allows other repo contexts', () => { const workspace = linked('same-key') expect(matches([workspace], issue, { ...source, hostId: 'runtime:cloud' })).toEqual([]) - expect(matches([workspace], issue, { ...source, accountLabel: 'another' })).toEqual([]) + // Another login on the same site has its own siteId, and its issues carry it. + const otherSite: JiraSite = { ...site, id: 'site-a2', accountId: 'other-user' } + const otherLogin: TaskSourceContext = { + ...source, + providerIdentity: { provider: 'jira', siteId: otherSite.id, siteUrl: site.siteUrl } + } + expect(matches([workspace], { ...issue, siteId: otherSite.id }, otherLogin)).toEqual([]) + expect( + collectLinkedJiraIssueRefs([workspace], [site, otherSite], otherSite.id, otherLogin) + ).toEqual([]) expect( matches([workspace], issue, { ...source, projectId: 'other-repo-project', repoId: 'other' }) ).toEqual([workspace]) @@ -138,6 +147,27 @@ describe('Jira workspace attachment identity', () => { }) expect(matches([legacy])).toEqual([legacy]) }) + it('matches links whose account label differs from the Tasks page label', () => { + const emailLabeled = makeWorktree({ + id: 'email-labeled', + repoId: 'repo', + linkedItems: [ + { + provider: 'jira', + type: 'issue', + number: 0, + identifier: 'APP-1', + url: issue.url, + taskSourceContext: { ...source, accountLabel: 'dev@acme.example' } + } + ] + }) + const tasksPage = { ...source, accountLabel: 'Acme' } + expect(matches([emailLabeled], issue, tasksPage)).toEqual([emailLabeled]) + expect(collectLinkedJiraIssueRefs([emailLabeled], [site], 'all', tasksPage)).toMatchObject([ + { key: 'APP-1', siteId: site.id } + ]) + }) it('matches account-scoped links in the all-sites view', () => { expect(matches([linked('one')], issue, { ...source, accountLabel: null })).toHaveLength(1) }) diff --git a/src/renderer/src/lib/jira-issue-workspace-attachment.ts b/src/renderer/src/lib/jira-issue-workspace-attachment.ts index be805ba630a..7ac41683549 100644 --- a/src/renderer/src/lib/jira-issue-workspace-attachment.ts +++ b/src/renderer/src/lib/jira-issue-workspace-attachment.ts @@ -21,18 +21,9 @@ function jiraKey(item: WorkspaceAttachment): string | null { : (parseJiraIssueUrl(value)?.issueKey ?? null) } +// Why host only: the login is matched by siteId below and the label is display-only; legacy links are local. function sameSource(item: WorkspaceAttachment, source?: TaskSourceContext | null): boolean { - if (!source) { - return true - } - const stored = item.taskSourceContext - // Legacy links were created by the desktop-owned Jira connection. - if ((stored?.hostId ?? 'local') !== source.hostId) { - return false - } - const storedAccount = stored?.accountLabel?.trim() - const selectedAccount = source.accountLabel?.trim() - return !storedAccount || !selectedAccount || storedAccount === selectedAccount + return !source || (item.taskSourceContext?.hostId ?? 'local') === source.hostId } function itemSite(item: WorkspaceAttachment) { diff --git a/src/shared/task-source-context.ts b/src/shared/task-source-context.ts index 1ae7636d6eb..cc54a689a12 100644 --- a/src/shared/task-source-context.ts +++ b/src/shared/task-source-context.ts @@ -142,6 +142,21 @@ export function areTaskSourceContextsEqual( ) } +/** Jira identity is siteId (site + login); its label is display-only. Callers match providers first. */ +export function haveConflictingTaskSourceAccounts( + a: Pick, + b: Pick +): boolean { + if (a.provider === 'jira') { + const left = a.providerIdentity?.provider === 'jira' ? a.providerIdentity.siteId : null + const right = b.providerIdentity?.provider === 'jira' ? b.providerIdentity.siteId : null + return Boolean(left && right && left !== right) + } + const left = a.accountLabel?.trim() + const right = b.accountLabel?.trim() + return Boolean(left && right && left !== right) +} + export function getTaskSourceRuntimeSettings( context: Pick | null | undefined ): Pick {