Skip to content

Commit b2e3c6d

Browse files
committed
feat(plane): add current API integration and self-hosted compatibility
1 parent d4f26a7 commit b2e3c6d

517 files changed

Lines changed: 138291 additions & 263 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/docs/components/icons.tsx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10124,3 +10124,11 @@ export function YouComIcon(props: SVGProps<SVGSVGElement>) {
1012410124
</svg>
1012510125
)
1012610126
}
10127+
10128+
export function PlaneIcon(props: SVGProps<SVGSVGElement>) {
10129+
return (
10130+
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 85 52' fill='currentColor' {...props}>
10131+
<path d='M44.322 2.926C44.322 0.755 46.608 -0.658 48.551 0.313L80.455 16.265C82.929 17.502 84.492 20.032 84.492 22.799V48.249C84.492 50.42 82.207 51.833 80.265 50.862L62.328 41.893V22.797C62.328 20.031 60.765 17.502 58.291 16.264L44.322 9.28V2.926ZM0 2.925C0 0.754 2.286 -0.659 4.229 0.312L22.158 9.277V28.377C22.158 31.143 23.721 33.672 26.195 34.909L40.17 41.895V48.249C40.17 50.42 37.885 51.832 35.942 50.861L4.037 34.909C1.563 33.672 0 31.143 0 28.377V2.925ZM22.158 2.925C22.158 0.754 24.444 -0.659 26.387 0.312L44.322 9.28V28.378C44.322 31.144 45.886 33.673 48.36 34.91L62.328 41.893V48.249C62.328 50.42 60.043 51.832 58.101 50.861L40.17 41.895V22.797C40.17 20.031 38.607 17.502 36.133 16.264L22.158 9.277V2.925Z' />
10132+
</svg>
10133+
)
10134+
}

‎apps/docs/components/ui/icon-mapping.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ import {
212212
PineconeIcon,
213213
PipedriveIcon,
214214
PitchBookIcon,
215+
PlaneIcon,
215216
PlanetScaleIcon,
216217
PolymarketIcon,
217218
PostgresIcon,
@@ -558,6 +559,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
558559
pinecone: PineconeIcon,
559560
pipedrive: PipedriveIcon,
560561
pitchbook: PitchBookIcon,
562+
plane: PlaneIcon,
561563
planetscale: PlanetScaleIcon,
562564
polymarket: PolymarketIcon,
563565
postgresql: PostgresIcon,

‎apps/docs/content/docs/integrations/meta.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@
199199
"pipedrive",
200200
"pipedrive-service-account",
201201
"pitchbook",
202+
"plane",
202203
"planetscale",
203204
"polymarket",
204205
"postgresql",

‎apps/docs/content/docs/integrations/plane.mdx‎

Lines changed: 21789 additions & 0 deletions
Large diffs are not rendered by default.

‎apps/sim/blocks/blocks/plane.ts‎

Lines changed: 17314 additions & 0 deletions
Large diffs are not rendered by default.

‎apps/sim/blocks/registry-maps.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@ import { PiBlock } from '@/blocks/blocks/pi'
266266
import { PineconeBlock, PineconeBlockMeta } from '@/blocks/blocks/pinecone'
267267
import { PipedriveBlock, PipedriveBlockMeta } from '@/blocks/blocks/pipedrive'
268268
import { PitchBookBlock, PitchBookBlockMeta } from '@/blocks/blocks/pitchbook'
269+
import { PlaneBlock, PlaneBlockMeta } from '@/blocks/blocks/plane'
269270
import { PlanetScaleBlock, PlanetScaleBlockMeta } from '@/blocks/blocks/planetscale'
270271
import { PolymarketBlock, PolymarketBlockMeta } from '@/blocks/blocks/polymarket'
271272
import { PostgreSQLBlock, PostgreSQLBlockMeta } from '@/blocks/blocks/postgresql'
@@ -622,6 +623,7 @@ export const BLOCK_REGISTRY: Record<string, BlockConfig> = {
622623
pinecone: PineconeBlock,
623624
pipedrive: PipedriveBlock,
624625
pitchbook: PitchBookBlock,
626+
plane: PlaneBlock,
625627
planetscale: PlanetScaleBlock,
626628
polymarket: PolymarketBlock,
627629
postgresql: PostgreSQLBlock,
@@ -956,6 +958,7 @@ export const BLOCK_META_REGISTRY: Record<string, BlockMeta> = {
956958
pinecone: PineconeBlockMeta,
957959
pipedrive: PipedriveBlockMeta,
958960
pitchbook: PitchBookBlockMeta,
961+
plane: PlaneBlockMeta,
959962
planetscale: PlanetScaleBlockMeta,
960963
polymarket: PolymarketBlockMeta,
961964
postgresql: PostgreSQLBlockMeta,

‎apps/sim/components/icons.tsx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10124,3 +10124,11 @@ export function YouComIcon(props: SVGProps<SVGSVGElement>) {
1012410124
</svg>
1012510125
)
1012610126
}
10127+
10128+
export function PlaneIcon(props: SVGProps<SVGSVGElement>) {
10129+
return (
10130+
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 85 52' fill='currentColor' {...props}>
10131+
<path d='M44.322 2.926C44.322 0.755 46.608 -0.658 48.551 0.313L80.455 16.265C82.929 17.502 84.492 20.032 84.492 22.799V48.249C84.492 50.42 82.207 51.833 80.265 50.862L62.328 41.893V22.797C62.328 20.031 60.765 17.502 58.291 16.264L44.322 9.28V2.926ZM0 2.925C0 0.754 2.286 -0.659 4.229 0.312L22.158 9.277V28.377C22.158 31.143 23.721 33.672 26.195 34.909L40.17 41.895V48.249C40.17 50.42 37.885 51.832 35.942 50.861L4.037 34.909C1.563 33.672 0 31.143 0 28.377V2.925ZM22.158 2.925C22.158 0.754 24.444 -0.659 26.387 0.312L44.322 9.28V28.378C44.322 31.144 45.886 33.673 48.36 34.91L62.328 41.893V48.249C62.328 50.42 60.043 51.832 58.101 50.861L40.17 41.895V22.797C40.17 20.031 38.607 17.502 36.133 16.264L22.158 9.277V2.925Z' />
10132+
</svg>
10133+
)
10134+
}

‎apps/sim/connectors/plane/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { planeConnector } from './plane'

‎apps/sim/connectors/plane/meta.ts‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { PlaneIcon } from '@/components/icons'
2+
import type { ConnectorMeta } from '@/connectors/types'
3+
4+
export const planeConnectorMeta: ConnectorMeta = {
5+
id: 'plane',
6+
name: 'Plane',
7+
description: 'Sync work items or project pages from Plane',
8+
version: '1.0.0',
9+
icon: PlaneIcon,
10+
auth: {
11+
mode: 'apiKey',
12+
label: 'API Token',
13+
placeholder: 'Enter your Plane personal access token',
14+
},
15+
configFields: [
16+
{
17+
id: 'workspaceSlug',
18+
title: 'Workspace Slug',
19+
type: 'short-input',
20+
required: true,
21+
placeholder: 'my-team',
22+
},
23+
{
24+
id: 'projectId',
25+
title: 'Project ID',
26+
type: 'short-input',
27+
required: true,
28+
placeholder: 'Project UUID',
29+
},
30+
{
31+
id: 'contentType',
32+
title: 'Content',
33+
type: 'dropdown',
34+
required: false,
35+
options: [
36+
{ id: 'work_items', label: 'Work items' },
37+
{ id: 'pages', label: 'Project pages' },
38+
],
39+
description: 'Project pages require a Plane edition with the public pages API.',
40+
},
41+
{
42+
id: 'baseUrl',
43+
title: 'Instance URL',
44+
type: 'short-input',
45+
required: false,
46+
placeholder: 'https://api.plane.so',
47+
description:
48+
'Cloud API or self-hosted instance origin, optionally with a path prefix. Omit /api/v1 and /api/v2.',
49+
},
50+
{
51+
id: 'webUrl',
52+
title: 'App URL',
53+
type: 'short-input',
54+
required: false,
55+
placeholder: 'https://app.plane.so',
56+
description:
57+
'Plane app origin for source links. Defaults to the cloud app, or the configured self-hosted instance.',
58+
},
59+
{
60+
id: 'maxDocuments',
61+
title: 'Max Documents',
62+
type: 'short-input',
63+
required: false,
64+
placeholder: 'Unlimited',
65+
},
66+
],
67+
tagDefinitions: [
68+
{ id: 'projectId', displayName: 'Project ID', fieldType: 'text' },
69+
{ id: 'state', displayName: 'State', fieldType: 'text' },
70+
{ id: 'priority', displayName: 'Priority', fieldType: 'text' },
71+
{ id: 'assignees', displayName: 'Assignees', fieldType: 'text' },
72+
{ id: 'labels', displayName: 'Labels', fieldType: 'text' },
73+
{ id: 'lastModified', displayName: 'Last Modified', fieldType: 'date' },
74+
],
75+
}

‎apps/sim/connectors/plane/plane.ts‎

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { secureFetchWithRetry } from '@/lib/knowledge/documents/secure-fetch.server'
4+
import { type RetryOptions, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
5+
import { planeConnectorMeta } from '@/connectors/plane/meta'
6+
import {
7+
type PlaneContentConfig,
8+
parsePlanePage,
9+
planePageDocument,
10+
planeWorkItemDocument,
11+
} from '@/connectors/plane/utils'
12+
import type { ConnectorConfig, ExternalDocument } from '@/connectors/types'
13+
import { joinTagArray, listingRequestError, parseTagDate } from '@/connectors/utils'
14+
import { planeApiUrl, planeHeaders, planeRedirectPolicy } from '@/tools/plane/utils'
15+
import { safeUrlPathSegment } from '@/tools/url-path'
16+
17+
const logger = createLogger('PlaneConnector')
18+
const PAGE_SIZE = 100
19+
const WORK_ITEM_FIELDS =
20+
'id,name,sequence_id,description_html,description_stripped,priority,state,assignees,labels,updated_at'
21+
const PAGE_FIELDS = 'id,name,updated_at'
22+
23+
interface PlaneSourceConfig extends PlaneContentConfig {
24+
baseUrl?: string
25+
contentType: 'work_items' | 'pages'
26+
maxDocuments: number
27+
}
28+
29+
function readConfig(source: Record<string, unknown>): PlaneSourceConfig {
30+
const workspaceSlug = typeof source.workspaceSlug === 'string' ? source.workspaceSlug.trim() : ''
31+
const projectId = typeof source.projectId === 'string' ? source.projectId.trim() : ''
32+
if (!workspaceSlug || !projectId) throw new Error('Workspace slug and project ID are required')
33+
safeUrlPathSegment(workspaceSlug, 'workspaceSlug')
34+
safeUrlPathSegment(projectId, 'projectId')
35+
const contentType = source.contentType || 'work_items'
36+
if (contentType !== 'work_items' && contentType !== 'pages')
37+
throw new Error('Content must be work items or project pages')
38+
const maxInput = source.maxDocuments
39+
if (
40+
maxInput !== undefined &&
41+
maxInput !== null &&
42+
typeof maxInput !== 'string' &&
43+
typeof maxInput !== 'number'
44+
)
45+
throw new Error('Max documents must be a positive integer')
46+
const maxDocuments =
47+
maxInput === undefined || maxInput === null || maxInput === '' ? 0 : Number(maxInput)
48+
if (
49+
!Number.isSafeInteger(maxDocuments) ||
50+
maxDocuments < 0 ||
51+
(maxDocuments === 0 && maxInput !== undefined && maxInput !== null && maxInput !== '')
52+
)
53+
throw new Error('Max documents must be a positive integer')
54+
const baseUrl =
55+
typeof source.baseUrl === 'string' && source.baseUrl.trim() ? source.baseUrl.trim() : undefined
56+
const apiOrigin = new URL(planeApiUrl(baseUrl, '/'))
57+
const defaultWebUrl =
58+
apiOrigin.origin === 'https://api.plane.so'
59+
? 'https://app.plane.so'
60+
: `${apiOrigin.origin}${apiOrigin.pathname.replace(/\/+$/, '')}`
61+
const webUrl =
62+
typeof source.webUrl === 'string' && source.webUrl.trim() ? source.webUrl.trim() : defaultWebUrl
63+
const normalizedWeb = new URL(planeApiUrl(webUrl, '/'))
64+
return {
65+
workspaceSlug,
66+
projectId,
67+
contentType,
68+
maxDocuments,
69+
baseUrl,
70+
webUrl: `${normalizedWeb.origin}${normalizedWeb.pathname.replace(/\/+$/, '')}`,
71+
}
72+
}
73+
74+
function resourcePath(config: PlaneSourceConfig, id?: string): string {
75+
const resource = config.contentType === 'pages' ? 'pages' : 'work-items'
76+
return `/api/v1/workspaces/${safeUrlPathSegment(config.workspaceSlug, 'workspaceSlug')}/projects/${safeUrlPathSegment(config.projectId, 'projectId')}/${resource}/${id ? `${safeUrlPathSegment(id, 'externalId')}/` : ''}`
77+
}
78+
79+
async function requestPlane(
80+
token: string,
81+
config: PlaneSourceConfig,
82+
path: string,
83+
query: Record<string, unknown> = {},
84+
retryOptions?: RetryOptions
85+
) {
86+
return secureFetchWithRetry(
87+
planeApiUrl(config.baseUrl, path, query),
88+
{
89+
method: 'GET',
90+
headers: planeHeaders(token),
91+
profile: 'configuredEndpoint',
92+
redirectPolicy: planeRedirectPolicy(),
93+
maxResponseBytes: 8 * 1024 * 1024,
94+
},
95+
retryOptions
96+
)
97+
}
98+
99+
export const planeConnector: ConnectorConfig = {
100+
...planeConnectorMeta,
101+
102+
async listDocuments(token, sourceConfig, cursor, syncContext) {
103+
const config = readConfig(sourceConfig)
104+
const fetched =
105+
typeof syncContext?.totalDocsFetched === 'number' ? syncContext.totalDocsFetched : 0
106+
const remaining =
107+
config.maxDocuments > 0 ? Math.max(0, config.maxDocuments - fetched) : PAGE_SIZE
108+
if (remaining === 0) {
109+
if (syncContext) syncContext.listingCapped = true
110+
return { documents: [], hasMore: false, reconciliationSafe: false }
111+
}
112+
const response = await requestPlane(token, config, resourcePath(config), {
113+
cursor,
114+
per_page: Math.min(PAGE_SIZE, remaining),
115+
fields: config.contentType === 'pages' ? PAGE_FIELDS : WORK_ITEM_FIELDS,
116+
...(config.contentType === 'work_items'
117+
? { expand: 'state,assignees,labels', order_by: 'created_at' }
118+
: {}),
119+
})
120+
if (!response.ok) throw listingRequestError('Plane listing failed', response.status)
121+
const page = parsePlanePage(await response.json(), cursor)
122+
const documents = await Promise.all(
123+
page.results.map((item) =>
124+
config.contentType === 'pages'
125+
? planePageDocument(item, config, true)
126+
: planeWorkItemDocument(item, config)
127+
)
128+
)
129+
const total = fetched + documents.length
130+
if (syncContext) syncContext.totalDocsFetched = total
131+
const capped =
132+
config.maxDocuments > 0 && total >= config.maxDocuments && page.nextCursor !== undefined
133+
if (capped && syncContext) syncContext.listingCapped = true
134+
logger.info('Listed Plane documents', {
135+
count: documents.length,
136+
hasMore: page.nextCursor !== undefined && !capped,
137+
capped,
138+
})
139+
return {
140+
documents,
141+
nextCursor: capped ? undefined : page.nextCursor,
142+
hasMore: page.nextCursor !== undefined && !capped,
143+
// Plane v1 cursors encode offsets, so concurrent source changes can shift records between pages.
144+
reconciliationSafe: false,
145+
}
146+
},
147+
148+
async getDocument(token, sourceConfig, externalId): Promise<ExternalDocument | null> {
149+
const config = readConfig(sourceConfig)
150+
const prefix = config.contentType === 'pages' ? 'page:' : 'work_item:'
151+
if (!externalId.startsWith(prefix) || externalId.length === prefix.length) return null
152+
const response = await requestPlane(
153+
token,
154+
config,
155+
resourcePath(config, externalId.slice(prefix.length)),
156+
{
157+
fields:
158+
config.contentType === 'pages'
159+
? 'id,name,updated_at,description_html,description_stripped,description'
160+
: WORK_ITEM_FIELDS,
161+
...(config.contentType === 'work_items' ? { expand: 'state,assignees,labels' } : {}),
162+
}
163+
)
164+
if (response.status === 404) return null
165+
if (!response.ok) throw new Error(`Plane document read failed (HTTP ${response.status})`)
166+
const payload: unknown = await response.json()
167+
return config.contentType === 'pages'
168+
? planePageDocument(payload, config, false)
169+
: planeWorkItemDocument(payload, config)
170+
},
171+
172+
async validateConfig(token, sourceConfig) {
173+
try {
174+
const config = readConfig(sourceConfig)
175+
const response = await requestPlane(
176+
token,
177+
config,
178+
resourcePath(config),
179+
{
180+
per_page: 1,
181+
fields: config.contentType === 'pages' ? PAGE_FIELDS : 'id,name',
182+
},
183+
VALIDATE_RETRY_OPTIONS
184+
)
185+
if (!response.ok)
186+
return {
187+
valid: false,
188+
error: `Plane access failed (HTTP ${response.status}). Check the token, workspace, project and edition.`,
189+
}
190+
parsePlanePage(await response.json())
191+
return { valid: true }
192+
} catch (error) {
193+
return {
194+
valid: false,
195+
error: getErrorMessage(error, 'Failed to validate Plane configuration'),
196+
}
197+
}
198+
},
199+
200+
mapTags(metadata) {
201+
return {
202+
projectId: metadata.projectId,
203+
state: metadata.state,
204+
priority: metadata.priority,
205+
assignees: joinTagArray(metadata.assignees),
206+
labels: joinTagArray(metadata.labels),
207+
lastModified: parseTagDate(metadata.lastModified),
208+
}
209+
},
210+
}

0 commit comments

Comments
 (0)