From 0eb114aef73b683721dde427abd1d5a2c8b95618 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:11:22 +0200 Subject: [PATCH 01/16] feat(integrations): add Notion integration Add Notion as a non-issue integration with token-based auth and credential verification. Move checkConfiguredConnections from the issues controller to the integrations controller so it covers all integration providers, and generalize issue registry and integration UI types from IssueProviderType to string to support non-issue providers. --- .../src/main/core/integrations/controller.ts | 16 +++ .../integration-connection-service.ts | 4 + .../src/main/core/issues/registry.ts | 5 +- .../integrations-provider.test.ts | 2 +- .../integrations/integrations-provider.tsx | 2 +- .../settings/components/IntegrationsCard.tsx | 85 +++++++-------- apps/emdash-desktop/src/shared/telemetry.ts | 4 +- packages/plugins/package.json | 1 + .../src/integrations/impl/notion/client.ts | 51 +++++++++ .../src/integrations/impl/notion/error.ts | 51 +++++++++ .../src/integrations/impl/notion/icon.ts | 13 +++ .../integrations/impl/notion/index.test.ts | 100 ++++++++++++++++++ .../src/integrations/impl/notion/index.ts | 44 ++++++++ .../src/integrations/impl/notion/types.ts | 17 +++ packages/plugins/src/integrations/registry.ts | 2 + pnpm-lock.yaml | 9 ++ 16 files changed, 355 insertions(+), 51 deletions(-) create mode 100644 packages/plugins/src/integrations/impl/notion/client.ts create mode 100644 packages/plugins/src/integrations/impl/notion/error.ts create mode 100644 packages/plugins/src/integrations/impl/notion/icon.ts create mode 100644 packages/plugins/src/integrations/impl/notion/index.test.ts create mode 100644 packages/plugins/src/integrations/impl/notion/index.ts create mode 100644 packages/plugins/src/integrations/impl/notion/types.ts diff --git a/apps/emdash-desktop/src/main/core/integrations/controller.ts b/apps/emdash-desktop/src/main/core/integrations/controller.ts index 44a291ee01..03fcabc985 100644 --- a/apps/emdash-desktop/src/main/core/integrations/controller.ts +++ b/apps/emdash-desktop/src/main/core/integrations/controller.ts @@ -1,4 +1,5 @@ import type { IntegrationCredentials } from '@emdash/plugins/integrations'; +import { getIssueProvider } from '@main/core/issues/registry'; import { createRPCController } from '@shared/lib/ipc/rpc'; import { integrationConnectionService } from './integration-connection-service'; import { buildIntegrationListPayload } from './integration-payload-builder'; @@ -6,6 +7,21 @@ import { buildIntegrationListPayload } from './integration-payload-builder'; export const integrationsController = createRPCController({ list: async () => buildIntegrationListPayload(), + checkConfiguredConnections: async () => { + const integrations = buildIntegrationListPayload(); + const settled = await Promise.all( + integrations.map(async (integration) => { + const issueProvider = getIssueProvider(integration.id); + const configured = issueProvider?.isConfigured + ? await issueProvider.isConfigured() + : await integrationConnectionService.isConfigured(integration.id); + return [integration.id, configured] as const; + }) + ); + + return Object.fromEntries(settled); + }, + connect: async (integrationId: string, credentials: IntegrationCredentials) => integrationConnectionService.connect(integrationId, credentials), diff --git a/apps/emdash-desktop/src/main/core/integrations/integration-connection-service.ts b/apps/emdash-desktop/src/main/core/integrations/integration-connection-service.ts index 2569cf95f6..11313f7b17 100644 --- a/apps/emdash-desktop/src/main/core/integrations/integration-connection-service.ts +++ b/apps/emdash-desktop/src/main/core/integrations/integration-connection-service.ts @@ -53,6 +53,10 @@ export class IntegrationConnectionService { } } + async isConfigured(integrationId: string): Promise { + return integrationCredentialStore.isConfigured(integrationId); + } + async checkConnection( integrationId: string, capabilities: ConnectionStatus['capabilities'], diff --git a/apps/emdash-desktop/src/main/core/issues/registry.ts b/apps/emdash-desktop/src/main/core/issues/registry.ts index da27210ae1..d07faf9a4f 100644 --- a/apps/emdash-desktop/src/main/core/issues/registry.ts +++ b/apps/emdash-desktop/src/main/core/issues/registry.ts @@ -1,10 +1,9 @@ import { issuesPluginRegistry } from '@emdash/plugins/issues'; import { createGitHubPluginIssueProvider } from '@main/core/github/github-plugin-issue-provider'; import { createPluginIssueProvider } from '@main/core/integrations/plugin-issue-provider'; -import type { IssueProviderType } from '@shared/issue-providers'; import type { IssueProvider } from './issue-provider'; -const providers = new Map(); +const providers = new Map(); function register(provider: IssueProvider) { providers.set(provider.type, provider); @@ -18,7 +17,7 @@ for (const plugin of issuesPluginRegistry.getAll()) { register(provider); } -export function getIssueProvider(type: IssueProviderType): IssueProvider | undefined { +export function getIssueProvider(type: string): IssueProvider | undefined { return providers.get(type); } diff --git a/apps/emdash-desktop/src/renderer/features/integrations/integrations-provider.test.ts b/apps/emdash-desktop/src/renderer/features/integrations/integrations-provider.test.ts index b4ee6eac5f..70c5e764fa 100644 --- a/apps/emdash-desktop/src/renderer/features/integrations/integrations-provider.test.ts +++ b/apps/emdash-desktop/src/renderer/features/integrations/integrations-provider.test.ts @@ -21,9 +21,9 @@ vi.mock('@renderer/lib/ipc', () => ({ rpc: { issues: { checkAllConnections: mocks.checkAllConnections, - checkConfiguredConnections: mocks.checkConfiguredConnections, }, integrations: { + checkConfiguredConnections: mocks.checkConfiguredConnections, list: mocks.listIntegrations, connect: mocks.connectIntegration, disconnect: mocks.disconnectIntegration, diff --git a/apps/emdash-desktop/src/renderer/features/integrations/integrations-provider.tsx b/apps/emdash-desktop/src/renderer/features/integrations/integrations-provider.tsx index 338a5edf62..a581864fab 100644 --- a/apps/emdash-desktop/src/renderer/features/integrations/integrations-provider.tsx +++ b/apps/emdash-desktop/src/renderer/features/integrations/integrations-provider.tsx @@ -72,7 +72,7 @@ export function IntegrationsProvider({ children }: { children: React.ReactNode } const { data: configuredConnections = {}, isFetching: isCheckingConfiguredConnections } = useQuery({ queryKey: [...ISSUE_CONNECTION_STATUS_QUERY_KEY, 'configured'], - queryFn: () => rpc.issues.checkConfiguredConnections(), + queryFn: () => rpc.integrations.checkConfiguredConnections(), staleTime: Infinity, refetchOnWindowFocus: false, }); diff --git a/apps/emdash-desktop/src/renderer/features/settings/components/IntegrationsCard.tsx b/apps/emdash-desktop/src/renderer/features/settings/components/IntegrationsCard.tsx index 608f03bf83..c2df561618 100644 --- a/apps/emdash-desktop/src/renderer/features/settings/components/IntegrationsCard.tsx +++ b/apps/emdash-desktop/src/renderer/features/settings/components/IntegrationsCard.tsx @@ -1,5 +1,4 @@ import React, { useMemo, useState } from 'react'; -import { isIssueIntegration } from '@renderer/features/integrations/integration-display'; import { useIntegrationsContext } from '@renderer/features/integrations/integrations-provider'; import { sortGitHubAccountsByDefault } from '@renderer/features/projects/components/github-account-select-model'; import { useGitHubAccounts } from '@renderer/lib/hooks/useGithubAccounts'; @@ -7,12 +6,12 @@ import { useShowModal } from '@renderer/lib/modal/modal-provider'; import { Sheet, SheetContent } from '@renderer/lib/ui/sheet'; import { TooltipProvider } from '@renderer/lib/ui/tooltip'; import type { AgentIconAsset } from '@shared/core/agents/agent-payload'; -import type { ConnectionStatus, IssueProviderType } from '@shared/issue-providers'; +import type { ConnectionStatus } from '@shared/issue-providers'; import { IntegrationDetailSidebar } from './IntegrationDetailSidebar'; import { IntegrationGridCard } from './IntegrationGridCard'; export type IntegrationItem = { - id: IssueProviderType; + id: string; name: string; description: string; icon: AgentIconAsset; @@ -41,7 +40,7 @@ const IntegrationsCard: React.FC = () => { () => sortGitHubAccountsByDefault(githubAccounts), [githubAccounts] ); - const [selectedProvider, setSelectedProvider] = useState(null); + const [selectedProvider, setSelectedProvider] = useState(null); const showIntegrationSetup = useShowModal('integrationSetupModal'); const showConnectGitHub = useShowModal('githubConnectModal'); const showConfirm = useShowModal('confirmActionModal'); @@ -67,35 +66,17 @@ const IntegrationsCard: React.FC = () => { }); }; - const integrations: IntegrationItem[] = integrationMetadata - .filter(isIssueIntegration) - .map((integration) => { - const provider = integration.id; - const status: ConnectionStatus = connectionStatus[provider] ?? { - connected: false, - capabilities: integration.capabilities, - }; - const isConfigured = configuredConnections[provider] ?? false; - const isConfigurationKnown = - provider in configuredConnections || !isCheckingConfiguredConnections; - - if (provider === 'github') { - return { - id: provider, - name: integration.name, - description: integration.description, - icon: integration.icon, - features: integration.features, - isConfigured, - isConfigurationKnown, - isMutating: false, - connectionError: isConfigured ? status.error : undefined, - displayName: sortedGithubAccounts[0]?.login ?? status.displayName, - displayDetail: status.displayDetail, - onConnect: () => showConnectGitHub({}), - }; - } + const integrations: IntegrationItem[] = integrationMetadata.map((integration) => { + const provider = integration.id; + const status: ConnectionStatus = connectionStatus[provider] ?? { + connected: false, + capabilities: integration.capabilities, + }; + const isConfigured = configuredConnections[provider] ?? false; + const isConfigurationKnown = + provider in configuredConnections || !isCheckingConfiguredConnections; + if (provider === 'github') { return { id: provider, name: integration.name, @@ -104,21 +85,37 @@ const IntegrationsCard: React.FC = () => { features: integration.features, isConfigured, isConfigurationKnown, - isMutating: isIntegrationMutating(provider), + isMutating: false, connectionError: isConfigured ? status.error : undefined, - displayName: status.displayName, + displayName: sortedGithubAccounts[0]?.login ?? status.displayName, displayDetail: status.displayDetail, - onConnect: () => showIntegrationSetup({ integration: provider }), - onDisconnect: () => - confirmDisconnect({ - name: integration.name, - credential: integration.disconnectCredentialLabel, - onDisconnect: () => { - void disconnectIntegration(provider); - }, - }), + onConnect: () => showConnectGitHub({}), }; - }); + } + + return { + id: provider, + name: integration.name, + description: integration.description, + icon: integration.icon, + features: integration.features, + isConfigured, + isConfigurationKnown, + isMutating: isIntegrationMutating(provider), + connectionError: isConfigured ? status.error : undefined, + displayName: status.displayName, + displayDetail: status.displayDetail, + onConnect: () => showIntegrationSetup({ integration: provider }), + onDisconnect: () => + confirmDisconnect({ + name: integration.name, + credential: integration.disconnectCredentialLabel, + onDisconnect: () => { + void disconnectIntegration(provider); + }, + }), + }; + }); const connectedIntegrations = integrations.filter((integration) => integration.isConfigured); const availableIntegrations = integrations.filter( diff --git a/apps/emdash-desktop/src/shared/telemetry.ts b/apps/emdash-desktop/src/shared/telemetry.ts index d1a138da41..bd18921268 100644 --- a/apps/emdash-desktop/src/shared/telemetry.ts +++ b/apps/emdash-desktop/src/shared/telemetry.ts @@ -131,8 +131,8 @@ export type TelemetryEventProperties = { user_signed_in: EmptyProps; user_signed_out: EmptyProps; - integration_connected: { provider: 'github' | 'linear' | 'jira' | 'asana' }; - integration_disconnected: { provider: 'github' | 'linear' | 'jira' | 'asana' }; + integration_connected: { provider: string }; + integration_disconnected: { provider: string }; issue_linked_to_task: { provider: | 'github' 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..ece115699f --- /dev/null +++ b/packages/plugins/src/integrations/impl/notion/client.ts @@ -0,0 +1,51 @@ +import { err, ok, type Result } from '@emdash/shared'; +import { Client, type UserObjectResponse } 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 detail = displayDetail(user); + return ok({ + displayName: displayName(user), + ...(detail ? { displayDetail: detail } : {}), + credentials: credentials.data, + }); + } catch (error) { + return err(toNotionIntegrationError(error, 'Failed to validate Notion token.')); + } +} + +function displayName(user: UserObjectResponse): string { + return user.name ?? (user.type === 'bot' ? 'Notion bot' : 'Notion user'); +} + +function displayDetail(user: UserObjectResponse): string | undefined { + if (user.type === 'person') return user.person.email; + return undefined; +} 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..58251332a3 --- /dev/null +++ b/packages/plugins/src/integrations/impl/notion/icon.ts @@ -0,0 +1,13 @@ +import type { PluginIconAsset } from '@emdash/shared/plugins'; + +export const icon: PluginIconAsset = { + kind: 'svg', + alt: 'Notion', + variants: [ + { + minSize: 0, + light: ``, + dark: ``, + }, + ], +}; 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..e7a93a6d16 --- /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 the pages you want Emdash to access with that connection.', + 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/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 From 94b6d9b49c5bf26e7b97ec3056012c91c6a28cae Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:04:57 +0200 Subject: [PATCH 02/16] fix(notion): use official logo --- apps/emdash-desktop/src/assets/images/mcp/notion.svg | 5 +---- packages/plugins/src/integrations/impl/notion/icon.ts | 6 ++++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/emdash-desktop/src/assets/images/mcp/notion.svg b/apps/emdash-desktop/src/assets/images/mcp/notion.svg index 34953987b4..2153f4d6d1 100644 --- a/apps/emdash-desktop/src/assets/images/mcp/notion.svg +++ b/apps/emdash-desktop/src/assets/images/mcp/notion.svg @@ -1,4 +1 @@ - -Notion - - +Notion diff --git a/packages/plugins/src/integrations/impl/notion/icon.ts b/packages/plugins/src/integrations/impl/notion/icon.ts index 58251332a3..b7c7d32f75 100644 --- a/packages/plugins/src/integrations/impl/notion/icon.ts +++ b/packages/plugins/src/integrations/impl/notion/icon.ts @@ -1,13 +1,15 @@ import type { PluginIconAsset } from '@emdash/shared/plugins'; +const svg = + ''; + export const icon: PluginIconAsset = { kind: 'svg', alt: 'Notion', variants: [ { minSize: 0, - light: ``, - dark: ``, + light: svg, }, ], }; From 65f837cd47136e1b05bfd655263c9e22f68771d1 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:53:42 +0200 Subject: [PATCH 03/16] feat(notion): add issue provider --- .../integration-payload-builder.test.ts | 17 +++ .../src/shared/core/linked-issue.ts | 1 + .../plugins/src/issues/impl/notion/context.ts | 63 +++++++++++ .../plugins/src/issues/impl/notion/index.ts | 101 ++++++++++++++++++ .../plugins/src/issues/impl/notion/mapper.ts | 88 +++++++++++++++ packages/plugins/src/issues/registry.ts | 2 + 6 files changed, 272 insertions(+) create mode 100644 apps/emdash-desktop/src/main/core/integrations/integration-payload-builder.test.ts create mode 100644 packages/plugins/src/issues/impl/notion/context.ts create mode 100644 packages/plugins/src/issues/impl/notion/index.ts create mode 100644 packages/plugins/src/issues/impl/notion/mapper.ts diff --git a/apps/emdash-desktop/src/main/core/integrations/integration-payload-builder.test.ts b/apps/emdash-desktop/src/main/core/integrations/integration-payload-builder.test.ts new file mode 100644 index 0000000000..25eed578aa --- /dev/null +++ b/apps/emdash-desktop/src/main/core/integrations/integration-payload-builder.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { buildIntegrationListPayload } from './integration-payload-builder'; + +describe('buildIntegrationListPayload', () => { + it('marks Notion as an issue integration', () => { + const notion = buildIntegrationListPayload().find((integration) => integration.id === 'notion'); + + expect(notion).toMatchObject({ + id: 'notion', + features: expect.arrayContaining(['issues']), + capabilities: { + requiresRepositoryUrl: false, + supportsIssueContext: true, + }, + }); + }); +}); diff --git a/apps/emdash-desktop/src/shared/core/linked-issue.ts b/apps/emdash-desktop/src/shared/core/linked-issue.ts index 1bdb94ec31..85eb9d834a 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(), 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..b74078117b --- /dev/null +++ b/packages/plugins/src/issues/impl/notion/context.ts @@ -0,0 +1,63 @@ +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(block.paragraph.rich_text); + case 'heading_1': + return heading('#', richTextPlainText(block.heading_1.rich_text)); + case 'heading_2': + return heading('##', richTextPlainText(block.heading_2.rich_text)); + case 'heading_3': + return heading('###', richTextPlainText(block.heading_3.rich_text)); + case 'heading_4': + return heading('####', richTextPlainText(block.heading_4.rich_text)); + case 'bulleted_list_item': + return listItem('-', richTextPlainText(block.bulleted_list_item.rich_text)); + case 'numbered_list_item': + return listItem('1.', richTextPlainText(block.numbered_list_item.rich_text)); + case 'to_do': + return listItem( + `[${block.to_do.checked ? 'x' : ' '}]`, + richTextPlainText(block.to_do.rich_text) + ); + case 'quote': + return quote(richTextPlainText(block.quote.rich_text)); + case 'code': + return codeBlock(block.code.language, richTextPlainText(block.code.rich_text)); + case 'child_page': + return `Child page: ${block.child_page.title}`; + case 'child_database': + return `Child database: ${block.child_database.title}`; + 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.ts b/packages/plugins/src/issues/impl/notion/index.ts new file mode 100644 index 0000000000..e46e8b7c42 --- /dev/null +++ b/packages/plugins/src/issues/impl/notion/index.ts @@ -0,0 +1,101 @@ +import { err, ok } from '@emdash/shared'; +import { isFullPage } 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 { toIssueData } from './mapper'; + +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 response = await client.search({ + filter: { property: 'object', value: 'page' }, + sort: { timestamp: 'last_edited_time', direction: 'descending' }, + page_size: limit, + }); + return ok(response.results.filter(isFullPage).map(toIssueData)); + } 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(response.results.filter(isFullPage).map(toIssueData)); + } 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 = await client.blocks.children.list({ block_id: page.id, page_size: 50 }); + return ok({ ...toIssueData(page), context: formatNotionContext(blocks.results) }); + } 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..cd736190ab --- /dev/null +++ b/packages/plugins/src/issues/impl/notion/mapper.ts @@ -0,0 +1,88 @@ +import type { PageObjectResponse, RichTextItemResponse } from '@notionhq/client'; +import type { IssueData } from '../../types'; + +type NotionPageProperty = PageObjectResponse['properties'][string]; + +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, + }; +} + +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(); +} 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, ]) { From ba0d260c8bc7ef4c8b84a4406a516d4f8fc5a2e6 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:40:08 +0200 Subject: [PATCH 04/16] fix(tasks): show Notion titles for page IDs --- .../components/task-view/task-row.tsx | 4 +++- .../renderer/features/sidebar/task-item.tsx | 6 +++-- .../create-task-modal/issue-task-name.test.ts | 12 ++++++++++ .../create-task-modal/issue-task-name.ts | 5 ++++ .../features/tasks/stores/task-selectors.ts | 23 ++++++++++++++++++- .../renderer/features/tasks/task-titlebar.tsx | 9 +++++++- 6 files changed, 54 insertions(+), 5 deletions(-) diff --git a/apps/emdash-desktop/src/renderer/features/projects/components/task-view/task-row.tsx b/apps/emdash-desktop/src/renderer/features/projects/components/task-view/task-row.tsx index 5bbddabf0c..588f044aca 100644 --- a/apps/emdash-desktop/src/renderer/features/projects/components/task-view/task-row.tsx +++ b/apps/emdash-desktop/src/renderer/features/projects/components/task-view/task-row.tsx @@ -6,6 +6,7 @@ import { getTaskGitWorktreeStore, getTaskManagerStore, taskAgentStatus, + taskDisplayName, } from '@renderer/features/tasks/stores/task-selectors'; import { type TaskStore } from '@renderer/features/tasks/stores/task-store'; import { AgentStatusIndicator } from '@renderer/lib/components/agent-status-indicator'; @@ -56,6 +57,7 @@ export const TaskRow = observer(function TaskRow({ const canPin = task.state !== 'unregistered'; const agentAttention = taskAgentStatus(task); const currentPr = task.data.prs ? selectCurrentPr(task.data.prs) : undefined; + const displayName = taskDisplayName(task) ?? task.data.name; const branchName = getTaskGitWorktreeStore(task.data.projectId, task.data.id)?.branchName ?? undefined; @@ -106,7 +108,7 @@ export const TaskRow = observer(function TaskRow({
- {task.data.name} + {displayName} {currentPr && }
diff --git a/apps/emdash-desktop/src/renderer/features/sidebar/task-item.tsx b/apps/emdash-desktop/src/renderer/features/sidebar/task-item.tsx index ed04c03cf2..21e9306a18 100644 --- a/apps/emdash-desktop/src/renderer/features/sidebar/task-item.tsx +++ b/apps/emdash-desktop/src/renderer/features/sidebar/task-item.tsx @@ -7,6 +7,7 @@ import { getTaskManagerStore, getTaskStore, getWorkspaceForTask, + taskDisplayName, } from '@renderer/features/tasks/stores/task-selectors'; import { type TaskStore } from '@renderer/features/tasks/stores/task-store'; import { @@ -47,6 +48,7 @@ export const SidebarTaskItem = observer(function SidebarTaskItem({ const taskManager = getTaskManagerStore(projectId); const taskName = task.data.name; + const displayName = taskDisplayName(task) ?? taskName; const handleProvision = () => { if (task.state !== 'unprovisioned' || task.phase !== 'idle') return; @@ -110,7 +112,7 @@ export const SidebarTaskItem = observer(function SidebarTaskItem({ onClick={openTask} > - {taskName} + {displayName}
diff --git a/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/issue-task-name.test.ts b/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/issue-task-name.test.ts index d0e14732b2..a4105e14a4 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/issue-task-name.test.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/issue-task-name.test.ts @@ -75,6 +75,18 @@ describe('getIssueTaskName', () => { ).toBeNull(); }); + it('uses the Notion page title when Notion only provides a page ID', () => { + expect( + getIssueTaskName({ + provider: 'notion', + url: 'https://www.notion.so/example-page', + title: 'Write customer onboarding spec', + identifier: '37818d1b-a831-813a-b25f-f61cc3db1a08', + displayIdentifier: null, + }) + ).toBe('write-customer-onboarding-spec'); + }); + it('applies existing task-name length limits', () => { expect( getIssueTaskName({ diff --git a/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/issue-task-name.ts b/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/issue-task-name.ts index e6359a42c6..ab699bd213 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/issue-task-name.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/issue-task-name.ts @@ -10,6 +10,11 @@ export function getIssueTaskName( } const branchName = issue.branchName?.trim(); + if (!branchName && issue.provider === 'notion') { + const normalized = normalizeTaskName(issue.title, options); + return normalized || null; + } + if (!branchName) { return null; } diff --git a/apps/emdash-desktop/src/renderer/features/tasks/stores/task-selectors.ts b/apps/emdash-desktop/src/renderer/features/tasks/stores/task-selectors.ts index f1001685ca..f26e75c0d1 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/stores/task-selectors.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/stores/task-selectors.ts @@ -142,10 +142,31 @@ export function getTerminalsForTask(taskId: string) { return terminalRegistry.get(taskId); } +function isUuidLike(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + value + ); +} + /** Returns the display name from any task store variant. */ export function taskDisplayName(store: TaskStore | undefined): string | undefined { if (!store) return undefined; - return store.data.name; + + const name = store.data.name; + const linkedIssue = 'linkedIssue' in store.data ? store.data.linkedIssue : undefined; + if (!linkedIssue) return name; + + const issueTitle = linkedIssue.title.trim(); + if (!issueTitle) return name; + + const issueIdentifier = linkedIssue.displayIdentifier ?? linkedIssue.identifier; + const nameIsIdentifier = + name === store.data.id || + name === linkedIssue.identifier || + name === issueIdentifier || + isUuidLike(name); + + return nameIsIdentifier ? `${issueTitle} · ${name}` : name; } /** Returns the error message for error states. */ 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..f3a737b645 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/task-titlebar.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/task-titlebar.tsx @@ -72,6 +72,11 @@ const PendingTaskTitlebar = observer(function PendingTaskTitlebar({ const taskStore = getTaskStore(projectId, taskId); const projectName = projectDisplayName(getProjectStore(projectId)); const name = taskDisplayName(taskStore); + const taskLabel = name + ? name.includes(taskId) + ? name + : `${name} · ${taskId}` + : taskId; const { navigate } = useNavigate(); return ( @@ -87,7 +92,9 @@ const PendingTaskTitlebar = observer(function PendingTaskTitlebar({ {projectName} / - {name} + + {taskLabel} +
} From d0cd2f1e5264a510a472c6a57e7e6df28047d583 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:44:37 +0200 Subject: [PATCH 05/16] fix(notion): filter noisy issue list pages --- .../src/issues/impl/notion/index.test.ts | 139 ++++++++++++++++++ .../plugins/src/issues/impl/notion/index.ts | 12 +- .../plugins/src/issues/impl/notion/mapper.ts | 13 +- 3 files changed, 159 insertions(+), 5 deletions(-) create mode 100644 packages/plugins/src/issues/impl/notion/index.test.ts 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..4e6092c184 --- /dev/null +++ b/packages/plugins/src/issues/impl/notion/index.test.ts @@ -0,0 +1,139 @@ +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(() => ({ + constructor: vi.fn(), + search: vi.fn(), +})); + +vi.mock('@notionhq/client', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Client: class { + constructor(config: unknown) { + notionSdk.constructor(config); + } + + search = notionSdk.search; + }, + }; +}); + +const issues = provider.behavior.issues; +if (!issues) throw new Error('Notion issues behavior is not registered'); + +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; +} + +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: 50, + }); + 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' })], + }); + }); +}); diff --git a/packages/plugins/src/issues/impl/notion/index.ts b/packages/plugins/src/issues/impl/notion/index.ts index e46e8b7c42..764abceb69 100644 --- a/packages/plugins/src/issues/impl/notion/index.ts +++ b/packages/plugins/src/issues/impl/notion/index.ts @@ -16,7 +16,7 @@ import type { IssueSearchOpts, } from '../../types'; import { formatNotionContext } from './context'; -import { toIssueData } from './mapper'; +import { hasMeaningfulTitle, isDatabasePage, toIssueData } from './mapper'; export async function listIssues( host: ConnectedIntegrationHostContext, @@ -34,7 +34,13 @@ export async function listIssues( sort: { timestamp: 'last_edited_time', direction: 'descending' }, page_size: limit, }); - return ok(response.results.filter(isFullPage).map(toIssueData)); + return ok( + response.results + .filter(isFullPage) + .filter(isDatabasePage) + .filter(hasMeaningfulTitle) + .map(toIssueData) + ); } catch (error) { host.log.warn('Notion listIssues failed', { error }); return err(toNotionIntegrationError(error, 'Unable to fetch Notion pages.')); @@ -61,7 +67,7 @@ export async function searchIssues( sort: { timestamp: 'last_edited_time', direction: 'descending' }, page_size: limit, }); - return ok(response.results.filter(isFullPage).map(toIssueData)); + return ok(response.results.filter(isFullPage).filter(hasMeaningfulTitle).map(toIssueData)); } catch (error) { host.log.warn('Notion searchIssues failed', { error }); return err(toNotionIntegrationError(error, 'Unable to search Notion pages.')); diff --git a/packages/plugins/src/issues/impl/notion/mapper.ts b/packages/plugins/src/issues/impl/notion/mapper.ts index cd736190ab..1eeb900a49 100644 --- a/packages/plugins/src/issues/impl/notion/mapper.ts +++ b/packages/plugins/src/issues/impl/notion/mapper.ts @@ -2,6 +2,7 @@ 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 { @@ -17,10 +18,18 @@ export function toIssueData(page: PageObjectResponse): IssueData { }; } +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'; + if (!title || title.type !== 'title') return UNTITLED_NOTION_PAGE; + return richTextPlainText(title.title) || UNTITLED_NOTION_PAGE; } function firstRichTextProperty(page: PageObjectResponse): string | undefined { From 181cba3be5be1d500664b048d866aabd76039bb7 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:48:10 +0200 Subject: [PATCH 06/16] refactor(notion): clean up task title handling --- .../features/tasks/stores/task-selectors.ts | 7 ++++--- .../src/renderer/features/tasks/task-titlebar.tsx | 12 +++++++----- .../plugins/src/issues/impl/notion/index.test.ts | 5 ----- packages/plugins/src/issues/impl/notion/index.ts | 14 +++++++------- 4 files changed, 18 insertions(+), 20 deletions(-) diff --git a/apps/emdash-desktop/src/renderer/features/tasks/stores/task-selectors.ts b/apps/emdash-desktop/src/renderer/features/tasks/stores/task-selectors.ts index f26e75c0d1..00dd5b0147 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/stores/task-selectors.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/stores/task-selectors.ts @@ -142,10 +142,11 @@ export function getTerminalsForTask(taskId: string) { return terminalRegistry.get(taskId); } +const UUID_LIKE_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + function isUuidLike(value: string): boolean { - return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( - value - ); + return UUID_LIKE_PATTERN.test(value); } /** Returns the display name from any task store variant. */ 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 f3a737b645..2acdf9f498 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/task-titlebar.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/task-titlebar.tsx @@ -72,11 +72,7 @@ const PendingTaskTitlebar = observer(function PendingTaskTitlebar({ const taskStore = getTaskStore(projectId, taskId); const projectName = projectDisplayName(getProjectStore(projectId)); const name = taskDisplayName(taskStore); - const taskLabel = name - ? name.includes(taskId) - ? name - : `${name} · ${taskId}` - : taskId; + const taskLabel = pendingTaskLabel(name, taskId); const { navigate } = useNavigate(); return ( @@ -102,6 +98,12 @@ const PendingTaskTitlebar = observer(function PendingTaskTitlebar({ ); }); +function pendingTaskLabel(name: string | undefined, taskId: string): string { + if (!name) return taskId; + if (name === taskId || name.endsWith(` · ${taskId}`)) return name; + return `${name} · ${taskId}`; +} + const ActiveTaskTitlebar = observer(function ActiveTaskTitlebar({ projectId, taskId, diff --git a/packages/plugins/src/issues/impl/notion/index.test.ts b/packages/plugins/src/issues/impl/notion/index.test.ts index 4e6092c184..faec8d52a9 100644 --- a/packages/plugins/src/issues/impl/notion/index.test.ts +++ b/packages/plugins/src/issues/impl/notion/index.test.ts @@ -5,7 +5,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { provider } from './index'; const notionSdk = vi.hoisted(() => ({ - constructor: vi.fn(), search: vi.fn(), })); @@ -14,10 +13,6 @@ vi.mock('@notionhq/client', async (importOriginal) => { return { ...actual, Client: class { - constructor(config: unknown) { - notionSdk.constructor(config); - } - search = notionSdk.search; }, }; diff --git a/packages/plugins/src/issues/impl/notion/index.ts b/packages/plugins/src/issues/impl/notion/index.ts index 764abceb69..07d3bd2082 100644 --- a/packages/plugins/src/issues/impl/notion/index.ts +++ b/packages/plugins/src/issues/impl/notion/index.ts @@ -1,5 +1,5 @@ import { err, ok } from '@emdash/shared'; -import { isFullPage } from '@notionhq/client'; +import { isFullPage, type PageObjectResponse } from '@notionhq/client'; import type { ConnectedIntegrationHostContext } from '../../../integrations/host'; import { createNotionClient, @@ -35,11 +35,7 @@ export async function listIssues( page_size: limit, }); return ok( - response.results - .filter(isFullPage) - .filter(isDatabasePage) - .filter(hasMeaningfulTitle) - .map(toIssueData) + toIssueListItems(response.results.filter(isFullPage).filter(isDatabasePage)) ); } catch (error) { host.log.warn('Notion listIssues failed', { error }); @@ -67,7 +63,7 @@ export async function searchIssues( sort: { timestamp: 'last_edited_time', direction: 'descending' }, page_size: limit, }); - return ok(response.results.filter(isFullPage).filter(hasMeaningfulTitle).map(toIssueData)); + 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.')); @@ -105,3 +101,7 @@ const plugin = defineIssuesPlugin({ integrationId: 'notion' }, { issues: {} }, { export const provider = registerIssuesPluginBehavior(plugin, { issues: { listIssues, searchIssues, getIssue }, }); + +function toIssueListItems(pages: PageObjectResponse[]) { + return pages.filter(hasMeaningfulTitle).map(toIssueData); +} From 20bbd8fcb754e91df08b37f8d4484280fb7491ce Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:55:41 +0200 Subject: [PATCH 07/16] fix(tasks): hide linked issues in create modal --- .../issue-selector/issue-selector.test.ts | 40 +++++++++++++++++++ .../issue-selector/issue-selector.tsx | 29 ++++++++++++-- .../issue-combobox-field.tsx | 1 + 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-selector.test.ts b/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-selector.test.ts index 4fb139af83..5bfda31087 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-selector.test.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-selector.test.ts @@ -181,4 +181,44 @@ describe('IssueSelector', () => { ); expect(container.textContent).not.toContain('No issues found'); }); + + it('does not include hidden provider identifiers in input labels', async () => { + const { issueToStringLabel } = await import('./issue-selector'); + + expect( + issueToStringLabel({ + provider: 'notion', + identifier: '37818d1b-a831-8118-9d00-cf3c00000000', + displayIdentifier: null, + title: 'commandcode auf svg machen', + url: 'https://www.notion.so/page', + }) + ).toBe('commandcode auf svg machen'); + }); + + it('filters already linked issues when requested', async () => { + const { getVisibleIssues } = await import('./issue-selector'); + const linkedIssue = { + provider: 'notion' as const, + identifier: 'linked-page-id', + displayIdentifier: null, + title: 'Already linked', + url: 'https://www.notion.so/linked-page-id', + }; + const unlinkedIssue = { + provider: 'notion' as const, + identifier: 'unlinked-page-id', + displayIdentifier: null, + title: 'Available', + url: 'https://www.notion.so/unlinked-page-id', + }; + + expect( + getVisibleIssues( + [linkedIssue, unlinkedIssue], + new Map([[linkedIssue.url, { taskId: 'task-1', taskName: 'Existing task' }]]), + { excludeLinkedIssues: true } + ) + ).toEqual([unlinkedIssue]); + }); }); 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..39ee21cf2a 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 @@ -132,6 +132,7 @@ export interface IssueSelectorProps { projectPath?: string; /** Skip "already linked" indicator for this task — useful when re-selecting the same task's issue. */ excludeTaskId?: string; + excludeLinkedIssues?: boolean; disabled?: boolean; renderSelectedValue?: (issue: LinkedIssue) => ReactNode; renderPlaceholder?: (ctx: IssueSelectorTriggerContext) => ReactNode; @@ -144,6 +145,7 @@ export const IssueSelector = observer(function IssueSelector({ value, onValueChange, excludeTaskId, + excludeLinkedIssues = false, disabled, renderSelectedValue, renderPlaceholder, @@ -170,6 +172,12 @@ export const IssueSelector = observer(function IssueSelector({ const providerSelectOpenRef = useRef(false); const inputRef = useRef(null); + const visibleIssues = useMemo( + () => + getVisibleIssues(issues, linkedIssueMap, { excludeLinkedIssues }), + [excludeLinkedIssues, issues, linkedIssueMap] + ); + const handleSelectIssueProvider = useCallback( (provider: LinkedIssue['provider']) => { setSelectedIssueProvider(provider); @@ -246,11 +254,9 @@ export const IssueSelector = observer(function IssueSelector({ {hasAnyIntegration ? ( - issue ? `${issue.identifier} ${issue.title}` : '' - } + itemToStringLabel={issueToStringLabel} value={value} onValueChange={(next: LinkedIssue | null) => onValueChange(next)} onInputValueChange={(val: string, { reason }: { reason: string }) => { @@ -321,6 +327,21 @@ export const IssueSelector = observer(function IssueSelector({ ); }); +export function issueToStringLabel(issue: LinkedIssue | null): string { + if (!issue) return ''; + const identifier = issue.displayIdentifier === null ? null : issue.displayIdentifier; + return identifier ? `${identifier} ${issue.title}` : issue.title; +} + +export function getVisibleIssues( + issues: LinkedIssue[], + linkedIssueMap: Map, + opts: { excludeLinkedIssues: boolean } +): LinkedIssue[] { + if (!opts.excludeLinkedIssues) return issues; + return issues.filter((issue) => !issue.url || !linkedIssueMap.has(issue.url)); +} + export function SelectedIssueValue({ issue }: { issue: LinkedIssue }) { return (
diff --git a/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/issue-combobox-field.tsx b/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/issue-combobox-field.tsx index 08f42bd6f6..b2c86af9aa 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/issue-combobox-field.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/issue-combobox-field.tsx @@ -64,6 +64,7 @@ export function IssueComboboxField({ projectId={projectId} repositoryUrl={repositoryUrl} projectPath={projectPath} + excludeLinkedIssues disabled={disabled} renderSelectedValue={(issue) => (
Date: Tue, 7 Jul 2026 18:05:59 +0200 Subject: [PATCH 08/16] fix(tasks): show issue title in composer mentions --- .../issue-selector/issue-selector.test.ts | 2 +- .../initial-conversation-section.test.ts | 27 +++++++++++++++++++ .../initial-conversation-section.tsx | 7 ++++- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-selector.test.ts b/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-selector.test.ts index 5bfda31087..eca2e8999c 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-selector.test.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-selector.test.ts @@ -180,7 +180,7 @@ describe('IssueSelector', () => { 'acme/repo on github.com was not found, or the selected GitHub account does not have access.' ); expect(container.textContent).not.toContain('No issues found'); - }); + }, 10000); it('does not include hidden provider identifiers in input labels', async () => { const { issueToStringLabel } = await import('./issue-selector'); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/task-config/initial-conversation-section.test.ts b/apps/emdash-desktop/src/renderer/features/tasks/task-config/initial-conversation-section.test.ts index 3469db6649..937e316159 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/task-config/initial-conversation-section.test.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/task-config/initial-conversation-section.test.ts @@ -3,6 +3,7 @@ import React, { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + issueMentionName, useInitialConversationState, type InitialConversationState, } from './initial-conversation-section'; @@ -190,3 +191,29 @@ describe('useInitialConversationState', () => { expect(latestState?.useChatUi).toBe(false); }); }); + +describe('issueMentionName', () => { + it('uses the issue title when provider identifiers are hidden', () => { + expect( + issueMentionName({ + provider: 'notion', + identifier: '9fa18d1b-a831-8231-ba19-0129d0000000', + displayIdentifier: null, + title: 'Jan', + url: 'https://www.notion.so/page', + }) + ).toBe('Jan'); + }); + + it('uses the display identifier when one exists', () => { + expect( + issueMentionName({ + provider: 'linear', + identifier: 'issue-id', + displayIdentifier: 'ENG-123', + title: 'Fix login', + url: 'https://linear.app/issue/ENG-123', + }) + ).toBe('ENG-123'); + }); +}); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/task-config/initial-conversation-section.tsx b/apps/emdash-desktop/src/renderer/features/tasks/task-config/initial-conversation-section.tsx index 6e754cda83..d88de6fd40 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/task-config/initial-conversation-section.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/task-config/initial-conversation-section.tsx @@ -183,12 +183,17 @@ function toIssueMentionItem(issue: LinkedIssue): MentionItem { return toLinkedIssueMentionItem(issue, true); } +export function issueMentionName(issue: LinkedIssue): string { + if (issue.displayIdentifier === null) return issue.title; + return issue.displayIdentifier ?? issue.identifier; +} + function toLinkedIssueMentionItem(issue: LinkedIssue, pending = false): MentionItem { const token = issueMentionToken(issue.provider, issue.identifier); return { id: token, label: token, - name: issue.displayIdentifier ?? issue.identifier, + name: issueMentionName(issue), kind: 'issue', description: issue.title, icon: , From a87eacbaf16fe33af86f86d94b97b3007a3f996e Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:12:29 +0200 Subject: [PATCH 09/16] refactor(notion): remove unrelated task UI changes --- .../src/main/core/integrations/controller.ts | 16 -------- .../integration-connection-service.ts | 4 -- .../integration-payload-builder.test.ts | 17 -------- .../integrations-provider.test.ts | 2 +- .../integrations/integrations-provider.tsx | 2 +- .../components/task-view/task-row.tsx | 4 +- .../renderer/features/sidebar/task-item.tsx | 6 +-- .../issue-selector/issue-selector.test.ts | 40 ------------------- .../issue-selector/issue-selector.tsx | 29 ++------------ .../issue-combobox-field.tsx | 1 - .../create-task-modal/issue-task-name.test.ts | 12 ------ .../create-task-modal/issue-task-name.ts | 5 --- .../features/tasks/stores/task-selectors.ts | 24 +---------- .../initial-conversation-section.test.ts | 27 ------------- .../initial-conversation-section.tsx | 7 +--- .../renderer/features/tasks/task-titlebar.tsx | 11 +---- apps/emdash-desktop/src/shared/telemetry.ts | 4 +- 17 files changed, 14 insertions(+), 197 deletions(-) delete mode 100644 apps/emdash-desktop/src/main/core/integrations/integration-payload-builder.test.ts diff --git a/apps/emdash-desktop/src/main/core/integrations/controller.ts b/apps/emdash-desktop/src/main/core/integrations/controller.ts index 03fcabc985..44a291ee01 100644 --- a/apps/emdash-desktop/src/main/core/integrations/controller.ts +++ b/apps/emdash-desktop/src/main/core/integrations/controller.ts @@ -1,5 +1,4 @@ import type { IntegrationCredentials } from '@emdash/plugins/integrations'; -import { getIssueProvider } from '@main/core/issues/registry'; import { createRPCController } from '@shared/lib/ipc/rpc'; import { integrationConnectionService } from './integration-connection-service'; import { buildIntegrationListPayload } from './integration-payload-builder'; @@ -7,21 +6,6 @@ import { buildIntegrationListPayload } from './integration-payload-builder'; export const integrationsController = createRPCController({ list: async () => buildIntegrationListPayload(), - checkConfiguredConnections: async () => { - const integrations = buildIntegrationListPayload(); - const settled = await Promise.all( - integrations.map(async (integration) => { - const issueProvider = getIssueProvider(integration.id); - const configured = issueProvider?.isConfigured - ? await issueProvider.isConfigured() - : await integrationConnectionService.isConfigured(integration.id); - return [integration.id, configured] as const; - }) - ); - - return Object.fromEntries(settled); - }, - connect: async (integrationId: string, credentials: IntegrationCredentials) => integrationConnectionService.connect(integrationId, credentials), diff --git a/apps/emdash-desktop/src/main/core/integrations/integration-connection-service.ts b/apps/emdash-desktop/src/main/core/integrations/integration-connection-service.ts index 11313f7b17..2569cf95f6 100644 --- a/apps/emdash-desktop/src/main/core/integrations/integration-connection-service.ts +++ b/apps/emdash-desktop/src/main/core/integrations/integration-connection-service.ts @@ -53,10 +53,6 @@ export class IntegrationConnectionService { } } - async isConfigured(integrationId: string): Promise { - return integrationCredentialStore.isConfigured(integrationId); - } - async checkConnection( integrationId: string, capabilities: ConnectionStatus['capabilities'], diff --git a/apps/emdash-desktop/src/main/core/integrations/integration-payload-builder.test.ts b/apps/emdash-desktop/src/main/core/integrations/integration-payload-builder.test.ts deleted file mode 100644 index 25eed578aa..0000000000 --- a/apps/emdash-desktop/src/main/core/integrations/integration-payload-builder.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { buildIntegrationListPayload } from './integration-payload-builder'; - -describe('buildIntegrationListPayload', () => { - it('marks Notion as an issue integration', () => { - const notion = buildIntegrationListPayload().find((integration) => integration.id === 'notion'); - - expect(notion).toMatchObject({ - id: 'notion', - features: expect.arrayContaining(['issues']), - capabilities: { - requiresRepositoryUrl: false, - supportsIssueContext: true, - }, - }); - }); -}); diff --git a/apps/emdash-desktop/src/renderer/features/integrations/integrations-provider.test.ts b/apps/emdash-desktop/src/renderer/features/integrations/integrations-provider.test.ts index 70c5e764fa..b4ee6eac5f 100644 --- a/apps/emdash-desktop/src/renderer/features/integrations/integrations-provider.test.ts +++ b/apps/emdash-desktop/src/renderer/features/integrations/integrations-provider.test.ts @@ -21,9 +21,9 @@ vi.mock('@renderer/lib/ipc', () => ({ rpc: { issues: { checkAllConnections: mocks.checkAllConnections, + checkConfiguredConnections: mocks.checkConfiguredConnections, }, integrations: { - checkConfiguredConnections: mocks.checkConfiguredConnections, list: mocks.listIntegrations, connect: mocks.connectIntegration, disconnect: mocks.disconnectIntegration, diff --git a/apps/emdash-desktop/src/renderer/features/integrations/integrations-provider.tsx b/apps/emdash-desktop/src/renderer/features/integrations/integrations-provider.tsx index a581864fab..338a5edf62 100644 --- a/apps/emdash-desktop/src/renderer/features/integrations/integrations-provider.tsx +++ b/apps/emdash-desktop/src/renderer/features/integrations/integrations-provider.tsx @@ -72,7 +72,7 @@ export function IntegrationsProvider({ children }: { children: React.ReactNode } const { data: configuredConnections = {}, isFetching: isCheckingConfiguredConnections } = useQuery({ queryKey: [...ISSUE_CONNECTION_STATUS_QUERY_KEY, 'configured'], - queryFn: () => rpc.integrations.checkConfiguredConnections(), + queryFn: () => rpc.issues.checkConfiguredConnections(), staleTime: Infinity, refetchOnWindowFocus: false, }); diff --git a/apps/emdash-desktop/src/renderer/features/projects/components/task-view/task-row.tsx b/apps/emdash-desktop/src/renderer/features/projects/components/task-view/task-row.tsx index 588f044aca..5bbddabf0c 100644 --- a/apps/emdash-desktop/src/renderer/features/projects/components/task-view/task-row.tsx +++ b/apps/emdash-desktop/src/renderer/features/projects/components/task-view/task-row.tsx @@ -6,7 +6,6 @@ import { getTaskGitWorktreeStore, getTaskManagerStore, taskAgentStatus, - taskDisplayName, } from '@renderer/features/tasks/stores/task-selectors'; import { type TaskStore } from '@renderer/features/tasks/stores/task-store'; import { AgentStatusIndicator } from '@renderer/lib/components/agent-status-indicator'; @@ -57,7 +56,6 @@ export const TaskRow = observer(function TaskRow({ const canPin = task.state !== 'unregistered'; const agentAttention = taskAgentStatus(task); const currentPr = task.data.prs ? selectCurrentPr(task.data.prs) : undefined; - const displayName = taskDisplayName(task) ?? task.data.name; const branchName = getTaskGitWorktreeStore(task.data.projectId, task.data.id)?.branchName ?? undefined; @@ -108,7 +106,7 @@ export const TaskRow = observer(function TaskRow({
- {displayName} + {task.data.name} {currentPr && }
diff --git a/apps/emdash-desktop/src/renderer/features/sidebar/task-item.tsx b/apps/emdash-desktop/src/renderer/features/sidebar/task-item.tsx index 21e9306a18..ed04c03cf2 100644 --- a/apps/emdash-desktop/src/renderer/features/sidebar/task-item.tsx +++ b/apps/emdash-desktop/src/renderer/features/sidebar/task-item.tsx @@ -7,7 +7,6 @@ import { getTaskManagerStore, getTaskStore, getWorkspaceForTask, - taskDisplayName, } from '@renderer/features/tasks/stores/task-selectors'; import { type TaskStore } from '@renderer/features/tasks/stores/task-store'; import { @@ -48,7 +47,6 @@ export const SidebarTaskItem = observer(function SidebarTaskItem({ const taskManager = getTaskManagerStore(projectId); const taskName = task.data.name; - const displayName = taskDisplayName(task) ?? taskName; const handleProvision = () => { if (task.state !== 'unprovisioned' || task.phase !== 'idle') return; @@ -112,7 +110,7 @@ export const SidebarTaskItem = observer(function SidebarTaskItem({ onClick={openTask} > - {displayName} + {taskName}
diff --git a/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-selector.test.ts b/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-selector.test.ts index eca2e8999c..4fb139af83 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-selector.test.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-selector.test.ts @@ -180,45 +180,5 @@ describe('IssueSelector', () => { 'acme/repo on github.com was not found, or the selected GitHub account does not have access.' ); expect(container.textContent).not.toContain('No issues found'); - }, 10000); - - it('does not include hidden provider identifiers in input labels', async () => { - const { issueToStringLabel } = await import('./issue-selector'); - - expect( - issueToStringLabel({ - provider: 'notion', - identifier: '37818d1b-a831-8118-9d00-cf3c00000000', - displayIdentifier: null, - title: 'commandcode auf svg machen', - url: 'https://www.notion.so/page', - }) - ).toBe('commandcode auf svg machen'); - }); - - it('filters already linked issues when requested', async () => { - const { getVisibleIssues } = await import('./issue-selector'); - const linkedIssue = { - provider: 'notion' as const, - identifier: 'linked-page-id', - displayIdentifier: null, - title: 'Already linked', - url: 'https://www.notion.so/linked-page-id', - }; - const unlinkedIssue = { - provider: 'notion' as const, - identifier: 'unlinked-page-id', - displayIdentifier: null, - title: 'Available', - url: 'https://www.notion.so/unlinked-page-id', - }; - - expect( - getVisibleIssues( - [linkedIssue, unlinkedIssue], - new Map([[linkedIssue.url, { taskId: 'task-1', taskName: 'Existing task' }]]), - { excludeLinkedIssues: true } - ) - ).toEqual([unlinkedIssue]); }); }); 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 39ee21cf2a..cbe6f3d45b 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 @@ -132,7 +132,6 @@ export interface IssueSelectorProps { projectPath?: string; /** Skip "already linked" indicator for this task — useful when re-selecting the same task's issue. */ excludeTaskId?: string; - excludeLinkedIssues?: boolean; disabled?: boolean; renderSelectedValue?: (issue: LinkedIssue) => ReactNode; renderPlaceholder?: (ctx: IssueSelectorTriggerContext) => ReactNode; @@ -145,7 +144,6 @@ export const IssueSelector = observer(function IssueSelector({ value, onValueChange, excludeTaskId, - excludeLinkedIssues = false, disabled, renderSelectedValue, renderPlaceholder, @@ -172,12 +170,6 @@ export const IssueSelector = observer(function IssueSelector({ const providerSelectOpenRef = useRef(false); const inputRef = useRef(null); - const visibleIssues = useMemo( - () => - getVisibleIssues(issues, linkedIssueMap, { excludeLinkedIssues }), - [excludeLinkedIssues, issues, linkedIssueMap] - ); - const handleSelectIssueProvider = useCallback( (provider: LinkedIssue['provider']) => { setSelectedIssueProvider(provider); @@ -254,9 +246,11 @@ export const IssueSelector = observer(function IssueSelector({ {hasAnyIntegration ? ( + issue ? `${issue.identifier} ${issue.title}` : '' + } value={value} onValueChange={(next: LinkedIssue | null) => onValueChange(next)} onInputValueChange={(val: string, { reason }: { reason: string }) => { @@ -327,21 +321,6 @@ export const IssueSelector = observer(function IssueSelector({ ); }); -export function issueToStringLabel(issue: LinkedIssue | null): string { - if (!issue) return ''; - const identifier = issue.displayIdentifier === null ? null : issue.displayIdentifier; - return identifier ? `${identifier} ${issue.title}` : issue.title; -} - -export function getVisibleIssues( - issues: LinkedIssue[], - linkedIssueMap: Map, - opts: { excludeLinkedIssues: boolean } -): LinkedIssue[] { - if (!opts.excludeLinkedIssues) return issues; - return issues.filter((issue) => !issue.url || !linkedIssueMap.has(issue.url)); -} - export function SelectedIssueValue({ issue }: { issue: LinkedIssue }) { return (
diff --git a/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/issue-combobox-field.tsx b/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/issue-combobox-field.tsx index b2c86af9aa..08f42bd6f6 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/issue-combobox-field.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/issue-combobox-field.tsx @@ -64,7 +64,6 @@ export function IssueComboboxField({ projectId={projectId} repositoryUrl={repositoryUrl} projectPath={projectPath} - excludeLinkedIssues disabled={disabled} renderSelectedValue={(issue) => (
{ ).toBeNull(); }); - it('uses the Notion page title when Notion only provides a page ID', () => { - expect( - getIssueTaskName({ - provider: 'notion', - url: 'https://www.notion.so/example-page', - title: 'Write customer onboarding spec', - identifier: '37818d1b-a831-813a-b25f-f61cc3db1a08', - displayIdentifier: null, - }) - ).toBe('write-customer-onboarding-spec'); - }); - it('applies existing task-name length limits', () => { expect( getIssueTaskName({ diff --git a/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/issue-task-name.ts b/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/issue-task-name.ts index ab699bd213..e6359a42c6 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/issue-task-name.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/issue-task-name.ts @@ -10,11 +10,6 @@ export function getIssueTaskName( } const branchName = issue.branchName?.trim(); - if (!branchName && issue.provider === 'notion') { - const normalized = normalizeTaskName(issue.title, options); - return normalized || null; - } - if (!branchName) { return null; } diff --git a/apps/emdash-desktop/src/renderer/features/tasks/stores/task-selectors.ts b/apps/emdash-desktop/src/renderer/features/tasks/stores/task-selectors.ts index 00dd5b0147..f1001685ca 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/stores/task-selectors.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/stores/task-selectors.ts @@ -142,32 +142,10 @@ export function getTerminalsForTask(taskId: string) { return terminalRegistry.get(taskId); } -const UUID_LIKE_PATTERN = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -function isUuidLike(value: string): boolean { - return UUID_LIKE_PATTERN.test(value); -} - /** Returns the display name from any task store variant. */ export function taskDisplayName(store: TaskStore | undefined): string | undefined { if (!store) return undefined; - - const name = store.data.name; - const linkedIssue = 'linkedIssue' in store.data ? store.data.linkedIssue : undefined; - if (!linkedIssue) return name; - - const issueTitle = linkedIssue.title.trim(); - if (!issueTitle) return name; - - const issueIdentifier = linkedIssue.displayIdentifier ?? linkedIssue.identifier; - const nameIsIdentifier = - name === store.data.id || - name === linkedIssue.identifier || - name === issueIdentifier || - isUuidLike(name); - - return nameIsIdentifier ? `${issueTitle} · ${name}` : name; + return store.data.name; } /** Returns the error message for error states. */ diff --git a/apps/emdash-desktop/src/renderer/features/tasks/task-config/initial-conversation-section.test.ts b/apps/emdash-desktop/src/renderer/features/tasks/task-config/initial-conversation-section.test.ts index 937e316159..3469db6649 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/task-config/initial-conversation-section.test.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/task-config/initial-conversation-section.test.ts @@ -3,7 +3,6 @@ import React, { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { - issueMentionName, useInitialConversationState, type InitialConversationState, } from './initial-conversation-section'; @@ -191,29 +190,3 @@ describe('useInitialConversationState', () => { expect(latestState?.useChatUi).toBe(false); }); }); - -describe('issueMentionName', () => { - it('uses the issue title when provider identifiers are hidden', () => { - expect( - issueMentionName({ - provider: 'notion', - identifier: '9fa18d1b-a831-8231-ba19-0129d0000000', - displayIdentifier: null, - title: 'Jan', - url: 'https://www.notion.so/page', - }) - ).toBe('Jan'); - }); - - it('uses the display identifier when one exists', () => { - expect( - issueMentionName({ - provider: 'linear', - identifier: 'issue-id', - displayIdentifier: 'ENG-123', - title: 'Fix login', - url: 'https://linear.app/issue/ENG-123', - }) - ).toBe('ENG-123'); - }); -}); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/task-config/initial-conversation-section.tsx b/apps/emdash-desktop/src/renderer/features/tasks/task-config/initial-conversation-section.tsx index d88de6fd40..6e754cda83 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/task-config/initial-conversation-section.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/task-config/initial-conversation-section.tsx @@ -183,17 +183,12 @@ function toIssueMentionItem(issue: LinkedIssue): MentionItem { return toLinkedIssueMentionItem(issue, true); } -export function issueMentionName(issue: LinkedIssue): string { - if (issue.displayIdentifier === null) return issue.title; - return issue.displayIdentifier ?? issue.identifier; -} - function toLinkedIssueMentionItem(issue: LinkedIssue, pending = false): MentionItem { const token = issueMentionToken(issue.provider, issue.identifier); return { id: token, label: token, - name: issueMentionName(issue), + name: issue.displayIdentifier ?? issue.identifier, kind: 'issue', description: issue.title, icon: , 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 2acdf9f498..0eacce925a 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/task-titlebar.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/task-titlebar.tsx @@ -72,7 +72,6 @@ const PendingTaskTitlebar = observer(function PendingTaskTitlebar({ const taskStore = getTaskStore(projectId, taskId); const projectName = projectDisplayName(getProjectStore(projectId)); const name = taskDisplayName(taskStore); - const taskLabel = pendingTaskLabel(name, taskId); const { navigate } = useNavigate(); return ( @@ -88,9 +87,7 @@ const PendingTaskTitlebar = observer(function PendingTaskTitlebar({ {projectName} / - - {taskLabel} - + {name}
} @@ -98,12 +95,6 @@ const PendingTaskTitlebar = observer(function PendingTaskTitlebar({ ); }); -function pendingTaskLabel(name: string | undefined, taskId: string): string { - if (!name) return taskId; - if (name === taskId || name.endsWith(` · ${taskId}`)) return name; - return `${name} · ${taskId}`; -} - const ActiveTaskTitlebar = observer(function ActiveTaskTitlebar({ projectId, taskId, diff --git a/apps/emdash-desktop/src/shared/telemetry.ts b/apps/emdash-desktop/src/shared/telemetry.ts index bd18921268..d1a138da41 100644 --- a/apps/emdash-desktop/src/shared/telemetry.ts +++ b/apps/emdash-desktop/src/shared/telemetry.ts @@ -131,8 +131,8 @@ export type TelemetryEventProperties = { user_signed_in: EmptyProps; user_signed_out: EmptyProps; - integration_connected: { provider: string }; - integration_disconnected: { provider: string }; + integration_connected: { provider: 'github' | 'linear' | 'jira' | 'asana' }; + integration_disconnected: { provider: 'github' | 'linear' | 'jira' | 'asana' }; issue_linked_to_task: { provider: | 'github' From 90e86e27e4e2a659a20b1f255e7dee60e5cf8fd6 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:16:08 +0200 Subject: [PATCH 10/16] refactor(issues): keep provider registry typed --- apps/emdash-desktop/src/main/core/issues/registry.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/emdash-desktop/src/main/core/issues/registry.ts b/apps/emdash-desktop/src/main/core/issues/registry.ts index d07faf9a4f..da27210ae1 100644 --- a/apps/emdash-desktop/src/main/core/issues/registry.ts +++ b/apps/emdash-desktop/src/main/core/issues/registry.ts @@ -1,9 +1,10 @@ import { issuesPluginRegistry } from '@emdash/plugins/issues'; import { createGitHubPluginIssueProvider } from '@main/core/github/github-plugin-issue-provider'; import { createPluginIssueProvider } from '@main/core/integrations/plugin-issue-provider'; +import type { IssueProviderType } from '@shared/issue-providers'; import type { IssueProvider } from './issue-provider'; -const providers = new Map(); +const providers = new Map(); function register(provider: IssueProvider) { providers.set(provider.type, provider); @@ -17,7 +18,7 @@ for (const plugin of issuesPluginRegistry.getAll()) { register(provider); } -export function getIssueProvider(type: string): IssueProvider | undefined { +export function getIssueProvider(type: IssueProviderType): IssueProvider | undefined { return providers.get(type); } From ce14ca41d6345cdab8d5a115115c18f20b65afb2 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:17:49 +0200 Subject: [PATCH 11/16] refactor(integrations): keep settings card typed --- .../settings/components/IntegrationsCard.tsx | 85 ++++++++++--------- 1 file changed, 44 insertions(+), 41 deletions(-) diff --git a/apps/emdash-desktop/src/renderer/features/settings/components/IntegrationsCard.tsx b/apps/emdash-desktop/src/renderer/features/settings/components/IntegrationsCard.tsx index c2df561618..608f03bf83 100644 --- a/apps/emdash-desktop/src/renderer/features/settings/components/IntegrationsCard.tsx +++ b/apps/emdash-desktop/src/renderer/features/settings/components/IntegrationsCard.tsx @@ -1,4 +1,5 @@ import React, { useMemo, useState } from 'react'; +import { isIssueIntegration } from '@renderer/features/integrations/integration-display'; import { useIntegrationsContext } from '@renderer/features/integrations/integrations-provider'; import { sortGitHubAccountsByDefault } from '@renderer/features/projects/components/github-account-select-model'; import { useGitHubAccounts } from '@renderer/lib/hooks/useGithubAccounts'; @@ -6,12 +7,12 @@ import { useShowModal } from '@renderer/lib/modal/modal-provider'; import { Sheet, SheetContent } from '@renderer/lib/ui/sheet'; import { TooltipProvider } from '@renderer/lib/ui/tooltip'; import type { AgentIconAsset } from '@shared/core/agents/agent-payload'; -import type { ConnectionStatus } from '@shared/issue-providers'; +import type { ConnectionStatus, IssueProviderType } from '@shared/issue-providers'; import { IntegrationDetailSidebar } from './IntegrationDetailSidebar'; import { IntegrationGridCard } from './IntegrationGridCard'; export type IntegrationItem = { - id: string; + id: IssueProviderType; name: string; description: string; icon: AgentIconAsset; @@ -40,7 +41,7 @@ const IntegrationsCard: React.FC = () => { () => sortGitHubAccountsByDefault(githubAccounts), [githubAccounts] ); - const [selectedProvider, setSelectedProvider] = useState(null); + const [selectedProvider, setSelectedProvider] = useState(null); const showIntegrationSetup = useShowModal('integrationSetupModal'); const showConnectGitHub = useShowModal('githubConnectModal'); const showConfirm = useShowModal('confirmActionModal'); @@ -66,17 +67,35 @@ const IntegrationsCard: React.FC = () => { }); }; - const integrations: IntegrationItem[] = integrationMetadata.map((integration) => { - const provider = integration.id; - const status: ConnectionStatus = connectionStatus[provider] ?? { - connected: false, - capabilities: integration.capabilities, - }; - const isConfigured = configuredConnections[provider] ?? false; - const isConfigurationKnown = - provider in configuredConnections || !isCheckingConfiguredConnections; + const integrations: IntegrationItem[] = integrationMetadata + .filter(isIssueIntegration) + .map((integration) => { + const provider = integration.id; + const status: ConnectionStatus = connectionStatus[provider] ?? { + connected: false, + capabilities: integration.capabilities, + }; + const isConfigured = configuredConnections[provider] ?? false; + const isConfigurationKnown = + provider in configuredConnections || !isCheckingConfiguredConnections; + + if (provider === 'github') { + return { + id: provider, + name: integration.name, + description: integration.description, + icon: integration.icon, + features: integration.features, + isConfigured, + isConfigurationKnown, + isMutating: false, + connectionError: isConfigured ? status.error : undefined, + displayName: sortedGithubAccounts[0]?.login ?? status.displayName, + displayDetail: status.displayDetail, + onConnect: () => showConnectGitHub({}), + }; + } - if (provider === 'github') { return { id: provider, name: integration.name, @@ -85,37 +104,21 @@ const IntegrationsCard: React.FC = () => { features: integration.features, isConfigured, isConfigurationKnown, - isMutating: false, + isMutating: isIntegrationMutating(provider), connectionError: isConfigured ? status.error : undefined, - displayName: sortedGithubAccounts[0]?.login ?? status.displayName, + displayName: status.displayName, displayDetail: status.displayDetail, - onConnect: () => showConnectGitHub({}), + onConnect: () => showIntegrationSetup({ integration: provider }), + onDisconnect: () => + confirmDisconnect({ + name: integration.name, + credential: integration.disconnectCredentialLabel, + onDisconnect: () => { + void disconnectIntegration(provider); + }, + }), }; - } - - return { - id: provider, - name: integration.name, - description: integration.description, - icon: integration.icon, - features: integration.features, - isConfigured, - isConfigurationKnown, - isMutating: isIntegrationMutating(provider), - connectionError: isConfigured ? status.error : undefined, - displayName: status.displayName, - displayDetail: status.displayDetail, - onConnect: () => showIntegrationSetup({ integration: provider }), - onDisconnect: () => - confirmDisconnect({ - name: integration.name, - credential: integration.disconnectCredentialLabel, - onDisconnect: () => { - void disconnectIntegration(provider); - }, - }), - }; - }); + }); const connectedIntegrations = integrations.filter((integration) => integration.isConfigured); const availableIntegrations = integrations.filter( From 75733fb3248d2acce6001777ca2a1fbbf2ca43f3 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:19:01 +0200 Subject: [PATCH 12/16] refactor(notion): keep existing mcp icon --- apps/emdash-desktop/src/assets/images/mcp/notion.svg | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/emdash-desktop/src/assets/images/mcp/notion.svg b/apps/emdash-desktop/src/assets/images/mcp/notion.svg index 2153f4d6d1..34953987b4 100644 --- a/apps/emdash-desktop/src/assets/images/mcp/notion.svg +++ b/apps/emdash-desktop/src/assets/images/mcp/notion.svg @@ -1 +1,4 @@ -Notion + +Notion + + From e77f4ca38fed8f0cc4f0dbb4efc89849651797e5 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:50:46 +0200 Subject: [PATCH 13/16] fix(tasks): hide internal issue IDs --- .../conversations/acp/acp-chat-panel.tsx | 4 +- .../issue-selector/issue-selector.tsx | 5 +-- .../initial-conversation-section.tsx | 4 +- .../renderer/features/tasks/task-titlebar.tsx | 5 +-- .../src/shared/core/linked-issue.test.ts | 41 +++++++++++++++++++ .../src/shared/core/linked-issue.ts | 12 ++++++ 6 files changed, 61 insertions(+), 10 deletions(-) create mode 100644 apps/emdash-desktop/src/shared/core/linked-issue.test.ts 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/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-config/initial-conversation-section.tsx b/apps/emdash-desktop/src/renderer/features/tasks/task-config/initial-conversation-section.tsx index 6e754cda83..bd6a660a66 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/task-config/initial-conversation-section.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/task-config/initial-conversation-section.tsx @@ -42,7 +42,7 @@ import { issueMentionToken, parseIssueMentionToken, } from '@shared/core/issues/issue-context'; -import type { LinkedIssue } from '@shared/core/linked-issue'; +import { linkedIssueMentionName, type LinkedIssue } from '@shared/core/linked-issue'; import type { IssueProviderType } from '@shared/issue-providers'; import { buildIssueContextText } from '../context-bar/context-actions'; import { appendInitialConversationText } from '../create-task-modal/initial-conversation-text'; @@ -188,7 +188,7 @@ function toLinkedIssueMentionItem(issue: LinkedIssue, pending = false): MentionI 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/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 85eb9d834a..66156e7b1c 100644 --- a/apps/emdash-desktop/src/shared/core/linked-issue.ts +++ b/apps/emdash-desktop/src/shared/core/linked-issue.ts @@ -55,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'); +} From bb5c683e5e357eb407e80e020570f00504203507 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:03:31 +0200 Subject: [PATCH 14/16] fix(notion): align setup help link --- .../integrations/integration-setup-modal.tsx | 27 +++++++++---------- .../src/integrations/impl/notion/index.ts | 2 +- 2 files changed, 13 insertions(+), 16 deletions(-) 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/packages/plugins/src/integrations/impl/notion/index.ts b/packages/plugins/src/integrations/impl/notion/index.ts index e7a93a6d16..6f900d8280 100644 --- a/packages/plugins/src/integrations/impl/notion/index.ts +++ b/packages/plugins/src/integrations/impl/notion/index.ts @@ -24,7 +24,7 @@ const plugin = defineIntegrationPlugin( placeholder: 'ntn_…', }, ], - help: 'Create a Notion internal integration token or personal access token, then share the pages you want Emdash to access with that connection.', + 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', }, ], From 71dc05a9e317fd43148bcaa14c8077e4a6bd0126 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:19:34 +0200 Subject: [PATCH 15/16] fix(notion): paginate issue context --- .../plugins/src/issues/impl/notion/context.ts | 54 +++++-- .../src/issues/impl/notion/index.test.ts | 134 +++++++++++++++++- .../plugins/src/issues/impl/notion/index.ts | 48 +++++-- 3 files changed, 211 insertions(+), 25 deletions(-) diff --git a/packages/plugins/src/issues/impl/notion/context.ts b/packages/plugins/src/issues/impl/notion/context.ts index b74078117b..91604463e4 100644 --- a/packages/plugins/src/issues/impl/notion/context.ts +++ b/packages/plugins/src/issues/impl/notion/context.ts @@ -15,32 +15,60 @@ function blockToMarkdown( switch (block.type) { case 'paragraph': - return richTextPlainText(block.paragraph.rich_text); + return richTextPlainText('paragraph' in block ? block.paragraph?.rich_text : undefined); case 'heading_1': - return heading('#', richTextPlainText(block.heading_1.rich_text)); + return heading( + '#', + richTextPlainText('heading_1' in block ? block.heading_1?.rich_text : undefined) + ); case 'heading_2': - return heading('##', richTextPlainText(block.heading_2.rich_text)); + return heading( + '##', + richTextPlainText('heading_2' in block ? block.heading_2?.rich_text : undefined) + ); case 'heading_3': - return heading('###', richTextPlainText(block.heading_3.rich_text)); + return heading( + '###', + richTextPlainText('heading_3' in block ? block.heading_3?.rich_text : undefined) + ); case 'heading_4': - return heading('####', richTextPlainText(block.heading_4.rich_text)); + return heading( + '####', + richTextPlainText('heading_4' in block ? block.heading_4?.rich_text : undefined) + ); case 'bulleted_list_item': - return listItem('-', richTextPlainText(block.bulleted_list_item.rich_text)); + return listItem( + '-', + richTextPlainText( + 'bulleted_list_item' in block ? block.bulleted_list_item?.rich_text : undefined + ) + ); case 'numbered_list_item': - return listItem('1.', richTextPlainText(block.numbered_list_item.rich_text)); + 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) + `[${block.to_do?.checked ? 'x' : ' '}]`, + richTextPlainText(block.to_do?.rich_text) ); case 'quote': - return quote(richTextPlainText(block.quote.rich_text)); + return quote(richTextPlainText('quote' in block ? block.quote?.rich_text : undefined)); case 'code': - return codeBlock(block.code.language, richTextPlainText(block.code.rich_text)); + if (!('code' in block)) return undefined; + return codeBlock(block.code?.language ?? '', richTextPlainText(block.code?.rich_text)); case 'child_page': - return `Child page: ${block.child_page.title}`; + return 'child_page' in block && block.child_page?.title + ? `Child page: ${block.child_page.title}` + : undefined; case 'child_database': - return `Child database: ${block.child_database.title}`; + return 'child_database' in block && block.child_database?.title + ? `Child database: ${block.child_database.title}` + : undefined; default: return undefined; } diff --git a/packages/plugins/src/issues/impl/notion/index.test.ts b/packages/plugins/src/issues/impl/notion/index.test.ts index faec8d52a9..34e60e9c08 100644 --- a/packages/plugins/src/issues/impl/notion/index.test.ts +++ b/packages/plugins/src/issues/impl/notion/index.test.ts @@ -5,6 +5,8 @@ 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(), })); @@ -13,6 +15,8 @@ vi.mock('@notionhq/client', async (importOriginal) => { return { ...actual, Client: class { + blocks = { children: { list: notionSdk.blocksChildrenList } }; + pages = { retrieve: notionSdk.pagesRetrieve }; search = notionSdk.search; }, }; @@ -20,6 +24,8 @@ vi.mock('@notionhq/client', async (importOriginal) => { 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' } }; @@ -76,6 +82,34 @@ function notionPage( } 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(); @@ -100,7 +134,43 @@ describe('notion issues plugin', () => { expect(notionSdk.search).toHaveBeenCalledWith({ filter: { property: 'object', value: 'page' }, sort: { timestamp: 'last_edited_time', direction: 'descending' }, - page_size: 50, + 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, @@ -131,4 +201,66 @@ describe('notion issues plugin', () => { 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 index 07d3bd2082..3440b21193 100644 --- a/packages/plugins/src/issues/impl/notion/index.ts +++ b/packages/plugins/src/issues/impl/notion/index.ts @@ -1,5 +1,10 @@ import { err, ok } from '@emdash/shared'; -import { isFullPage, type PageObjectResponse } from '@notionhq/client'; +import { + isFullPage, + type BlockObjectResponse, + type PageObjectResponse, + type PartialBlockObjectResponse, +} from '@notionhq/client'; import type { ConnectedIntegrationHostContext } from '../../../integrations/host'; import { createNotionClient, @@ -18,6 +23,8 @@ import type { import { formatNotionContext } from './context'; import { hasMeaningfulTitle, isDatabasePage, toIssueData } from './mapper'; +const NOTION_PAGE_SIZE = 100; + export async function listIssues( host: ConnectedIntegrationHostContext, opts: IssueQueryOpts @@ -29,14 +36,21 @@ export async function listIssues( const limit = clampIssueLimit(opts.limit, 50, 100); try { - const response = await client.search({ - filter: { property: 'object', value: 'page' }, - sort: { timestamp: 'last_edited_time', direction: 'descending' }, - page_size: limit, - }); - return ok( - toIssueListItems(response.results.filter(isFullPage).filter(isDatabasePage)) - ); + 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.')); @@ -88,8 +102,20 @@ export async function getIssue( }); } - const blocks = await client.blocks.children.list({ block_id: page.id, page_size: 50 }); - return ok({ ...toIssueData(page), context: formatNotionContext(blocks.results) }); + 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.')); From ad7b25076961b96fdbbc9772750b9f5081546d5b Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:07:14 -0700 Subject: [PATCH 16/16] chore: small cleanup --- .../src/integrations/impl/notion/client.ts | 18 +++++------------- .../plugins/src/issues/impl/notion/index.ts | 6 +----- .../plugins/src/issues/impl/notion/mapper.ts | 4 ++++ 3 files changed, 10 insertions(+), 18 deletions(-) diff --git a/packages/plugins/src/integrations/impl/notion/client.ts b/packages/plugins/src/integrations/impl/notion/client.ts index ece115699f..ecc591894f 100644 --- a/packages/plugins/src/integrations/impl/notion/client.ts +++ b/packages/plugins/src/integrations/impl/notion/client.ts @@ -1,5 +1,5 @@ import { err, ok, type Result } from '@emdash/shared'; -import { Client, type UserObjectResponse } from '@notionhq/client'; +import { Client } from '@notionhq/client'; import { parseCredentials } from '../../helpers/credentials'; import type { IntegrationCredentials } from '../../host'; import type { IntegrationError } from '../../types'; @@ -30,22 +30,14 @@ export async function verifyNotionCredentials( const client = createNotionClient(credentials.data); try { const user = await client.users.me({}); - const detail = displayDetail(user); + const displayName = user.name ?? (user.type === 'bot' ? 'Notion bot' : 'Notion user'); + const displayDetail = user.type === 'person' ? user.person.email : undefined; return ok({ - displayName: displayName(user), - ...(detail ? { displayDetail: detail } : {}), + displayName, + ...(displayDetail ? { displayDetail } : {}), credentials: credentials.data, }); } catch (error) { return err(toNotionIntegrationError(error, 'Failed to validate Notion token.')); } } - -function displayName(user: UserObjectResponse): string { - return user.name ?? (user.type === 'bot' ? 'Notion bot' : 'Notion user'); -} - -function displayDetail(user: UserObjectResponse): string | undefined { - if (user.type === 'person') return user.person.email; - return undefined; -} diff --git a/packages/plugins/src/issues/impl/notion/index.ts b/packages/plugins/src/issues/impl/notion/index.ts index 3440b21193..bd1eff5d43 100644 --- a/packages/plugins/src/issues/impl/notion/index.ts +++ b/packages/plugins/src/issues/impl/notion/index.ts @@ -21,7 +21,7 @@ import type { IssueSearchOpts, } from '../../types'; import { formatNotionContext } from './context'; -import { hasMeaningfulTitle, isDatabasePage, toIssueData } from './mapper'; +import { hasMeaningfulTitle, isDatabasePage, toIssueData, toIssueListItems } from './mapper'; const NOTION_PAGE_SIZE = 100; @@ -127,7 +127,3 @@ const plugin = defineIssuesPlugin({ integrationId: 'notion' }, { issues: {} }, { export const provider = registerIssuesPluginBehavior(plugin, { issues: { listIssues, searchIssues, getIssue }, }); - -function toIssueListItems(pages: PageObjectResponse[]) { - return pages.filter(hasMeaningfulTitle).map(toIssueData); -} diff --git a/packages/plugins/src/issues/impl/notion/mapper.ts b/packages/plugins/src/issues/impl/notion/mapper.ts index 1eeb900a49..6ff54fbe92 100644 --- a/packages/plugins/src/issues/impl/notion/mapper.ts +++ b/packages/plugins/src/issues/impl/notion/mapper.ts @@ -95,3 +95,7 @@ export function richTextPlainText(richText: RichTextItemResponse[] | undefined): .join('') .trim(); } + +export function toIssueListItems(pages: PageObjectResponse[]) { + return pages.filter(hasMeaningfulTitle).map(toIssueData); +}