@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
|
@@ -19,6 +19,7 @@ export declare class ConfluenceClient {
|
|
|
19
19
|
* Make HTTP request to Confluence API with retries
|
|
20
20
|
*/
|
|
21
21
|
private request;
|
|
22
|
+
private paginate;
|
|
22
23
|
/**
|
|
23
24
|
* Get all spaces (with pagination)
|
|
24
25
|
*/
|
|
@@ -55,17 +56,10 @@ export declare class ConfluenceClient {
|
|
|
55
56
|
* Returns only label names as strings
|
|
56
57
|
*/
|
|
57
58
|
getPageLabels(pageId: string): Promise<string[]>;
|
|
58
|
-
/**
|
|
59
|
-
* Get numeric space ID from space key
|
|
60
|
-
* Helper method to convert space key to numeric ID required by v2 API
|
|
61
|
-
* @param spaceKey - The space key (e.g., "TEST")
|
|
62
|
-
* @returns The numeric space ID
|
|
63
|
-
*/
|
|
64
|
-
getSpaceIdByKey(spaceKey: string): Promise<string>;
|
|
65
59
|
/**
|
|
66
60
|
* Create a new page in Confluence using REST API v2 with ADF (Atlassian Document Format)
|
|
67
61
|
* This is the modern format for the new Confluence editor
|
|
68
|
-
* @param spaceId - The numeric space ID (not space key)
|
|
62
|
+
* @param spaceId - The numeric space ID (not space key)
|
|
69
63
|
* @param title - The page title
|
|
70
64
|
* @param adfBody - The page content in ADF format (from @atlaskit/editor-markdown-transformer)
|
|
71
65
|
* @param parentId - Optional parent page ID to create as child page
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"confluence.client.d.ts","sourceRoot":"","sources":["../../../../src/integrations/confluence/confluence.client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACR,cAAc,EAEd,eAAe,EAKlB,MAAM,uBAAuB,CAAC;AAE/B,MAAM,WAAW,sBAAsB;IACnC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;CACtB;AAuBD,qBAAa,gBAAgB;IACzB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,eAAe,CAAa;gBAExB,MAAM,EAAE,sBAAsB;IAS1C;;OAEG;YACW,gBAAgB;IAa9B;;OAEG;YACW,OAAO;
|
|
1
|
+
{"version":3,"file":"confluence.client.d.ts","sourceRoot":"","sources":["../../../../src/integrations/confluence/confluence.client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACR,cAAc,EAEd,eAAe,EAKlB,MAAM,uBAAuB,CAAC;AAE/B,MAAM,WAAW,sBAAsB;IACnC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;CACtB;AAuBD,qBAAa,gBAAgB;IACzB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,eAAe,CAAa;gBAExB,MAAM,EAAE,sBAAsB;IAS1C;;OAEG;YACW,gBAAgB;IAa9B;;OAEG;YACW,OAAO;YAwDP,QAAQ;IActB;;OAEG;IACG,SAAS,CAAC,KAAK,GAAE,MAAW,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC;IAI/D;;;OAGG;IACG,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,GAAE,MAAW,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC;IAKpF;;OAEG;IACG,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,UAAU,GAAE,SAAS,GAAG,kBAA8B,GAAG,OAAO,CAAC,cAAc,CAAC;IAKlH;;OAEG;IACG,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAUrD;;OAEG;IACG,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC;IAW/D;;OAEG;IACG,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAoBhE;;OAEG;IACG,cAAc,CAAC,SAAS,CAAC,EAAE,MAAM,EAAE,EAAE,KAAK,GAAE,MAAW,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC;IAYzF;;;OAGG;IACG,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IA0BtD;;;;;;;;OAQG;IACG,iBAAiB,CAAC,MAAM,EAAE;QAC5B,OAAO,EAAE,MAAM,CAAC;QAChB,KAAK,EAAE,MAAM,CAAC;QACd,OAAO,EAAE,OAAO,CAAC;QACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;KACrB,GAAG,OAAO,CAAC,cAAc,CAAC;CAuC9B"}
|
|
@@ -73,46 +73,29 @@ export class ConfluenceClient {
|
|
|
73
73
|
}
|
|
74
74
|
throw lastError || new Error('Request failed after max retries');
|
|
75
75
|
}
|
|
76
|
+
async paginate(initialUrl) {
|
|
77
|
+
const items = [];
|
|
78
|
+
let nextUrl = initialUrl;
|
|
79
|
+
while (nextUrl) {
|
|
80
|
+
const response = await this.request(nextUrl);
|
|
81
|
+
items.push(...response.results);
|
|
82
|
+
nextUrl = response._links.next?.replace(this.baseUrl, '');
|
|
83
|
+
}
|
|
84
|
+
return items;
|
|
85
|
+
}
|
|
76
86
|
/**
|
|
77
87
|
* Get all spaces (with pagination)
|
|
78
88
|
*/
|
|
79
89
|
async getSpaces(limit = 25) {
|
|
80
|
-
|
|
81
|
-
let nextUrl = `/wiki/api/v2/spaces?limit=${limit}`;
|
|
82
|
-
while (nextUrl) {
|
|
83
|
-
const response = await this.request(nextUrl);
|
|
84
|
-
const results = response.results;
|
|
85
|
-
spaces.push(...results);
|
|
86
|
-
// Extract cursor from next link if exists
|
|
87
|
-
const links = response._links;
|
|
88
|
-
const nextLink = links.next;
|
|
89
|
-
nextUrl = nextLink ? nextLink.replace(this.baseUrl, '') : undefined;
|
|
90
|
-
}
|
|
91
|
-
return spaces;
|
|
90
|
+
return this.paginate(`/wiki/api/v2/spaces?limit=${limit}`);
|
|
92
91
|
}
|
|
93
92
|
/**
|
|
94
93
|
* Get all pages in a space (with pagination)
|
|
95
94
|
* Note: Accepts space key and automatically resolves to space ID for API v2
|
|
96
95
|
*/
|
|
97
96
|
async getSpacePages(spaceKey, limit = 25) {
|
|
98
|
-
// First, resolve space key to space ID (required by API v2)
|
|
99
97
|
const space = await this.getSpaceByKey(spaceKey);
|
|
100
|
-
|
|
101
|
-
const pages = [];
|
|
102
|
-
let nextUrl = `/wiki/api/v2/spaces/${spaceId}/pages?limit=${limit}&status=current`;
|
|
103
|
-
while (nextUrl) {
|
|
104
|
-
const response = await this.request(nextUrl);
|
|
105
|
-
const results = response.results;
|
|
106
|
-
pages.push(...results);
|
|
107
|
-
// Extract cursor from next link if exists
|
|
108
|
-
const links = response._links;
|
|
109
|
-
nextUrl = links.next || undefined;
|
|
110
|
-
if (nextUrl) {
|
|
111
|
-
// Convert full URL to relative path
|
|
112
|
-
nextUrl = nextUrl.replace(this.baseUrl, '');
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
return pages;
|
|
98
|
+
return this.paginate(`/wiki/api/v2/spaces/${space.id}/pages?limit=${limit}&status=current`);
|
|
116
99
|
}
|
|
117
100
|
/**
|
|
118
101
|
* Get a single page by ID with content
|
|
@@ -197,20 +180,10 @@ export class ConfluenceClient {
|
|
|
197
180
|
// Return only the label names (without prefix)
|
|
198
181
|
return labels.map(label => label.name);
|
|
199
182
|
}
|
|
200
|
-
/**
|
|
201
|
-
* Get numeric space ID from space key
|
|
202
|
-
* Helper method to convert space key to numeric ID required by v2 API
|
|
203
|
-
* @param spaceKey - The space key (e.g., "TEST")
|
|
204
|
-
* @returns The numeric space ID
|
|
205
|
-
*/
|
|
206
|
-
async getSpaceIdByKey(spaceKey) {
|
|
207
|
-
const space = await this.getSpaceByKey(spaceKey);
|
|
208
|
-
return space.id;
|
|
209
|
-
}
|
|
210
183
|
/**
|
|
211
184
|
* Create a new page in Confluence using REST API v2 with ADF (Atlassian Document Format)
|
|
212
185
|
* This is the modern format for the new Confluence editor
|
|
213
|
-
* @param spaceId - The numeric space ID (not space key)
|
|
186
|
+
* @param spaceId - The numeric space ID (not space key)
|
|
214
187
|
* @param title - The page title
|
|
215
188
|
* @param adfBody - The page content in ADF format (from @atlaskit/editor-markdown-transformer)
|
|
216
189
|
* @param parentId - Optional parent page ID to create as child page
|
|
@@ -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;
|
|
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 @@
|
|
|
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
|
+
});
|
|
@@ -48,9 +48,10 @@ export interface JiraClientConfig {
|
|
|
48
48
|
apiToken: string;
|
|
49
49
|
}
|
|
50
50
|
export declare class JiraClient {
|
|
51
|
-
private baseUrl;
|
|
52
|
-
private authHeader;
|
|
51
|
+
private readonly baseUrl;
|
|
52
|
+
private readonly authHeader;
|
|
53
53
|
constructor(config: JiraClientConfig);
|
|
54
|
+
private request;
|
|
54
55
|
/**
|
|
55
56
|
* Parse Atlassian Document Format (ADF) to plain text
|
|
56
57
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"jira.client.d.ts","sourceRoot":"","sources":["../../../../src/integrations/jira/jira.client.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,WAAW;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACnB;AAkBD,MAAM,WAAW,SAAS;IACtB,EAAE,EAAE,MAAM,CAAC;IACX,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE;QACJ,OAAO,EAAE,MAAM,CAAC;QAChB,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAC5B,MAAM,EAAE;YACJ,IAAI,EAAE,MAAM,CAAC;YACb,EAAE,EAAE,MAAM,CAAC;SACd,CAAC;QACF,SAAS,EAAE;YACP,IAAI,EAAE,MAAM,CAAC;YACb,EAAE,EAAE,MAAM,CAAC;SACd,CAAC;QACF,OAAO,EAAE;YACL,GAAG,EAAE,MAAM,CAAC;YACZ,EAAE,EAAE,MAAM,CAAC;YACX,IAAI,EAAE,MAAM,CAAC;SAChB,CAAC;QACF,QAAQ,CAAC,EAAE;YACP,SAAS,EAAE,MAAM,CAAC;YAClB,WAAW,EAAE,MAAM,CAAC;SACvB,GAAG,IAAI,CAAC;QACT,QAAQ,CAAC,EAAE;YACP,SAAS,EAAE,MAAM,CAAC;YAClB,WAAW,EAAE,MAAM,CAAC;SACvB,GAAG,IAAI,CAAC;QACT,OAAO,EAAE,MAAM,CAAC;QAChB,OAAO,EAAE,MAAM,CAAC;QAChB,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAC/B,MAAM,EAAE,MAAM,EAAE,CAAC;QACjB,UAAU,EAAE,KAAK,CAAC;YAAE,IAAI,EAAE,MAAM,CAAC;YAAC,EAAE,EAAE,MAAM,CAAA;SAAE,CAAC,CAAC;KACnD,CAAC;IACF,QAAQ,EAAE,WAAW,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,gBAAgB;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;CACpB;AAED,qBAAa,UAAU;IACnB,OAAO,CAAC,OAAO,CAAS;
|
|
1
|
+
{"version":3,"file":"jira.client.d.ts","sourceRoot":"","sources":["../../../../src/integrations/jira/jira.client.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,WAAW;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACnB;AAkBD,MAAM,WAAW,SAAS;IACtB,EAAE,EAAE,MAAM,CAAC;IACX,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE;QACJ,OAAO,EAAE,MAAM,CAAC;QAChB,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAC5B,MAAM,EAAE;YACJ,IAAI,EAAE,MAAM,CAAC;YACb,EAAE,EAAE,MAAM,CAAC;SACd,CAAC;QACF,SAAS,EAAE;YACP,IAAI,EAAE,MAAM,CAAC;YACb,EAAE,EAAE,MAAM,CAAC;SACd,CAAC;QACF,OAAO,EAAE;YACL,GAAG,EAAE,MAAM,CAAC;YACZ,EAAE,EAAE,MAAM,CAAC;YACX,IAAI,EAAE,MAAM,CAAC;SAChB,CAAC;QACF,QAAQ,CAAC,EAAE;YACP,SAAS,EAAE,MAAM,CAAC;YAClB,WAAW,EAAE,MAAM,CAAC;SACvB,GAAG,IAAI,CAAC;QACT,QAAQ,CAAC,EAAE;YACP,SAAS,EAAE,MAAM,CAAC;YAClB,WAAW,EAAE,MAAM,CAAC;SACvB,GAAG,IAAI,CAAC;QACT,OAAO,EAAE,MAAM,CAAC;QAChB,OAAO,EAAE,MAAM,CAAC;QAChB,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAC/B,MAAM,EAAE,MAAM,EAAE,CAAC;QACjB,UAAU,EAAE,KAAK,CAAC;YAAE,IAAI,EAAE,MAAM,CAAC;YAAC,EAAE,EAAE,MAAM,CAAA;SAAE,CAAC,CAAC;KACnD,CAAC;IACF,QAAQ,EAAE,WAAW,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,gBAAgB;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;CACpB;AAED,qBAAa,UAAU;IACnB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;gBAExB,MAAM,EAAE,gBAAgB;YAKtB,OAAO;IAgBrB;;OAEG;IACH,OAAO,CAAC,cAAc;IAgCtB;;OAEG;IACH,OAAO,CAAC,gBAAgB;IAexB;;OAEG;IACG,QAAQ,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC;CA4B3D"}
|