Skip to content

Commit f59c69f

Browse files
BillLeoutsakosvl346Bill Leoutsakos
andauthored
fix(confluence): normalize space identifiers (#7324)
* fix(confluence): normalize space identifiers * fix(confluence): hydrate legacy numeric space selections --------- Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
1 parent e7cfef9 commit f59c69f

7 files changed

Lines changed: 308 additions & 41 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
import { ConfluenceV2Block } from '@/blocks/blocks/confluence'
6+
7+
const { mockGetBlock } = vi.hoisted(() => ({ mockGetBlock: vi.fn() }))
8+
9+
vi.mock('@/blocks/registry', () => ({
10+
getBlock: mockGetBlock,
11+
getAllBlocks: vi.fn(() => []),
12+
getLatestBlock: vi.fn(() => undefined),
13+
getBlockRegistry: vi.fn(() => ({})),
14+
getBlockByToolName: vi.fn(() => undefined),
15+
getBlocksByCategory: vi.fn(() => []),
16+
}))
17+
18+
import { migrateSubblockIds } from '@/lib/workflows/migrations/subblock-migrations'
19+
import { extractBlockParams } from '@/serializer'
20+
import type { BlockState } from '@/stores/workflows/workflow/types'
21+
22+
function legacySearchBlock(field: string, value: string, advancedMode: boolean): BlockState {
23+
const values = { operation: 'search_in_space', [field]: value }
24+
return {
25+
id: 'block-1',
26+
type: 'confluence_v2',
27+
name: 'Confluence 1',
28+
position: { x: 0, y: 0 },
29+
advancedMode,
30+
subBlocks: Object.fromEntries(
31+
Object.entries(values).map(([id, fieldValue]) => [
32+
id,
33+
{ id, type: 'short-input', value: fieldValue },
34+
])
35+
),
36+
outputs: {},
37+
enabled: true,
38+
} as unknown as BlockState
39+
}
40+
41+
function mappedSearchParams(state: BlockState): {
42+
blocks: Record<string, BlockState>
43+
params: Record<string, unknown>
44+
} {
45+
const { blocks } = migrateSubblockIds({ 'block-1': state })
46+
const params = extractBlockParams(blocks['block-1'])
47+
const transform = ConfluenceV2Block.tools.config?.params
48+
if (!transform) throw new Error('Confluence V2 block has no params transform')
49+
return { blocks, params: { ...params, ...transform(params) } }
50+
}
51+
52+
describe('Confluence search-in-space values saved before the selector split', () => {
53+
beforeEach(() => {
54+
vi.clearAllMocks()
55+
mockGetBlock.mockReturnValue(ConfluenceV2Block)
56+
})
57+
58+
it.each([
59+
{
60+
mode: 'basic',
61+
source: 'spaceSelector',
62+
target: 'spaceKeySelector',
63+
value: 'ENG',
64+
advancedMode: false,
65+
},
66+
{
67+
mode: 'advanced',
68+
source: 'spaceId',
69+
target: 'manualSpaceKey',
70+
value: '12345',
71+
advancedMode: true,
72+
},
73+
])('migrates the $mode value and sends it as spaceKey', (testCase) => {
74+
const { blocks, params } = mappedSearchParams(
75+
legacySearchBlock(testCase.source, testCase.value, testCase.advancedMode)
76+
)
77+
78+
expect(blocks['block-1'].subBlocks[testCase.target]?.value).toBe(testCase.value)
79+
expect(params).toMatchObject({ operation: 'search_in_space', spaceKey: testCase.value })
80+
expect(params.spaceId).toBeUndefined()
81+
})
82+
})

apps/sim/blocks/blocks/confluence.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ const PAGE_FIELD = ['pageId', 'manualPageId'] as const
1616
*/
1717
const SPACE_FIELD = ['spaceSelector', 'spaceId'] as const
1818

19+
/** Canonical basic/advanced pair for V1 operations that require a space key. */
20+
const SPACE_KEY_FIELD = ['spaceKeySelector', 'manualSpaceKey'] as const
21+
1922
/** Canonical upload/reference pair for an attachment's file. V2 only. */
2023
const ATTACHMENT_FILE_FIELD = ['attachmentFileUpload', 'attachmentFileReference'] as const
2124

@@ -485,7 +488,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
485488
{ text: ', up to', field: 'limit', after: 'results' },
486489
],
487490
search_in_space: [
488-
{ text: 'Search', field: SPACE_FIELD, core: true },
491+
{ text: 'Search', field: SPACE_KEY_FIELD, core: true },
489492
{ text: 'for', field: 'query' },
490493
],
491494
list_blogposts: ['List blog posts', { text: ', up to', field: 'limit', after: 'results' }],
@@ -834,7 +837,6 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
834837
'update_space',
835838
'delete_space',
836839
'list_pages_in_space',
837-
'search_in_space',
838840
'create_blogpost',
839841
'list_blogposts_in_space',
840842
'list_space_labels',
@@ -861,7 +863,6 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
861863
'update_space',
862864
'delete_space',
863865
'list_pages_in_space',
864-
'search_in_space',
865866
'create_blogpost',
866867
'list_blogposts_in_space',
867868
'list_space_labels',
@@ -872,6 +873,29 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
872873
],
873874
},
874875
},
876+
{
877+
id: 'spaceKeySelector',
878+
title: 'Space',
879+
type: 'project-selector',
880+
canonicalParamId: 'selectedSpaceKey',
881+
serviceId: 'confluence',
882+
selectorKey: 'confluence.spaces',
883+
placeholder: 'Select Confluence space',
884+
dependsOn: ['credential', 'domain'],
885+
mode: 'basic',
886+
required: true,
887+
condition: { field: 'operation', value: 'search_in_space' },
888+
},
889+
{
890+
id: 'manualSpaceKey',
891+
title: 'Space Key',
892+
type: 'short-input',
893+
canonicalParamId: 'selectedSpaceKey',
894+
placeholder: 'Enter Confluence space key',
895+
mode: 'advanced',
896+
required: true,
897+
condition: { field: 'operation', value: 'search_in_space' },
898+
},
875899
{
876900
id: 'blogPostId',
877901
title: 'Blog Post ID',
@@ -1461,6 +1485,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
14611485
taskAssignedTo,
14621486
spaceName,
14631487
spaceKey,
1488+
selectedSpaceKey,
14641489
spaceDescription,
14651490
spacePropertyKey,
14661491
spacePropertyValue,
@@ -1626,6 +1651,15 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
16261651
}
16271652
}
16281653

1654+
if (operation === 'search_in_space') {
1655+
return {
1656+
credential: oauthCredential,
1657+
operation,
1658+
spaceKey: selectedSpaceKey,
1659+
...rest,
1660+
}
1661+
}
1662+
16291663
if (operation === 'update_space') {
16301664
return {
16311665
credential: oauthCredential,
@@ -1730,6 +1764,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
17301764
oauthCredential: { type: 'string', description: 'Confluence access token' },
17311765
pageId: { type: 'string', description: 'Page identifier' },
17321766
spaceId: { type: 'string', description: 'Space identifier' },
1767+
selectedSpaceKey: { type: 'string', description: 'Selected space key' },
17331768
blogPostId: { type: 'string', description: 'Blog post identifier' },
17341769
versionNumber: { type: 'number', description: 'Page version number' },
17351770
accountId: { type: 'string', description: 'Atlassian account ID' },
@@ -1758,7 +1793,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
17581793
taskStatus: { type: 'string', description: 'Task status (complete or incomplete)' },
17591794
taskAssignedTo: { type: 'string', description: 'Filter tasks by assignee account ID' },
17601795
spaceName: { type: 'string', description: 'Space name for create/update' },
1761-
spaceKey: { type: 'string', description: 'Space key for create' },
1796+
spaceKey: { type: 'string', description: 'Space key for create or scoped search' },
17621797
spaceDescription: { type: 'string', description: 'Space description' },
17631798
spacePropertyKey: { type: 'string', description: 'Space property key' },
17641799
spacePropertyValue: { type: 'json', description: 'Space property value' },

apps/sim/lib/internal/confluence/operations.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ vi.mock('@/lib/uploads/utils/file-utils.server', () => ({
2424
import { ConfluenceOperationError } from '@/lib/internal/confluence/errors'
2525
import {
2626
executeConfluenceListLabels,
27+
executeConfluenceListPagesInSpace,
28+
executeConfluenceSearchInSpace,
2729
executeConfluenceUploadAttachment,
2830
} from '@/lib/internal/confluence/operations'
2931

@@ -83,6 +85,63 @@ describe('Confluence operations', () => {
8385
expect(response.bodyUsed).toBe(true)
8486
})
8587

88+
it.each([
89+
{ selectedValue: 'ENG', expectedCalls: 2 },
90+
{ selectedValue: '12345', expectedCalls: 1 },
91+
])(
92+
'uses numeric space IDs for V2 requests when the selected value is $selectedValue',
93+
async ({ selectedValue, expectedCalls }) => {
94+
const fetchMock = vi.fn(async (request: string | URL | Request) => {
95+
const url = String(request)
96+
if (url.includes('/spaces?')) {
97+
return Response.json({
98+
results: [{ id: '12345', key: 'ENG', name: 'Engineering', status: 'current' }],
99+
})
100+
}
101+
return Response.json({ results: [] })
102+
})
103+
vi.stubGlobal('fetch', fetchMock)
104+
105+
await expect(
106+
executeConfluenceListPagesInSpace(
107+
{ ...CONNECTION, spaceId: selectedValue, limit: 25 },
108+
{ headers: new Headers(), requestId: 'request-1' }
109+
)
110+
).resolves.toEqual({ pages: [], nextCursor: null })
111+
112+
expect(fetchMock).toHaveBeenCalledTimes(expectedCalls)
113+
const urls = fetchMock.mock.calls.map(([request]) => String(request))
114+
expect(urls.at(-1)).toContain('/spaces/12345/pages?limit=25')
115+
if (selectedValue === 'ENG') {
116+
expect(urls[0]).toContain('/spaces?keys=ENG&limit=1&status=current')
117+
}
118+
}
119+
)
120+
121+
it('resolves a legacy numeric space value before constructing key-based CQL', async () => {
122+
const fetchMock = vi.fn(async (request: string | URL | Request) => {
123+
const url = String(request)
124+
if (url.includes('/api/v2/spaces/12345')) {
125+
return Response.json({ id: '12345', key: 'ENG', name: 'Engineering' })
126+
}
127+
return Response.json({ results: [], totalSize: 0 })
128+
})
129+
vi.stubGlobal('fetch', fetchMock)
130+
131+
await expect(
132+
executeConfluenceSearchInSpace(
133+
{ ...CONNECTION, spaceKey: '12345', query: 'release notes', limit: 25 },
134+
{ headers: new Headers(), requestId: 'request-1' }
135+
)
136+
).resolves.toEqual({ results: [], spaceKey: 'ENG', totalSize: 0 })
137+
138+
expect(fetchMock).toHaveBeenCalledTimes(2)
139+
const searchUrl = String(fetchMock.mock.calls[1][0])
140+
expect(new URL(searchUrl).searchParams.get('cql')).toBe(
141+
'space = "ENG" AND text ~ "release notes"'
142+
)
143+
})
144+
86145
it('fails closed before downloading a stored file without an acting user', async () => {
87146
let caught: unknown
88147
try {

0 commit comments

Comments
 (0)