diff --git a/apps/emdash-desktop/src/main/core/issues/registry.ts b/apps/emdash-desktop/src/main/core/issues/registry.ts index 4de7259cf4..bfc53cb9d2 100644 --- a/apps/emdash-desktop/src/main/core/issues/registry.ts +++ b/apps/emdash-desktop/src/main/core/issues/registry.ts @@ -6,6 +6,7 @@ import { gitlabIssueProvider } from '@main/core/gitlab/gitlab-issue-provider'; import { jiraIssueProvider } from '@main/core/jira/jira-issue-provider'; import { linearIssueProvider } from '@main/core/linear/linear-issue-provider'; import { mondayIssueProvider } from '@main/core/monday/monday-issue-provider'; +import { notionIssueProvider } from '@main/core/notion/notion-issue-provider'; import { plainIssueProvider } from '@main/core/plain/plain-issue-provider'; import { planeIssueProvider } from '@main/core/plane/plane-issue-provider'; import { trelloIssueProvider } from '@main/core/trello/trello-issue-provider'; @@ -29,6 +30,7 @@ register(plainIssueProvider); register(asanaIssueProvider); register(mondayIssueProvider); register(trelloIssueProvider); +register(notionIssueProvider); export function getIssueProvider(type: IssueProviderType): IssueProvider | undefined { return providers.get(type); diff --git a/apps/emdash-desktop/src/main/core/notion/controller.ts b/apps/emdash-desktop/src/main/core/notion/controller.ts new file mode 100644 index 0000000000..ee30392a6c --- /dev/null +++ b/apps/emdash-desktop/src/main/core/notion/controller.ts @@ -0,0 +1,26 @@ +import { createRPCController } from '@shared/lib/ipc/rpc'; +import { notionConnectionService } from './notion-connection-service'; + +export const notionController = createRPCController({ + saveCredentials: async (input: { + token?: string; + databaseUrls: string; + preserveToken?: boolean; + }) => { + if ( + !input || + (input.token !== undefined && typeof input.token !== 'string') || + typeof input.databaseUrls !== 'string' || + (input.preserveToken !== undefined && typeof input.preserveToken !== 'boolean') + ) { + return { success: false, error: 'A Notion access token and scope URLs are required.' }; + } + return notionConnectionService.saveCredentials(input); + }, + + getConfiguration: async () => notionConnectionService.getConfiguration(), + + checkConnection: async () => notionConnectionService.checkConnection(), + + clearCredentials: async () => notionConnectionService.clearCredentials(), +}); diff --git a/apps/emdash-desktop/src/main/core/notion/notion-connection-service.test.ts b/apps/emdash-desktop/src/main/core/notion/notion-connection-service.test.ts new file mode 100644 index 0000000000..c5d5453efa --- /dev/null +++ b/apps/emdash-desktop/src/main/core/notion/notion-connection-service.test.ts @@ -0,0 +1,287 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockSetSecret = vi.fn(); +const mockGetSecret = vi.fn(); +const mockDeleteSecret = vi.fn(); + +vi.mock('@main/core/secrets/encrypted-app-secrets-store', () => ({ + encryptedAppSecretsStore: { + setSecret: (...args: unknown[]) => mockSetSecret(...args), + getSecret: (...args: unknown[]) => mockGetSecret(...args), + deleteSecret: (...args: unknown[]) => mockDeleteSecret(...args), + }, +})); + +vi.mock('@main/lib/logger', () => ({ + log: { error: vi.fn() }, +})); + +vi.mock('@main/lib/telemetry', () => ({ + telemetryService: { capture: vi.fn() }, +})); + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +import { + getNotionIssueErrorType, + NOTION_API_ERROR_MESSAGES, + NotionConnectionService, +} from './notion-connection-service'; + +function jsonResponse(body: unknown): { ok: boolean; json: () => Promise } { + return { ok: true, json: async () => body }; +} + +describe('NotionConnectionService', () => { + let service: NotionConnectionService; + + beforeEach(() => { + vi.resetAllMocks(); + service = new NotionConnectionService(); + }); + + describe('saveCredentials', () => { + it('validates token against Notion API and stores credentials', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ id: 'bot-1', name: 'Emdash', bot: { workspace_name: 'Acme' } }) + ); + + const input = { token: 'secret_token', databaseUrls: '' }; + const result = await service.saveCredentials(input); + + expect(result).toEqual({ success: true, displayName: 'Acme' }); + expect(mockSetSecret).toHaveBeenCalledWith( + 'emdash-notion-credentials', + JSON.stringify({ token: input.token, scope: { type: 'all-shared' } }) + ); + }); + + it('sends Notion auth and version headers', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ id: 'bot-1', name: 'Emdash' })); + + await service.saveCredentials({ token: 'secret_token', databaseUrls: '' }); + + const init = mockFetch.mock.calls[0][1] as RequestInit; + const headers = init.headers as Headers; + expect(mockFetch.mock.calls[0][0]).toBe('https://api.notion.com/v1/users/me'); + expect(headers.get('Authorization')).toBe('Bearer secret_token'); + expect(headers.get('Notion-Version')).toEqual(expect.any(String)); + }); + + it('parses and deduplicates data source IDs from URLs and raw IDs', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ id: 'bot-1', name: 'Emdash' })); + + const dataSourceId = 'abcdefabcdefabcdefabcdefabcdefab'; + const result = await service.saveCredentials({ + token: 'secret_token', + databaseUrls: `https://www.notion.so/acme/Roadmap-${dataSourceId}?v=123\n${dataSourceId}`, + }); + + expect(result.success).toBe(true); + expect(mockSetSecret).toHaveBeenCalledWith( + 'emdash-notion-credentials', + JSON.stringify({ + token: 'secret_token', + scope: { + type: 'data-sources', + dataSourceIds: [dataSourceId], + sourceUrls: [`https://www.notion.so/acme/Roadmap-${dataSourceId}?v=123`, dataSourceId], + }, + }) + ); + }); + + it('parses data source IDs from notion.com and app.notion.com URLs', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ id: 'bot-1', name: 'Emdash' })); + + const dataSourceId = 'abcdefabcdefabcdefabcdefabcdefab'; + const notionComUrl = `https://www.notion.com/acme/Roadmap-${dataSourceId}?v=123`; + const appNotionUrl = `https://app.notion.com/p/Roadmap-${dataSourceId}`; + const result = await service.saveCredentials({ + token: 'secret_token', + databaseUrls: `${notionComUrl}, ${appNotionUrl}`, + }); + + expect(result.success).toBe(true); + expect(mockSetSecret).toHaveBeenCalledWith( + 'emdash-notion-credentials', + JSON.stringify({ + token: 'secret_token', + scope: { + type: 'data-sources', + dataSourceIds: [dataSourceId], + sourceUrls: [notionComUrl, appNotionUrl], + }, + }) + ); + }); + + it('returns error for invalid database URL format', async () => { + const result = await service.saveCredentials({ + token: 'secret_token', + databaseUrls: 'https://example.com/not-notion', + }); + + expect(result).toEqual({ + success: false, + error: expect.stringContaining('Could not parse Notion ID'), + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('preserves the existing token when explicitly requested', async () => { + mockGetSecret.mockResolvedValueOnce( + JSON.stringify({ token: 'stored-token', scope: { type: 'all-shared' } }) + ); + mockFetch.mockResolvedValueOnce(jsonResponse({ id: 'bot-1', name: 'Emdash' })); + + const dataSourceId = 'abcdefabcdefabcdefabcdefabcdefab'; + const result = await service.saveCredentials({ + databaseUrls: dataSourceId, + preserveToken: true, + }); + + expect(result.success).toBe(true); + expect((mockFetch.mock.calls[0][1] as RequestInit).headers).toEqual(expect.any(Headers)); + expect( + ((mockFetch.mock.calls[0][1] as RequestInit).headers as Headers).get('Authorization') + ).toBe('Bearer stored-token'); + expect(mockSetSecret).toHaveBeenCalledWith( + 'emdash-notion-credentials', + JSON.stringify({ + token: 'stored-token', + scope: { + type: 'data-sources', + dataSourceIds: [dataSourceId], + sourceUrls: [dataSourceId], + }, + }) + ); + }); + + it('returns error for empty token', async () => { + const result = await service.saveCredentials({ token: ' ', databaseUrls: '' }); + + expect(result).toEqual({ + success: false, + error: 'Access token cannot be empty.', + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('returns a helpful authentication error when Notion rejects the token', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({}), + }); + + const result = await service.saveCredentials({ token: 'bad-token', databaseUrls: '' }); + + expect(result).toEqual({ + success: false, + error: NOTION_API_ERROR_MESSAGES.AUTH_FAILED, + }); + }); + }); + + describe('getStoredCredentials', () => { + it('defaults missing database scope to all shared pages', async () => { + mockGetSecret.mockResolvedValueOnce(JSON.stringify({ token: 'stored-token' })); + + const result = await service.getStoredCredentials(); + + expect(result).toEqual({ token: 'stored-token', scope: { type: 'all-shared' } }); + }); + + it('migrates legacy database scope to data source scope', async () => { + const dataSourceId = 'abcdefabcdefabcdefabcdefabcdefab'; + mockGetSecret.mockResolvedValueOnce( + JSON.stringify({ + token: 'stored-token', + databaseIds: [dataSourceId, dataSourceId], + databaseUrls: ['https://notion.so/example'], + }) + ); + + const result = await service.getStoredCredentials(); + + expect(result).toEqual({ + token: 'stored-token', + scope: { + type: 'data-sources', + dataSourceIds: [dataSourceId], + sourceUrls: ['https://notion.so/example'], + }, + }); + }); + + it('returns null for invalid stored credential shapes', async () => { + mockGetSecret.mockResolvedValueOnce(JSON.stringify({ token: 123 })); + + const result = await service.getStoredCredentials(); + + expect(result).toBeNull(); + }); + }); + + describe('getConfiguration', () => { + it('returns saved data source URLs as a single-line comma-separated value', async () => { + const firstUrl = 'https://www.notion.so/acme/Roadmap-abcdefabcdefabcdefabcdefabcdefab'; + const secondUrl = 'https://app.notion.com/p/Backlog-fedcbafedcbafedcbafedcbafedcbafe'; + mockGetSecret.mockResolvedValueOnce( + JSON.stringify({ + token: 'stored-token', + scope: { + type: 'data-sources', + dataSourceIds: ['abcdefabcdefabcdefabcdefabcdefab', 'fedcbafedcbafedcbafedcbafedcbafe'], + sourceUrls: [firstUrl, secondUrl], + }, + }) + ); + + const result = await service.getConfiguration(); + + expect(result).toEqual({ + hasCredentials: true, + databaseUrls: `${firstUrl}, ${secondUrl}`, + }); + }); + }); + + describe('clearCredentials', () => { + it('deletes stored credentials', async () => { + const result = await service.clearCredentials(); + + expect(result).toEqual({ success: true }); + expect(mockDeleteSecret).toHaveBeenCalledWith('emdash-notion-credentials'); + }); + }); + + describe('request', () => { + it('normalizes Notion database access errors into actionable messages', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({ + message: + 'Could not find database with ID: 94143f25-f30a-47ac-ac47-4a2d0692d2b0. Make sure the relevant pages and databases are shared with your integration "emdash".', + }), + }); + + let thrown: unknown; + try { + await service.request('token', '/data_sources/abc/query', { method: 'POST' }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(Error); + expect((thrown as Error).message).toBe( + 'Notion cannot access the configured data source. Share the page or database with emdash, or update the scope URLs in Emdash settings.' + ); + expect(getNotionIssueErrorType(thrown)).toBe('not_found_or_no_access'); + }); + }); +}); diff --git a/apps/emdash-desktop/src/main/core/notion/notion-connection-service.ts b/apps/emdash-desktop/src/main/core/notion/notion-connection-service.ts new file mode 100644 index 0000000000..5d7ce60ab0 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/notion/notion-connection-service.ts @@ -0,0 +1,404 @@ +import { encryptedAppSecretsStore } from '@main/core/secrets/encrypted-app-secrets-store'; +import { log } from '@main/lib/logger'; +import { telemetryService } from '@main/lib/telemetry'; +import { + ISSUE_PROVIDER_CAPABILITIES, + type ConnectionStatus, + type IssueListError, +} from '@shared/issue-providers'; + +const NOTION_API_BASE_URL = 'https://api.notion.com/v1'; +const NOTION_VERSION = '2026-03-11'; +const CREDENTIALS_KEY = 'emdash-notion-credentials'; +const MAX_SELECTED_DATABASES = 50; + +export const NOTION_API_ERROR_MESSAGES = { + AUTH_FAILED: 'Notion authentication failed. Check your access token.', + MISSING_PERMISSIONS: + 'Notion token was accepted but is missing access to the selected pages or databases.', + RATE_LIMITED: 'Notion API rate limit exceeded. Please try again shortly.', + UNAVAILABLE: 'Notion API is temporarily unavailable. Please try again.', +} as const; + +export class NotionApiError extends Error { + constructor( + message: string, + readonly errorType: IssueListError['type'] + ) { + super(message); + this.name = 'NotionApiError'; + } +} + +export function getNotionIssueErrorType(error: unknown): IssueListError['type'] | undefined { + return error instanceof NotionApiError ? error.errorType : undefined; +} + +export type NotionPageScope = + | { type: 'all-shared' } + | { type: 'data-sources'; dataSourceIds: string[]; sourceUrls: string[] }; + +export type NotionCredentials = { + token: string; + scope: NotionPageScope; +}; + +type SaveCredentialsInput = { + token?: string; + databaseUrls: string; + preserveToken?: boolean; +}; + +export type NotionConfiguration = { + hasCredentials: boolean; + databaseUrls: string; +}; + +type NotionUser = { + id: string; + name?: string | null; + bot?: { owner?: { type?: string; workspace?: boolean }; workspace_name?: string | null }; +}; + +type StoredNotionCredentials = { + token?: unknown; + scope?: unknown; + databaseIds?: unknown; + databaseUrls?: unknown; +}; + +function uniqueStrings(values: unknown): string[] | null { + if (!Array.isArray(values) || values.some((value) => typeof value !== 'string')) { + return null; + } + return [...new Set(values)]; +} + +function normalizeStoredScope(candidate: StoredNotionCredentials): NotionPageScope | null { + if (candidate.scope && typeof candidate.scope === 'object') { + const scope = candidate.scope as Partial; + if (scope.type === 'all-shared') { + return { type: 'all-shared' }; + } + if (scope.type === 'data-sources') { + const dataSourceIds = uniqueStrings(scope.dataSourceIds); + const sourceUrls = uniqueStrings(scope.sourceUrls); + if (!dataSourceIds || !sourceUrls) return null; + return dataSourceIds.length + ? { type: 'data-sources', dataSourceIds, sourceUrls } + : { type: 'all-shared' }; + } + return null; + } + + const legacyDatabaseIds = uniqueStrings(candidate.databaseIds ?? []); + const legacyDatabaseUrls = uniqueStrings(candidate.databaseUrls ?? []); + if (!legacyDatabaseIds || !legacyDatabaseUrls) return null; + return legacyDatabaseIds.length + ? { + type: 'data-sources', + dataSourceIds: legacyDatabaseIds, + sourceUrls: legacyDatabaseUrls, + } + : { type: 'all-shared' }; +} + +function normalizeStoredCredentials(raw: unknown): NotionCredentials | null { + if (!raw || typeof raw !== 'object') return null; + + const candidate = raw as StoredNotionCredentials; + if (typeof candidate.token !== 'string' || !candidate.token.trim()) { + return null; + } + + const scope = normalizeStoredScope(candidate); + if (!scope) return null; + + return { + token: candidate.token, + scope, + }; +} + +function normalizeNotionApiMessage(message: string): string { + if (/Could not find (?:database|page|data source) with ID:/i.test(message)) { + const integrationMatch = message.match(/integration "([^"]+)"/i); + const integrationName = integrationMatch?.[1] ?? 'your Notion integration'; + return `Notion cannot access the configured data source. Share the page or database with ${integrationName}, or update the scope URLs in Emdash settings.`; + } + + if (/Make sure the relevant pages and databases are shared/i.test(message)) { + const integrationMatch = message.match(/integration "([^"]+)"/i); + const integrationName = integrationMatch?.[1] ?? 'your Notion integration'; + return `Share the relevant pages and databases with ${integrationName}, or update the scope URLs in Emdash settings.`; + } + + return message; +} + +function getNotionErrorType(status: number, apiMessage?: string): IssueListError['type'] { + if ( + apiMessage && + /Could not find (?:database|page|data source) with ID:|Make sure the relevant pages and databases are shared/i.test( + apiMessage + ) + ) { + return 'not_found_or_no_access'; + } + + if (status === 401) return 'auth_required'; + if (status === 403) return 'forbidden'; + if (status === 429) return 'rate_limited'; + if (status >= 500) return 'host_unreachable'; + return 'generic'; +} + +function toNotionApiError(status: number, apiMessage?: string): NotionApiError { + if (apiMessage) { + return new NotionApiError( + normalizeNotionApiMessage(apiMessage), + getNotionErrorType(status, apiMessage) + ); + } + + if (status === 401) { + return new NotionApiError(NOTION_API_ERROR_MESSAGES.AUTH_FAILED, 'auth_required'); + } + if (status === 403) { + return new NotionApiError(NOTION_API_ERROR_MESSAGES.MISSING_PERMISSIONS, 'forbidden'); + } + if (status === 429) { + return new NotionApiError(NOTION_API_ERROR_MESSAGES.RATE_LIMITED, 'rate_limited'); + } + if (status >= 500) { + return new NotionApiError(NOTION_API_ERROR_MESSAGES.UNAVAILABLE, 'host_unreachable'); + } + + return new NotionApiError(`Notion API error (${status})`, 'generic'); +} + +function normalizeNotionId(value: string): string | null { + const compact = value.replace(/-/g, '').trim(); + if (!/^[a-fA-F0-9]{32}$/.test(compact)) return null; + return compact.toLowerCase(); +} + +function isNotionUrlHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase(); + return ( + normalized === 'notion.so' || + normalized.endsWith('.notion.so') || + normalized === 'notion.com' || + normalized.endsWith('.notion.com') || + normalized === 'notion.site' || + normalized.endsWith('.notion.site') + ); +} + +function parseDatabaseIdFromUrl(rawUrl: string): string | null { + try { + const url = new URL(rawUrl); + if (!isNotionUrlHostname(url.hostname)) { + return null; + } + + const candidates = [ + ...url.pathname.split('/'), + ...url.searchParams.values(), + url.hash.replace(/^#/, ''), + ]; + + for (const candidate of candidates) { + const match = candidate.match(/[a-fA-F0-9]{32}/); + if (!match) continue; + const id = normalizeNotionId(match[0]); + if (id) return id; + } + + return null; + } catch { + return normalizeNotionId(rawUrl); + } +} + +export class NotionConnectionService { + private cachedCredentials: NotionCredentials | null | undefined = undefined; + + async saveCredentials( + input: SaveCredentialsInput + ): Promise<{ success: boolean; displayName?: string; error?: string }> { + const tokenInput = input.token?.trim(); + const existingCredentials = input.preserveToken ? await this.getStoredCredentials() : null; + const token = tokenInput || existingCredentials?.token || ''; + if (!token) { + return { + success: false, + error: input.preserveToken + ? 'No existing Notion access token was found. Enter an access token and try again.' + : 'Access token cannot be empty.', + }; + } + + const scope = this.parseDatabaseUrls(input.databaseUrls); + if (scope === null) { + return { + success: false, + error: + 'Could not parse Notion ID from one or more URLs. Paste Notion page, database, or data source URLs or 32-character IDs.', + }; + } + if (scope.type === 'data-sources' && scope.dataSourceIds.length > MAX_SELECTED_DATABASES) { + return { + success: false, + error: `Notion scope is limited to ${MAX_SELECTED_DATABASES} data sources. Remove some URLs and try again.`, + }; + } + + try { + const user = await this.fetchMe(token); + const credentials: NotionCredentials = { token, scope }; + await this.storeCredentials(credentials); + telemetryService.capture('integration_connected', { provider: 'notion' }); + return { success: true, displayName: user.bot?.workspace_name ?? user.name ?? 'Notion' }; + } catch (error) { + const message = + error instanceof Error + ? error.message + : 'Failed to validate Notion token. Please try again.'; + return { success: false, error: message }; + } + } + + async getConfiguration(): Promise { + const credentials = await this.getStoredCredentials(); + if (!credentials) { + return { hasCredentials: false, databaseUrls: '' }; + } + + return { + hasCredentials: true, + databaseUrls: + credentials.scope.type === 'data-sources' ? credentials.scope.sourceUrls.join(', ') : '', + }; + } + + async clearCredentials(): Promise<{ success: boolean; error?: string }> { + try { + await encryptedAppSecretsStore.deleteSecret(CREDENTIALS_KEY); + this.cachedCredentials = null; + telemetryService.capture('integration_disconnected', { provider: 'notion' }); + return { success: true }; + } catch (error) { + log.error('Failed to clear Notion credentials:', error); + return { + success: false, + error: 'Unable to remove Notion credentials from secure storage.', + }; + } + } + + async checkConnection(): Promise { + try { + const credentials = await this.getStoredCredentials(); + if (!credentials) { + return { connected: false, capabilities: ISSUE_PROVIDER_CAPABILITIES.notion }; + } + + const user = await this.fetchMe(credentials.token); + return { + connected: true, + displayName: user.bot?.workspace_name ?? user.name ?? 'Notion', + displayDetail: user.bot?.workspace_name && user.name ? user.name : undefined, + capabilities: ISSUE_PROVIDER_CAPABILITIES.notion, + }; + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to verify Notion connection.'; + return { connected: false, error: message, capabilities: ISSUE_PROVIDER_CAPABILITIES.notion }; + } + } + + async isConfigured(): Promise { + return !!(await this.getStoredCredentials()); + } + + async getStoredCredentials(): Promise { + if (this.cachedCredentials !== undefined) { + return this.cachedCredentials; + } + + try { + const raw = await encryptedAppSecretsStore.getSecret(CREDENTIALS_KEY); + if (!raw) { + this.cachedCredentials = null; + return null; + } + this.cachedCredentials = normalizeStoredCredentials(JSON.parse(raw)); + return this.cachedCredentials; + } catch (error) { + log.error('Failed to read Notion credentials from secure storage:', error); + return null; + } + } + + async request(token: string, path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + headers.set('Accept', 'application/json'); + headers.set('Content-Type', 'application/json'); + headers.set('Notion-Version', NOTION_VERSION); + headers.set('Authorization', `Bearer ${token}`); + + const response = await fetch(`${NOTION_API_BASE_URL}${path}`, { + ...init, + headers, + }); + + if (!response.ok) { + const body = await response.json().catch(() => null); + throw toNotionApiError(response.status, body?.message); + } + + return (await response.json()) as T; + } + + private parseDatabaseUrls(databaseUrls: string): NotionPageScope | null { + const raw = databaseUrls.trim(); + if (!raw) return { type: 'all-shared' }; + + const values = raw + .split(/[,\n]+/) + .map((s) => s.trim()) + .filter(Boolean); + const dataSourceIds = new Set(); + const sourceUrls = new Set(); + + for (const value of values) { + const id = parseDatabaseIdFromUrl(value); + if (!id) return null; + dataSourceIds.add(id); + sourceUrls.add(value); + } + + return { + type: 'data-sources', + dataSourceIds: [...dataSourceIds], + sourceUrls: [...sourceUrls], + }; + } + + private async fetchMe(token: string): Promise { + return this.request(token, '/users/me'); + } + + private async storeCredentials(credentials: NotionCredentials): Promise { + try { + await encryptedAppSecretsStore.setSecret(CREDENTIALS_KEY, JSON.stringify(credentials)); + this.cachedCredentials = credentials; + } catch (error) { + log.error('Failed to store Notion credentials:', error); + throw new Error('Unable to save Notion credentials securely.'); + } + } +} + +export const notionConnectionService = new NotionConnectionService(); diff --git a/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.test.ts b/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.test.ts new file mode 100644 index 0000000000..7862ff90b0 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.test.ts @@ -0,0 +1,407 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { notionConnectionService } from './notion-connection-service'; +import { notionIssueProvider } from './notion-issue-provider'; + +vi.mock('./notion-connection-service', () => ({ + notionConnectionService: { + getStoredCredentials: vi.fn(), + isConfigured: vi.fn(), + checkConnection: vi.fn(), + request: vi.fn(), + }, +})); + +const mockGetStoredCredentials = vi.mocked(notionConnectionService.getStoredCredentials); +const mockRequest = vi.mocked(notionConnectionService.request); + +const DATA_SOURCE_ID = 'abcdefabcdefabcdefabcdefabcdefab'; +const PAGE = { + object: 'page', + id: 'page-1', + url: 'https://www.notion.so/acme/Fix-login-bug-page-1', + parent: { type: 'data_source_id', data_source_id: DATA_SOURCE_ID }, + last_edited_time: '2026-05-20T10:00:00.000Z', + properties: { + Name: { type: 'title', title: [{ plain_text: 'Fix login bug' }] }, + Description: { type: 'rich_text', rich_text: [{ plain_text: 'Steps to reproduce...' }] }, + Status: { type: 'status', status: { name: 'In progress' } }, + Assignee: { type: 'people', people: [{ name: 'Jan' }] }, + }, +} as const; + +describe('notionIssueProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('listIssues', () => { + it('queries configured data sources directly', async () => { + const credentials = { + token: 'tok', + scope: { type: 'data-sources' as const, dataSourceIds: [DATA_SOURCE_ID], sourceUrls: [] }, + }; + mockGetStoredCredentials.mockResolvedValue(credentials); + mockRequest.mockResolvedValue({ + results: [PAGE], + has_more: false, + next_cursor: null, + }); + + const result = await notionIssueProvider.listIssues({ limit: 50 }); + + expect(result).toEqual({ + success: true, + issues: [ + expect.objectContaining({ + provider: 'notion', + identifier: PAGE.id, + title: 'Fix login bug', + url: PAGE.url, + description: 'Steps to reproduce...', + status: 'In progress', + assignees: ['Jan'], + }), + ], + }); + expect(mockRequest).toHaveBeenCalledWith( + credentials.token, + `/data_sources/${DATA_SOURCE_ID}/query`, + expect.objectContaining({ + method: 'POST', + body: expect.not.stringContaining('result_type'), + }) + ); + }); + + it('discovers shared data sources when no data source scope is configured', async () => { + const credentials = { token: 'tok', scope: { type: 'all-shared' as const } }; + mockGetStoredCredentials.mockResolvedValue(credentials); + mockRequest + .mockResolvedValueOnce({ + results: [ + { + object: 'data_source', + id: DATA_SOURCE_ID, + url: 'https://app.notion.com/p/source', + title: [{ plain_text: 'Goals' }], + }, + ], + has_more: false, + next_cursor: null, + }) + .mockResolvedValueOnce({ + results: [PAGE], + has_more: false, + next_cursor: null, + }); + + const result = await notionIssueProvider.listIssues({ limit: 50 }); + + expect(result).toEqual({ + success: true, + issues: [expect.objectContaining({ title: 'Fix login bug' })], + }); + expect(mockRequest).toHaveBeenCalledWith( + credentials.token, + '/search', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"value":"data_source"'), + }) + ); + expect(mockRequest).toHaveBeenCalledWith( + credentials.token, + `/data_sources/${DATA_SOURCE_ID}/query`, + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('merges shared pages with discovered data source pages', async () => { + const credentials = { token: 'tok', scope: { type: 'all-shared' as const } }; + const sharedPage = { + ...PAGE, + id: 'shared-page-1', + parent: { type: 'page_id' }, + properties: { + ...PAGE.properties, + Name: { type: 'title', title: [{ plain_text: 'Standalone plan' }] }, + }, + }; + mockGetStoredCredentials.mockResolvedValue(credentials); + mockRequest + .mockResolvedValueOnce({ + results: [ + { object: 'data_source', id: DATA_SOURCE_ID, url: 'https://app.notion.com/p/source' }, + ], + has_more: false, + next_cursor: null, + }) + .mockResolvedValueOnce({ + results: [PAGE], + has_more: false, + next_cursor: null, + }) + .mockResolvedValueOnce({ + results: [sharedPage], + has_more: false, + next_cursor: null, + }); + + const result = await notionIssueProvider.listIssues({ limit: 50 }); + + expect(result).toEqual({ + success: true, + issues: [ + expect.objectContaining({ title: 'Fix login bug' }), + expect.objectContaining({ title: 'Standalone plan' }), + ], + }); + expect(mockRequest).toHaveBeenLastCalledWith( + credentials.token, + '/search', + expect.objectContaining({ body: expect.stringContaining('"value":"page"') }) + ); + }); + + it('returns error when no credentials stored', async () => { + mockGetStoredCredentials.mockResolvedValue(null); + + const result = await notionIssueProvider.listIssues({ limit: 50 }); + + expect(result).toEqual({ success: false, error: 'Notion is not connected.' }); + }); + }); + + describe('searchIssues', () => { + it('returns no results for an empty search term without querying Notion', async () => { + const result = await notionIssueProvider.searchIssues({ searchTerm: ' ', limit: 20 }); + + expect(result).toEqual({ success: true, issues: [] }); + expect(mockGetStoredCredentials).not.toHaveBeenCalled(); + expect(mockRequest).not.toHaveBeenCalled(); + }); + + it('passes the search query to Notion', async () => { + const credentials = { token: 'tok', scope: { type: 'all-shared' as const } }; + mockGetStoredCredentials.mockResolvedValue(credentials); + mockRequest.mockResolvedValue({ + results: [PAGE], + has_more: false, + next_cursor: null, + }); + + const result = await notionIssueProvider.searchIssues({ searchTerm: 'login', limit: 20 }); + + expect(result).toEqual({ success: true, issues: [expect.any(Object)] }); + expect(mockRequest).toHaveBeenCalledWith( + credentials.token, + '/search', + expect.objectContaining({ body: expect.stringContaining('"query":"login"') }) + ); + }); + + it('searches configured data sources without global parent filtering', async () => { + const credentials = { + token: 'tok', + scope: { type: 'data-sources' as const, dataSourceIds: [DATA_SOURCE_ID], sourceUrls: [] }, + }; + mockGetStoredCredentials.mockResolvedValue(credentials); + mockRequest.mockResolvedValue({ + results: [ + PAGE, + { + ...PAGE, + id: 'page-2', + properties: { + ...PAGE.properties, + Name: { type: 'title', title: [{ plain_text: 'Write release notes' }] }, + }, + }, + ], + has_more: false, + next_cursor: null, + }); + + const result = await notionIssueProvider.searchIssues({ searchTerm: 'login', limit: 20 }); + + expect(result).toEqual({ + success: true, + issues: [expect.objectContaining({ identifier: PAGE.id })], + }); + expect(mockRequest).toHaveBeenCalledWith( + credentials.token, + `/data_sources/${DATA_SOURCE_ID}/query`, + expect.objectContaining({ method: 'POST' }) + ); + expect(mockRequest).not.toHaveBeenCalledWith(credentials.token, '/search', expect.anything()); + }); + + it('continues paginating configured data source search until it finds matches', async () => { + const credentials = { + token: 'tok', + scope: { type: 'data-sources' as const, dataSourceIds: [DATA_SOURCE_ID], sourceUrls: [] }, + }; + mockGetStoredCredentials.mockResolvedValue(credentials); + mockRequest + .mockResolvedValueOnce({ + results: [ + { + ...PAGE, + id: 'page-2', + properties: { + ...PAGE.properties, + Name: { type: 'title', title: [{ plain_text: 'Write release notes' }] }, + }, + }, + ], + has_more: true, + next_cursor: 'cursor-2', + }) + .mockResolvedValueOnce({ + results: [PAGE], + has_more: false, + next_cursor: null, + }); + + const result = await notionIssueProvider.searchIssues({ searchTerm: 'login', limit: 20 }); + + expect(result).toEqual({ + success: true, + issues: [expect.objectContaining({ identifier: PAGE.id })], + }); + expect(mockRequest).toHaveBeenLastCalledWith( + credentials.token, + `/data_sources/${DATA_SOURCE_ID}/query`, + expect.objectContaining({ body: expect.stringContaining('"start_cursor":"cursor-2"') }) + ); + }); + }); + + describe('getIssueContext', () => { + it('returns page metadata with block children as context', async () => { + const credentials = { token: 'tok', scope: { type: 'all-shared' as const } }; + mockGetStoredCredentials.mockResolvedValue(credentials); + mockRequest.mockResolvedValueOnce(PAGE).mockResolvedValueOnce({ + results: [ + { id: 'block-1', type: 'paragraph', paragraph: { rich_text: [{ plain_text: 'Body' }] } }, + { + id: 'block-2', + type: 'to_do', + to_do: { checked: true, rich_text: [{ plain_text: 'Verified locally' }] }, + }, + ], + }); + + const result = await notionIssueProvider.getIssueContext!({ identifier: PAGE.id }); + + expect(result).toEqual({ + success: true, + issue: expect.objectContaining({ + provider: 'notion', + identifier: PAGE.id, + title: 'Fix login bug', + context: expect.stringContaining('Body'), + }), + }); + if (result.success) { + expect(result.issue.context).toContain('- [x] Verified locally'); + } + expect(mockRequest).toHaveBeenCalledWith(credentials.token, `/pages/${PAGE.id}`); + expect(mockRequest).toHaveBeenCalledWith( + credentials.token, + `/blocks/${PAGE.id}/children?page_size=100` + ); + }); + + it('follows block children pagination when building context', async () => { + const credentials = { token: 'tok', scope: { type: 'all-shared' as const } }; + mockGetStoredCredentials.mockResolvedValue(credentials); + mockRequest + .mockResolvedValueOnce(PAGE) + .mockResolvedValueOnce({ + results: [ + { + id: 'block-1', + type: 'paragraph', + paragraph: { rich_text: [{ plain_text: 'First page body' }] }, + }, + ], + has_more: true, + next_cursor: 'cursor-2', + }) + .mockResolvedValueOnce({ + results: [ + { + id: 'block-2', + type: 'paragraph', + paragraph: { rich_text: [{ plain_text: 'Second page body' }] }, + }, + ], + has_more: false, + next_cursor: null, + }); + + const result = await notionIssueProvider.getIssueContext!({ identifier: PAGE.id }); + + expect(result).toEqual({ + success: true, + issue: expect.objectContaining({ + context: expect.stringContaining('First page body'), + }), + }); + if (result.success) { + expect(result.issue.context).toContain('Second page body'); + } + expect(mockRequest).toHaveBeenCalledWith( + credentials.token, + `/blocks/${PAGE.id}/children?page_size=100&start_cursor=cursor-2` + ); + }); + + it('includes nested child blocks when building context', async () => { + const credentials = { token: 'tok', scope: { type: 'all-shared' as const } }; + mockGetStoredCredentials.mockResolvedValue(credentials); + mockRequest + .mockResolvedValueOnce(PAGE) + .mockResolvedValueOnce({ + results: [ + { + id: 'block-parent', + type: 'toggle', + has_children: true, + toggle: { rich_text: [{ plain_text: 'Investigation notes' }] }, + }, + ], + has_more: false, + next_cursor: null, + }) + .mockResolvedValueOnce({ + results: [ + { + id: 'block-child', + type: 'paragraph', + paragraph: { rich_text: [{ plain_text: 'Nested reproduction detail' }] }, + }, + ], + has_more: false, + next_cursor: null, + }); + + const result = await notionIssueProvider.getIssueContext!({ identifier: PAGE.id }); + + expect(result).toEqual({ + success: true, + issue: expect.objectContaining({ + context: expect.stringContaining('Investigation notes'), + }), + }); + if (result.success) { + expect(result.issue.context).toContain('Nested reproduction detail'); + } + expect(mockRequest).toHaveBeenCalledWith( + credentials.token, + '/blocks/block-parent/children?page_size=100' + ); + }); + }); +}); diff --git a/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.ts b/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.ts new file mode 100644 index 0000000000..ad4fcac91a --- /dev/null +++ b/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.ts @@ -0,0 +1,466 @@ +import { mapWithConcurrency } from '@main/core/issues/helpers/map-with-concurrency'; +import { clampIssueLimit, normalizeSearchTerm } from '@main/core/issues/helpers/provider-inputs'; +import type { + IssueContextOpts, + IssueProvider, + IssueQueryOpts, + IssueSearchOpts, +} from '@main/core/issues/issue-provider'; +import type { LinkedIssue } from '@shared/core/linked-issue'; +import { + ISSUE_PROVIDER_CAPABILITIES, + type IssueContextResult, + type IssueListResult, +} from '@shared/issue-providers'; +import { + getNotionIssueErrorType, + notionConnectionService, + type NotionCredentials, +} from './notion-connection-service'; + +type NotionRichText = { plain_text?: string }; +type NotionProperty = { + type: string; + title?: NotionRichText[]; + rich_text?: NotionRichText[]; + status?: { name?: string | null } | null; + select?: { name?: string | null } | null; + people?: { name?: string | null }[]; + last_edited_time?: string; +}; + +type NotionPage = { + object: 'page'; + id: string; + url: string; + parent?: { type?: string; database_id?: string; data_source_id?: string }; + last_edited_time?: string; + properties: Record; +}; + +type NotionSearchResponse = { + results: (NotionPage | NotionDataSource)[]; + has_more: boolean; + next_cursor?: string | null; +}; + +type NotionDataSource = { + object: 'data_source'; + id: string; + url: string; + title?: NotionRichText[]; + parent?: { type?: string; database_id?: string }; +}; + +type NotionDataSourceQueryResponse = { + results: NotionPage[]; + has_more: boolean; + next_cursor?: string | null; +}; + +type NotionBlockChildrenResponse = { + results: NotionBlock[]; + has_more: boolean; + next_cursor?: string | null; +}; + +type NotionBlock = { + id: string; + type: string; + has_children?: boolean; + [key: string]: unknown; +}; + +const BLOCK_CONTEXT_PAGE_SIZE = 100; +const MAX_CONTEXT_BLOCKS = 300; +const MAX_CONTEXT_DEPTH = 3; +const NOTION_DATA_SOURCE_CONCURRENCY = 4; +const DATA_SOURCE_QUERY_PAGE_SIZE = 100; + +function plainText(value: NotionRichText[] | undefined): string | undefined { + const text = value + ?.map((part) => part.plain_text ?? '') + .join('') + .trim(); + return text || undefined; +} + +function findProperty( + page: NotionPage, + types: string[], + preferredNames: string[] = [] +): NotionProperty | undefined { + const entries = Object.entries(page.properties); + for (const name of preferredNames) { + const property = page.properties[name]; + if (property && types.includes(property.type)) return property; + } + + return entries.find(([, property]) => types.includes(property.type))?.[1]; +} + +function getTitle(page: NotionPage): string { + const property = findProperty(page, ['title']); + if (property?.type !== 'title') return 'Untitled Notion page'; + return plainText(property.title) ?? 'Untitled Notion page'; +} + +function getDescription(page: NotionPage): string | undefined { + const property = findProperty( + page, + ['rich_text'], + ['Description', 'Summary', 'Details', 'Context'] + ); + return property?.type === 'rich_text' ? plainText(property.rich_text) : undefined; +} + +function getStatus(page: NotionPage): string | undefined { + const property = findProperty(page, ['status', 'select'], ['Status', 'State']); + if (property?.type === 'status') return property.status?.name ?? undefined; + if (property?.type === 'select') return property.select?.name ?? undefined; + return undefined; +} + +function getAssignees(page: NotionPage): string[] | undefined { + const property = findProperty(page, ['people'], ['Assignee', 'Assignees', 'Owner']); + if (property?.type !== 'people') return undefined; + + const assignees = property.people?.map((person) => person.name).filter(Boolean) ?? []; + return assignees.length ? (assignees as string[]) : undefined; +} + +function toIssue(page: NotionPage, context?: string): LinkedIssue { + return { + provider: 'notion', + identifier: page.id, + title: getTitle(page), + url: page.url, + description: getDescription(page), + status: getStatus(page), + assignees: getAssignees(page), + updatedAt: page.last_edited_time, + fetchedAt: new Date().toISOString(), + context, + }; +} + +function sortByUpdatedAtDesc(issues: LinkedIssue[]): LinkedIssue[] { + return [...issues].sort( + (a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime() + ); +} + +function issueMatchesTerm(issue: LinkedIssue, term: string): boolean { + const normalizedTerm = term.toLowerCase(); + return [ + issue.title, + issue.description, + issue.status, + issue.project, + ...(issue.assignees ?? []), + ].some((value) => value?.toLowerCase().includes(normalizedTerm)); +} + +async function searchSharedPages( + credentials: NotionCredentials, + searchTerm: string | undefined, + limit: number +): Promise { + const pages: NotionPage[] = []; + let startCursor: string | undefined; + + do { + const body: Record = { + page_size: Math.min(100, limit), + filter: { property: 'object', value: 'page' }, + sort: { direction: 'descending', timestamp: 'last_edited_time' }, + }; + if (searchTerm) body.query = searchTerm; + if (startCursor) body.start_cursor = startCursor; + + const data = await notionConnectionService.request( + credentials.token, + '/search', + { method: 'POST', body: JSON.stringify(body) } + ); + + pages.push(...data.results.filter((result) => result.object === 'page')); + startCursor = data.next_cursor ?? undefined; + } while (startCursor && pages.length < limit); + + return pages.slice(0, limit); +} + +async function searchSharedDataSources( + credentials: NotionCredentials +): Promise { + const dataSources: NotionDataSource[] = []; + let startCursor: string | undefined; + + do { + const body: Record = { + page_size: 100, + filter: { property: 'object', value: 'data_source' }, + sort: { direction: 'descending', timestamp: 'last_edited_time' }, + }; + if (startCursor) body.start_cursor = startCursor; + + const data = await notionConnectionService.request( + credentials.token, + '/search', + { method: 'POST', body: JSON.stringify(body) } + ); + + dataSources.push(...data.results.filter((result) => result.object === 'data_source')); + startCursor = data.next_cursor ?? undefined; + } while (startCursor); + + return dataSources; +} + +async function queryDataSourcePageBatch( + token: string, + dataSourceId: string, + startCursor?: string +): Promise { + const body: Record = { + page_size: DATA_SOURCE_QUERY_PAGE_SIZE, + sorts: [{ direction: 'descending', timestamp: 'last_edited_time' }], + }; + if (startCursor) body.start_cursor = startCursor; + + return notionConnectionService.request( + token, + `/data_sources/${encodeURIComponent(dataSourceId)}/query`, + { method: 'POST', body: JSON.stringify(body) } + ); +} + +async function queryDataSourcePages( + token: string, + dataSourceId: string, + limit: number +): Promise { + const pages: NotionPage[] = []; + let startCursor: string | undefined; + + do { + const data = await queryDataSourcePageBatch(token, dataSourceId, startCursor); + pages.push(...data.results); + startCursor = data.next_cursor ?? undefined; + } while (startCursor && pages.length < limit); + + return pages.slice(0, limit); +} + +async function searchDataSourceIssues( + token: string, + dataSourceId: string, + searchTerm: string, + limit: number +): Promise { + const issues: LinkedIssue[] = []; + let startCursor: string | undefined; + + do { + const data = await queryDataSourcePageBatch(token, dataSourceId, startCursor); + for (const page of data.results) { + const issue = toIssue(page); + if (issueMatchesTerm(issue, searchTerm)) { + issues.push(issue); + if (issues.length >= limit) break; + } + } + startCursor = data.next_cursor ?? undefined; + } while (startCursor && issues.length < limit); + + return issues; +} + +function dedupeIssuesByIdentifier(issues: LinkedIssue[]): LinkedIssue[] { + return [...new Map(issues.map((issue) => [issue.identifier, issue])).values()]; +} + +function toIssueListError(error: unknown, fallback: string): IssueListResult { + return { + success: false, + error: error instanceof Error ? error.message : fallback, + errorType: getNotionIssueErrorType(error), + }; +} + +async function listScopedIssues( + credentials: NotionCredentials, + searchTerm: string | undefined, + limit: number +): Promise { + if (credentials.scope.type === 'all-shared') { + if (!searchTerm) { + const dataSources = await searchSharedDataSources(credentials); + const pagesBySource = await mapWithConcurrency( + dataSources, + NOTION_DATA_SOURCE_CONCURRENCY, + (dataSource) => queryDataSourcePages(credentials.token, dataSource.id, limit) + ); + const dataSourceIssues = pagesBySource.flat().map((page) => toIssue(page)); + const sharedPageIssues = (await searchSharedPages(credentials, undefined, limit)).map( + (page) => toIssue(page) + ); + return sortByUpdatedAtDesc( + dedupeIssuesByIdentifier([...dataSourceIssues, ...sharedPageIssues]) + ).slice(0, limit); + } + + const pages = await searchSharedPages(credentials, searchTerm, limit); + return pages.map((page) => toIssue(page)); + } + + if (searchTerm) { + const issuesBySource = await mapWithConcurrency( + credentials.scope.dataSourceIds, + NOTION_DATA_SOURCE_CONCURRENCY, + (dataSourceId) => searchDataSourceIssues(credentials.token, dataSourceId, searchTerm, limit) + ); + return sortByUpdatedAtDesc(dedupeIssuesByIdentifier(issuesBySource.flat())).slice(0, limit); + } + + const pagesBySource = await mapWithConcurrency( + credentials.scope.dataSourceIds, + NOTION_DATA_SOURCE_CONCURRENCY, + (dataSourceId) => queryDataSourcePages(credentials.token, dataSourceId, limit) + ); + const issues = pagesBySource.flat().map((page) => toIssue(page)); + return sortByUpdatedAtDesc(dedupeIssuesByIdentifier(issues)).slice(0, limit); +} + +async function listIssues(opts: IssueQueryOpts): Promise { + const credentials = await notionConnectionService.getStoredCredentials(); + if (!credentials) { + return { success: false, error: 'Notion is not connected.' }; + } + + const sanitizedLimit = clampIssueLimit(opts.limit, 50, 200); + + try { + const issues = await listScopedIssues(credentials, undefined, sanitizedLimit); + return { success: true, issues }; + } catch (error) { + return toIssueListError(error, 'Failed to fetch Notion pages.'); + } +} + +async function searchIssues(opts: IssueSearchOpts): Promise { + const term = normalizeSearchTerm(opts.searchTerm); + if (!term) { + return { success: true, issues: [] }; + } + + const credentials = await notionConnectionService.getStoredCredentials(); + if (!credentials) { + return { success: false, error: 'Notion is not connected.' }; + } + + const sanitizedLimit = clampIssueLimit(opts.limit, 20, 200); + + try { + const issues = await listScopedIssues(credentials, term, sanitizedLimit); + return { success: true, issues }; + } catch (error) { + return toIssueListError(error, 'Failed to search Notion pages.'); + } +} + +function getBlockText(block: NotionBlock): string | undefined { + const value = block[block.type]; + if (!value || typeof value !== 'object') return undefined; + + const richText = (value as { rich_text?: NotionRichText[] }).rich_text; + const text = plainText(richText); + if (!text) return undefined; + + if (block.type === 'to_do') { + const checked = (value as { checked?: boolean }).checked; + return `- [${checked ? 'x' : ' '}] ${text}`; + } + if (block.type === 'bulleted_list_item') return `- ${text}`; + if (block.type === 'numbered_list_item') return `1. ${text}`; + if (block.type === 'heading_1') return `# ${text}`; + if (block.type === 'heading_2') return `## ${text}`; + if (block.type === 'heading_3') return `### ${text}`; + return text; +} + +function blockChildrenPath(blockId: string, startCursor?: string): string { + const params = new URLSearchParams({ page_size: String(BLOCK_CONTEXT_PAGE_SIZE) }); + if (startCursor) params.set('start_cursor', startCursor); + return `/blocks/${encodeURIComponent(blockId)}/children?${params.toString()}`; +} + +async function collectBlockLines( + token: string, + blockId: string, + depth: number, + remainingBlocks: { count: number } +): Promise { + const lines: string[] = []; + let startCursor: string | undefined; + + do { + const data = await notionConnectionService.request( + token, + blockChildrenPath(blockId, startCursor) + ); + + for (const block of data.results) { + if (remainingBlocks.count <= 0) return lines; + remainingBlocks.count -= 1; + + const text = getBlockText(block); + if (text) lines.push(text); + + if (block.has_children && depth < MAX_CONTEXT_DEPTH && remainingBlocks.count > 0) { + lines.push(...(await collectBlockLines(token, block.id, depth + 1, remainingBlocks))); + } + } + + startCursor = data.next_cursor ?? undefined; + } while (startCursor && remainingBlocks.count > 0); + + return lines; +} + +async function fetchBlockContext(token: string, pageId: string): Promise { + const lines = await collectBlockLines(token, pageId, 0, { count: MAX_CONTEXT_BLOCKS }); + return lines.length ? lines.join('\n\n') : undefined; +} + +async function getIssueContext(opts: IssueContextOpts): Promise { + const credentials = await notionConnectionService.getStoredCredentials(); + if (!credentials) { + return { success: false, error: 'Notion is not connected.' }; + } + + try { + const page = await notionConnectionService.request( + credentials.token, + `/pages/${encodeURIComponent(opts.identifier)}` + ); + const context = await fetchBlockContext(credentials.token, page.id).catch(() => undefined); + return { success: true, issue: toIssue(page, context) }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch Notion page context.', + }; + } +} + +export const notionIssueProvider: IssueProvider = { + type: 'notion', + capabilities: ISSUE_PROVIDER_CAPABILITIES.notion, + isConfigured: () => notionConnectionService.isConfigured(), + checkConnection: () => notionConnectionService.checkConnection(), + listIssues, + searchIssues, + getIssueContext, +}; diff --git a/apps/emdash-desktop/src/main/rpc.ts b/apps/emdash-desktop/src/main/rpc.ts index 5f5df792ff..392b0fb927 100644 --- a/apps/emdash-desktop/src/main/rpc.ts +++ b/apps/emdash-desktop/src/main/rpc.ts @@ -19,6 +19,7 @@ import { jiraController } from './core/jira/controller'; import { linearController } from './core/linear/controller'; import { mcpController } from './core/mcp/controller'; import { mondayController } from './core/monday/controller'; +import { notionController } from './core/notion/controller'; import { plainController } from './core/plain/controller'; import { planeController } from './core/plane/controller'; import { previewServersController } from './core/preview-servers/controller'; @@ -64,6 +65,7 @@ export const rpcRouter = createRPCRouter({ jira: jiraController, linear: linearController, monday: mondayController, + notion: notionController, plane: planeController, plain: plainController, trello: trelloController, diff --git a/apps/emdash-desktop/src/renderer/features/integrations/NotionSetupForm.tsx b/apps/emdash-desktop/src/renderer/features/integrations/NotionSetupForm.tsx new file mode 100644 index 0000000000..67a33af412 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/integrations/NotionSetupForm.tsx @@ -0,0 +1,96 @@ +import { useQuery } from '@tanstack/react-query'; +import { useState } from 'react'; +import { rpc } from '@renderer/lib/ipc'; +import { Input } from '@renderer/lib/ui/input'; +import { SetupFormShell, type SetupFormProps } from './SetupFormShell'; + +function NotionSetupForm(props: SetupFormProps) { + const { data: configuration, isPending } = useQuery({ + queryKey: ['notion:configuration'], + queryFn: () => rpc.notion.getConfiguration(), + staleTime: 0, + }); + + if (isPending) { + return ; + } + + return ( + + ); +} + +function NotionSetupFormLoading({ onSuccess, onClose }: SetupFormProps) { + return ( + ({ databaseUrls: '' })} + canSubmit={false} + onSuccess={onSuccess} + onClose={onClose} + > +
+ + +
+
+ ); +} + +function NotionSetupFormFields({ + onSuccess, + onClose, + hasCredentials, + initialDatabaseUrls, +}: SetupFormProps & { hasCredentials: boolean; initialDatabaseUrls: string }) { + const [token, setToken] = useState(''); + const [databaseUrls, setDatabaseUrls] = useState(initialDatabaseUrls); + const trimmedToken = token.trim(); + const isEditing = hasCredentials; + + return ( + ({ + token: trimmedToken || undefined, + databaseUrls: databaseUrls.trim(), + preserveToken: isEditing && !trimmedToken, + })} + canSubmit={isEditing || !!trimmedToken} + submitLabel={isEditing ? 'Save changes' : 'Connect'} + successTitle={isEditing ? 'Integration updated' : 'Integration connected'} + successDescription={ + isEditing ? 'Notion settings updated successfully.' : 'Integration set up successfully.' + } + onSuccess={onSuccess} + onClose={onClose} + > +
+ setToken(e.target.value)} + className="h-9 w-full" + autoFocus + /> + setDatabaseUrls(e.target.value)} + className="h-9 w-full" + /> +

+ Share pages or databases with your Notion connection. Leave URLs empty to search + everything shared. +

+
+
+ ); +} + +export default NotionSetupForm; diff --git a/apps/emdash-desktop/src/renderer/features/integrations/SetupFormShell.tsx b/apps/emdash-desktop/src/renderer/features/integrations/SetupFormShell.tsx index b772b18457..01538d3352 100644 --- a/apps/emdash-desktop/src/renderer/features/integrations/SetupFormShell.tsx +++ b/apps/emdash-desktop/src/renderer/features/integrations/SetupFormShell.tsx @@ -16,6 +16,9 @@ type SetupFormShellProps

= { providerId: P; getInput: () => ProviderInput[P]; canSubmit: boolean; + submitLabel?: string; + successTitle?: string; + successDescription?: string; onSuccess: () => void; onClose: () => void; children: ReactNode; @@ -25,6 +28,9 @@ export function SetupFormShell

({ providerId, getInput, canSubmit, + submitLabel = 'Connect', + successTitle = 'Integration connected', + successDescription = 'Integration set up successfully.', onSuccess, onClose, children, @@ -40,8 +46,8 @@ export function SetupFormShell

({ try { await providers[providerId].connect(getInput()); toast({ - title: 'Integration connected', - description: 'Integration set up successfully.', + title: successTitle, + description: successDescription, }); onSuccess(); } catch (e) { @@ -65,7 +71,7 @@ export function SetupFormShell

({ void handleSubmit()} disabled={!canSubmit || isMutating}> {isMutating && } - Connect + {submitLabel} 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 a5da5753ee..65b2b77d98 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 @@ -5,10 +5,11 @@ import AsanaSetupForm from './AsanaSetupForm'; import FeaturebaseSetupForm from './FeaturebaseSetupForm'; import ForgejoSetupForm from './ForgejoSetupForm'; import GitLabSetupForm from './GitLabSetupForm'; -import { SETUP_PROVIDER_META } from './issue-provider-meta'; +import { ISSUE_PROVIDER_META, SETUP_PROVIDER_META } from './issue-provider-meta'; import JiraSetupForm from './JiraSetupForm'; import LinearSetupForm from './LinearSetupForm'; import MondaySetupForm from './MondaySetupForm'; +import NotionSetupForm from './NotionSetupForm'; import PlainSetupForm from './PlainSetupForm'; import PlaneSetupForm from './PlaneSetupForm'; import { type SetupFormProps } from './SetupFormShell'; @@ -17,6 +18,7 @@ import type { SetupIntegrationType } from './types'; type IntegrationSetupModalArgs = { integration: SetupIntegrationType; + mode?: 'connect' | 'edit'; }; type Props = BaseModalProps & IntegrationSetupModalArgs; @@ -32,17 +34,28 @@ const SETUP_FORMS: Record> = asana: AsanaSetupForm, monday: MondaySetupForm, trello: TrelloSetupForm, + notion: NotionSetupForm, }; -export function IntegrationSetupModal({ integration, onSuccess, onClose }: Props) { +export function IntegrationSetupModal({ + integration, + mode = 'connect', + onSuccess, + onClose, +}: Props) { const { title, subtitle } = SETUP_PROVIDER_META[integration]; const Form = SETUP_FORMS[integration]; + const isEditing = mode === 'edit'; + const modalTitle = isEditing ? `Edit ${ISSUE_PROVIDER_META[integration].displayName}` : title; + const modalSubtitle = isEditing + ? 'Update the saved integration settings. Leave the access token blank to keep the current one.' + : subtitle; return ( <> - {title} - {subtitle} + {modalTitle} + {modalSubtitle}

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 f0eb83a196..f575b84008 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 @@ -12,6 +12,7 @@ import { IntegrationsProvider, useIntegrationsContext } from './integrations-pro const mocks = vi.hoisted(() => ({ checkAllConnections: vi.fn(), checkConfiguredConnections: vi.fn(), + notionSaveCredentials: vi.fn(), })); vi.mock('@renderer/lib/ipc', () => ({ @@ -29,6 +30,7 @@ vi.mock('@renderer/lib/ipc', () => ({ asana: { saveToken: vi.fn(), clearToken: vi.fn() }, monday: { saveCredentials: vi.fn(), clearCredentials: vi.fn() }, trello: { saveCredentials: vi.fn(), clearCredentials: vi.fn() }, + notion: { saveCredentials: mocks.notionSaveCredentials, clearCredentials: vi.fn() }, }, })); @@ -37,13 +39,20 @@ type ProbeState = { linearIsMutating: boolean; }; -function Probe({ onRender }: { onRender: (state: ProbeState) => void }) { +function Probe({ + onRender, + onProviders, +}: { + onRender: (state: ProbeState) => void; + onProviders?: (providers: ReturnType['providers']) => void; +}) { const { isCheckingConnections, providers } = useIntegrationsContext(); onRender({ isCheckingConnections, linearIsMutating: providers.linear.isMutating, }); + onProviders?.(providers); return null; } @@ -113,4 +122,39 @@ describe('IntegrationsProvider', () => { expect(latest?.isCheckingConnections).toBe(true); expect(latest?.linearIsMutating).toBe(false); }); + + it('allows explicit Notion scope updates without a replacement token', async () => { + let providers: ReturnType['providers'] | null = null; + mocks.checkAllConnections.mockResolvedValue({}); + mocks.notionSaveCredentials.mockResolvedValue({ success: true }); + + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement( + IntegrationsProvider, + null, + React.createElement(Probe, { + onRender: (state) => (latest = state), + onProviders: (nextProviders) => (providers = nextProviders), + }) + ) + ) + ); + }); + + await act(async () => { + await providers?.notion.connect({ + databaseUrls: 'https://notion.com/acme/page', + preserveToken: true, + }); + }); + + expect(mocks.notionSaveCredentials).toHaveBeenCalledWith({ + databaseUrls: 'https://notion.com/acme/page', + preserveToken: true, + }); + }); }); 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 9a23c10c44..8c9d011644 100644 --- a/apps/emdash-desktop/src/renderer/features/integrations/integrations-provider.tsx +++ b/apps/emdash-desktop/src/renderer/features/integrations/integrations-provider.tsx @@ -77,6 +77,17 @@ function validateTrelloCredentials(input: { return null; } +function validateNotionCredentials(input: { + token?: string; + databaseUrls: string; + preserveToken?: boolean; +}): string | null { + if (!input.token?.trim() && !input.preserveToken) { + return 'Access token is required.'; + } + return null; +} + const PROVIDER_CONNECTION_CONFIG: { [P in SetupIntegrationType]: ProviderConnectionConfig

; } = { @@ -130,6 +141,11 @@ const PROVIDER_CONNECTION_CONFIG: { disconnectMutationFn: () => rpc.trello.clearCredentials(), validateInput: validateTrelloCredentials, }, + notion: { + connectMutationFn: (credentials) => rpc.notion.saveCredentials(credentials), + disconnectMutationFn: () => rpc.notion.clearCredentials(), + validateInput: validateNotionCredentials, + }, }; type ProviderConnectionEntry

= { @@ -172,6 +188,7 @@ export function IntegrationsProvider({ children }: { children: React.ReactNode } const invalidateStatuses = useCallback(() => { void queryClient.invalidateQueries({ queryKey: ISSUE_CONNECTION_STATUS_QUERY_KEY }); + void queryClient.invalidateQueries({ queryKey: ['notion:configuration'] }); }, [queryClient]); const linearConnection = useProviderConnection({ @@ -214,6 +231,10 @@ export function IntegrationsProvider({ children }: { children: React.ReactNode } ...PROVIDER_CONNECTION_CONFIG.trello, invalidate: invalidateStatuses, }); + const notionConnection = useProviderConnection({ + ...PROVIDER_CONNECTION_CONFIG.notion, + invalidate: invalidateStatuses, + }); const connectionStatus = statusData ? { ...DEFAULT_CONNECTION_STATUS, ...statusData } @@ -229,6 +250,7 @@ export function IntegrationsProvider({ children }: { children: React.ReactNode } asana: asanaConnection, monday: mondayConnection, trello: trelloConnection, + notion: notionConnection, }; return ( diff --git a/apps/emdash-desktop/src/renderer/features/integrations/issue-provider-meta.ts b/apps/emdash-desktop/src/renderer/features/integrations/issue-provider-meta.ts index 9f85048923..b89938498b 100644 --- a/apps/emdash-desktop/src/renderer/features/integrations/issue-provider-meta.ts +++ b/apps/emdash-desktop/src/renderer/features/integrations/issue-provider-meta.ts @@ -11,6 +11,7 @@ export const ISSUE_PROVIDER_ORDER: IssueProviderType[] = [ 'trello', 'asana', 'monday', + 'notion', 'featurebase', 'plain', ]; @@ -71,6 +72,12 @@ export const ISSUE_PROVIDER_META: Record< features: ['issues'], disconnectCredentialLabel: 'API token', }, + notion: { + displayName: 'Notion', + description: 'Work on Notion pages', + features: ['issues'], + disconnectCredentialLabel: 'access token', + }, trello: { displayName: 'Trello', description: 'Work on Trello cards', @@ -140,6 +147,10 @@ export const SETUP_PROVIDER_META: Record< title: 'Connect Monday.com', subtitle: 'Enter your Monday.com API token and optionally specify board URLs.', }, + notion: { + title: 'Connect Notion', + subtitle: 'Enter your access token and optional page or database URLs.', + }, trello: { title: 'Connect Trello', subtitle: 'Enter your Trello API key and token, and optionally specify board URLs.', diff --git a/apps/emdash-desktop/src/renderer/features/integrations/provider-icons.tsx b/apps/emdash-desktop/src/renderer/features/integrations/provider-icons.tsx index 74cb6b265d..e1be9ff8c3 100644 --- a/apps/emdash-desktop/src/renderer/features/integrations/provider-icons.tsx +++ b/apps/emdash-desktop/src/renderer/features/integrations/provider-icons.tsx @@ -198,6 +198,21 @@ function PlainIcon(props: ProviderIconProps) { ); } +function NotionIcon(props: ProviderIconProps) { + return ( + + + + + ); +} + export const PROVIDER_ICON_COMPONENTS = { linear: LinearIcon, github: GitHubIcon, @@ -207,6 +222,7 @@ export const PROVIDER_ICON_COMPONENTS = { asana: AsanaIcon, monday: MondayIcon, trello: TrelloIcon, + notion: NotionIcon, forgejo: ForgejoIcon, featurebase: FeaturebaseIcon, plain: PlainIcon, diff --git a/apps/emdash-desktop/src/renderer/features/integrations/types.ts b/apps/emdash-desktop/src/renderer/features/integrations/types.ts index 62724c9a80..57427914e8 100644 --- a/apps/emdash-desktop/src/renderer/features/integrations/types.ts +++ b/apps/emdash-desktop/src/renderer/features/integrations/types.ts @@ -13,4 +13,5 @@ export type ProviderInput = { asana: string; monday: { token: string; boardUrls: string }; trello: { apiKey: string; token: string; boardUrls: string }; + notion: { token?: string; databaseUrls: string; preserveToken?: boolean }; }; diff --git a/apps/emdash-desktop/src/renderer/features/integrations/use-issues.ts b/apps/emdash-desktop/src/renderer/features/integrations/use-issues.ts index 875d6d4520..0a9254edb5 100644 --- a/apps/emdash-desktop/src/renderer/features/integrations/use-issues.ts +++ b/apps/emdash-desktop/src/renderer/features/integrations/use-issues.ts @@ -2,7 +2,7 @@ import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { useEffect, useMemo, useState } from 'react'; import { rpc } from '@renderer/lib/ipc'; import type { LinkedIssue } from '@shared/core/linked-issue'; -import type { IssueProviderType } from '@shared/issue-providers'; +import type { IssueListError, IssueProviderType } from '@shared/issue-providers'; const INITIAL_FETCH_LIMIT = 50; const SEARCH_LIMIT = 20; @@ -17,6 +17,7 @@ export interface UseIssuesResult { issues: LinkedIssue[]; isLoading: boolean; error: string | null; + errorType: IssueListError['type'] | null; searchTerm: string; setSearchTerm: (term: string) => void; isSearching: boolean; @@ -134,11 +135,16 @@ export function useIssues( : activeQueryError instanceof Error ? activeQueryError.message : null; + const errorType = + activeResult && !activeResult.success && 'errorType' in activeResult + ? (activeResult.errorType ?? null) + : null; return { issues, isLoading: isLoadingInitial, error, + errorType, searchTerm, setSearchTerm, isSearching: isActiveSearch && isSearching, diff --git a/apps/emdash-desktop/src/renderer/features/settings/components/IntegrationDetailSidebar.tsx b/apps/emdash-desktop/src/renderer/features/settings/components/IntegrationDetailSidebar.tsx index c3568a1244..ca79605fa4 100644 --- a/apps/emdash-desktop/src/renderer/features/settings/components/IntegrationDetailSidebar.tsx +++ b/apps/emdash-desktop/src/renderer/features/settings/components/IntegrationDetailSidebar.tsx @@ -1,4 +1,4 @@ -import { Loader2, Plus, Trash2, X } from 'lucide-react'; +import { Loader2, Pencil, Plus, Trash2, X } from 'lucide-react'; import type { ReactNode } from 'react'; import { ISSUE_FEATURE_LABELS } from '@renderer/features/integrations/issue-provider-meta'; import { PROVIDER_ICON_COMPONENTS } from '@renderer/features/integrations/provider-icons'; @@ -107,22 +107,40 @@ function SingleIntegrationAccount({ integration }: { integration: IntegrationIte {integration.displayDetail ?? 'Connected'}

- {integration.onDisconnect && ( - - - - - Disconnect - - )} +
+ {integration.onEdit && ( + + + + + Edit + + )} + {integration.onDisconnect && ( + + + + + Disconnect + + )} +
); } 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 37d69b90e8..d70b913367 100644 --- a/apps/emdash-desktop/src/renderer/features/settings/components/IntegrationsCard.tsx +++ b/apps/emdash-desktop/src/renderer/features/settings/components/IntegrationsCard.tsx @@ -25,6 +25,7 @@ export type IntegrationItem = { displayName?: string; displayDetail?: string; onConnect: () => void; + onEdit?: () => void; onDisconnect?: () => void | Promise; }; @@ -97,6 +98,10 @@ const IntegrationsCard: React.FC = () => { displayName: status.displayName, displayDetail: status.displayDetail, onConnect: () => showIntegrationSetup({ integration: provider }), + onEdit: + provider === 'notion' + ? () => showIntegrationSetup({ integration: provider, mode: 'edit' }) + : undefined, onDisconnect: () => confirmDisconnect({ name: meta.displayName, diff --git a/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-search-empty-state.tsx b/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-search-empty-state.tsx new file mode 100644 index 0000000000..1fd49c66e8 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-search-empty-state.tsx @@ -0,0 +1,84 @@ +import { AlertCircle } from 'lucide-react'; +import { ISSUE_PROVIDER_META } from '@renderer/features/integrations/issue-provider-meta'; +import { PROVIDER_ICON_COMPONENTS } from '@renderer/features/integrations/provider-icons'; +import { useNavigate } from '@renderer/lib/layout/navigation-provider'; +import { Button } from '@renderer/lib/ui/button'; +import { cn } from '@renderer/utils/utils'; +import type { LinkedIssue } from '@shared/core/linked-issue'; +import type { IssueListError } from '@shared/issue-providers'; +import { parseIssueSearchError } from './parse-issue-search-error'; + +function ProviderIcon({ + provider, + className, +}: { + provider: LinkedIssue['provider']; + className?: string; +}) { + const Icon = PROVIDER_ICON_COMPONENTS[provider]; + + return ( + + + + ); +} + +export function IssueSearchEmptyState({ + provider, + error, + errorType, +}: { + provider: LinkedIssue['provider'] | null; + error: string | null; + errorType: IssueListError['type'] | null; +}) { + const { navigate } = useNavigate(); + const parsed = parseIssueSearchError(provider, error, errorType); + + if (!parsed) { + return No issues found; + } + + const openIntegrations = () => navigate('settings', { tab: 'integrations' }); + + return ( +
+ + {provider && parsed.kind !== 'generic' ? ( + + ) : ( + + )} + +
+

{parsed.title}

+

+ {parsed.description} +

+
+ {parsed.actionLabel ? ( + + ) : null} +
+ ); +} 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 3b29fc7454..39dc14a60d 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 @@ -85,7 +85,7 @@ vi.mock('@renderer/lib/components/inline-markdown', async () => { }); vi.mock('@renderer/lib/layout/navigation-provider', () => ({ - useNavigate: () => vi.fn(), + useNavigate: () => ({ navigate: vi.fn() }), })); describe('IssueSelector', () => { @@ -139,9 +139,11 @@ describe('IssueSelector', () => { ); }); + expect(container.textContent).toContain('GitHub access required'); expect(container.textContent).toContain( 'acme/repo on github.com was not found, or the selected GitHub account does not have access.' ); + expect(container.textContent).toContain('Open integrations'); expect(container.textContent).not.toContain('No issues found'); }); }); 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 719d1a0468..19025b23d7 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,6 +28,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger } from '@renderer/lib/ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/lib/ui/tooltip'; import { cn } from '@renderer/utils/utils'; import type { LinkedIssue } from '@shared/core/linked-issue'; +import { IssueSearchEmptyState } from './issue-search-empty-state'; import { getLinkedIssueMap, type LinkedIssueInfo } from './use-linked-issue-urls'; import { useIssueSearch } from './useIssueSearch'; @@ -41,18 +42,30 @@ export function IssueIdentifier({ className?: string; }) { if (provider === 'asana') return null; + const displayIdentifier = compactIssueIdentifier(provider, identifier); + return ( - {identifier} + {displayIdentifier} ); } +export function compactIssueIdentifier( + provider: LinkedIssue['provider'] | undefined, + identifier: string +): string { + return provider === 'notion' && identifier.length > 12 + ? `${identifier.slice(0, 8)}…` + : identifier; +} + export function ProviderLogo({ provider, className, @@ -148,6 +161,7 @@ export const IssueSelector = observer(function IssueSelector({ const { issues, error, + errorType, issueProvider, hasAnyIntegration, isProviderLoading, @@ -281,10 +295,8 @@ export const IssueSelector = observer(function IssueSelector({ placeholder={`Search ${issueProvider ?? 'issues'}…`} disabled={!hasAnyIntegration} /> - - - {error ?? 'No issues found'} - + + {(issue: LinkedIssue) => { @@ -321,20 +333,24 @@ export function SelectedIssueValue({ issue }: { issue: LinkedIssue }) { ) : null}
- - -
{issue.title}
+ + +
{issue.title}
- + - + {issue.description ? ( diff --git a/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/parse-issue-search-error.test.ts b/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/parse-issue-search-error.test.ts new file mode 100644 index 0000000000..32cc2dc4f9 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/parse-issue-search-error.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { parseIssueSearchError } from './parse-issue-search-error'; + +describe('parseIssueSearchError', () => { + it('returns a Notion access error with an integrations action', () => { + const result = parseIssueSearchError( + 'notion', + 'Notion cannot access the configured data source. Share the page or database with emdash, or update the scope URLs in Emdash settings.', + 'not_found_or_no_access' + ); + + expect(result).toEqual({ + kind: 'access', + title: 'Notion access required', + description: + 'Notion cannot access the configured data source. Share the page or database with emdash, or update the scope URLs in Emdash settings.', + actionLabel: 'Open integrations', + }); + }); + + it('returns a GitHub access error with an integrations action', () => { + const result = parseIssueSearchError( + 'github', + 'acme/repo on github.com was not found, or the selected GitHub account does not have access.' + ); + + expect(result).toEqual({ + kind: 'access', + title: 'GitHub access required', + description: + 'acme/repo on github.com was not found, or the selected GitHub account does not have access.', + actionLabel: 'Open integrations', + }); + }); + + it('returns a generic error when no provider-specific pattern matches', () => { + const result = parseIssueSearchError('linear', 'Linear API rate limit exceeded.'); + + expect(result).toEqual({ + kind: 'generic', + title: 'Could not load issues', + description: 'Linear API rate limit exceeded.', + }); + }); + + it('returns a Notion auth error from a typed error', () => { + const result = parseIssueSearchError( + 'notion', + 'Notion authentication failed. Check your access token.', + 'auth_required' + ); + + expect(result).toEqual({ + kind: 'auth', + title: 'Notion connection issue', + description: 'Notion authentication failed. Check your access token.', + actionLabel: 'Open integrations', + }); + }); + + it('returns null when there is no error', () => { + expect(parseIssueSearchError('notion', null)).toBeNull(); + }); +}); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/parse-issue-search-error.ts b/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/parse-issue-search-error.ts new file mode 100644 index 0000000000..b462fd1497 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/parse-issue-search-error.ts @@ -0,0 +1,63 @@ +import type { LinkedIssue } from '@shared/core/linked-issue'; +import type { IssueListError } from '@shared/issue-providers'; + +export type IssueSearchErrorDisplay = { + kind: 'access' | 'auth' | 'generic'; + title: string; + description: string; + actionLabel?: string; +}; + +function notionAccessErrorDescription(error: string): string { + const integrationMatch = error.match(/integration "([^"]+)"/i); + const integrationName = integrationMatch?.[1] ?? 'your Notion integration'; + + if (/scope URLs|Emdash settings/i.test(error)) { + return error; + } + + return `Share the page or database with ${integrationName}, or update the scope URLs in Emdash settings.`; +} + +export function parseIssueSearchError( + provider: LinkedIssue['provider'] | null, + error: string | null, + errorType?: IssueListError['type'] | null +): IssueSearchErrorDisplay | null { + if (!error) return null; + + if (provider === 'notion') { + if (errorType === 'not_found_or_no_access' || errorType === 'forbidden') { + return { + kind: 'access', + title: 'Notion access required', + description: notionAccessErrorDescription(error), + actionLabel: 'Open integrations', + }; + } + + if (errorType === 'auth_required' || errorType === 'token_missing') { + return { + kind: 'auth', + title: 'Notion connection issue', + description: error, + actionLabel: 'Open integrations', + }; + } + } + + if (provider === 'github' && /does not have access|not found|Connect GitHub/i.test(error)) { + return { + kind: 'access', + title: 'GitHub access required', + description: error, + actionLabel: 'Open integrations', + }; + } + + return { + kind: 'generic', + title: 'Could not load issues', + description: error, + }; +} diff --git a/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/useIssueSearch.ts b/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/useIssueSearch.ts index 7e3e52ff84..9a3c863b63 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/useIssueSearch.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/useIssueSearch.ts @@ -65,6 +65,7 @@ export function useIssueSearch(repositoryUrl: string, projectPath = '', projectI return { issues: issuesHook.issues, error: issuesHook.error, + errorType: issuesHook.errorType, issueProvider, hasAnyIntegration: hasAnyIssueIntegration, isProviderLoading, diff --git a/apps/emdash-desktop/src/renderer/features/tasks/conversations/context-actions.ts b/apps/emdash-desktop/src/renderer/features/tasks/conversations/context-actions.ts index ca754b7208..9cfea641e0 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/conversations/context-actions.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/conversations/context-actions.ts @@ -44,6 +44,7 @@ const PROVIDER_LABELS: Record = { asana: 'Asana', monday: 'Monday.com', trello: 'Trello', + notion: 'Notion', }; export function buildIssueContextText(issue: LinkedIssue): string { diff --git a/apps/emdash-desktop/src/renderer/features/tasks/conversations/initial-conversation-section.tsx b/apps/emdash-desktop/src/renderer/features/tasks/conversations/initial-conversation-section.tsx index d7c16186e5..7791776100 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/conversations/initial-conversation-section.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/conversations/initial-conversation-section.tsx @@ -20,7 +20,7 @@ import { cn } from '@renderer/utils/utils'; import { providerSupportsAutoApprove } from '@shared/core/agents/agent-auto-approve'; import type { AgentProviderId } from '@shared/core/agents/agent-provider-registry'; import type { LinkedIssue } from '@shared/core/linked-issue'; -import { ProviderLogo } from '../components/issue-selector/issue-selector'; +import { compactIssueIdentifier, ProviderLogo } from '../components/issue-selector/issue-selector'; import { appendInitialConversationText } from '../create-task-modal/initial-conversation-text'; import { usePromptFileDrop } from '../create-task-modal/use-prompt-file-drop'; import { AddContextPopover } from './add-context-popover'; @@ -243,12 +243,17 @@ export function InitialConversationField({ )} > - {linkedIssue.identifier} {linkedIssue.title && ( - + {linkedIssue.title} )} + + {compactIssueIdentifier(linkedIssue.provider, linkedIssue.identifier)} +