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.
This commit is contained in:
Jinwoo Hong
2026-10-15 16:30:34 -04:00
committed by GitHub
parent 3a3889c991
commit 2744bc7465
12 changed files with 114 additions and 36 deletions
@@ -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: {
@@ -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(
@@ -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
? [
@@ -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)
})
}
@@ -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',
@@ -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))
)
})
}
@@ -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)
})
}
@@ -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,
@@ -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
}
@@ -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)
})
@@ -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) {
+15
View File
@@ -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<TaskSourceContext, 'provider' | 'providerIdentity' | 'accountLabel'>,
b: Pick<TaskSourceContext, 'provider' | 'providerIdentity' | 'accountLabel'>
): 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<TaskSourceContext, 'hostId'> | null | undefined
): Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> {