diff --git a/apps/emdash-desktop/src/renderer/features/conversations/acp/acp-chat-panel.tsx b/apps/emdash-desktop/src/renderer/features/conversations/acp/acp-chat-panel.tsx index 653beb5702..11ad0d8f2a 100644 --- a/apps/emdash-desktop/src/renderer/features/conversations/acp/acp-chat-panel.tsx +++ b/apps/emdash-desktop/src/renderer/features/conversations/acp/acp-chat-panel.tsx @@ -47,7 +47,7 @@ import { isHeicLikeFile, isUnstableDropPath } from '@renderer/lib/pty/terminal-i import { useAgents } from '@renderer/lib/stores/use-agents'; import { Button } from '@renderer/lib/ui/button'; import { log } from '@renderer/utils/logger'; -import type { LinkedIssue } from '@shared/core/linked-issue'; +import { linkedIssueMentionName, type LinkedIssue } from '@shared/core/linked-issue'; import type { AcpChatStore, AcpPromptAttachment } from './acp-chat-store'; import type { AcpChatTabResource } from './acp-chat-tab-resource'; import { chatViewCommandForShortcut, executeChatViewCommand } from './acp-chat-view-commands'; @@ -78,7 +78,7 @@ function toIssueMentionItem(issue: LinkedIssue): MentionItem { return { id: token, label: token, - name: issue.displayIdentifier ?? issue.identifier, + name: linkedIssueMentionName(issue), kind: 'issue', description: issue.title, icon: , diff --git a/apps/emdash-desktop/src/renderer/features/integrations/integration-setup-modal.tsx b/apps/emdash-desktop/src/renderer/features/integrations/integration-setup-modal.tsx index 7884a00c0e..db56da4c39 100644 --- a/apps/emdash-desktop/src/renderer/features/integrations/integration-setup-modal.tsx +++ b/apps/emdash-desktop/src/renderer/features/integrations/integration-setup-modal.tsx @@ -92,21 +92,18 @@ function IntegrationSetupForm({ ))} {method.help || method.helpUrl ? ( -
- {method.help ? ( -

- {method.help} - {method.helpUrl ? ( - - ) : null} -

+
+ {method.help ?

{method.help}

: null} + {method.helpUrl ? ( + ) : null}
) : null} diff --git a/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-selector.tsx b/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-selector.tsx index cbe6f3d45b..f93fa241be 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-selector.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-selector.tsx @@ -28,7 +28,7 @@ import { import { Select, SelectContent, SelectItem, SelectTrigger } from '@renderer/lib/ui/select'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/lib/ui/tooltip'; import { cn } from '@renderer/utils/utils'; -import type { LinkedIssue } from '@shared/core/linked-issue'; +import { linkedIssueDisplayIdentifier, type LinkedIssue } from '@shared/core/linked-issue'; import { getLinkedIssueMap, type LinkedIssueInfo } from './use-linked-issue-urls'; import { useIssueSearch } from './useIssueSearch'; @@ -39,8 +39,7 @@ export function IssueIdentifier({ issue: Pick; className?: string; }) { - const identifier = - issue.displayIdentifier === null ? null : (issue.displayIdentifier ?? issue.identifier); + const identifier = linkedIssueDisplayIdentifier(issue); if (!identifier) return null; return ( diff --git a/apps/emdash-desktop/src/renderer/features/tasks/task-titlebar.tsx b/apps/emdash-desktop/src/renderer/features/tasks/task-titlebar.tsx index 0eacce925a..1b77068838 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/task-titlebar.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/task-titlebar.tsx @@ -43,7 +43,7 @@ import { ToggleGroup, ToggleGroupItem } from '@renderer/lib/ui/toggle-group'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/lib/ui/tooltip'; import { formatDiffLineCount } from '@renderer/utils/format-diff-line-count'; import { cn } from '@renderer/utils/utils'; -import type { LinkedIssue } from '@shared/core/linked-issue'; +import { linkedIssueDisplayIdentifier, type LinkedIssue } from '@shared/core/linked-issue'; import { AutomationRunPill } from './components/automation-run-pill'; import { IssueSelector, ProviderLogo } from './components/issue-selector/issue-selector'; import { PreviewServerPills } from './components/preview-servers/preview-server-pills'; @@ -416,8 +416,7 @@ const ActiveTaskTitlebar = observer(function ActiveTaskTitlebar({ }); function LinkedIssueBadge({ issue }: { issue: LinkedIssue }) { - const displayIdentifier = - issue.displayIdentifier === null ? null : (issue.displayIdentifier ?? issue.identifier); + const displayIdentifier = linkedIssueDisplayIdentifier(issue); return ( diff --git a/apps/emdash-desktop/src/shared/core/linked-issue.test.ts b/apps/emdash-desktop/src/shared/core/linked-issue.test.ts new file mode 100644 index 0000000000..11e7a40eac --- /dev/null +++ b/apps/emdash-desktop/src/shared/core/linked-issue.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { linkedIssueDisplayIdentifier, linkedIssueMentionName } from './linked-issue'; + +describe('linked issue display helpers', () => { + it('uses displayIdentifier for issue mentions when available', () => { + expect( + linkedIssueMentionName({ + identifier: 'internal-id', + displayIdentifier: 'ENG-123', + title: 'Fix issue mentions', + }) + ).toBe('ENG-123'); + }); + + it('uses title for issue mentions when the provider hides internal identifiers', () => { + expect( + linkedIssueMentionName({ + identifier: '37818d1b-a831-812e-8ca0-c115c72de662', + displayIdentifier: null, + title: 'ai health paper website', + }) + ).toBe('ai health paper website'); + }); + + it('keeps raw identifiers visible only when displayIdentifier is unspecified', () => { + const issue = { identifier: '#42', title: 'Fix login' }; + + expect(linkedIssueDisplayIdentifier(issue)).toBe('#42'); + expect(linkedIssueMentionName(issue)).toBe('#42'); + }); + + it('uses a generic mention name when both display identifier and title are hidden', () => { + expect( + linkedIssueMentionName({ + identifier: 'internal-id', + displayIdentifier: null, + title: '', + }) + ).toBe('Linked issue'); + }); +}); diff --git a/apps/emdash-desktop/src/shared/core/linked-issue.ts b/apps/emdash-desktop/src/shared/core/linked-issue.ts index 1bdb94ec31..66156e7b1c 100644 --- a/apps/emdash-desktop/src/shared/core/linked-issue.ts +++ b/apps/emdash-desktop/src/shared/core/linked-issue.ts @@ -17,6 +17,7 @@ const v0Schema = z.object({ 'featurebase', 'asana', 'monday', + 'notion', 'trello', ]), url: z.string(), @@ -54,3 +55,15 @@ export const linkedIssueSchema = linkedIssue.schema; /** The TypeScript type for a linked issue. */ export type LinkedIssue = typeof linkedIssue.Type; + +export function linkedIssueDisplayIdentifier( + issue: Pick +): string | null { + return issue.displayIdentifier === null ? null : (issue.displayIdentifier ?? issue.identifier); +} + +export function linkedIssueMentionName( + issue: Pick +): string { + return linkedIssueDisplayIdentifier(issue) ?? (issue.title || 'Linked issue'); +} diff --git a/packages/plugins/package.json b/packages/plugins/package.json index f8ea97ba6e..f17b0ae5f8 100644 --- a/packages/plugins/package.json +++ b/packages/plugins/package.json @@ -46,6 +46,7 @@ "@llamaduck/forgejo-ts": "^14.0.3", "@makeplane/plane-node-sdk": "^0.2.11", "@mondaydotcomorg/api": "^14.0.0", + "@notionhq/client": "^5.22.0", "@octokit/rest": "^22.0.1", "@team-plain/graphql": "^1.0.1", "asana": "^3.1.12", diff --git a/packages/plugins/src/integrations/impl/notion/client.ts b/packages/plugins/src/integrations/impl/notion/client.ts new file mode 100644 index 0000000000..ecc591894f --- /dev/null +++ b/packages/plugins/src/integrations/impl/notion/client.ts @@ -0,0 +1,43 @@ +import { err, ok, type Result } from '@emdash/shared'; +import { Client } from '@notionhq/client'; +import { parseCredentials } from '../../helpers/credentials'; +import type { IntegrationCredentials } from '../../host'; +import type { IntegrationError } from '../../types'; +import { toNotionIntegrationError } from './error'; +import { + type NotionClient, + type NotionCredentials, + notionCredentialsSchema, + type NotionVerifiedConnection, +} from './types'; + +export function readNotionCredentials( + credentials: IntegrationCredentials +): Result { + return parseCredentials(notionCredentialsSchema, credentials); +} + +export function createNotionClient(credentials: NotionCredentials): NotionClient { + return new Client({ auth: credentials.apiToken }); +} + +export async function verifyNotionCredentials( + rawCredentials: IntegrationCredentials +): Promise> { + const credentials = readNotionCredentials(rawCredentials); + if (!credentials.success) return err(credentials.error); + + const client = createNotionClient(credentials.data); + try { + const user = await client.users.me({}); + const displayName = user.name ?? (user.type === 'bot' ? 'Notion bot' : 'Notion user'); + const displayDetail = user.type === 'person' ? user.person.email : undefined; + return ok({ + displayName, + ...(displayDetail ? { displayDetail } : {}), + credentials: credentials.data, + }); + } catch (error) { + return err(toNotionIntegrationError(error, 'Failed to validate Notion token.')); + } +} diff --git a/packages/plugins/src/integrations/impl/notion/error.ts b/packages/plugins/src/integrations/impl/notion/error.ts new file mode 100644 index 0000000000..ae4ea56489 --- /dev/null +++ b/packages/plugins/src/integrations/impl/notion/error.ts @@ -0,0 +1,51 @@ +import { APIErrorCode, ClientErrorCode, isNotionClientError } from '@notionhq/client'; +import type { IntegrationError } from '../../types'; + +export function toNotionIntegrationError( + error: unknown, + fallback = 'Notion request failed.' +): IntegrationError { + if (!isNotionClientError(error)) { + if (error instanceof Error) return { type: 'generic', message: error.message || fallback }; + return { type: 'generic', message: fallback }; + } + + switch (error.code) { + case APIErrorCode.Unauthorized: + return { + type: 'auth_failed', + message: 'Notion authentication failed. Check your integration token.', + }; + case APIErrorCode.RestrictedResource: + return { + type: 'auth_failed', + message: 'Notion token is missing the required capabilities or page access.', + }; + case APIErrorCode.ObjectNotFound: + return { + type: 'not_found_or_no_access', + message: 'Notion resource was not found or the integration does not have access.', + }; + case APIErrorCode.RateLimited: + return { + type: 'rate_limited', + message: 'Notion API rate limit exceeded. Please try again shortly.', + }; + case APIErrorCode.InternalServerError: + case APIErrorCode.ServiceUnavailable: + case APIErrorCode.GatewayTimeout: + case ClientErrorCode.RequestTimeout: + return { + type: 'host_unreachable', + message: 'Notion API is temporarily unavailable. Please try again.', + }; + case APIErrorCode.InvalidJSON: + case APIErrorCode.InvalidRequestURL: + case APIErrorCode.InvalidRequest: + case APIErrorCode.ValidationError: + case APIErrorCode.ConflictError: + case ClientErrorCode.InvalidPathParameter: + case ClientErrorCode.ResponseError: + return { type: 'generic', message: error.message || fallback }; + } +} diff --git a/packages/plugins/src/integrations/impl/notion/icon.ts b/packages/plugins/src/integrations/impl/notion/icon.ts new file mode 100644 index 0000000000..b7c7d32f75 --- /dev/null +++ b/packages/plugins/src/integrations/impl/notion/icon.ts @@ -0,0 +1,15 @@ +import type { PluginIconAsset } from '@emdash/shared/plugins'; + +const svg = + ''; + +export const icon: PluginIconAsset = { + kind: 'svg', + alt: 'Notion', + variants: [ + { + minSize: 0, + light: svg, + }, + ], +}; diff --git a/packages/plugins/src/integrations/impl/notion/index.test.ts b/packages/plugins/src/integrations/impl/notion/index.test.ts new file mode 100644 index 0000000000..5d167e6a9c --- /dev/null +++ b/packages/plugins/src/integrations/impl/notion/index.test.ts @@ -0,0 +1,100 @@ +import type { Logger } from '@emdash/shared/logger'; +import { APIErrorCode, APIResponseError } from '@notionhq/client'; +import type * as NotionSdk from '@notionhq/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { IntegrationHostContext } from '../../host'; +import { provider } from './index'; + +const notionSdk = vi.hoisted(() => ({ + constructor: vi.fn(), + me: vi.fn(), +})); + +vi.mock('@notionhq/client', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Client: class { + constructor(config: unknown) { + notionSdk.constructor(config); + } + + users = { + me: notionSdk.me, + }; + }, + }; +}); + +const logger: Logger = { + level: 'error', + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: () => logger, +}; + +const auth = provider.behavior.auth; +if (!auth) throw new Error('Notion auth behavior is not registered.'); + +const host: IntegrationHostContext = { log: logger }; + +afterEach(() => { + notionSdk.constructor.mockReset(); + notionSdk.me.mockReset(); +}); + +describe('notion integration verify', () => { + it('validates the integration token and returns normalized credentials', async () => { + notionSdk.me.mockResolvedValueOnce({ + object: 'user', + id: 'bot-1', + type: 'bot', + name: 'Emdash', + avatar_url: null, + bot: {}, + }); + + const result = await auth.verify(host, { apiToken: ' ntn_valid ' }); + + expect(result).toEqual({ + connected: true, + displayName: 'Emdash', + credentials: { apiToken: 'ntn_valid' }, + }); + expect(notionSdk.constructor).toHaveBeenCalledWith({ auth: 'ntn_valid' }); + expect(notionSdk.me).toHaveBeenCalledWith({}); + }); + + it('returns an error for an empty token', async () => { + const result = await auth.verify(host, { apiToken: ' ' }); + + expect(result).toEqual({ + connected: false, + error: 'Notion integration token is required.', + }); + expect(notionSdk.constructor).not.toHaveBeenCalled(); + }); + + it('maps Notion unauthorized errors to a helpful auth message', async () => { + notionSdk.me.mockRejectedValueOnce( + new APIResponseError({ + code: APIErrorCode.Unauthorized, + status: 401, + message: 'API token is invalid.', + headers: new Headers(), + rawBodyText: '{}', + additional_data: undefined, + request_id: undefined, + }) + ); + + const result = await auth.verify(host, { apiToken: 'bad-token' }); + + expect(result).toEqual({ + connected: false, + error: 'Notion authentication failed. Check your integration token.', + }); + }); +}); diff --git a/packages/plugins/src/integrations/impl/notion/index.ts b/packages/plugins/src/integrations/impl/notion/index.ts new file mode 100644 index 0000000000..6f900d8280 --- /dev/null +++ b/packages/plugins/src/integrations/impl/notion/index.ts @@ -0,0 +1,44 @@ +import type { VerifyResult } from '../../capabilities/auth'; +import { defineIntegrationPlugin, registerIntegrationPluginBehavior } from '../../plugin'; +import { verifyNotionCredentials } from './client'; +import { icon } from './icon'; + +const plugin = defineIntegrationPlugin( + { + id: 'notion', + name: 'Notion', + description: 'Connect your Notion workspace', + websiteUrl: 'https://www.notion.so', + }, + { + auth: { + methods: [ + { + kind: 'form', + fields: [ + { + id: 'apiToken', + label: 'Integration token', + secret: true, + required: true, + placeholder: 'ntn_…', + }, + ], + help: 'Create a Notion internal integration token or personal access token, then share each page or database you want Emdash to access with that integration.', + helpUrl: 'https://developers.notion.com/guides/get-started/authorization', + }, + ], + }, + }, + { icon } +); + +export const provider = registerIntegrationPluginBehavior(plugin, { + auth: { + async verify(_host, credentials): Promise { + const result = await verifyNotionCredentials(credentials); + if (!result.success) return { connected: false, error: result.error.message }; + return { connected: true, ...result.data }; + }, + }, +}); diff --git a/packages/plugins/src/integrations/impl/notion/types.ts b/packages/plugins/src/integrations/impl/notion/types.ts new file mode 100644 index 0000000000..41bfea15b8 --- /dev/null +++ b/packages/plugins/src/integrations/impl/notion/types.ts @@ -0,0 +1,17 @@ +import type { Client as NotionSdkClient } from '@notionhq/client'; +import z from 'zod'; +import { credentialString } from '../../helpers/credentials'; + +export const notionCredentialsSchema = z.object({ + apiToken: credentialString('Notion integration token is required.'), +}); + +export type NotionCredentials = z.infer; + +export type NotionClient = NotionSdkClient; + +export type NotionVerifiedConnection = { + displayName?: string; + displayDetail?: string; + credentials: NotionCredentials; +}; diff --git a/packages/plugins/src/integrations/registry.ts b/packages/plugins/src/integrations/registry.ts index e223b28c03..b0acf3a854 100644 --- a/packages/plugins/src/integrations/registry.ts +++ b/packages/plugins/src/integrations/registry.ts @@ -7,6 +7,7 @@ import { provider as gitlab } from './impl/gitlab'; import { provider as jira } from './impl/jira'; import { provider as linear } from './impl/linear'; import { provider as monday } from './impl/monday'; +import { provider as notion } from './impl/notion'; import { provider as plain } from './impl/plain'; import { provider as plane } from './impl/plane'; import { provider as trello } from './impl/trello'; @@ -24,6 +25,7 @@ for (const provider of [ trello, asana, monday, + notion, featurebase, plain, ]) { diff --git a/packages/plugins/src/issues/impl/notion/context.ts b/packages/plugins/src/issues/impl/notion/context.ts new file mode 100644 index 0000000000..91604463e4 --- /dev/null +++ b/packages/plugins/src/issues/impl/notion/context.ts @@ -0,0 +1,91 @@ +import type { BlockObjectResponse, PartialBlockObjectResponse } from '@notionhq/client'; +import { richTextPlainText } from './mapper'; + +export function formatNotionContext( + blocks: Array +): string | undefined { + const lines = blocks.map(blockToMarkdown).filter((line): line is string => !!line?.trim()); + return lines.length ? lines.join('\n') : undefined; +} + +function blockToMarkdown( + block: BlockObjectResponse | PartialBlockObjectResponse +): string | undefined { + if (!('type' in block)) return undefined; + + switch (block.type) { + case 'paragraph': + return richTextPlainText('paragraph' in block ? block.paragraph?.rich_text : undefined); + case 'heading_1': + return heading( + '#', + richTextPlainText('heading_1' in block ? block.heading_1?.rich_text : undefined) + ); + case 'heading_2': + return heading( + '##', + richTextPlainText('heading_2' in block ? block.heading_2?.rich_text : undefined) + ); + case 'heading_3': + return heading( + '###', + richTextPlainText('heading_3' in block ? block.heading_3?.rich_text : undefined) + ); + case 'heading_4': + return heading( + '####', + richTextPlainText('heading_4' in block ? block.heading_4?.rich_text : undefined) + ); + case 'bulleted_list_item': + return listItem( + '-', + richTextPlainText( + 'bulleted_list_item' in block ? block.bulleted_list_item?.rich_text : undefined + ) + ); + case 'numbered_list_item': + return listItem( + '1.', + richTextPlainText( + 'numbered_list_item' in block ? block.numbered_list_item?.rich_text : undefined + ) + ); + case 'to_do': + if (!('to_do' in block)) return undefined; + return listItem( + `[${block.to_do?.checked ? 'x' : ' '}]`, + richTextPlainText(block.to_do?.rich_text) + ); + case 'quote': + return quote(richTextPlainText('quote' in block ? block.quote?.rich_text : undefined)); + case 'code': + if (!('code' in block)) return undefined; + return codeBlock(block.code?.language ?? '', richTextPlainText(block.code?.rich_text)); + case 'child_page': + return 'child_page' in block && block.child_page?.title + ? `Child page: ${block.child_page.title}` + : undefined; + case 'child_database': + return 'child_database' in block && block.child_database?.title + ? `Child database: ${block.child_database.title}` + : undefined; + default: + return undefined; + } +} + +function heading(prefix: string, text: string): string | undefined { + return text ? `${prefix} ${text}` : undefined; +} + +function listItem(prefix: string, text: string): string | undefined { + return text ? `${prefix} ${text}` : undefined; +} + +function quote(text: string): string | undefined { + return text ? `> ${text}` : undefined; +} + +function codeBlock(language: string, text: string): string | undefined { + return text ? `\`\`\`${language}\n${text}\n\`\`\`` : undefined; +} diff --git a/packages/plugins/src/issues/impl/notion/index.test.ts b/packages/plugins/src/issues/impl/notion/index.test.ts new file mode 100644 index 0000000000..34e60e9c08 --- /dev/null +++ b/packages/plugins/src/issues/impl/notion/index.test.ts @@ -0,0 +1,266 @@ +import { noopLogger } from '@emdash/shared/logger'; +import type { PageObjectResponse } from '@notionhq/client'; +import type * as NotionSdk from '@notionhq/client'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { provider } from './index'; + +const notionSdk = vi.hoisted(() => ({ + blocksChildrenList: vi.fn(), + pagesRetrieve: vi.fn(), + search: vi.fn(), +})); + +vi.mock('@notionhq/client', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Client: class { + blocks = { children: { list: notionSdk.blocksChildrenList } }; + pages = { retrieve: notionSdk.pagesRetrieve }; + search = notionSdk.search; + }, + }; +}); + +const issues = provider.behavior.issues; +if (!issues) throw new Error('Notion issues behavior is not registered'); +if (!issues.getIssue) throw new Error('Notion getIssue behavior is not registered'); +const getIssue = issues.getIssue; + +const host = { log: noopLogger, credentials: { apiToken: 'ntn_valid' } }; + +function notionPage( + id: string, + title: string, + parentType: 'database_id' | 'data_source_id' | 'page_id' | 'workspace' = 'database_id' +): PageObjectResponse { + const parent = + parentType === 'workspace' + ? { type: 'workspace' as const, workspace: true } + : { type: parentType, [parentType]: `${parentType}-1` }; + + return { + object: 'page', + id, + created_time: '2026-01-01T00:00:00.000Z', + last_edited_time: '2026-01-02T00:00:00.000Z', + created_by: { object: 'user', id: 'user-1' }, + last_edited_by: { object: 'user', id: 'user-1' }, + cover: null, + icon: null, + parent, + archived: false, + in_trash: false, + is_locked: false, + properties: { + Name: { + id: 'title', + type: 'title', + title: title + ? [ + { + type: 'text', + plain_text: title, + href: null, + annotations: { + bold: false, + italic: false, + strikethrough: false, + underline: false, + code: false, + color: 'default', + }, + text: { content: title, link: null }, + }, + ] + : [], + }, + }, + url: `https://www.notion.so/${id}`, + public_url: null, + request_id: 'request-1', + } as unknown as PageObjectResponse; +} + +function richText(text: string) { + return [ + { + type: 'text', + plain_text: text, + href: null, + annotations: { + bold: false, + italic: false, + strikethrough: false, + underline: false, + code: false, + color: 'default', + }, + text: { content: text, link: null }, + }, + ]; +} + +function paragraphBlock(id: string, text: string) { + return { + object: 'block', + id, + type: 'paragraph', + paragraph: { rich_text: richText(text), color: 'default' }, + }; +} + +describe('notion issues plugin', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('lists only titled database pages by default', async () => { + notionSdk.search.mockResolvedValueOnce({ + object: 'list', + results: [ + notionPage('task-page', 'Implement onboarding', 'database_id'), + notionPage('plain-page', 'Team notes', 'page_id'), + notionPage('untitled-page', '', 'database_id'), + ], + next_cursor: null, + has_more: false, + type: 'page_or_database', + page_or_database: {}, + }); + + const result = await issues.listIssues(host, { limit: 50 }); + + expect(notionSdk.search).toHaveBeenCalledWith({ + filter: { property: 'object', value: 'page' }, + sort: { timestamp: 'last_edited_time', direction: 'descending' }, + page_size: 100, + }); + expect(result).toEqual({ + success: true, + data: [expect.objectContaining({ identifier: 'task-page', title: 'Implement onboarding' })], + }); + }); + + it('continues listing until enough database pages survive filtering', async () => { + notionSdk.search + .mockResolvedValueOnce({ + object: 'list', + results: [ + notionPage('plain-page', 'Team notes', 'page_id'), + notionPage('untitled-page', ''), + ], + next_cursor: 'next-page', + has_more: true, + type: 'page_or_database', + page_or_database: {}, + }) + .mockResolvedValueOnce({ + object: 'list', + results: [notionPage('task-page', 'Implement onboarding', 'database_id')], + next_cursor: null, + has_more: false, + type: 'page_or_database', + page_or_database: {}, + }); + + const result = await issues.listIssues(host, { limit: 1 }); + + expect(notionSdk.search).toHaveBeenNthCalledWith(2, { + filter: { property: 'object', value: 'page' }, + sort: { timestamp: 'last_edited_time', direction: 'descending' }, + page_size: 100, + start_cursor: 'next-page', + }); + expect(result).toEqual({ + success: true, + data: [expect.objectContaining({ identifier: 'task-page', title: 'Implement onboarding' })], + }); + }); + + it('keeps explicit search broad but filters untitled pages', async () => { + notionSdk.search.mockResolvedValueOnce({ + object: 'list', + results: [notionPage('plain-page', 'Team notes', 'page_id'), notionPage('untitled-page', '')], + next_cursor: null, + has_more: false, + type: 'page_or_database', + page_or_database: {}, + }); + + const result = await issues.searchIssues(host, { searchTerm: 'team', limit: 20 }); + + expect(notionSdk.search).toHaveBeenCalledWith({ + query: 'team', + filter: { property: 'object', value: 'page' }, + sort: { timestamp: 'last_edited_time', direction: 'descending' }, + page_size: 20, + }); + expect(result).toEqual({ + success: true, + data: [expect.objectContaining({ identifier: 'plain-page', title: 'Team notes' })], + }); + }); + + it('reads every block page when getting issue context', async () => { + notionSdk.pagesRetrieve.mockResolvedValueOnce(notionPage('task-page', 'Implement onboarding')); + notionSdk.blocksChildrenList + .mockResolvedValueOnce({ + object: 'list', + results: [paragraphBlock('block-1', 'First requirement')], + next_cursor: 'next-blocks', + has_more: true, + type: 'block', + block: {}, + }) + .mockResolvedValueOnce({ + object: 'list', + results: [paragraphBlock('block-2', 'Later requirement')], + next_cursor: null, + has_more: false, + type: 'block', + block: {}, + }); + + const result = await getIssue(host, { identifier: 'task-page' }); + + expect(notionSdk.blocksChildrenList).toHaveBeenNthCalledWith(1, { + block_id: 'task-page', + page_size: 100, + }); + expect(notionSdk.blocksChildrenList).toHaveBeenNthCalledWith(2, { + block_id: 'task-page', + page_size: 100, + start_cursor: 'next-blocks', + }); + expect(result).toEqual({ + success: true, + data: expect.objectContaining({ + identifier: 'task-page', + context: 'First requirement\nLater requirement', + }), + }); + }); + + it('skips partial blocks while formatting issue context', async () => { + notionSdk.pagesRetrieve.mockResolvedValueOnce(notionPage('task-page', 'Implement onboarding')); + notionSdk.blocksChildrenList.mockResolvedValueOnce({ + object: 'list', + results: [ + { object: 'block', id: 'partial-paragraph', type: 'paragraph' }, + paragraphBlock('block-1', 'Accessible requirement'), + ], + next_cursor: null, + has_more: false, + type: 'block', + block: {}, + }); + + const result = await getIssue(host, { identifier: 'task-page' }); + + expect(result).toEqual({ + success: true, + data: expect.objectContaining({ context: 'Accessible requirement' }), + }); + }); +}); diff --git a/packages/plugins/src/issues/impl/notion/index.ts b/packages/plugins/src/issues/impl/notion/index.ts new file mode 100644 index 0000000000..bd1eff5d43 --- /dev/null +++ b/packages/plugins/src/issues/impl/notion/index.ts @@ -0,0 +1,129 @@ +import { err, ok } from '@emdash/shared'; +import { + isFullPage, + type BlockObjectResponse, + type PageObjectResponse, + type PartialBlockObjectResponse, +} from '@notionhq/client'; +import type { ConnectedIntegrationHostContext } from '../../../integrations/host'; +import { + createNotionClient, + readNotionCredentials, +} from '../../../integrations/impl/notion/client'; +import { toNotionIntegrationError } from '../../../integrations/impl/notion/error'; +import { clampIssueLimit, normalizeSearchTerm } from '../../helpers/provider-inputs'; +import { defineIssuesPlugin, registerIssuesPluginBehavior } from '../../plugin'; +import type { + IssueGetOpts, + IssueGetResult, + IssueListResult, + IssueQueryOpts, + IssueSearchOpts, +} from '../../types'; +import { formatNotionContext } from './context'; +import { hasMeaningfulTitle, isDatabasePage, toIssueData, toIssueListItems } from './mapper'; + +const NOTION_PAGE_SIZE = 100; + +export async function listIssues( + host: ConnectedIntegrationHostContext, + opts: IssueQueryOpts +): Promise { + const parsedCredentials = readNotionCredentials(host.credentials); + if (!parsedCredentials.success) return err(parsedCredentials.error); + + const client = createNotionClient(parsedCredentials.data); + const limit = clampIssueLimit(opts.limit, 50, 100); + + try { + const pages: PageObjectResponse[] = []; + let startCursor: string | null | undefined; + + do { + const response = await client.search({ + filter: { property: 'object', value: 'page' }, + sort: { timestamp: 'last_edited_time', direction: 'descending' }, + page_size: NOTION_PAGE_SIZE, + ...(startCursor ? { start_cursor: startCursor } : {}), + }); + pages.push(...response.results.filter(isFullPage).filter(isDatabasePage)); + startCursor = response.has_more ? response.next_cursor : null; + } while (startCursor && pages.filter(hasMeaningfulTitle).length < limit); + + return ok(toIssueListItems(pages).slice(0, limit)); + } catch (error) { + host.log.warn('Notion listIssues failed', { error }); + return err(toNotionIntegrationError(error, 'Unable to fetch Notion pages.')); + } +} + +export async function searchIssues( + host: ConnectedIntegrationHostContext, + opts: IssueSearchOpts +): Promise { + const term = normalizeSearchTerm(opts.searchTerm); + if (!term) return ok([]); + + const parsedCredentials = readNotionCredentials(host.credentials); + if (!parsedCredentials.success) return err(parsedCredentials.error); + + const client = createNotionClient(parsedCredentials.data); + const limit = clampIssueLimit(opts.limit, 20, 100); + + try { + const response = await client.search({ + query: term, + filter: { property: 'object', value: 'page' }, + sort: { timestamp: 'last_edited_time', direction: 'descending' }, + page_size: limit, + }); + return ok(toIssueListItems(response.results.filter(isFullPage))); + } catch (error) { + host.log.warn('Notion searchIssues failed', { error }); + return err(toNotionIntegrationError(error, 'Unable to search Notion pages.')); + } +} + +export async function getIssue( + host: ConnectedIntegrationHostContext, + opts: IssueGetOpts +): Promise { + const parsedCredentials = readNotionCredentials(host.credentials); + if (!parsedCredentials.success) return err(parsedCredentials.error); + + const client = createNotionClient(parsedCredentials.data); + + try { + const page = await client.pages.retrieve({ page_id: opts.identifier }); + if (!isFullPage(page)) { + return err({ + type: 'not_found_or_no_access', + message: 'Notion page was not found or the integration does not have access.', + }); + } + + const blocks: Array = []; + let startCursor: string | null | undefined; + + do { + const response = await client.blocks.children.list({ + block_id: page.id, + page_size: NOTION_PAGE_SIZE, + ...(startCursor ? { start_cursor: startCursor } : {}), + }); + blocks.push(...response.results); + startCursor = response.has_more ? response.next_cursor : null; + } while (startCursor); + + return ok({ ...toIssueData(page), context: formatNotionContext(blocks) }); + } catch (error) { + host.log.warn('Notion getIssue failed', { error }); + return err(toNotionIntegrationError(error, 'Unable to fetch Notion page context.')); + } +} + +const plugin = defineIssuesPlugin({ integrationId: 'notion' }, { issues: {} }, {}); + +export const provider = registerIssuesPluginBehavior(plugin, { + issues: { listIssues, searchIssues, getIssue }, +}); diff --git a/packages/plugins/src/issues/impl/notion/mapper.ts b/packages/plugins/src/issues/impl/notion/mapper.ts new file mode 100644 index 0000000000..6ff54fbe92 --- /dev/null +++ b/packages/plugins/src/issues/impl/notion/mapper.ts @@ -0,0 +1,101 @@ +import type { PageObjectResponse, RichTextItemResponse } from '@notionhq/client'; +import type { IssueData } from '../../types'; + +type NotionPageProperty = PageObjectResponse['properties'][string]; +const UNTITLED_NOTION_PAGE = 'Untitled Notion page'; + +export function toIssueData(page: PageObjectResponse): IssueData { + return { + identifier: page.id, + displayIdentifier: null, + title: pageTitle(page), + url: page.url, + description: firstRichTextProperty(page), + status: firstStatusProperty(page), + assignees: peopleProperty(page), + project: pageParentLabel(page), + updatedAt: page.last_edited_time, + }; +} + +export function hasMeaningfulTitle(page: PageObjectResponse): boolean { + return pageTitle(page) !== UNTITLED_NOTION_PAGE; +} + +export function isDatabasePage(page: PageObjectResponse): boolean { + return page.parent.type === 'database_id' || page.parent.type === 'data_source_id'; +} + +function pageTitle(page: PageObjectResponse): string { + const title = Object.values(page.properties).find((property) => property.type === 'title'); + if (!title || title.type !== 'title') return UNTITLED_NOTION_PAGE; + return richTextPlainText(title.title) || UNTITLED_NOTION_PAGE; +} + +function firstRichTextProperty(page: PageObjectResponse): string | undefined { + for (const property of Object.values(page.properties)) { + if (property.type !== 'rich_text') continue; + const value = richTextPlainText(property.rich_text); + if (value) return value; + } + return undefined; +} + +function firstStatusProperty(page: PageObjectResponse): string | undefined { + const preferredNames = ['status', 'state', 'stage']; + for (const name of preferredNames) { + const property = findProperty(page.properties, name); + const value = statusPropertyLabel(property); + if (value) return value; + } + + for (const property of Object.values(page.properties)) { + const value = statusPropertyLabel(property); + if (value) return value; + } + return undefined; +} + +function statusPropertyLabel(property: NotionPageProperty | undefined): string | undefined { + if (!property) return undefined; + if (property.type === 'status') return property.status?.name; + if (property.type === 'select') return property.select?.name; + return undefined; +} + +function peopleProperty(page: PageObjectResponse): string[] | undefined { + const people = Object.values(page.properties) + .filter((property) => property.type === 'people') + .flatMap((property) => (property.type === 'people' ? property.people : [])) + .map((person) => ('name' in person ? person.name : undefined)) + .filter((name): name is string => !!name); + + return people.length ? people : undefined; +} + +function pageParentLabel(page: PageObjectResponse): string | undefined { + if (page.parent.type === 'database_id') return 'Database'; + if (page.parent.type === 'data_source_id') return 'Data source'; + if (page.parent.type === 'page_id') return 'Page'; + if (page.parent.type === 'workspace') return 'Workspace'; + return undefined; +} + +function findProperty( + properties: PageObjectResponse['properties'], + name: string +): NotionPageProperty | undefined { + const lowerName = name.toLowerCase(); + return Object.entries(properties).find(([key]) => key.toLowerCase() === lowerName)?.[1]; +} + +export function richTextPlainText(richText: RichTextItemResponse[] | undefined): string { + return (richText ?? []) + .map((item) => item.plain_text) + .join('') + .trim(); +} + +export function toIssueListItems(pages: PageObjectResponse[]) { + return pages.filter(hasMeaningfulTitle).map(toIssueData); +} diff --git a/packages/plugins/src/issues/registry.ts b/packages/plugins/src/issues/registry.ts index 33958c24f2..f35b29194f 100644 --- a/packages/plugins/src/issues/registry.ts +++ b/packages/plugins/src/issues/registry.ts @@ -6,6 +6,7 @@ import { provider as gitlab } from './impl/gitlab'; import { provider as jira } from './impl/jira'; import { provider as linear } from './impl/linear'; import { provider as monday } from './impl/monday'; +import { provider as notion } from './impl/notion'; import { provider as plain } from './impl/plain'; import { provider as plane } from './impl/plane'; import { provider as trello } from './impl/trello'; @@ -36,6 +37,7 @@ for (const provider of [ trello, asana, monday, + notion, featurebase, plain, ]) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8a37b69e3..74b30bc5d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -561,6 +561,9 @@ importers: '@mondaydotcomorg/api': specifier: ^14.0.0 version: 14.0.0 + '@notionhq/client': + specifier: ^5.22.0 + version: 5.22.0 '@octokit/rest': specifier: ^22.0.1 version: 22.0.1 @@ -2047,6 +2050,10 @@ packages: resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} engines: {node: '>= 20.19.0'} + '@notionhq/client@5.22.0': + resolution: {integrity: sha512-lZ3JGBCd6O6MNHWn/58QcUqX1FgmlcODcx/EaUEEpuxLXF5tSi+v29Vzoz8mZ6JgDWDn5pMzzjB69QevYjQQZA==} + engines: {node: '>=18'} + '@nx/nx-darwin-arm64@23.0.0': resolution: {integrity: sha512-c/rXP3LYXJLC1F+9KDrWE+n1nkDnTEfHnA1KAK3A/CSk8EfgY0RhekcdbGISrHqgbccdVTBRTNUeTdwD+w23Xw==} cpu: [arm64] @@ -10436,6 +10443,8 @@ snapshots: '@noble/hashes@2.2.0': {} + '@notionhq/client@5.22.0': {} + '@nx/nx-darwin-arm64@23.0.0': optional: true