mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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'> {
|
||||
|
||||
Reference in New Issue
Block a user