Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
36 changes: 32 additions & 4 deletions src/knowledge/retriever/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,34 @@ function readOptionalStringField(source: Record<string, unknown>, key: string):
return undefined;
}

function readConfluenceAuth(
auth: Config['knowledge']['sources'][number]['auth']
): { email: string; apiToken: string } | null {
if (!auth || typeof auth !== 'object') {
return null;
}

const authRecord = auth as Record<string, unknown>;
const email = readOptionalStringField(authRecord, 'email');
const apiToken = readOptionalStringField(authRecord, 'apiToken');
if (!email || !apiToken) {
return null;
}

return { email, apiToken };
}

function readLegacyApiToken(
auth: Config['knowledge']['sources'][number]['auth']
): string | undefined {
if (!auth || typeof auth !== 'object') {
return undefined;
}

const authRecord = auth as Record<string, unknown>;
return readOptionalStringField(authRecord, 'apiToken');
}

function normalizeConfiguredSources(
sources: Config['knowledge']['sources'],
baseDir: string
Expand All @@ -471,8 +499,8 @@ function normalizeConfiguredSources(
}

case 'confluence': {
const auth = source.auth;
if (!source.baseUrl || !source.spaceKey || !auth?.email || !auth?.apiToken) {
const auth = readConfluenceAuth(source.auth);
if (!source.baseUrl || !source.spaceKey || !auth) {
console.warn('Skipping incomplete Confluence knowledge source.');
continue;
}
Expand Down Expand Up @@ -517,7 +545,7 @@ function normalizeConfiguredSources(
const sourceRecord = source as Record<string, unknown>;
const notionApiKey =
readOptionalStringField(sourceRecord, 'apiKey') ||
source.auth?.apiToken ||
readLegacyApiToken(source.auth) ||
process.env.RUNBOOK_NOTION_API_KEY ||
process.env.NOTION_API_KEY;

Expand Down Expand Up @@ -547,7 +575,7 @@ function normalizeConfiguredSources(
path: source.path || '',
token:
explicitToken ||
source.auth?.apiToken ||
readLegacyApiToken(source.auth) ||
process.env.RUNBOOK_GITHUB_TOKEN ||
process.env.GITHUB_TOKEN,
});
Expand Down
177 changes: 177 additions & 0 deletions src/knowledge/sources/__tests__/notion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { loadFromNotion } from '../notion';
import type { NotionSourceConfig } from '../../types';

function asFetchResponse(body: unknown, ok: boolean = true, status: number = 200): Response {
return {
ok,
status,
text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
json: async () => body,
} as Response;
}

const notionConfig: NotionSourceConfig = {
type: 'notion',
databaseId: 'db_123',
apiKey: 'secret_notion_key',
};

describe('Notion knowledge source', () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it('loads database pages and converts blocks to markdown documents', async () => {
const fetchMock = vi.fn().mockImplementation(async (url: string) => {
if (url.includes('/databases/db_123/query')) {
return asFetchResponse({
results: [
{
id: 'page_1',
url: 'https://www.notion.so/page_1',
created_time: '2026-01-01T00:00:00.000Z',
last_edited_time: '2026-01-02T00:00:00.000Z',
properties: {
Name: {
type: 'title',
title: [{ plain_text: 'Checkout API Runbook' }],
},
Type: {
type: 'select',
select: { name: 'runbook' },
},
Services: {
type: 'multi_select',
multi_select: [{ name: 'checkout' }, { name: 'payments' }],
},
},
},
],
has_more: false,
next_cursor: null,
});
}

if (url.includes('/blocks/page_1/children')) {
return asFetchResponse({
results: [
{
id: 'blk_1',
type: 'heading_1',
heading_1: {
rich_text: [{ plain_text: 'Mitigation Steps' }],
},
has_children: false,
},
{
id: 'blk_2',
type: 'paragraph',
paragraph: {
rich_text: [{ plain_text: 'Restart workers and flush stale connections.' }],
},
has_children: false,
},
],
has_more: false,
next_cursor: null,
});
}

return asFetchResponse('not found', false, 404);
});

vi.stubGlobal('fetch', fetchMock);

const documents = await loadFromNotion(notionConfig);
expect(documents.length).toBe(1);
expect(documents[0].title).toBe('Checkout API Runbook');
expect(documents[0].services).toEqual(['checkout', 'payments']);
expect(documents[0].type).toBe('runbook');
expect(documents[0].content).toContain('# Mitigation Steps');
expect(documents[0].content).toContain('Restart workers');
expect(documents[0].chunks.length).toBeGreaterThan(0);
});

it('respects the since option and skips older pages', async () => {
const fetchMock = vi.fn().mockImplementation(async (url: string) => {
if (url.includes('/databases/db_123/query')) {
return asFetchResponse({
results: [
{
id: 'old_page',
url: 'https://www.notion.so/old_page',
created_time: '2026-01-01T00:00:00.000Z',
last_edited_time: '2026-01-01T00:00:00.000Z',
properties: {
Name: {
type: 'title',
title: [{ plain_text: 'Old Runbook' }],
},
},
},
],
has_more: false,
next_cursor: null,
});
}
return asFetchResponse({
results: [],
has_more: false,
next_cursor: null,
});
});

vi.stubGlobal('fetch', fetchMock);

const documents = await loadFromNotion(notionConfig, {
since: '2026-01-02T00:00:00.000Z',
});
expect(documents).toEqual([]);
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it('uses fallback title when title property is missing', async () => {
const fetchMock = vi.fn().mockImplementation(async (url: string) => {
if (url.includes('/databases/db_123/query')) {
return asFetchResponse({
results: [
{
id: 'page_no_title',
url: 'https://www.notion.so/page_no_title',
created_time: '2026-01-01T00:00:00.000Z',
last_edited_time: '2026-01-03T00:00:00.000Z',
properties: {},
},
],
has_more: false,
next_cursor: null,
});
}
if (url.includes('/blocks/page_no_title/children')) {
return asFetchResponse({
results: [
{
id: 'blk_x',
type: 'paragraph',
paragraph: {
rich_text: [{ plain_text: 'General recovery guidance.' }],
},
has_children: false,
},
],
has_more: false,
next_cursor: null,
});
}
return asFetchResponse('not found', false, 404);
});

vi.stubGlobal('fetch', fetchMock);

const documents = await loadFromNotion(notionConfig);
expect(documents.length).toBe(1);
expect(documents[0].title).toBe('Untitled');
expect(documents[0].type).toBe('runbook');
});
});
6 changes: 5 additions & 1 deletion src/knowledge/sources/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { loadFromFilesystem } from './filesystem';
import { loadFromConfluence } from './confluence';
import { loadFromGoogleDrive } from './google-drive';
import { loadFromNotion } from './notion';
import { loadFromGitHub } from './github';
import type { KnowledgeDocument, KnowledgeSourceConfig } from '../types';

Expand All @@ -28,13 +29,15 @@ export async function loadFromSource(
case 'confluence':
return loadFromConfluence(config, options);

case 'notion':
return loadFromNotion(config, options);

case 'google_drive':
return loadFromGoogleDrive(config, options);

case 'github':
return loadFromGitHub(config, options);

case 'notion':
case 'api':
console.warn(`Source type '${config.type}' is not yet implemented`);
return [];
Expand All @@ -48,4 +51,5 @@ export async function loadFromSource(
export { loadFromFilesystem } from './filesystem';
export { loadFromConfluence } from './confluence';
export { loadFromGoogleDrive } from './google-drive';
export { loadFromNotion } from './notion';
export { loadFromGitHub } from './github';
Loading
Loading