Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c50fee4
feat(issues): add notion provider
janburzinski Jun 24, 2026
fa0cdde
feat(integrations): add notion setup UI
janburzinski Jun 24, 2026
3a3579a
fix(notion): fetch full page context
janburzinski Jun 24, 2026
a9fd597
fix(telemetry): use issue provider type
janburzinski Jun 24, 2026
df3b9fb
fix(notion): model page scope explicitly
janburzinski Jun 24, 2026
028bcc1
fix(notion): align connection copy with docs
janburzinski Jun 24, 2026
c6c8d30
fix(integrations): update Notion icon
janburzinski Jun 24, 2026
05cc91e
fix(notion): simplify access token copy
janburzinski Jun 24, 2026
7ff6f98
fix(issues): clarify provider access errors
janburzinski Jun 24, 2026
1ed7e26
feat(notion): allow editing configuration
janburzinski Jun 24, 2026
cd3bb4d
fix(notion): query shared data source pages
janburzinski Jun 24, 2026
1033ad6
fix(notion): distinguish edit setup flow
janburzinski Jun 24, 2026
c2ed3fd
fix(notion): compact long issue ids
janburzinski Jun 24, 2026
171e7f1
fix(notion): compact composer issue id
janburzinski Jun 24, 2026
707cb78
fix(notion): allow scope edits without token
janburzinski Jun 24, 2026
861cbcf
fix(notion): shorten setup copy
janburzinski Jun 24, 2026
71ef9bb
fix(integrations): restore transparent Notion icon
janburzinski Jun 24, 2026
1ddd136
style(notion): format connection service test
janburzinski Jun 24, 2026
f036f1c
fix(notion): search scoped data sources fully
janburzinski Jun 24, 2026
3a9ac71
fix(integrations): keep Notion setup input stable
janburzinski Jun 24, 2026
4933f81
fix(notion): make token preservation explicit
janburzinski Jun 24, 2026
e1ec799
fix(notion): classify issue errors with codes
janburzinski Jun 24, 2026
4a50142
fix(integrations): use provider names for edit titles
janburzinski Jun 24, 2026
00cd7bb
chore: format Notion fixes
janburzinski Jun 24, 2026
018a40d
fix(notion): limit scoped issue queries
janburzinski Jun 24, 2026
235ed70
fix(notion): keep database urls single-line
janburzinski Jun 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/emdash-desktop/src/main/core/issues/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand Down
26 changes: 26 additions & 0 deletions apps/emdash-desktop/src/main/core/notion/controller.ts
Original file line number Diff line number Diff line change
@@ -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(),
});
Original file line number Diff line number Diff line change
@@ -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<unknown> } {
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');
});
});
});
Loading
Loading