Files
orca/src/shared/task-source-context.ts
T
Jinwoo Hong 2744bc7465 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.
2026-10-15 16:30:34 -04:00

244 lines
7.3 KiB
TypeScript

import {
getRepoExecutionHostId,
LOCAL_EXECUTION_HOST_ID,
normalizeExecutionHostId,
parseExecutionHostId,
toRuntimeExecutionHostId,
type ExecutionHostId
} from './execution-host'
import {
areTaskProviderIdentitiesEqual,
isStoredTaskProviderIdentity,
normalizeTaskProviderIdentity,
taskProviderIdentityCachePart,
type TaskProviderIdentity
} from './task-provider-identity'
import type { TaskProvider } from './task-providers'
import type { GlobalSettings } from './global-settings-types'
import type { Repo } from './repo-types'
export type {
GitHubTaskProviderIdentity,
GitLabTaskProviderIdentity,
JiraTaskProviderIdentity,
LinearTaskProviderIdentity,
TaskProviderIdentity
} from './task-provider-identity'
export type { TaskProvider } from './task-providers'
export type TaskSourceContext = {
kind: 'task-source'
provider: TaskProvider
projectId: string
hostId: ExecutionHostId
projectHostSetupId?: string | null
repoId?: string | null
providerIdentity?: TaskProviderIdentity | null
accountLabel?: string | null
}
export type WorkspaceRunContext = {
kind: 'workspace-run'
projectId: string
hostId: ExecutionHostId
projectHostSetupId: string
repoId: string
path: string
}
export type TaskSourceContextInput = Omit<TaskSourceContext, 'kind' | 'hostId'> & {
kind?: 'task-source'
hostId?: string | null
}
export function normalizeTaskSourceContext(
input: TaskSourceContextInput
): TaskSourceContext | null {
const projectId = normalizeNonEmptyString(input.projectId)
if (!projectId) {
return null
}
const provider = normalizeTaskProvider(input.provider)
if (!provider) {
return null
}
return {
kind: 'task-source',
provider,
projectId,
hostId: normalizeExecutionHostId(input.hostId) ?? LOCAL_EXECUTION_HOST_ID,
projectHostSetupId: normalizeNonEmptyString(input.projectHostSetupId),
repoId: normalizeNonEmptyString(input.repoId),
providerIdentity: normalizeTaskProviderIdentity(provider, input.providerIdentity),
accountLabel: normalizeNonEmptyString(input.accountLabel)
}
}
export function normalizeStoredTaskSourceContext(value: unknown): TaskSourceContext | null {
if (!value || typeof value !== 'object') {
return null
}
const input = value as Record<string, unknown>
const provider = normalizeTaskProvider(input.provider)
if (
!provider ||
typeof input.projectId !== 'string' ||
(input.kind !== undefined && input.kind !== 'task-source') ||
!isNullableOptionalString(input.hostId) ||
!isNullableOptionalString(input.projectHostSetupId) ||
!isNullableOptionalString(input.repoId) ||
!isNullableOptionalString(input.accountLabel) ||
!isStoredTaskProviderIdentity(provider, input.providerIdentity)
) {
return null
}
if (
typeof input.hostId === 'string' &&
input.hostId.trim().length > 0 &&
normalizeExecutionHostId(input.hostId) === null
) {
return null
}
return normalizeTaskSourceContext(input as TaskSourceContextInput)
}
export function buildTaskSourceContextFromRepo(args: {
provider: TaskProvider
projectId: string
repo: Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>
projectHostSetupId?: string | null
providerIdentity?: TaskProviderIdentity | null
accountLabel?: string | null
}): TaskSourceContext | null {
return normalizeTaskSourceContext({
provider: args.provider,
projectId: args.projectId,
hostId: getRepoExecutionHostId(args.repo),
repoId: args.repo.id,
projectHostSetupId: args.projectHostSetupId,
providerIdentity: args.providerIdentity,
accountLabel: args.accountLabel
})
}
export function areTaskSourceContextsEqual(
a: TaskSourceContext | null | undefined,
b: TaskSourceContext | null | undefined
): boolean {
if (a === b) {
return true
}
if (!a || !b) {
return !a && !b
}
return (
a.provider === b.provider &&
a.projectId === b.projectId &&
a.hostId === b.hostId &&
(a.projectHostSetupId ?? null) === (b.projectHostSetupId ?? null) &&
(a.repoId ?? null) === (b.repoId ?? null) &&
(a.accountLabel ?? null) === (b.accountLabel ?? null) &&
areTaskProviderIdentitiesEqual(a.providerIdentity, b.providerIdentity)
)
}
/** 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'> {
const parsed = parseExecutionHostId(context?.hostId)
return {
activeRuntimeEnvironmentId: parsed?.kind === 'runtime' ? parsed.environmentId : null
}
}
export function getTaskSourceCacheScope(
context: Pick<TaskSourceContext, 'provider' | 'hostId' | 'projectId' | 'projectHostSetupId'> & {
providerIdentity?: TaskProviderIdentity | null
repoId?: string | null
accountLabel?: string | null
}
): string {
return [
context.provider,
context.hostId,
context.projectId,
context.projectHostSetupId ?? '',
context.repoId ?? '',
taskProviderIdentityCachePart(context.providerIdentity),
...(context.accountLabel?.trim() ? [context.accountLabel.trim()] : [])
]
.map(encodeCachePart)
.join(':')
}
export function buildWorkspaceRunContext(args: {
projectId: string
hostId: string | null | undefined
projectHostSetupId: string
repoId: string
path: string
}): WorkspaceRunContext | null {
const projectId = normalizeNonEmptyString(args.projectId)
const projectHostSetupId = normalizeNonEmptyString(args.projectHostSetupId)
const repoId = normalizeNonEmptyString(args.repoId)
const repoPath = normalizeNonEmptyString(args.path)
if (!projectId || !projectHostSetupId || !repoId || !repoPath) {
return null
}
return {
kind: 'workspace-run',
projectId,
hostId: normalizeExecutionHostId(args.hostId) ?? LOCAL_EXECUTION_HOST_ID,
projectHostSetupId,
repoId,
path: repoPath
}
}
function normalizeTaskProvider(value: unknown): TaskProvider | null {
switch (value) {
case 'github':
case 'gitlab':
case 'linear':
case 'jira':
return value
default:
return null
}
}
function normalizeNonEmptyString(value: unknown): string | null {
const trimmed = typeof value === 'string' ? value.trim() : ''
return trimmed ? trimmed : null
}
function isNullableOptionalString(value: unknown): boolean {
return value === undefined || value === null || typeof value === 'string'
}
function encodeCachePart(value: string): string {
return encodeURIComponent(value)
}
export function runtimeHostIdFromEnvironmentId(
environmentId: string | null | undefined
): ExecutionHostId {
const trimmed = normalizeNonEmptyString(environmentId)
return trimmed ? toRuntimeExecutionHostId(trimmed) : LOCAL_EXECUTION_HOST_ID
}