@hubblecommerce/overmind-core 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. package/dist/src/integrations/confluence/confluence.client.test.d.ts +2 -0
  2. package/dist/src/integrations/confluence/confluence.client.test.d.ts.map +1 -0
  3. package/dist/src/integrations/confluence/confluence.client.test.js +320 -0
  4. package/dist/src/integrations/gitlab/gitlab.client.d.ts +8 -0
  5. package/dist/src/integrations/gitlab/gitlab.client.d.ts.map +1 -1
  6. package/dist/src/integrations/gitlab/gitlab.client.js +20 -0
  7. package/dist/src/integrations/gitlab/gitlab.client.test.d.ts +2 -0
  8. package/dist/src/integrations/gitlab/gitlab.client.test.d.ts.map +1 -0
  9. package/dist/src/integrations/gitlab/gitlab.client.test.js +62 -0
  10. package/dist/src/integrations/jira/jira.client.test.d.ts +2 -0
  11. package/dist/src/integrations/jira/jira.client.test.d.ts.map +1 -0
  12. package/dist/src/integrations/jira/jira.client.test.js +171 -0
  13. package/dist/src/llm/anthropic-retry-wrapper.test.d.ts +2 -0
  14. package/dist/src/llm/anthropic-retry-wrapper.test.d.ts.map +1 -0
  15. package/dist/src/llm/anthropic-retry-wrapper.test.js +125 -0
  16. package/dist/src/processors/confluence-document.processor.test.d.ts +2 -0
  17. package/dist/src/processors/confluence-document.processor.test.d.ts.map +1 -0
  18. package/dist/src/processors/confluence-document.processor.test.js +202 -0
  19. package/dist/src/processors/confluence-html-parser.test.d.ts +2 -0
  20. package/dist/src/processors/confluence-html-parser.test.d.ts.map +1 -0
  21. package/dist/src/processors/confluence-html-parser.test.js +214 -0
  22. package/dist/src/tools/confluence-search.tool.d.ts +6 -6
  23. package/package.json +7 -2
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=confluence.client.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"confluence.client.test.d.ts","sourceRoot":"","sources":["../../../../src/integrations/confluence/confluence.client.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,320 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { ConfluenceClient } from './confluence.client.js';
3
+ const BASE_URL = 'https://example.atlassian.net';
4
+ function makeClient() {
5
+ return new ConfluenceClient({
6
+ baseUrl: BASE_URL,
7
+ email: 'test@test.com',
8
+ apiToken: 'token',
9
+ rateLimit: 10000, // effectively no rate-limit delay in tests
10
+ });
11
+ }
12
+ function jsonResponse(data, status = 200) {
13
+ return new Response(JSON.stringify(data), {
14
+ status,
15
+ headers: { 'Content-Type': 'application/json' },
16
+ });
17
+ }
18
+ const spaceFixture = {
19
+ id: 'space-id-1',
20
+ key: 'TEST',
21
+ name: 'Test Space',
22
+ type: 'global',
23
+ status: 'current',
24
+ authorId: 'author1',
25
+ createdAt: '2024-01-01T00:00:00.000Z',
26
+ _links: { webui: '/spaces/TEST' },
27
+ };
28
+ const spaceResponse = { results: [spaceFixture], _links: {} };
29
+ function makePageFixture(id) {
30
+ return {
31
+ id,
32
+ status: 'current',
33
+ title: `Page ${id}`,
34
+ spaceId: spaceFixture.id,
35
+ authorId: 'author1',
36
+ createdAt: '2024-01-01T00:00:00.000Z',
37
+ version: { createdAt: '2024-01-01T00:00:00.000Z', message: '', number: 1, minorEdit: false, authorId: 'author1' },
38
+ _links: { webui: `/wiki/spaces/TEST/pages/${id}` },
39
+ };
40
+ }
41
+ describe('ConfluenceClient', () => {
42
+ let fetchMock;
43
+ beforeEach(() => {
44
+ fetchMock = vi.fn();
45
+ vi.stubGlobal('fetch', fetchMock);
46
+ vi.useFakeTimers();
47
+ });
48
+ afterEach(() => {
49
+ vi.unstubAllGlobals();
50
+ vi.useRealTimers();
51
+ });
52
+ describe('getSpacePages', () => {
53
+ it('returns pages for a single-page response (no pagination)', async () => {
54
+ const pageResponse = { results: [makePageFixture('p1')], _links: {} };
55
+ fetchMock
56
+ .mockResolvedValueOnce(jsonResponse(spaceResponse))
57
+ .mockResolvedValueOnce(jsonResponse(pageResponse));
58
+ const promise = makeClient().getSpacePages('TEST');
59
+ await vi.runAllTimersAsync();
60
+ const pages = await promise;
61
+ expect(pages).toHaveLength(1);
62
+ expect(pages[0].id).toBe('p1');
63
+ });
64
+ it('follows pagination and combines results from multiple pages', async () => {
65
+ const page1Response = {
66
+ results: [makePageFixture('p1')],
67
+ _links: { next: `${BASE_URL}/wiki/api/v2/spaces/space-id-1/pages?cursor=abc` },
68
+ };
69
+ const page2Response = {
70
+ results: [makePageFixture('p2')],
71
+ _links: {},
72
+ };
73
+ fetchMock
74
+ .mockResolvedValueOnce(jsonResponse(spaceResponse))
75
+ .mockResolvedValueOnce(jsonResponse(page1Response))
76
+ .mockResolvedValueOnce(jsonResponse(page2Response));
77
+ const promise = makeClient().getSpacePages('TEST');
78
+ await vi.runAllTimersAsync();
79
+ const pages = await promise;
80
+ expect(pages).toHaveLength(2);
81
+ expect(pages[0].id).toBe('p1');
82
+ expect(pages[1].id).toBe('p2');
83
+ });
84
+ it('throws when space key is not found', async () => {
85
+ fetchMock.mockResolvedValueOnce(jsonResponse({ results: [], _links: {} }));
86
+ const promise = makeClient().getSpacePages('UNKNOWN');
87
+ const assertion = expect(promise).rejects.toThrow('Space with key "UNKNOWN" not found');
88
+ await vi.runAllTimersAsync();
89
+ await assertion;
90
+ });
91
+ });
92
+ describe('getPageLabels', () => {
93
+ it('returns label names from the response', async () => {
94
+ const labelResponse = {
95
+ results: [
96
+ { id: '1', name: 'project-info', prefix: 'global' },
97
+ { id: '2', name: 'active', prefix: 'global' },
98
+ ],
99
+ start: 0, limit: 200, size: 2, _links: {},
100
+ };
101
+ fetchMock.mockResolvedValueOnce(jsonResponse(labelResponse));
102
+ const promise = makeClient().getPageLabels('page1');
103
+ await vi.runAllTimersAsync();
104
+ const labels = await promise;
105
+ expect(labels).toEqual(['project-info', 'active']);
106
+ });
107
+ it('returns empty array when page has no labels', async () => {
108
+ const labelResponse = { results: [], start: 0, limit: 200, size: 0, _links: {} };
109
+ fetchMock.mockResolvedValueOnce(jsonResponse(labelResponse));
110
+ const promise = makeClient().getPageLabels('page1');
111
+ await vi.runAllTimersAsync();
112
+ const labels = await promise;
113
+ expect(labels).toEqual([]);
114
+ });
115
+ });
116
+ describe('createPageWithADF', () => {
117
+ const createPageResponse = {
118
+ id: 'new-page-1',
119
+ status: 'current',
120
+ title: 'My New Page',
121
+ spaceId: spaceFixture.id,
122
+ authorId: 'author1',
123
+ createdAt: '2024-01-01T00:00:00.000Z',
124
+ version: { createdAt: '2024-01-01T00:00:00.000Z', message: '', number: 1, minorEdit: false, authorId: 'author1' },
125
+ _links: { webui: '/wiki/spaces/TEST/pages/new-page-1' },
126
+ };
127
+ it('sends correct body structure with ADF content', async () => {
128
+ fetchMock.mockResolvedValueOnce(jsonResponse(createPageResponse));
129
+ const adfBody = { type: 'doc', version: 1, content: [] };
130
+ const promise = makeClient().createPageWithADF({ spaceId: 'S1', title: 'My New Page', adfBody });
131
+ await vi.runAllTimersAsync();
132
+ await promise;
133
+ const [, options] = fetchMock.mock.calls[0];
134
+ const body = JSON.parse(options.body);
135
+ expect(body.title).toBe('My New Page');
136
+ expect(body.spaceId).toBe('S1');
137
+ expect(body.body.representation).toBe('atlas_doc_format');
138
+ expect(JSON.parse(body.body.value)).toEqual(adfBody);
139
+ });
140
+ it('includes parentId in payload when provided', async () => {
141
+ fetchMock.mockResolvedValueOnce(jsonResponse(createPageResponse));
142
+ const promise = makeClient().createPageWithADF({ spaceId: 'S1', title: 'Child', adfBody: {}, parentId: 'parent-1' });
143
+ await vi.runAllTimersAsync();
144
+ await promise;
145
+ const [, options] = fetchMock.mock.calls[0];
146
+ const body = JSON.parse(options.body);
147
+ expect(body.parentId).toBe('parent-1');
148
+ });
149
+ it('omits parentId from payload when not provided', async () => {
150
+ fetchMock.mockResolvedValueOnce(jsonResponse(createPageResponse));
151
+ const promise = makeClient().createPageWithADF({ spaceId: 'S1', title: 'Root Page', adfBody: {} });
152
+ await vi.runAllTimersAsync();
153
+ await promise;
154
+ const [, options] = fetchMock.mock.calls[0];
155
+ const body = JSON.parse(options.body);
156
+ expect(body.parentId).toBeUndefined();
157
+ });
158
+ it('returns the created page', async () => {
159
+ fetchMock.mockResolvedValueOnce(jsonResponse(createPageResponse));
160
+ const promise = makeClient().createPageWithADF({ spaceId: 'S1', title: 'My New Page', adfBody: {} });
161
+ await vi.runAllTimersAsync();
162
+ const page = await promise;
163
+ expect(page.id).toBe('new-page-1');
164
+ expect(page.title).toBe('My New Page');
165
+ });
166
+ });
167
+ describe('getSpaces', () => {
168
+ it('returns spaces from paginated response', async () => {
169
+ fetchMock.mockResolvedValueOnce(jsonResponse({ results: [spaceFixture], _links: {} }));
170
+ const promise = makeClient().getSpaces();
171
+ await vi.runAllTimersAsync();
172
+ const spaces = await promise;
173
+ expect(spaces).toHaveLength(1);
174
+ expect(spaces[0].key).toBe('TEST');
175
+ });
176
+ });
177
+ describe('getPageById', () => {
178
+ it('returns the page for a given id', async () => {
179
+ const page = makePageFixture('p42');
180
+ fetchMock.mockResolvedValueOnce(jsonResponse(page));
181
+ const promise = makeClient().getPageById('p42');
182
+ await vi.runAllTimersAsync();
183
+ const result = await promise;
184
+ expect(result.id).toBe('p42');
185
+ });
186
+ it('uses default body format storage', async () => {
187
+ fetchMock.mockResolvedValueOnce(jsonResponse(makePageFixture('p1')));
188
+ const promise = makeClient().getPageById('p1');
189
+ await vi.runAllTimersAsync();
190
+ await promise;
191
+ const [url] = fetchMock.mock.calls[0];
192
+ expect(url).toContain('body-format=storage');
193
+ });
194
+ it('accepts atlas_doc_format body format', async () => {
195
+ fetchMock.mockResolvedValueOnce(jsonResponse(makePageFixture('p1')));
196
+ const promise = makeClient().getPageById('p1', 'atlas_doc_format');
197
+ await vi.runAllTimersAsync();
198
+ await promise;
199
+ const [url] = fetchMock.mock.calls[0];
200
+ expect(url).toContain('body-format=atlas_doc_format');
201
+ });
202
+ });
203
+ describe('getPageContent', () => {
204
+ it('returns body storage value', async () => {
205
+ const page = { ...makePageFixture('p1'), body: { storage: { value: '<p>Hello</p>' } } };
206
+ fetchMock.mockResolvedValueOnce(jsonResponse(page));
207
+ const promise = makeClient().getPageContent('p1');
208
+ await vi.runAllTimersAsync();
209
+ const content = await promise;
210
+ expect(content).toBe('<p>Hello</p>');
211
+ });
212
+ it('throws when page has no body', async () => {
213
+ const page = makePageFixture('p1'); // no body field
214
+ fetchMock.mockResolvedValueOnce(jsonResponse(page));
215
+ const promise = makeClient().getPageContent('p1');
216
+ const assertion = expect(promise).rejects.toThrow('no content');
217
+ await vi.runAllTimersAsync();
218
+ await assertion;
219
+ });
220
+ });
221
+ describe('getChildPages', () => {
222
+ it('returns child pages in a single request when below limit', async () => {
223
+ const results = [{ id: 'c1' }, { id: 'c2' }];
224
+ fetchMock.mockResolvedValueOnce(jsonResponse({ results, size: 2 }));
225
+ const promise = makeClient().getChildPages('parent1');
226
+ await vi.runAllTimersAsync();
227
+ const pages = await promise;
228
+ expect(pages).toHaveLength(2);
229
+ expect(fetchMock).toHaveBeenCalledTimes(1);
230
+ });
231
+ it('paginates when first response returns exactly the limit', async () => {
232
+ const fullPage = Array.from({ length: 250 }, (_, i) => ({ id: `c${i}` }));
233
+ fetchMock
234
+ .mockResolvedValueOnce(jsonResponse({ results: fullPage, size: 250 }))
235
+ .mockResolvedValueOnce(jsonResponse({ results: [{ id: 'c250' }], size: 1 }));
236
+ const promise = makeClient().getChildPages('parent1');
237
+ await vi.runAllTimersAsync();
238
+ const pages = await promise;
239
+ expect(pages).toHaveLength(251);
240
+ expect(fetchMock).toHaveBeenCalledTimes(2);
241
+ const [secondUrl] = fetchMock.mock.calls[1];
242
+ expect(secondUrl).toContain('start=250');
243
+ });
244
+ });
245
+ describe('getRecentPages', () => {
246
+ it('returns pages without space filter when no spaceKeys provided', async () => {
247
+ const pageResponse = { results: [makePageFixture('p1')] };
248
+ fetchMock.mockResolvedValueOnce(jsonResponse(pageResponse));
249
+ const promise = makeClient().getRecentPages();
250
+ await vi.runAllTimersAsync();
251
+ const pages = await promise;
252
+ expect(pages).toHaveLength(1);
253
+ const [url] = fetchMock.mock.calls[0];
254
+ expect(url).not.toContain('space-key');
255
+ });
256
+ it('adds space-key filter when spaceKeys provided', async () => {
257
+ const pageResponse = { results: [makePageFixture('p1')] };
258
+ fetchMock.mockResolvedValueOnce(jsonResponse(pageResponse));
259
+ const promise = makeClient().getRecentPages(['TEST', 'PROJ']);
260
+ await vi.runAllTimersAsync();
261
+ await promise;
262
+ const [url] = fetchMock.mock.calls[0];
263
+ expect(url).toContain('space-key=TEST');
264
+ expect(url).toContain('space-key=PROJ');
265
+ });
266
+ it('respects custom limit', async () => {
267
+ fetchMock.mockResolvedValueOnce(jsonResponse({ results: [] }));
268
+ const promise = makeClient().getRecentPages(undefined, 50);
269
+ await vi.runAllTimersAsync();
270
+ await promise;
271
+ const [url] = fetchMock.mock.calls[0];
272
+ expect(url).toContain('limit=50');
273
+ });
274
+ });
275
+ describe('getPageLabels pagination', () => {
276
+ it('fetches next page when first response fills the limit', async () => {
277
+ const fullPage = Array.from({ length: 200 }, (_, i) => ({ id: `${i}`, name: `label-${i}`, prefix: 'global' }));
278
+ const secondPage = { results: [{ id: '200', name: 'extra', prefix: 'global' }], start: 200, limit: 200, size: 1, _links: {} };
279
+ fetchMock
280
+ .mockResolvedValueOnce(jsonResponse({ results: fullPage, start: 0, limit: 200, size: 200, _links: {} }))
281
+ .mockResolvedValueOnce(jsonResponse(secondPage));
282
+ const promise = makeClient().getPageLabels('page1');
283
+ await vi.runAllTimersAsync();
284
+ const labels = await promise;
285
+ expect(labels).toHaveLength(201);
286
+ expect(labels[200]).toBe('extra');
287
+ });
288
+ });
289
+ describe('error handling', () => {
290
+ it('throws on 4xx error without retrying', async () => {
291
+ fetchMock.mockResolvedValue(new Response('Not Found', { status: 404, statusText: 'Not Found' }));
292
+ const promise = makeClient().getPageById('page1');
293
+ const assertion = expect(promise).rejects.toThrow();
294
+ await vi.runAllTimersAsync();
295
+ await assertion;
296
+ // Only 1 fetch call (no retries for 4xx)
297
+ expect(fetchMock).toHaveBeenCalledTimes(1);
298
+ });
299
+ it('retries on 5xx error and eventually throws', async () => {
300
+ fetchMock.mockResolvedValue(new Response('Server Error', { status: 500, statusText: 'Internal Server Error' }));
301
+ const promise = makeClient().getPageById('page1');
302
+ const assertion = expect(promise).rejects.toThrow();
303
+ await vi.runAllTimersAsync();
304
+ await assertion;
305
+ // 3 attempts total (initial + 2 retries)
306
+ expect(fetchMock).toHaveBeenCalledTimes(3);
307
+ });
308
+ it('succeeds on retry after transient 5xx', async () => {
309
+ const page = makePageFixture('p1');
310
+ fetchMock
311
+ .mockResolvedValueOnce(new Response('Server Error', { status: 500, statusText: 'Internal Server Error' }))
312
+ .mockResolvedValueOnce(jsonResponse(page));
313
+ const promise = makeClient().getPageById('p1');
314
+ await vi.runAllTimersAsync();
315
+ const result = await promise;
316
+ expect(result.id).toBe('p1');
317
+ expect(fetchMock).toHaveBeenCalledTimes(2);
318
+ });
319
+ });
320
+ });
@@ -1,3 +1,11 @@
1
1
  import { Gitlab } from '@gitbeaker/rest';
2
2
  export declare function getGitlabClient(): InstanceType<typeof Gitlab>;
3
+ /**
4
+ * Get the SHA of the latest commit on the default branch.
5
+ */
6
+ export declare function getLatestCommitSha(client: InstanceType<typeof Gitlab>, projectId: number | string): Promise<string>;
7
+ /**
8
+ * Download the repository archive as a Buffer.
9
+ */
10
+ export declare function downloadArchive(client: InstanceType<typeof Gitlab>, projectId: number | string): Promise<Buffer>;
3
11
  //# sourceMappingURL=gitlab.client.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"gitlab.client.d.ts","sourceRoot":"","sources":["../../../../src/integrations/gitlab/gitlab.client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAKzC,wBAAgB,eAAe,IAAI,YAAY,CAAC,OAAO,MAAM,CAAC,CAa7D"}
1
+ {"version":3,"file":"gitlab.client.d.ts","sourceRoot":"","sources":["../../../../src/integrations/gitlab/gitlab.client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAMzC,wBAAgB,eAAe,IAAI,YAAY,CAAC,OAAO,MAAM,CAAC,CAa7D;AAGD;;GAEG;AACH,wBAAsB,kBAAkB,CACpC,MAAM,EAAE,YAAY,CAAC,OAAO,MAAM,CAAC,EACnC,SAAS,EAAE,MAAM,GAAG,MAAM,GAC3B,OAAO,CAAC,MAAM,CAAC,CAQjB;AAED;;GAEG;AACH,wBAAsB,eAAe,CACjC,MAAM,EAAE,YAAY,CAAC,OAAO,MAAM,CAAC,EACnC,SAAS,EAAE,MAAM,GAAG,MAAM,GAC3B,OAAO,CAAC,MAAM,CAAC,CAIjB"}
@@ -1,5 +1,6 @@
1
1
  import { Gitlab } from '@gitbeaker/rest';
2
2
  import { config } from '../../config/app.config.js';
3
+ /* v8 ignore start -- gitbeaker singleton wiring, no custom logic */
3
4
  let gitlabClient = null;
4
5
  export function getGitlabClient() {
5
6
  if (!gitlabClient) {
@@ -13,3 +14,22 @@ export function getGitlabClient() {
13
14
  }
14
15
  return gitlabClient;
15
16
  }
17
+ /* v8 ignore stop */
18
+ /**
19
+ * Get the SHA of the latest commit on the default branch.
20
+ */
21
+ export async function getLatestCommitSha(client, projectId) {
22
+ const commits = await client.Commits.all(projectId, { perPage: 1, maxPages: 1 });
23
+ if (commits.length === 0) {
24
+ throw new Error(`No commits found for project ${projectId}`);
25
+ }
26
+ return commits[0].id;
27
+ }
28
+ /**
29
+ * Download the repository archive as a Buffer.
30
+ */
31
+ export async function downloadArchive(client, projectId) {
32
+ const blob = await client.Repositories.showArchive(projectId, { fileType: 'tar.gz' });
33
+ const arrayBuffer = await blob.arrayBuffer();
34
+ return Buffer.from(arrayBuffer);
35
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=gitlab.client.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gitlab.client.test.d.ts","sourceRoot":"","sources":["../../../../src/integrations/gitlab/gitlab.client.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { getLatestCommitSha, downloadArchive } from './gitlab.client.js';
3
+ function makeMockClient(overrides = {}) {
4
+ const commitsAll = overrides.commitsAll ?? vi.fn();
5
+ const repositoriesShowArchive = overrides.repositoriesShowArchive ?? vi.fn();
6
+ const client = {
7
+ Commits: { all: commitsAll },
8
+ Repositories: { showArchive: repositoriesShowArchive },
9
+ };
10
+ return { client, mocks: { commitsAll, repositoriesShowArchive } };
11
+ }
12
+ describe('getLatestCommitSha', () => {
13
+ it('returns the SHA of the latest commit', async () => {
14
+ const { client, mocks } = makeMockClient({
15
+ commitsAll: vi.fn().mockResolvedValue([
16
+ { id: 'abc123def456', short_id: 'abc123d', title: 'Latest commit' },
17
+ ]),
18
+ });
19
+ const sha = await getLatestCommitSha(client, 42);
20
+ expect(sha).toBe('abc123def456');
21
+ expect(mocks.commitsAll).toHaveBeenCalledWith(42, { perPage: 1, maxPages: 1 });
22
+ });
23
+ it('accepts string project IDs', async () => {
24
+ const { client, mocks } = makeMockClient({
25
+ commitsAll: vi.fn().mockResolvedValue([
26
+ { id: 'sha999', short_id: 'sha999' },
27
+ ]),
28
+ });
29
+ const sha = await getLatestCommitSha(client, 'group%2Fproject');
30
+ expect(sha).toBe('sha999');
31
+ expect(mocks.commitsAll).toHaveBeenCalledWith('group%2Fproject', { perPage: 1, maxPages: 1 });
32
+ });
33
+ it('throws when the commits array is empty', async () => {
34
+ const { client } = makeMockClient({
35
+ commitsAll: vi.fn().mockResolvedValue([]),
36
+ });
37
+ await expect(getLatestCommitSha(client, 42))
38
+ .rejects.toThrow('No commits found');
39
+ });
40
+ });
41
+ describe('downloadArchive', () => {
42
+ it('returns a Buffer with the archive contents', async () => {
43
+ const archiveData = new Uint8Array([0x1f, 0x8b, 0x08, 0x00]);
44
+ const blob = new Blob([archiveData]);
45
+ const { client, mocks } = makeMockClient({
46
+ repositoriesShowArchive: vi.fn().mockResolvedValue(blob),
47
+ });
48
+ const result = await downloadArchive(client, 42);
49
+ expect(result).toBeInstanceOf(Buffer);
50
+ expect(result.length).toBe(4);
51
+ expect(result[0]).toBe(0x1f);
52
+ expect(mocks.repositoriesShowArchive).toHaveBeenCalledWith(42, { fileType: 'tar.gz' });
53
+ });
54
+ it('accepts string project IDs', async () => {
55
+ const blob = new Blob([new Uint8Array([0x00])]);
56
+ const { client, mocks } = makeMockClient({
57
+ repositoriesShowArchive: vi.fn().mockResolvedValue(blob),
58
+ });
59
+ await downloadArchive(client, 'group%2Fproject');
60
+ expect(mocks.repositoriesShowArchive).toHaveBeenCalledWith('group%2Fproject', { fileType: 'tar.gz' });
61
+ });
62
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=jira.client.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"jira.client.test.d.ts","sourceRoot":"","sources":["../../../../src/integrations/jira/jira.client.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,171 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { JiraClient } from './jira.client.js';
3
+ const BASE_URL = 'https://example.atlassian.net';
4
+ function makeClient() {
5
+ return new JiraClient({ baseUrl: BASE_URL, email: 'test@test.com', apiToken: 'token' });
6
+ }
7
+ const issueFixture = {
8
+ id: '10001',
9
+ key: 'PROJ-1',
10
+ self: `${BASE_URL}/rest/api/3/issue/PROJ-1`,
11
+ fields: {
12
+ summary: 'Test Issue',
13
+ status: { name: 'In Progress', id: '1' },
14
+ issuetype: { name: 'Story', id: '1' },
15
+ project: { key: 'PROJ', id: '1', name: 'My Project' },
16
+ assignee: null,
17
+ reporter: null,
18
+ created: '2024-01-01T00:00:00.000Z',
19
+ updated: '2024-01-02T00:00:00.000Z',
20
+ resolutiondate: null,
21
+ labels: [],
22
+ components: [],
23
+ },
24
+ };
25
+ function makeCommentsResponse(comments) {
26
+ return { comments };
27
+ }
28
+ function makeAdfComment(content, displayName = 'John Doe') {
29
+ return {
30
+ id: '1',
31
+ author: { accountId: 'u1', displayName },
32
+ body: { type: 'doc', version: 1, content },
33
+ created: '2024-01-01T00:00:00.000Z',
34
+ updated: '2024-01-01T00:00:00.000Z',
35
+ };
36
+ }
37
+ function jsonResponse(data, status = 200) {
38
+ return new Response(JSON.stringify(data), {
39
+ status,
40
+ headers: { 'Content-Type': 'application/json' },
41
+ });
42
+ }
43
+ describe('JiraClient.getIssue', () => {
44
+ let fetchMock;
45
+ beforeEach(() => {
46
+ fetchMock = vi.fn();
47
+ vi.stubGlobal('fetch', fetchMock);
48
+ });
49
+ afterEach(() => {
50
+ vi.unstubAllGlobals();
51
+ });
52
+ it('returns issue with parsed ADF comments', async () => {
53
+ const comment = makeAdfComment([
54
+ { type: 'paragraph', content: [{ type: 'text', text: 'Hello world' }] },
55
+ ]);
56
+ fetchMock
57
+ .mockResolvedValueOnce(jsonResponse(issueFixture))
58
+ .mockResolvedValueOnce(jsonResponse(makeCommentsResponse([comment])));
59
+ const result = await makeClient().getIssue('PROJ-1');
60
+ expect(result.key).toBe('PROJ-1');
61
+ expect(result.comments).toHaveLength(1);
62
+ expect(result.comments[0].author).toBe('John Doe');
63
+ expect(result.comments[0].body).toContain('Hello world');
64
+ });
65
+ it('parses paragraph ADF node to plain text', async () => {
66
+ const comment = makeAdfComment([
67
+ { type: 'paragraph', content: [{ type: 'text', text: 'A paragraph.' }] },
68
+ ]);
69
+ fetchMock
70
+ .mockResolvedValueOnce(jsonResponse(issueFixture))
71
+ .mockResolvedValueOnce(jsonResponse(makeCommentsResponse([comment])));
72
+ const { comments } = await makeClient().getIssue('PROJ-1');
73
+ expect(comments[0].body).toBe('A paragraph.');
74
+ });
75
+ it('parses blockquote ADF node with > prefix', async () => {
76
+ const comment = makeAdfComment([
77
+ { type: 'blockquote', content: [
78
+ { type: 'paragraph', content: [{ type: 'text', text: 'quoted text' }] },
79
+ ] },
80
+ ]);
81
+ fetchMock
82
+ .mockResolvedValueOnce(jsonResponse(issueFixture))
83
+ .mockResolvedValueOnce(jsonResponse(makeCommentsResponse([comment])));
84
+ const { comments } = await makeClient().getIssue('PROJ-1');
85
+ expect(comments[0].body).toContain('> ');
86
+ expect(comments[0].body).toContain('quoted text');
87
+ });
88
+ it('parses heading ADF node with ## prefix', async () => {
89
+ const comment = makeAdfComment([
90
+ { type: 'heading', content: [{ type: 'text', text: 'Section Header' }] },
91
+ ]);
92
+ fetchMock
93
+ .mockResolvedValueOnce(jsonResponse(issueFixture))
94
+ .mockResolvedValueOnce(jsonResponse(makeCommentsResponse([comment])));
95
+ const { comments } = await makeClient().getIssue('PROJ-1');
96
+ expect(comments[0].body).toContain('## Section Header');
97
+ });
98
+ it('parses listItem ADF node with - prefix', async () => {
99
+ const comment = makeAdfComment([
100
+ { type: 'listItem', content: [{ type: 'text', text: 'list item' }] },
101
+ ]);
102
+ fetchMock
103
+ .mockResolvedValueOnce(jsonResponse(issueFixture))
104
+ .mockResolvedValueOnce(jsonResponse(makeCommentsResponse([comment])));
105
+ const { comments } = await makeClient().getIssue('PROJ-1');
106
+ expect(comments[0].body).toContain('- list item');
107
+ });
108
+ it('parses codeBlock ADF node as fenced code', async () => {
109
+ const comment = makeAdfComment([
110
+ { type: 'codeBlock', content: [{ type: 'text', text: 'console.log("hi")' }] },
111
+ ]);
112
+ fetchMock
113
+ .mockResolvedValueOnce(jsonResponse(issueFixture))
114
+ .mockResolvedValueOnce(jsonResponse(makeCommentsResponse([comment])));
115
+ const { comments } = await makeClient().getIssue('PROJ-1');
116
+ expect(comments[0].body).toContain('```');
117
+ expect(comments[0].body).toContain('console.log("hi")');
118
+ });
119
+ it('parses mention ADF node using attrs.text', async () => {
120
+ const comment = makeAdfComment([
121
+ { type: 'paragraph', content: [
122
+ { type: 'mention', attrs: { text: '@Jane', id: 'u2' } },
123
+ ] },
124
+ ]);
125
+ fetchMock
126
+ .mockResolvedValueOnce(jsonResponse(issueFixture))
127
+ .mockResolvedValueOnce(jsonResponse(makeCommentsResponse([comment])));
128
+ const { comments } = await makeClient().getIssue('PROJ-1');
129
+ expect(comments[0].body).toContain('@Jane');
130
+ });
131
+ it('passes through string comment body as-is', async () => {
132
+ const comment = {
133
+ id: '2',
134
+ author: { accountId: 'u1', displayName: 'Jane' },
135
+ body: 'plain string comment',
136
+ created: '2024-01-01T00:00:00.000Z',
137
+ updated: '2024-01-01T00:00:00.000Z',
138
+ };
139
+ fetchMock
140
+ .mockResolvedValueOnce(jsonResponse(issueFixture))
141
+ .mockResolvedValueOnce(jsonResponse(makeCommentsResponse([comment])));
142
+ const { comments } = await makeClient().getIssue('PROJ-1');
143
+ // String comments don't have type 'doc' so they are filtered out
144
+ expect(comments).toHaveLength(0);
145
+ });
146
+ it('filters out non-ADF comments (type !== "doc")', async () => {
147
+ const badComment = {
148
+ id: '3',
149
+ author: { accountId: 'u1', displayName: 'Bob' },
150
+ body: { type: 'unknown', content: [] },
151
+ created: '2024-01-01T00:00:00.000Z',
152
+ updated: '2024-01-01T00:00:00.000Z',
153
+ };
154
+ fetchMock
155
+ .mockResolvedValueOnce(jsonResponse(issueFixture))
156
+ .mockResolvedValueOnce(jsonResponse(makeCommentsResponse([badComment])));
157
+ const { comments } = await makeClient().getIssue('PROJ-1');
158
+ expect(comments).toHaveLength(0);
159
+ });
160
+ it('returns empty comments array when no comments exist', async () => {
161
+ fetchMock
162
+ .mockResolvedValueOnce(jsonResponse(issueFixture))
163
+ .mockResolvedValueOnce(jsonResponse(makeCommentsResponse([])));
164
+ const { comments } = await makeClient().getIssue('PROJ-1');
165
+ expect(comments).toEqual([]);
166
+ });
167
+ it('throws on non-ok HTTP response', async () => {
168
+ fetchMock.mockResolvedValueOnce(new Response('Not Found', { status: 404, statusText: 'Not Found' }));
169
+ await expect(makeClient().getIssue('PROJ-999')).rejects.toThrow('Jira API error: 404');
170
+ });
171
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=anthropic-retry-wrapper.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"anthropic-retry-wrapper.test.d.ts","sourceRoot":"","sources":["../../../src/llm/anthropic-retry-wrapper.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,125 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { invokeWithRetry, RateLimitError } from './anthropic-retry-wrapper.js';
3
+ function make429Error(overrides = {}) {
4
+ const err = new Error('rate limit exceeded');
5
+ err.status = 429;
6
+ return Object.assign(err, overrides);
7
+ }
8
+ describe('invokeWithRetry', () => {
9
+ beforeEach(() => { vi.useFakeTimers(); });
10
+ afterEach(() => { vi.useRealTimers(); });
11
+ it('returns result on first attempt', async () => {
12
+ const agent = { invoke: vi.fn().mockResolvedValue('result') };
13
+ expect(await invokeWithRetry(agent, 'input')).toBe('result');
14
+ expect(agent.invoke).toHaveBeenCalledOnce();
15
+ });
16
+ it('retries after 429 and returns result on second attempt', async () => {
17
+ const agent = { invoke: vi.fn() };
18
+ agent.invoke
19
+ .mockRejectedValueOnce(make429Error())
20
+ .mockResolvedValueOnce('result');
21
+ const promise = invokeWithRetry(agent, 'input');
22
+ await vi.runAllTimersAsync();
23
+ expect(await promise).toBe('result');
24
+ expect(agent.invoke).toHaveBeenCalledTimes(2);
25
+ });
26
+ it('throws RateLimitError after exhausting all retries', async () => {
27
+ const agent = { invoke: vi.fn().mockRejectedValue(make429Error()) };
28
+ const promise = invokeWithRetry(agent, 'input', 3);
29
+ // Attach rejection handler before running timers to avoid unhandled rejection warnings
30
+ const assertion = expect(promise).rejects.toBeInstanceOf(RateLimitError);
31
+ await vi.runAllTimersAsync();
32
+ await assertion;
33
+ expect(agent.invoke).toHaveBeenCalledTimes(3);
34
+ });
35
+ it('throws immediately on non-429 error without retrying', async () => {
36
+ const agent = { invoke: vi.fn().mockRejectedValue(new Error('Internal server error')) };
37
+ await expect(invokeWithRetry(agent, 'input')).rejects.toThrow('Internal server error');
38
+ expect(agent.invoke).toHaveBeenCalledOnce();
39
+ });
40
+ it('detects 429 via message containing "rate limit"', async () => {
41
+ const err = new Error('Claude rate limit reached');
42
+ const agent = { invoke: vi.fn() };
43
+ agent.invoke.mockRejectedValueOnce(err).mockResolvedValueOnce('ok');
44
+ const promise = invokeWithRetry(agent, 'input');
45
+ await vi.runAllTimersAsync();
46
+ expect(await promise).toBe('ok');
47
+ });
48
+ it('detects 429 via message containing "too many requests"', async () => {
49
+ const err = new Error('Too Many Requests from this endpoint');
50
+ const agent = { invoke: vi.fn() };
51
+ agent.invoke.mockRejectedValueOnce(err).mockResolvedValueOnce('ok');
52
+ const promise = invokeWithRetry(agent, 'input');
53
+ await vi.runAllTimersAsync();
54
+ expect(await promise).toBe('ok');
55
+ });
56
+ it('uses custom maxRetries count', async () => {
57
+ const agent = { invoke: vi.fn().mockRejectedValue(make429Error()) };
58
+ const promise = invokeWithRetry(agent, 'input', 2);
59
+ const assertion = expect(promise).rejects.toBeInstanceOf(RateLimitError);
60
+ await vi.runAllTimersAsync();
61
+ await assertion;
62
+ expect(agent.invoke).toHaveBeenCalledTimes(2);
63
+ });
64
+ });
65
+ describe('RateLimitError', () => {
66
+ it('is instanceof Error', () => {
67
+ expect(new RateLimitError('msg', 60)).toBeInstanceOf(Error);
68
+ });
69
+ it('has name RateLimitError', () => {
70
+ expect(new RateLimitError('msg', 60).name).toBe('RateLimitError');
71
+ });
72
+ it('stores retryAfterSeconds', () => {
73
+ expect(new RateLimitError('msg', 30).retryAfterSeconds).toBe(30);
74
+ });
75
+ it('stores null retryAfterSeconds', () => {
76
+ expect(new RateLimitError('msg', null).retryAfterSeconds).toBeNull();
77
+ });
78
+ });
79
+ describe('extractRetryAfter (tested via RateLimitError on exhaustion)', () => {
80
+ beforeEach(() => { vi.useFakeTimers(); });
81
+ afterEach(() => { vi.useRealTimers(); });
82
+ async function exhaustAndGetError(err) {
83
+ const agent = { invoke: vi.fn().mockRejectedValue(err) };
84
+ const promise = invokeWithRetry(agent, 'input', 3);
85
+ // Attach handler before running timers to prevent unhandled rejection warnings
86
+ let caughtError;
87
+ promise.catch((e) => { caughtError = e; });
88
+ await vi.runAllTimersAsync();
89
+ return caughtError;
90
+ }
91
+ it('reads retry-after from error.headers', async () => {
92
+ const err = make429Error({ headers: { 'retry-after': '45' } });
93
+ const result = await exhaustAndGetError(err);
94
+ expect(result.retryAfterSeconds).toBe(45);
95
+ });
96
+ it('reads retry-after from error.response.headers as plain object', async () => {
97
+ const err = make429Error({ response: { headers: { 'retry-after': '30' } } });
98
+ const result = await exhaustAndGetError(err);
99
+ expect(result.retryAfterSeconds).toBe(30);
100
+ });
101
+ it('reads retry-after from error.response.headers.get() (fetch Headers interface)', async () => {
102
+ const err = make429Error({
103
+ response: {
104
+ headers: { get: (key) => key === 'retry-after' ? '20' : undefined },
105
+ },
106
+ });
107
+ const result = await exhaustAndGetError(err);
108
+ expect(result.retryAfterSeconds).toBe(20);
109
+ });
110
+ it('reads retry-after from error.error.headers (nested shape)', async () => {
111
+ const err = make429Error({ error: { headers: { 'retry-after': '15' } } });
112
+ const result = await exhaustAndGetError(err);
113
+ expect(result.retryAfterSeconds).toBe(15);
114
+ });
115
+ it('returns null retryAfterSeconds when no header present', async () => {
116
+ const err = make429Error();
117
+ const result = await exhaustAndGetError(err);
118
+ expect(result.retryAfterSeconds).toBeNull();
119
+ });
120
+ it('returns null retryAfterSeconds when retry-after is not a valid integer', async () => {
121
+ const err = make429Error({ headers: { 'retry-after': 'not-a-number' } });
122
+ const result = await exhaustAndGetError(err);
123
+ expect(result.retryAfterSeconds).toBeNull();
124
+ });
125
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=confluence-document.processor.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"confluence-document.processor.test.d.ts","sourceRoot":"","sources":["../../../src/processors/confluence-document.processor.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,202 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { splitMarkdownBySections, processConfluencePage } from './confluence-document.processor.js';
3
+ function makePage(overrides) {
4
+ const { html, ...rest } = overrides;
5
+ return {
6
+ id: 'page1',
7
+ status: 'current',
8
+ title: 'Test Page',
9
+ spaceId: 'SPACE1',
10
+ authorId: 'author1',
11
+ createdAt: '2024-01-01T00:00:00.000Z',
12
+ version: {
13
+ createdAt: '2024-01-02T00:00:00.000Z',
14
+ message: '',
15
+ number: 1,
16
+ minorEdit: false,
17
+ authorId: 'author1',
18
+ },
19
+ body: {
20
+ storage: {
21
+ value: html ?? '<p>Default content</p>',
22
+ representation: 'storage',
23
+ },
24
+ },
25
+ _links: { webui: '/spaces/TEST/pages/page1/Test+Page' },
26
+ ...rest,
27
+ };
28
+ }
29
+ describe('splitMarkdownBySections', () => {
30
+ it('returns a single chunk with undefined sectionTitle when no H2 headings', () => {
31
+ const chunks = splitMarkdownBySections('Some content without headings');
32
+ expect(chunks).toHaveLength(1);
33
+ expect(chunks[0].sectionTitle).toBeUndefined();
34
+ expect(chunks[0].sectionIndex).toBe(0);
35
+ expect(chunks[0].content).toBe('Some content without headings');
36
+ });
37
+ it('returns N chunks for N H2 headings', () => {
38
+ const md = '## First\nContent A\n## Second\nContent B\n## Third\nContent C';
39
+ expect(splitMarkdownBySections(md)).toHaveLength(3);
40
+ });
41
+ it('chunk sectionTitle matches the H2 heading text', () => {
42
+ const md = '## My Heading\nSome text';
43
+ const [chunk] = splitMarkdownBySections(md);
44
+ expect(chunk.sectionTitle).toBe('My Heading');
45
+ });
46
+ it('sectionIndex is 0-based and sequential', () => {
47
+ const md = '## A\ntext\n## B\ntext\n## C\ntext';
48
+ const chunks = splitMarkdownBySections(md);
49
+ expect(chunks.map(c => c.sectionIndex)).toEqual([0, 1, 2]);
50
+ });
51
+ it('each chunk content starts with the ## heading line', () => {
52
+ const md = '## Section One\nContent here';
53
+ const [chunk] = splitMarkdownBySections(md);
54
+ expect(chunk.content).toMatch(/^## Section One/);
55
+ });
56
+ it('H3 and H4 headings do not split sections', () => {
57
+ const md = '## Section\n### Sub A\n#### Sub B\nContent';
58
+ expect(splitMarkdownBySections(md)).toHaveLength(1);
59
+ });
60
+ it('trims whitespace from chunk content', () => {
61
+ const md = '## Title\n\n Content \n\n';
62
+ const [chunk] = splitMarkdownBySections(md);
63
+ expect(chunk.content).toBe(chunk.content.trim());
64
+ });
65
+ it('returns empty string content as a single chunk', () => {
66
+ const chunks = splitMarkdownBySections('');
67
+ expect(chunks).toHaveLength(1);
68
+ expect(chunks[0].content).toBe('');
69
+ });
70
+ });
71
+ describe('processConfluencePage', () => {
72
+ const BASE_URL = 'https://example.atlassian.net';
73
+ const SPACE_KEY = 'TEST';
74
+ it('returns empty array when page has no body content', () => {
75
+ const page = makePage({ body: undefined });
76
+ expect(processConfluencePage(page, SPACE_KEY, BASE_URL)).toEqual([]);
77
+ });
78
+ it('returns empty array when page body value is empty string', () => {
79
+ const page = makePage({ html: '' });
80
+ expect(processConfluencePage(page, SPACE_KEY, BASE_URL)).toHaveLength(0);
81
+ });
82
+ it('document_id uses format confluence:{spaceKey}:{pageId}_section_{index}', () => {
83
+ const page = makePage({ id: 'abc123', html: '<p>Hello</p>' });
84
+ const [doc] = processConfluencePage(page, 'PROJ', BASE_URL);
85
+ expect(doc.metadata.document_id).toBe('confluence:PROJ:abc123_section_0');
86
+ });
87
+ it('strips trailing slash from baseUrl when constructing page_url', () => {
88
+ const page = makePage({ html: '<p>Hello</p>' });
89
+ const [doc] = processConfluencePage(page, SPACE_KEY, 'https://example.atlassian.net/');
90
+ // Trailing slash stripped: result should be /wiki/spaces/... not //wiki/spaces/...
91
+ expect(doc.metadata.page_url).not.toContain('//wiki');
92
+ expect(doc.metadata.page_url).toContain('https://example.atlassian.net/wiki');
93
+ });
94
+ it('page_url includes the page webui path', () => {
95
+ const page = makePage({ html: '<p>Hello</p>' });
96
+ const [doc] = processConfluencePage(page, SPACE_KEY, BASE_URL);
97
+ expect(doc.metadata.page_url).toContain(page._links.webui);
98
+ });
99
+ it('chunk_type is full_page when there are no H2 headings', () => {
100
+ const page = makePage({ html: '<p>Simple content with no headings</p>' });
101
+ const [doc] = processConfluencePage(page, SPACE_KEY, BASE_URL);
102
+ expect(doc.metadata.chunk_type).toBe('full_page');
103
+ });
104
+ it('chunk_type is section when H2 headings are present', () => {
105
+ const page = makePage({ html: '<h2>Section</h2><p>Content</p>' });
106
+ const docs = processConfluencePage(page, SPACE_KEY, BASE_URL);
107
+ expect(docs[0].metadata.chunk_type).toBe('section');
108
+ });
109
+ it('page with two H2s produces two documents', () => {
110
+ const page = makePage({ html: '<h2>Part A</h2><p>First</p><h2>Part B</h2><p>Second</p>' });
111
+ expect(processConfluencePage(page, SPACE_KEY, BASE_URL)).toHaveLength(2);
112
+ });
113
+ it('metadata.source is always "confluence"', () => {
114
+ const page = makePage({ html: '<p>Hello</p>' });
115
+ const [doc] = processConfluencePage(page, SPACE_KEY, BASE_URL);
116
+ expect(doc.metadata.source).toBe('confluence');
117
+ });
118
+ it('metadata.document_type is always "confluence_page"', () => {
119
+ const page = makePage({ html: '<p>Hello</p>' });
120
+ const [doc] = processConfluencePage(page, SPACE_KEY, BASE_URL);
121
+ expect(doc.metadata.document_type).toBe('confluence_page');
122
+ });
123
+ it('passes labels through to metadata', () => {
124
+ const page = makePage({ html: '<p>Hello</p>' });
125
+ const [doc] = processConfluencePage(page, SPACE_KEY, BASE_URL, ['faq', 'active']);
126
+ expect(doc.metadata.labels).toEqual(['faq', 'active']);
127
+ });
128
+ describe('label metadata extraction', () => {
129
+ it('maps "project-info" label to content_type', () => {
130
+ const page = makePage({ html: '<p>Hello</p>' });
131
+ const [doc] = processConfluencePage(page, SPACE_KEY, BASE_URL, ['project-info']);
132
+ expect(doc.metadata.content_type).toBe('project-info');
133
+ });
134
+ it('maps "faq" label to content_type', () => {
135
+ const page = makePage({ html: '<p>Hello</p>' });
136
+ const [doc] = processConfluencePage(page, SPACE_KEY, BASE_URL, ['faq']);
137
+ expect(doc.metadata.content_type).toBe('faq');
138
+ });
139
+ it('maps "customer-acme" label to customer field', () => {
140
+ const page = makePage({ html: '<p>Hello</p>' });
141
+ const [doc] = processConfluencePage(page, SPACE_KEY, BASE_URL, ['customer-acme']);
142
+ expect(doc.metadata.customer).toBe('customer-acme');
143
+ });
144
+ it('maps "active" label to category', () => {
145
+ const page = makePage({ html: '<p>Hello</p>' });
146
+ const [doc] = processConfluencePage(page, SPACE_KEY, BASE_URL, ['active']);
147
+ expect(doc.metadata.category).toBe('active');
148
+ });
149
+ it('maps "archived" label to category', () => {
150
+ const page = makePage({ html: '<p>Hello</p>' });
151
+ const [doc] = processConfluencePage(page, SPACE_KEY, BASE_URL, ['archived']);
152
+ expect(doc.metadata.category).toBe('archived');
153
+ });
154
+ it('remaining labels go to keywords', () => {
155
+ const page = makePage({ html: '<p>Hello</p>' });
156
+ const [doc] = processConfluencePage(page, SPACE_KEY, BASE_URL, ['shopware', 'v6', 'backend']);
157
+ expect(doc.metadata.keywords).toEqual(['shopware', 'v6', 'backend']);
158
+ });
159
+ it('categorized labels are NOT duplicated in keywords', () => {
160
+ const page = makePage({ html: '<p>Hello</p>' });
161
+ const [doc] = processConfluencePage(page, SPACE_KEY, BASE_URL, ['faq', 'active', 'customer-xyz', 'shopware']);
162
+ expect(doc.metadata.keywords).toEqual(['shopware']);
163
+ expect(doc.metadata.keywords).not.toContain('faq');
164
+ expect(doc.metadata.keywords).not.toContain('active');
165
+ expect(doc.metadata.keywords).not.toContain('customer-xyz');
166
+ });
167
+ it('empty labels result in undefined content_type, customer, category and empty keywords', () => {
168
+ const page = makePage({ html: '<p>Hello</p>' });
169
+ const [doc] = processConfluencePage(page, SPACE_KEY, BASE_URL, []);
170
+ expect(doc.metadata.content_type).toBeUndefined();
171
+ expect(doc.metadata.customer).toBeUndefined();
172
+ expect(doc.metadata.category).toBeUndefined();
173
+ expect(doc.metadata.keywords).toEqual([]);
174
+ });
175
+ });
176
+ describe('content analysis', () => {
177
+ it('has_code_blocks is true when content contains triple backtick', () => {
178
+ // Use a Confluence code macro so the HTML parser produces backticks
179
+ const html = `<ac:structured-macro ac:name="code">
180
+ <ac:plain-text-body>const x = 1;</ac:plain-text-body>
181
+ </ac:structured-macro>`;
182
+ const page = makePage({ html });
183
+ const [doc] = processConfluencePage(page, SPACE_KEY, BASE_URL);
184
+ expect(doc.metadata.has_code_blocks).toBe(true);
185
+ });
186
+ it('has_code_blocks is false when content has no code blocks', () => {
187
+ const page = makePage({ html: '<p>Plain text only</p>' });
188
+ const [doc] = processConfluencePage(page, SPACE_KEY, BASE_URL);
189
+ expect(doc.metadata.has_code_blocks).toBe(false);
190
+ });
191
+ it('has_links is true when content contains a markdown link', () => {
192
+ const page = makePage({ html: '<a href="https://example.com">click</a>' });
193
+ const [doc] = processConfluencePage(page, SPACE_KEY, BASE_URL);
194
+ expect(doc.metadata.has_links).toBe(true);
195
+ });
196
+ it('word_count reflects number of words in content', () => {
197
+ const page = makePage({ html: '<p>one two three four five</p>' });
198
+ const [doc] = processConfluencePage(page, SPACE_KEY, BASE_URL);
199
+ expect(doc.metadata.word_count).toBe(5);
200
+ });
201
+ });
202
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=confluence-html-parser.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"confluence-html-parser.test.d.ts","sourceRoot":"","sources":["../../../src/processors/confluence-html-parser.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,214 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseConfluenceHtml } from './confluence-html-parser.js';
3
+ describe('parseConfluenceHtml', () => {
4
+ describe('basic HTML conversion', () => {
5
+ it('converts h2 to ## heading', () => {
6
+ expect(parseConfluenceHtml('<h2>My Section</h2>')).toBe('## My Section');
7
+ });
8
+ it('converts h1 to # heading', () => {
9
+ expect(parseConfluenceHtml('<h1>Title</h1>')).toBe('# Title');
10
+ });
11
+ it('converts strong to **bold**', () => {
12
+ expect(parseConfluenceHtml('<p><strong>bold text</strong></p>')).toContain('**bold text**');
13
+ });
14
+ it('converts inline code to backtick', () => {
15
+ expect(parseConfluenceHtml('<p><code>myFunc()</code></p>')).toContain('`myFunc()`');
16
+ });
17
+ it('converts anchor tag to markdown link', () => {
18
+ const result = parseConfluenceHtml('<a href="https://example.com">click here</a>');
19
+ expect(result).toContain('[click here](https://example.com)');
20
+ });
21
+ });
22
+ describe('whitespace cleanup', () => {
23
+ it('collapses 3+ consecutive newlines to 2', () => {
24
+ const html = '<p>First</p>\n\n\n\n<p>Second</p>';
25
+ const result = parseConfluenceHtml(html);
26
+ expect(result).not.toMatch(/\n{3,}/);
27
+ });
28
+ it('removes trailing spaces from lines', () => {
29
+ const result = parseConfluenceHtml('<p>Hello world</p>');
30
+ const lines = result.split('\n');
31
+ for (const line of lines) {
32
+ expect(line).toBe(line.trimEnd());
33
+ }
34
+ });
35
+ it('trims leading and trailing whitespace', () => {
36
+ const result = parseConfluenceHtml('<p>Hello</p>');
37
+ expect(result).toBe(result.trim());
38
+ });
39
+ });
40
+ describe('Confluence code macro', () => {
41
+ it('renders code macro as fenced code block', () => {
42
+ const html = `<ac:structured-macro ac:name="code">
43
+ <ac:plain-text-body>const x = 1;</ac:plain-text-body>
44
+ </ac:structured-macro>`;
45
+ const result = parseConfluenceHtml(html);
46
+ expect(result).toContain('```');
47
+ expect(result).toContain('const x = 1;');
48
+ });
49
+ it('includes language when parameter is present', () => {
50
+ const html = `<ac:structured-macro ac:name="code">
51
+ <ac:parameter ac:name="language">javascript</ac:parameter>
52
+ <ac:plain-text-body>const x = 1;</ac:plain-text-body>
53
+ </ac:structured-macro>`;
54
+ const result = parseConfluenceHtml(html);
55
+ expect(result).toContain('```javascript');
56
+ });
57
+ });
58
+ describe('Confluence panel macros', () => {
59
+ it('renders info macro as blockquote with INFO label', () => {
60
+ const html = `<ac:structured-macro ac:name="info">
61
+ <ac:rich-text-body><p>Important info</p></ac:rich-text-body>
62
+ </ac:structured-macro>`;
63
+ const result = parseConfluenceHtml(html);
64
+ expect(result).toContain('> **INFO**');
65
+ });
66
+ it('renders warning macro as blockquote with WARNING label', () => {
67
+ const html = `<ac:structured-macro ac:name="warning">
68
+ <ac:rich-text-body><p>Be careful</p></ac:rich-text-body>
69
+ </ac:structured-macro>`;
70
+ const result = parseConfluenceHtml(html);
71
+ expect(result).toContain('> **WARNING**');
72
+ });
73
+ it('renders tip macro as blockquote with TIP label', () => {
74
+ const html = `<ac:structured-macro ac:name="tip">
75
+ <ac:rich-text-body><p>A helpful tip</p></ac:rich-text-body>
76
+ </ac:structured-macro>`;
77
+ const result = parseConfluenceHtml(html);
78
+ expect(result).toContain('> **TIP**');
79
+ });
80
+ it('renders note macro as blockquote with NOTE label', () => {
81
+ const html = `<ac:structured-macro ac:name="note">
82
+ <ac:rich-text-body><p>Take note</p></ac:rich-text-body>
83
+ </ac:structured-macro>`;
84
+ const result = parseConfluenceHtml(html);
85
+ expect(result).toContain('> **NOTE**');
86
+ });
87
+ });
88
+ describe('Confluence TOC macro', () => {
89
+ it('renders toc macro as Table of Contents text', () => {
90
+ // Real Confluence TOC macros include parameters — an empty element is
91
+ // not matched by the filter in some DOM parsers
92
+ const html = `<ac:structured-macro ac:name="toc">
93
+ <ac:parameter ac:name="maxLevel">3</ac:parameter>
94
+ </ac:structured-macro>`;
95
+ const result = parseConfluenceHtml(html);
96
+ expect(result).toContain('**Table of Contents**');
97
+ });
98
+ });
99
+ describe('Confluence JIRA macro', () => {
100
+ it('renders jira macro with key as JIRA Issue reference', () => {
101
+ const html = `<ac:structured-macro ac:name="jira">
102
+ <ac:parameter ac:name="key">PROJ-123</ac:parameter>
103
+ </ac:structured-macro>`;
104
+ const result = parseConfluenceHtml(html);
105
+ expect(result).toContain('**JIRA Issue:** PROJ-123');
106
+ });
107
+ it('renders empty string when jira macro has no key', () => {
108
+ const html = `<ac:structured-macro ac:name="jira"></ac:structured-macro>`;
109
+ const result = parseConfluenceHtml(html);
110
+ expect(result).not.toContain('JIRA Issue');
111
+ });
112
+ });
113
+ describe('Confluence status macro', () => {
114
+ it('renders status macro as **[title]**', () => {
115
+ const html = `<ac:structured-macro ac:name="status">
116
+ <ac:parameter ac:name="title">IN PROGRESS</ac:parameter>
117
+ </ac:structured-macro>`;
118
+ const result = parseConfluenceHtml(html);
119
+ expect(result).toContain('**[IN PROGRESS]**');
120
+ });
121
+ it('appends color in parentheses when colour parameter is present', () => {
122
+ const html = `<ac:structured-macro ac:name="status">
123
+ <ac:parameter ac:name="title">DONE</ac:parameter>
124
+ <ac:parameter ac:name="colour">Green</ac:parameter>
125
+ </ac:structured-macro>`;
126
+ const result = parseConfluenceHtml(html);
127
+ expect(result).toContain('(Green)');
128
+ });
129
+ });
130
+ describe('Confluence anchor macro', () => {
131
+ it('renders anchor macro as named anchor tag', () => {
132
+ const html = `<ac:structured-macro ac:name="anchor">
133
+ <ac:parameter>my-anchor</ac:parameter>
134
+ </ac:structured-macro>`;
135
+ const result = parseConfluenceHtml(html);
136
+ expect(result).toContain('<a name="my-anchor"></a>');
137
+ });
138
+ it('renders empty string when anchor has no parameter text', () => {
139
+ const html = `<ac:structured-macro ac:name="anchor">
140
+ <ac:parameter></ac:parameter>
141
+ </ac:structured-macro>`;
142
+ const result = parseConfluenceHtml(html);
143
+ expect(result).not.toContain('<a name=');
144
+ });
145
+ });
146
+ describe('Confluence excerpt macro', () => {
147
+ it('returns rich-text-body content for excerpt macro', () => {
148
+ const html = `<ac:structured-macro ac:name="excerpt">
149
+ <ac:rich-text-body><p>Excerpt content</p></ac:rich-text-body>
150
+ </ac:structured-macro>`;
151
+ const result = parseConfluenceHtml(html);
152
+ expect(result).toContain('Excerpt content');
153
+ });
154
+ });
155
+ describe('Confluence expand macro', () => {
156
+ it('renders expand macro with title and body', () => {
157
+ const html = `<ac:structured-macro ac:name="expand">
158
+ <ac:parameter ac:name="title">Click to expand</ac:parameter>
159
+ <ac:rich-text-body><p>Hidden content</p></ac:rich-text-body>
160
+ </ac:structured-macro>`;
161
+ const result = parseConfluenceHtml(html);
162
+ expect(result).toContain('**Click to expand**');
163
+ expect(result).toContain('Hidden content');
164
+ });
165
+ it('uses "Details" as default title when no title parameter', () => {
166
+ const html = `<ac:structured-macro ac:name="expand">
167
+ <ac:rich-text-body><p>Some content</p></ac:rich-text-body>
168
+ </ac:structured-macro>`;
169
+ const result = parseConfluenceHtml(html);
170
+ expect(result).toContain('**Details**');
171
+ });
172
+ });
173
+ describe('Confluence quote macro', () => {
174
+ it('renders quote macro as blockquote', () => {
175
+ const html = `<ac:structured-macro ac:name="quote">
176
+ <ac:rich-text-body><p>A famous quote</p></ac:rich-text-body>
177
+ </ac:structured-macro>`;
178
+ const result = parseConfluenceHtml(html);
179
+ expect(result).toContain('> ');
180
+ expect(result).toContain('A famous quote');
181
+ });
182
+ });
183
+ describe('Confluence link macro', () => {
184
+ it('renders ac:link with ri:page as markdown link using content-title', () => {
185
+ const html = `<ac:link><ri:page ri:content-title="My Page"/><ac:plain-text-link-body>My Page</ac:plain-text-link-body></ac:link>`;
186
+ const result = parseConfluenceHtml(html);
187
+ expect(result).toContain('[My Page](My Page)');
188
+ });
189
+ it('renders ac:link without href as plain text', () => {
190
+ const html = `<ac:link><ac:plain-text-link-body>Just text</ac:plain-text-link-body></ac:link>`;
191
+ const result = parseConfluenceHtml(html);
192
+ expect(result).toContain('Just text');
193
+ });
194
+ });
195
+ describe('Confluence unknown macro', () => {
196
+ it('falls back to text content for unknown macros', () => {
197
+ const html = `<ac:structured-macro ac:name="unknown-macro">
198
+ <ac:rich-text-body><p>Fallback text</p></ac:rich-text-body>
199
+ </ac:structured-macro>`;
200
+ const result = parseConfluenceHtml(html);
201
+ expect(result).toContain('Fallback text');
202
+ });
203
+ });
204
+ describe('Confluence JIRA macro with server', () => {
205
+ it('includes server name when present', () => {
206
+ const html = `<ac:structured-macro ac:name="jira">
207
+ <ac:parameter ac:name="key">PROJ-456</ac:parameter>
208
+ <ac:parameter ac:name="server">My Jira</ac:parameter>
209
+ </ac:structured-macro>`;
210
+ const result = parseConfluenceHtml(html);
211
+ expect(result).toContain('(My Jira)');
212
+ });
213
+ });
214
+ });
@@ -22,7 +22,7 @@ export declare function createConfluenceAgentTool(vectorStore: VectorStoreProvid
22
22
  parent_page_id: z.ZodOptional<z.ZodString>;
23
23
  }, "strip", z.ZodTypeAny, {
24
24
  keywords?: string[] | undefined;
25
- content_type?: "template" | "project-info" | "faq" | "guide" | "decision" | undefined;
25
+ content_type?: "project-info" | "template" | "faq" | "guide" | "decision" | undefined;
26
26
  customer?: string | undefined;
27
27
  category?: "archived" | "active" | "maintenance" | "legacy" | undefined;
28
28
  space_key?: string | undefined;
@@ -36,7 +36,7 @@ export declare function createConfluenceAgentTool(vectorStore: VectorStoreProvid
36
36
  parent_page_id?: string | undefined;
37
37
  }, {
38
38
  keywords?: string[] | undefined;
39
- content_type?: "template" | "project-info" | "faq" | "guide" | "decision" | undefined;
39
+ content_type?: "project-info" | "template" | "faq" | "guide" | "decision" | undefined;
40
40
  customer?: string | undefined;
41
41
  category?: "archived" | "active" | "maintenance" | "legacy" | undefined;
42
42
  space_key?: string | undefined;
@@ -53,7 +53,7 @@ export declare function createConfluenceAgentTool(vectorStore: VectorStoreProvid
53
53
  query: string;
54
54
  filters?: {
55
55
  keywords?: string[] | undefined;
56
- content_type?: "template" | "project-info" | "faq" | "guide" | "decision" | undefined;
56
+ content_type?: "project-info" | "template" | "faq" | "guide" | "decision" | undefined;
57
57
  customer?: string | undefined;
58
58
  category?: "archived" | "active" | "maintenance" | "legacy" | undefined;
59
59
  space_key?: string | undefined;
@@ -70,7 +70,7 @@ export declare function createConfluenceAgentTool(vectorStore: VectorStoreProvid
70
70
  query: string;
71
71
  filters?: {
72
72
  keywords?: string[] | undefined;
73
- content_type?: "template" | "project-info" | "faq" | "guide" | "decision" | undefined;
73
+ content_type?: "project-info" | "template" | "faq" | "guide" | "decision" | undefined;
74
74
  customer?: string | undefined;
75
75
  category?: "archived" | "active" | "maintenance" | "legacy" | undefined;
76
76
  space_key?: string | undefined;
@@ -87,7 +87,7 @@ export declare function createConfluenceAgentTool(vectorStore: VectorStoreProvid
87
87
  query: string;
88
88
  filters?: {
89
89
  keywords?: string[] | undefined;
90
- content_type?: "template" | "project-info" | "faq" | "guide" | "decision" | undefined;
90
+ content_type?: "project-info" | "template" | "faq" | "guide" | "decision" | undefined;
91
91
  customer?: string | undefined;
92
92
  category?: "archived" | "active" | "maintenance" | "legacy" | undefined;
93
93
  space_key?: string | undefined;
@@ -104,7 +104,7 @@ export declare function createConfluenceAgentTool(vectorStore: VectorStoreProvid
104
104
  query: string;
105
105
  filters?: {
106
106
  keywords?: string[] | undefined;
107
- content_type?: "template" | "project-info" | "faq" | "guide" | "decision" | undefined;
107
+ content_type?: "project-info" | "template" | "faq" | "guide" | "decision" | undefined;
108
108
  customer?: string | undefined;
109
109
  category?: "archived" | "active" | "maintenance" | "legacy" | undefined;
110
110
  space_key?: string | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hubblecommerce/overmind-core",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Shared infrastructure package for the Overmind AI agent system",
6
6
  "main": "./dist/src/index.js",
@@ -67,6 +67,9 @@
67
67
  "zod": "^3.25.76"
68
68
  },
69
69
  "scripts": {
70
+ "test": "vitest run",
71
+ "test:watch": "vitest",
72
+ "test:coverage": "vitest run --coverage",
70
73
  "build": "tsc",
71
74
  "prepack": "npm run build",
72
75
  "typecheck": "tsc --noEmit",
@@ -84,12 +87,14 @@
84
87
  "@types/node": "^24.6.1",
85
88
  "@types/pg": "^8.15.5",
86
89
  "@types/turndown": "^5.0.5",
90
+ "@vitest/coverage-v8": "^4.1.2",
87
91
  "eslint": "^9.36.0",
88
92
  "husky": "^9.0.0",
89
93
  "lint-staged": "^15.0.0",
90
94
  "tsx": "^4.20.6",
91
95
  "typescript": "^5.9.3",
92
- "typescript-eslint": "^8.45.0"
96
+ "typescript-eslint": "^8.45.0",
97
+ "vitest": "^4.1.2"
93
98
  },
94
99
  "lint-staged": {
95
100
  "*.ts": "eslint --fix"