From 2744bc7465247a4d71bcd13c5fcb38dae1f84ff2 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:30:34 -0400 Subject: [PATCH] fix(jira): show linked workspaces in Has Workspace by matching the Jira login, not its label (#29201) * fix(jira): match workspace links by site identity, not account label Jira links saved the account email as accountLabel while the Tasks page source used the site display name, so sameSource() rejected every link and the "Has Workspace" view stayed empty (STA-10654, #29055). Jira connection identity is hostId + providerIdentity.siteId (per login per site), so link matching, cmd-J workspace lookup and duplicate-link detection now compare siteId for Jira and treat accountLabel as display-only. All Jira label producers share getJiraAccountLabel so labels stay consistent. * refactor(jira): simplify Has Workspace link matching after review Match Jira workspace links by host only in sameSource, since siteId checks downstream already separate logins; give getJiraAccountLabel a plain structural parameter; make the other-login test use a reachable state. --- .../cmd-j/task-search-activation.test.ts | 20 +++++++++++- .../cmd-j/task-search-activation.ts | 23 ++++--------- .../components/cmd-j/task-search-requests.ts | 3 +- .../new-workspace/use-jira-url-source.ts | 3 +- ...workspace-attachment-source-result.test.ts | 21 ++++++++++++ .../workspace-attachment-source-result.ts | 5 ++- .../task-page-jira-item-source-context.ts | 3 +- .../use-task-page-source-availability.ts | 3 +- src/renderer/src/lib/jira-account-label.ts | 9 ++++++ .../jira-issue-workspace-attachment.test.ts | 32 ++++++++++++++++++- .../lib/jira-issue-workspace-attachment.ts | 13 ++------ src/shared/task-source-context.ts | 15 +++++++++ 12 files changed, 114 insertions(+), 36 deletions(-) create mode 100644 src/renderer/src/lib/jira-account-label.ts 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 {