@hubblecommerce/overmind-core 0.1.3 → 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.
- package/dist/src/integrations/confluence/confluence.client.d.ts +2 -8
- package/dist/src/integrations/confluence/confluence.client.d.ts.map +1 -1
- package/dist/src/integrations/confluence/confluence.client.js +13 -40
- package/dist/src/integrations/confluence/confluence.client.test.d.ts +2 -0
- package/dist/src/integrations/confluence/confluence.client.test.d.ts.map +1 -0
- package/dist/src/integrations/confluence/confluence.client.test.js +320 -0
- package/dist/src/integrations/gitlab/gitlab.client.d.ts +8 -0
- package/dist/src/integrations/gitlab/gitlab.client.d.ts.map +1 -1
- package/dist/src/integrations/gitlab/gitlab.client.js +20 -0
- package/dist/src/integrations/gitlab/gitlab.client.test.d.ts +2 -0
- package/dist/src/integrations/gitlab/gitlab.client.test.d.ts.map +1 -0
- package/dist/src/integrations/gitlab/gitlab.client.test.js +62 -0
- package/dist/src/integrations/jira/jira.client.d.ts +3 -2
- package/dist/src/integrations/jira/jira.client.d.ts.map +1 -1
- package/dist/src/integrations/jira/jira.client.js +23 -86
- package/dist/src/integrations/jira/jira.client.test.d.ts +2 -0
- package/dist/src/integrations/jira/jira.client.test.d.ts.map +1 -0
- package/dist/src/integrations/jira/jira.client.test.js +171 -0
- package/dist/src/llm/anthropic-retry-wrapper.test.d.ts +2 -0
- package/dist/src/llm/anthropic-retry-wrapper.test.d.ts.map +1 -0
- package/dist/src/llm/anthropic-retry-wrapper.test.js +125 -0
- package/dist/src/processors/confluence-document.processor.test.d.ts +2 -0
- package/dist/src/processors/confluence-document.processor.test.d.ts.map +1 -0
- package/dist/src/processors/confluence-document.processor.test.js +202 -0
- package/dist/src/processors/confluence-html-parser.test.d.ts +2 -0
- package/dist/src/processors/confluence-html-parser.test.d.ts.map +1 -0
- package/dist/src/processors/confluence-html-parser.test.js +214 -0
- package/dist/src/tools/confluence-search.tool.d.ts +6 -6
- package/dist/src/tools/get-current-date.tool.d.ts.map +1 -1
- package/dist/src/tools/get-current-date.tool.js +1 -5
- package/package.json +7 -2
|
@@ -5,6 +5,19 @@ export class JiraClient {
|
|
|
5
5
|
this.baseUrl = config.baseUrl;
|
|
6
6
|
this.authHeader = 'Basic ' + Buffer.from(`${config.email}:${config.apiToken}`).toString('base64');
|
|
7
7
|
}
|
|
8
|
+
async request(endpoint) {
|
|
9
|
+
const response = await fetch(`${this.baseUrl}${endpoint}`, {
|
|
10
|
+
headers: {
|
|
11
|
+
'Authorization': this.authHeader,
|
|
12
|
+
'Accept': 'application/json',
|
|
13
|
+
'Content-Type': 'application/json',
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
if (!response.ok) {
|
|
17
|
+
throw new Error(`Jira API error: ${response.status} ${response.statusText}`);
|
|
18
|
+
}
|
|
19
|
+
return response.json();
|
|
20
|
+
}
|
|
8
21
|
/**
|
|
9
22
|
* Parse Atlassian Document Format (ADF) to plain text
|
|
10
23
|
*/
|
|
@@ -55,92 +68,16 @@ export class JiraClient {
|
|
|
55
68
|
*/
|
|
56
69
|
async getIssue(issueKeyOrId) {
|
|
57
70
|
try {
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
const issue = await issueResponse.json();
|
|
70
|
-
// Fetch comments
|
|
71
|
-
const commentsResponse = await fetch(`${this.baseUrl}/rest/api/3/issue/${issueKeyOrId}/comment`, {
|
|
72
|
-
headers: {
|
|
73
|
-
'Authorization': this.authHeader,
|
|
74
|
-
'Content-Type': 'application/json',
|
|
75
|
-
'Accept': 'application/json',
|
|
76
|
-
},
|
|
77
|
-
});
|
|
78
|
-
if (!commentsResponse.ok) {
|
|
79
|
-
throw new Error(`Failed to fetch comments: ${commentsResponse.status} ${commentsResponse.statusText}`);
|
|
80
|
-
}
|
|
81
|
-
const commentsData = await commentsResponse.json();
|
|
82
|
-
// Parse and filter comments (only 'doc' type, latest version only)
|
|
83
|
-
const commentsMap = new Map();
|
|
84
|
-
commentsData.comments?.forEach(comment => {
|
|
85
|
-
// Only include ADF doc type comments
|
|
86
|
-
if (typeof comment.body === 'object' && comment.body !== null) {
|
|
87
|
-
const adfDoc = comment.body;
|
|
88
|
-
if (adfDoc.type !== 'doc') {
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
const version = adfDoc.version || 1;
|
|
92
|
-
const existing = commentsMap.get(comment.id);
|
|
93
|
-
// Only keep the latest version of each comment
|
|
94
|
-
if (!existing || version > existing.version) {
|
|
95
|
-
commentsMap.set(comment.id, {
|
|
96
|
-
author: comment.author.displayName,
|
|
97
|
-
body: this.parseCommentBody(comment.body),
|
|
98
|
-
updated: comment.updated,
|
|
99
|
-
version,
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
});
|
|
104
|
-
// Convert map to array (without version field)
|
|
105
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
106
|
-
const parsedComments = Array.from(commentsMap.values()).map(({ version, ...comment }) => comment);
|
|
107
|
-
// Transform to our interface
|
|
108
|
-
return {
|
|
109
|
-
id: issue.id,
|
|
110
|
-
key: issue.key,
|
|
111
|
-
self: issue.self,
|
|
112
|
-
fields: {
|
|
113
|
-
summary: issue.fields.summary,
|
|
114
|
-
description: issue.fields.description,
|
|
115
|
-
status: {
|
|
116
|
-
name: issue.fields.status.name,
|
|
117
|
-
id: issue.fields.status.id,
|
|
118
|
-
},
|
|
119
|
-
issuetype: {
|
|
120
|
-
name: issue.fields.issuetype.name,
|
|
121
|
-
id: issue.fields.issuetype.id,
|
|
122
|
-
},
|
|
123
|
-
project: {
|
|
124
|
-
key: issue.fields.project.key,
|
|
125
|
-
id: issue.fields.project.id,
|
|
126
|
-
name: issue.fields.project.name,
|
|
127
|
-
},
|
|
128
|
-
assignee: issue.fields.assignee ? {
|
|
129
|
-
accountId: issue.fields.assignee.accountId,
|
|
130
|
-
displayName: issue.fields.assignee.displayName,
|
|
131
|
-
} : null,
|
|
132
|
-
reporter: issue.fields.reporter ? {
|
|
133
|
-
accountId: issue.fields.reporter.accountId,
|
|
134
|
-
displayName: issue.fields.reporter.displayName,
|
|
135
|
-
} : null,
|
|
136
|
-
created: issue.fields.created,
|
|
137
|
-
updated: issue.fields.updated,
|
|
138
|
-
resolutiondate: issue.fields.resolutiondate,
|
|
139
|
-
labels: issue.fields.labels || [],
|
|
140
|
-
components: issue.fields.components || [],
|
|
141
|
-
},
|
|
142
|
-
comments: parsedComments,
|
|
143
|
-
};
|
|
71
|
+
const issue = await this.request(`/rest/api/3/issue/${issueKeyOrId}`);
|
|
72
|
+
const commentsData = await this.request(`/rest/api/3/issue/${issueKeyOrId}/comment`);
|
|
73
|
+
const parsedComments = commentsData.comments
|
|
74
|
+
?.filter(c => typeof c.body === 'object' && c.body !== null && c.body.type === 'doc')
|
|
75
|
+
.map(c => ({
|
|
76
|
+
author: c.author.displayName,
|
|
77
|
+
body: this.parseCommentBody(c.body),
|
|
78
|
+
updated: c.updated,
|
|
79
|
+
})) ?? [];
|
|
80
|
+
return { ...issue, comments: parsedComments };
|
|
144
81
|
}
|
|
145
82
|
catch (error) {
|
|
146
83
|
console.error(`[JiraClient] Error fetching issue ${issueKeyOrId}:`, error);
|
|
@@ -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 @@
|
|
|
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 @@
|
|
|
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 @@
|
|
|
1
|
+
{"version":3,"file":"confluence-html-parser.test.d.ts","sourceRoot":"","sources":["../../../src/processors/confluence-html-parser.test.ts"],"names":[],"mappings":""}
|