@shipfox/api-integration-jira 12.0.0 → 12.1.1
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/.turbo/turbo-build.log +17 -2
- package/CHANGELOG.md +26 -0
- package/README.md +58 -2
- package/dist/api/client.d.ts +19 -0
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +87 -1
- package/dist/api/client.js.map +1 -1
- package/dist/core/agent-tools-provider.d.ts +26 -0
- package/dist/core/agent-tools-provider.d.ts.map +1 -0
- package/dist/core/agent-tools-provider.js +130 -0
- package/dist/core/agent-tools-provider.js.map +1 -0
- package/dist/core/agent-tools.d.ts +16 -0
- package/dist/core/agent-tools.d.ts.map +1 -0
- package/dist/core/agent-tools.js +441 -0
- package/dist/core/agent-tools.js.map +1 -0
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/scopes.d.ts +1 -1
- package/dist/core/scopes.d.ts.map +1 -1
- package/dist/core/tokens.d.ts +2 -0
- package/dist/core/tokens.d.ts.map +1 -1
- package/dist/core/tokens.js +129 -37
- package/dist/core/tokens.js.map +1 -1
- package/dist/db/db.d.ts +72 -4
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/installations.d.ts +23 -7
- package/dist/db/installations.d.ts.map +1 -1
- package/dist/db/installations.js +42 -19
- package/dist/db/installations.js.map +1 -1
- package/dist/db/schema/installations.d.ts +35 -1
- package/dist/db/schema/installations.d.ts.map +1 -1
- package/dist/db/schema/installations.js +12 -2
- package/dist/db/schema/installations.js.map +1 -1
- package/dist/db/schema/pending-selections.d.ts +1 -1
- package/dist/index.d.ts +21 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -5
- package/dist/index.js.map +1 -1
- package/dist/presentation/dto/integrations.d.ts.map +1 -1
- package/dist/temporal/activities/index.d.ts +2 -0
- package/dist/temporal/activities/index.d.ts.map +1 -0
- package/dist/temporal/activities/index.js +3 -0
- package/dist/temporal/activities/index.js.map +1 -0
- package/dist/temporal/activities/refresh-jira-tokens.d.ts +20 -0
- package/dist/temporal/activities/refresh-jira-tokens.d.ts.map +1 -0
- package/dist/temporal/activities/refresh-jira-tokens.js +58 -0
- package/dist/temporal/activities/refresh-jira-tokens.js.map +1 -0
- package/dist/temporal/constants.d.ts +6 -0
- package/dist/temporal/constants.d.ts.map +1 -0
- package/dist/temporal/constants.js +8 -0
- package/dist/temporal/constants.js.map +1 -0
- package/dist/temporal/worker.d.ts +9 -0
- package/dist/temporal/worker.d.ts.map +1 -0
- package/dist/temporal/worker.js +26 -0
- package/dist/temporal/worker.js.map +1 -0
- package/dist/temporal/workflows/index.bundle.js +25225 -0
- package/dist/temporal/workflows/index.bundle.meta.json +1 -0
- package/dist/temporal/workflows/index.d.ts +2 -0
- package/dist/temporal/workflows/index.d.ts.map +1 -0
- package/dist/temporal/workflows/index.js +3 -0
- package/dist/temporal/workflows/index.js.map +1 -0
- package/dist/temporal/workflows/refresh-jira-tokens-cron.d.ts +2 -0
- package/dist/temporal/workflows/refresh-jira-tokens-cron.d.ts.map +1 -0
- package/dist/temporal/workflows/refresh-jira-tokens-cron.js +20 -0
- package/dist/temporal/workflows/refresh-jira-tokens-cron.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0001_jira-token-refresh-state.sql +12 -0
- package/drizzle/meta/0001_snapshot.json +245 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +12 -5
- package/src/api/client.test.ts +304 -12
- package/src/api/client.ts +122 -1
- package/src/core/agent-tools-provider.test.ts +299 -0
- package/src/core/agent-tools-provider.ts +175 -0
- package/src/core/agent-tools.ts +450 -0
- package/src/core/tokens-refresh.test.ts +221 -4
- package/src/core/tokens.test.ts +1 -1
- package/src/core/tokens.ts +153 -33
- package/src/db/installations.test.ts +84 -2
- package/src/db/installations.ts +81 -21
- package/src/db/schema/installations.ts +21 -1
- package/src/index.test.ts +34 -0
- package/src/index.ts +47 -3
- package/src/temporal/activities/index.ts +5 -0
- package/src/temporal/activities/refresh-jira-tokens.test.ts +156 -0
- package/src/temporal/activities/refresh-jira-tokens.ts +88 -0
- package/src/temporal/constants.ts +8 -0
- package/src/temporal/worker.ts +39 -0
- package/src/temporal/workflows/index.ts +1 -0
- package/src/temporal/workflows/refresh-jira-tokens-cron.ts +19 -0
- package/test/factories/jira-installation.ts +4 -0
- package/tsconfig.build.tsbuildinfo +1 -1
package/src/api/client.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {JiraIntegrationProviderError} from '#core/errors.js';
|
|
|
6
6
|
|
|
7
7
|
const JIRA_API_TIMEOUT_MS = 10_000;
|
|
8
8
|
const SCOPE_SEPARATOR_RE = /[,\s]+/;
|
|
9
|
+
const TRAILING_SLASHES_RE = /\/+$/;
|
|
9
10
|
export const JIRA_DYNAMIC_WEBHOOK_EVENTS = jiraWebhookEventNames;
|
|
10
11
|
export const JIRA_DYNAMIC_WEBHOOK_JQL = '';
|
|
11
12
|
|
|
@@ -31,6 +32,34 @@ export interface JiraDynamicWebhookRegistration {
|
|
|
31
32
|
webhookId: number;
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
export type JiraAgentToolHttpMethod = 'GET' | 'POST' | 'PUT';
|
|
36
|
+
|
|
37
|
+
export type JiraAgentToolQueryValue =
|
|
38
|
+
| string
|
|
39
|
+
| number
|
|
40
|
+
| boolean
|
|
41
|
+
| readonly (string | number)[]
|
|
42
|
+
| undefined;
|
|
43
|
+
|
|
44
|
+
export interface JiraAgentToolRequest {
|
|
45
|
+
accessToken: string;
|
|
46
|
+
cloudId: string;
|
|
47
|
+
method: JiraAgentToolHttpMethod;
|
|
48
|
+
path: string;
|
|
49
|
+
query?: Record<string, JiraAgentToolQueryValue> | undefined;
|
|
50
|
+
body?: unknown;
|
|
51
|
+
operation?: string | undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface JiraAgentToolResponse {
|
|
55
|
+
status: number;
|
|
56
|
+
body: unknown;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface JiraAgentToolsClient {
|
|
60
|
+
request(input: JiraAgentToolRequest): Promise<JiraAgentToolResponse>;
|
|
61
|
+
}
|
|
62
|
+
|
|
34
63
|
export interface JiraApiClient {
|
|
35
64
|
exchangeAuthorizationCode(input: {code: string}): Promise<JiraAuthorization>;
|
|
36
65
|
refreshAccessToken(input: {refreshToken: string}): Promise<JiraAuthorization>;
|
|
@@ -70,6 +99,8 @@ interface JiraDynamicWebhookRegistrationResponse {
|
|
|
70
99
|
webhookRegistrationResult?: unknown;
|
|
71
100
|
}
|
|
72
101
|
|
|
102
|
+
const JIRA_REFRESH_TOKEN_TERMINAL_ERROR_CODES = new Set(['invalid_grant', 'unauthorized_client']);
|
|
103
|
+
|
|
73
104
|
export function createJiraApiClient(): JiraApiClient {
|
|
74
105
|
return {
|
|
75
106
|
async exchangeAuthorizationCode(input) {
|
|
@@ -170,6 +201,67 @@ export function createJiraApiClient(): JiraApiClient {
|
|
|
170
201
|
};
|
|
171
202
|
}
|
|
172
203
|
|
|
204
|
+
export function createJiraAgentToolsClient(): JiraAgentToolsClient {
|
|
205
|
+
return {request: requestJiraRest};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function requestJiraRest(input: JiraAgentToolRequest): Promise<JiraAgentToolResponse> {
|
|
209
|
+
return await mapJiraError(input.operation ?? 'agent-tool', async () => {
|
|
210
|
+
try {
|
|
211
|
+
const response = await ky(jiraRestUrl(input.cloudId, input.path), {
|
|
212
|
+
method: input.method,
|
|
213
|
+
headers: {authorization: `Bearer ${input.accessToken}`},
|
|
214
|
+
...(input.query === undefined ? {} : {searchParams: jiraQueryParams(input.query)}),
|
|
215
|
+
...(input.body === undefined ? {} : {json: input.body}),
|
|
216
|
+
timeout: JIRA_API_TIMEOUT_MS,
|
|
217
|
+
});
|
|
218
|
+
return {status: response.status, body: await readJiraResponseBody(response)};
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (
|
|
221
|
+
error instanceof HTTPError &&
|
|
222
|
+
(error.response.status === 400 || error.response.status === 404)
|
|
223
|
+
) {
|
|
224
|
+
return {
|
|
225
|
+
status: error.response.status,
|
|
226
|
+
body: await readJiraResponseBody(error.response),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function jiraRestUrl(cloudId: string, path: string): string {
|
|
235
|
+
const baseUrl = config.JIRA_API_BASE_URL.replace(TRAILING_SLASHES_RE, '');
|
|
236
|
+
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
237
|
+
return `${baseUrl}/ex/jira/${encodeURIComponent(cloudId)}/rest/api/3${normalizedPath}`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function jiraQueryParams(
|
|
241
|
+
query: Record<string, JiraAgentToolQueryValue>,
|
|
242
|
+
): URLSearchParams | undefined {
|
|
243
|
+
const params = new URLSearchParams();
|
|
244
|
+
for (const [key, value] of Object.entries(query)) {
|
|
245
|
+
if (value === undefined) continue;
|
|
246
|
+
if (Array.isArray(value)) {
|
|
247
|
+
for (const item of value) params.append(key, String(item));
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
params.set(key, String(value));
|
|
251
|
+
}
|
|
252
|
+
return params.size > 0 ? params : undefined;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function readJiraResponseBody(response: Response): Promise<unknown> {
|
|
256
|
+
const text = await response.text();
|
|
257
|
+
if (text.length === 0) return undefined;
|
|
258
|
+
try {
|
|
259
|
+
return JSON.parse(text);
|
|
260
|
+
} catch {
|
|
261
|
+
return text;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
173
265
|
function parseDynamicWebhookRegistration(
|
|
174
266
|
body: JiraDynamicWebhookRegistrationResponse,
|
|
175
267
|
): JiraDynamicWebhookRegistration {
|
|
@@ -190,7 +282,21 @@ function parseDynamicWebhookRegistration(
|
|
|
190
282
|
createdWebhookId?: unknown;
|
|
191
283
|
errors?: unknown;
|
|
192
284
|
};
|
|
193
|
-
if (errors !== undefined &&
|
|
285
|
+
if (errors !== undefined && !Array.isArray(errors)) {
|
|
286
|
+
throw malformed('Jira webhook registration returned errors');
|
|
287
|
+
}
|
|
288
|
+
if (Array.isArray(errors) && errors.length > 0) {
|
|
289
|
+
logger().warn(
|
|
290
|
+
{
|
|
291
|
+
operation: 'register-dynamic-webhook',
|
|
292
|
+
providerErrors: errors
|
|
293
|
+
.filter((error): error is string => typeof error === 'string')
|
|
294
|
+
.slice(0, 5)
|
|
295
|
+
.map((error) => error.slice(0, 500)),
|
|
296
|
+
providerErrorCount: errors.length,
|
|
297
|
+
},
|
|
298
|
+
'Jira dynamic webhook registration rejected',
|
|
299
|
+
);
|
|
194
300
|
throw malformed('Jira webhook registration returned errors');
|
|
195
301
|
}
|
|
196
302
|
if (
|
|
@@ -272,6 +378,15 @@ export async function mapJiraError<T>(operation: string, request: () => Promise<
|
|
|
272
378
|
if (status === 401 || status === 403) {
|
|
273
379
|
throw new JiraIntegrationProviderError('access-denied', 'Jira request was rejected');
|
|
274
380
|
}
|
|
381
|
+
if (status === 400 && operation === 'refresh-access-token') {
|
|
382
|
+
const errorCode = readJiraOAuthErrorCode(error.data);
|
|
383
|
+
if (errorCode && JIRA_REFRESH_TOKEN_TERMINAL_ERROR_CODES.has(errorCode)) {
|
|
384
|
+
throw new JiraIntegrationProviderError(
|
|
385
|
+
'access-denied',
|
|
386
|
+
'Jira refresh token was rejected; reconnect is required',
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
275
390
|
throw malformed('Jira request was rejected');
|
|
276
391
|
}
|
|
277
392
|
if (error instanceof TimeoutError) {
|
|
@@ -290,6 +405,12 @@ function malformed(message: string): JiraIntegrationProviderError {
|
|
|
290
405
|
return new JiraIntegrationProviderError('malformed-provider-response', message);
|
|
291
406
|
}
|
|
292
407
|
|
|
408
|
+
function readJiraOAuthErrorCode(data: unknown): string | undefined {
|
|
409
|
+
if (!data || typeof data !== 'object') return undefined;
|
|
410
|
+
const errorCode = (data as {error?: unknown}).error;
|
|
411
|
+
return typeof errorCode === 'string' ? errorCode : undefined;
|
|
412
|
+
}
|
|
413
|
+
|
|
293
414
|
function retryAfterSeconds(headers: Headers): number | undefined {
|
|
294
415
|
const value = headers.get('retry-after');
|
|
295
416
|
if (!value) return undefined;
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import type {IntegrationConnection} from '@shipfox/api-integration-spi';
|
|
2
|
+
import type {JiraAgentToolsClient} from '#api/client.js';
|
|
3
|
+
import {
|
|
4
|
+
type JiraAgentToolId,
|
|
5
|
+
jiraAgentToolCatalog,
|
|
6
|
+
jiraAgentToolSelectionCatalog,
|
|
7
|
+
jiraPlainTextToAdf,
|
|
8
|
+
} from '#core/agent-tools.js';
|
|
9
|
+
import {JiraAgentToolsProvider} from '#core/agent-tools-provider.js';
|
|
10
|
+
import {JiraIntegrationProviderError} from '#core/errors.js';
|
|
11
|
+
|
|
12
|
+
function jiraConnection(
|
|
13
|
+
overrides: Partial<IntegrationConnection<'jira'>> = {},
|
|
14
|
+
): IntegrationConnection<'jira'> {
|
|
15
|
+
const now = new Date();
|
|
16
|
+
return {
|
|
17
|
+
id: 'jira-connection-1',
|
|
18
|
+
workspaceId: 'workspace-1',
|
|
19
|
+
provider: 'jira',
|
|
20
|
+
externalAccountId: 'cloud-1',
|
|
21
|
+
slug: 'jira-acme',
|
|
22
|
+
displayName: 'Jira Acme',
|
|
23
|
+
lifecycleStatus: 'active',
|
|
24
|
+
createdAt: now,
|
|
25
|
+
updatedAt: now,
|
|
26
|
+
...overrides,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function catalogTool(id: JiraAgentToolId) {
|
|
31
|
+
const tool = jiraAgentToolCatalog.find((candidate) => candidate.id === id);
|
|
32
|
+
if (!tool) throw new Error(`Unknown test tool: ${id}`);
|
|
33
|
+
return tool;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function providerOptions(
|
|
37
|
+
request: JiraAgentToolsClient['request'] = async () => ({status: 200, body: {ok: true}}),
|
|
38
|
+
) {
|
|
39
|
+
return {
|
|
40
|
+
jira: {request: vi.fn(request)},
|
|
41
|
+
tokenStore: {getAccessToken: vi.fn().mockResolvedValue('access-token')},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function openSession(
|
|
46
|
+
options: ReturnType<typeof providerOptions>,
|
|
47
|
+
toolIds: JiraAgentToolId[],
|
|
48
|
+
connection = jiraConnection(),
|
|
49
|
+
) {
|
|
50
|
+
const provider = new JiraAgentToolsProvider(options);
|
|
51
|
+
return await provider.openSession({
|
|
52
|
+
connection,
|
|
53
|
+
tools: toolIds.map(catalogTool),
|
|
54
|
+
scope: {provider: 'jira'},
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('JiraAgentToolsProvider', () => {
|
|
59
|
+
it('returns the Jira agent tools catalogs', () => {
|
|
60
|
+
const provider = new JiraAgentToolsProvider(providerOptions());
|
|
61
|
+
|
|
62
|
+
expect(provider.catalog()).toBe(jiraAgentToolCatalog);
|
|
63
|
+
expect(provider.selectionCatalog()).toBe(jiraAgentToolSelectionCatalog);
|
|
64
|
+
expect(provider.catalog().map((tool) => [tool.id, tool.sensitivity, tool.sensitive])).toEqual([
|
|
65
|
+
['get_issue', 'read', false],
|
|
66
|
+
['search_issues', 'read', false],
|
|
67
|
+
['get_issue_comments', 'read', false],
|
|
68
|
+
['get_issue_transitions', 'read', false],
|
|
69
|
+
['get_project', 'read', false],
|
|
70
|
+
['get_user', 'read', false],
|
|
71
|
+
['create_issue', 'write', false],
|
|
72
|
+
['update_issue', 'write', false],
|
|
73
|
+
['add_comment', 'write', false],
|
|
74
|
+
['transition_issue', 'write', false],
|
|
75
|
+
['assign_issue', 'write', false],
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('reads the stored token before opening a session', async () => {
|
|
80
|
+
const options = providerOptions();
|
|
81
|
+
const provider = new JiraAgentToolsProvider(options);
|
|
82
|
+
|
|
83
|
+
await provider.openSession({
|
|
84
|
+
connection: jiraConnection({id: 'jira-connection-7'}),
|
|
85
|
+
tools: [],
|
|
86
|
+
scope: {provider: 'jira'},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(options.tokenStore.getAccessToken).toHaveBeenCalledWith({
|
|
90
|
+
connectionId: 'jira-connection-7',
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('dispatches a read request with the cloud id and returns structured content', async () => {
|
|
95
|
+
const body = {id: '1004', key: 'ENG-1004', fields: {summary: 'Jira tools'}};
|
|
96
|
+
const options = providerOptions(async () => ({status: 200, body}));
|
|
97
|
+
const session = await openSession(options, ['get_issue']);
|
|
98
|
+
|
|
99
|
+
const result = await session.call({
|
|
100
|
+
toolId: 'get_issue',
|
|
101
|
+
arguments: {idOrKey: 'ENG-1004', fields: ['summary']},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(options.jira.request).toHaveBeenCalledWith({
|
|
105
|
+
accessToken: 'access-token',
|
|
106
|
+
cloudId: 'cloud-1',
|
|
107
|
+
method: 'GET',
|
|
108
|
+
path: '/issue/ENG-1004',
|
|
109
|
+
query: {fields: ['summary']},
|
|
110
|
+
operation: 'get_issue',
|
|
111
|
+
});
|
|
112
|
+
expect(result).toEqual({
|
|
113
|
+
content: [{type: 'text', text: JSON.stringify(body)}],
|
|
114
|
+
structuredContent: body,
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('wraps add-comment text in the minimal Jira ADF document', async () => {
|
|
119
|
+
const options = providerOptions();
|
|
120
|
+
const session = await openSession(options, ['add_comment']);
|
|
121
|
+
|
|
122
|
+
await session.call({
|
|
123
|
+
toolId: 'add_comment',
|
|
124
|
+
arguments: {idOrKey: 'ENG-1004', body: 'The adapter is ready.'},
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
expect(options.jira.request).toHaveBeenCalledWith({
|
|
128
|
+
accessToken: 'access-token',
|
|
129
|
+
cloudId: 'cloud-1',
|
|
130
|
+
method: 'POST',
|
|
131
|
+
path: '/issue/ENG-1004/comment',
|
|
132
|
+
body: {body: jiraPlainTextToAdf('The adapter is ready.')},
|
|
133
|
+
operation: 'add_comment',
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('wraps create and update issue descriptions in ADF', async () => {
|
|
138
|
+
const options = providerOptions();
|
|
139
|
+
const createSession = await openSession(options, ['create_issue']);
|
|
140
|
+
const updateSession = await openSession(options, ['update_issue']);
|
|
141
|
+
|
|
142
|
+
await createSession.call({
|
|
143
|
+
toolId: 'create_issue',
|
|
144
|
+
arguments: {
|
|
145
|
+
projectKey: 'ENG',
|
|
146
|
+
summary: 'New issue',
|
|
147
|
+
issueType: 'Task',
|
|
148
|
+
body: 'A plain-text description',
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
await updateSession.call({
|
|
152
|
+
toolId: 'update_issue',
|
|
153
|
+
arguments: {idOrKey: 'ENG-1004', body: 'A replacement description'},
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
expect(options.jira.request).toHaveBeenNthCalledWith(1, {
|
|
157
|
+
accessToken: 'access-token',
|
|
158
|
+
cloudId: 'cloud-1',
|
|
159
|
+
method: 'POST',
|
|
160
|
+
path: '/issue',
|
|
161
|
+
body: {
|
|
162
|
+
fields: {
|
|
163
|
+
project: {key: 'ENG'},
|
|
164
|
+
summary: 'New issue',
|
|
165
|
+
issuetype: {name: 'Task'},
|
|
166
|
+
description: jiraPlainTextToAdf('A plain-text description'),
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
operation: 'create_issue',
|
|
170
|
+
});
|
|
171
|
+
expect(options.jira.request).toHaveBeenNthCalledWith(2, {
|
|
172
|
+
accessToken: 'access-token',
|
|
173
|
+
cloudId: 'cloud-1',
|
|
174
|
+
method: 'PUT',
|
|
175
|
+
path: '/issue/ENG-1004',
|
|
176
|
+
body: {fields: {description: jiraPlainTextToAdf('A replacement description')}},
|
|
177
|
+
operation: 'update_issue',
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('maps provider rate limits and preserves Retry-After details', async () => {
|
|
182
|
+
const options = providerOptions(() =>
|
|
183
|
+
Promise.reject(new JiraIntegrationProviderError('rate-limited', 'Try again later', 19)),
|
|
184
|
+
);
|
|
185
|
+
const session = await openSession(options, ['search_issues']);
|
|
186
|
+
|
|
187
|
+
const result = await session.call({
|
|
188
|
+
toolId: 'search_issues',
|
|
189
|
+
arguments: {jql: 'project = ENG'},
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
expect(result).toMatchObject({
|
|
193
|
+
isError: true,
|
|
194
|
+
content: [{type: 'text', text: 'Try again later'}],
|
|
195
|
+
structuredContent: {code: 'rate-limited', retryAfterSeconds: 19},
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('maps Jira authorization failures to access-denied', async () => {
|
|
200
|
+
const options = providerOptions(() =>
|
|
201
|
+
Promise.reject(
|
|
202
|
+
new JiraIntegrationProviderError('access-denied', 'Jira request was rejected'),
|
|
203
|
+
),
|
|
204
|
+
);
|
|
205
|
+
const session = await openSession(options, ['get_project']);
|
|
206
|
+
|
|
207
|
+
const result = await session.call({
|
|
208
|
+
toolId: 'get_project',
|
|
209
|
+
arguments: {idOrKey: 'ENG'},
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
expect(result).toMatchObject({
|
|
213
|
+
isError: true,
|
|
214
|
+
content: [{type: 'text', text: 'Jira request was rejected'}],
|
|
215
|
+
structuredContent: {code: 'access-denied'},
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('returns Jira resource errors as tool errors', async () => {
|
|
220
|
+
const options = providerOptions(async () => ({
|
|
221
|
+
status: 404,
|
|
222
|
+
body: {errorMessages: ['Issue does not exist']},
|
|
223
|
+
}));
|
|
224
|
+
const session = await openSession(options, ['get_issue']);
|
|
225
|
+
|
|
226
|
+
const result = await session.call({
|
|
227
|
+
toolId: 'get_issue',
|
|
228
|
+
arguments: {idOrKey: 'ENG-404'},
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
expect(result).toEqual({
|
|
232
|
+
isError: true,
|
|
233
|
+
content: [{type: 'text', text: 'Issue does not exist'}],
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('returns Jira validation details for a bad request', async () => {
|
|
238
|
+
const options = providerOptions(async () => ({
|
|
239
|
+
status: 400,
|
|
240
|
+
body: {errorMessages: ['The JQL query is invalid']},
|
|
241
|
+
}));
|
|
242
|
+
const session = await openSession(options, ['search_issues']);
|
|
243
|
+
|
|
244
|
+
const result = await session.call({
|
|
245
|
+
toolId: 'search_issues',
|
|
246
|
+
arguments: {jql: 'not valid'},
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
expect(result).toEqual({
|
|
250
|
+
isError: true,
|
|
251
|
+
content: [{type: 'text', text: 'The JQL query is invalid'}],
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('reports successful empty Jira responses with their HTTP status', async () => {
|
|
256
|
+
const options = providerOptions(async () => ({status: 204, body: undefined}));
|
|
257
|
+
const session = await openSession(options, ['assign_issue']);
|
|
258
|
+
|
|
259
|
+
const result = await session.call({
|
|
260
|
+
toolId: 'assign_issue',
|
|
261
|
+
arguments: {idOrKey: 'ENG-1004', accountId: null},
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
expect(result).toEqual({
|
|
265
|
+
content: [{type: 'text', text: JSON.stringify({status: 204})}],
|
|
266
|
+
structuredContent: {status: 204},
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('rejects calls for unselected tools and missing required arguments', async () => {
|
|
271
|
+
const options = providerOptions();
|
|
272
|
+
const session = await openSession(options, ['get_issue']);
|
|
273
|
+
|
|
274
|
+
await expect(
|
|
275
|
+
session.call({toolId: 'add_comment', arguments: {idOrKey: 'ENG-1004', body: 'Comment'}}),
|
|
276
|
+
).resolves.toEqual({
|
|
277
|
+
isError: true,
|
|
278
|
+
content: [{type: 'text', text: 'Unknown Jira tool: add_comment'}],
|
|
279
|
+
});
|
|
280
|
+
await expect(session.call({toolId: 'get_issue', arguments: {}})).resolves.toEqual({
|
|
281
|
+
isError: true,
|
|
282
|
+
content: [{type: 'text', text: 'Missing required parameter: idOrKey'}],
|
|
283
|
+
});
|
|
284
|
+
expect(options.jira.request).not.toHaveBeenCalled();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('rejects create calls that omit the summary', async () => {
|
|
288
|
+
const options = providerOptions();
|
|
289
|
+
const session = await openSession(options, ['create_issue']);
|
|
290
|
+
|
|
291
|
+
const result = await session.call({toolId: 'create_issue', arguments: {}});
|
|
292
|
+
|
|
293
|
+
expect(result).toEqual({
|
|
294
|
+
isError: true,
|
|
295
|
+
content: [{type: 'text', text: 'Missing required parameter: summary'}],
|
|
296
|
+
});
|
|
297
|
+
expect(options.jira.request).not.toHaveBeenCalled();
|
|
298
|
+
});
|
|
299
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentToolCatalogEntry,
|
|
3
|
+
AgentToolSession,
|
|
4
|
+
AgentToolsProvider,
|
|
5
|
+
IntegrationConnection,
|
|
6
|
+
OpenAgentToolsSessionInput,
|
|
7
|
+
} from '@shipfox/api-integration-spi';
|
|
8
|
+
import type {JiraAgentToolResponse, JiraAgentToolsClient} from '#api/client.js';
|
|
9
|
+
import type {JiraTokenStore} from '#core/tokens.js';
|
|
10
|
+
import {
|
|
11
|
+
JIRA_TOOL_OPERATIONS,
|
|
12
|
+
type JiraAgentToolRequiredScope,
|
|
13
|
+
jiraAgentToolCatalog,
|
|
14
|
+
jiraAgentToolSelectionCatalog,
|
|
15
|
+
} from './agent-tools.js';
|
|
16
|
+
import {JiraIntegrationProviderError} from './errors.js';
|
|
17
|
+
|
|
18
|
+
type JiraIntegrationConnection = IntegrationConnection<'jira'>;
|
|
19
|
+
|
|
20
|
+
export type JiraToolCallResult = {
|
|
21
|
+
isError?: boolean | undefined;
|
|
22
|
+
content: readonly {type: 'text'; text: string}[];
|
|
23
|
+
structuredContent?: Record<string, unknown> | undefined;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export interface JiraAgentToolsProviderOptions {
|
|
27
|
+
jira: Pick<JiraAgentToolsClient, 'request'>;
|
|
28
|
+
tokenStore: Pick<JiraTokenStore, 'getAccessToken'>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class JiraAgentToolsProvider
|
|
32
|
+
implements
|
|
33
|
+
AgentToolsProvider<
|
|
34
|
+
JiraIntegrationConnection,
|
|
35
|
+
JiraAgentToolRequiredScope,
|
|
36
|
+
unknown,
|
|
37
|
+
JiraToolCallResult
|
|
38
|
+
>
|
|
39
|
+
{
|
|
40
|
+
constructor(private readonly options: JiraAgentToolsProviderOptions) {}
|
|
41
|
+
|
|
42
|
+
catalog() {
|
|
43
|
+
return jiraAgentToolCatalog;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
selectionCatalog() {
|
|
47
|
+
return jiraAgentToolSelectionCatalog;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async openSession(
|
|
51
|
+
input: OpenAgentToolsSessionInput<
|
|
52
|
+
JiraIntegrationConnection,
|
|
53
|
+
JiraAgentToolRequiredScope,
|
|
54
|
+
unknown
|
|
55
|
+
>,
|
|
56
|
+
): Promise<AgentToolSession<JiraToolCallResult>> {
|
|
57
|
+
const accessToken = await this.options.tokenStore.getAccessToken({
|
|
58
|
+
connectionId: input.connection.id,
|
|
59
|
+
});
|
|
60
|
+
const cloudId = input.connection.externalAccountId;
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
call: async (call) => {
|
|
64
|
+
const tool = input.tools.find((candidate) => candidate.id === call.toolId);
|
|
65
|
+
if (!tool) return jiraToolError(`Unknown Jira tool: ${call.toolId}`);
|
|
66
|
+
|
|
67
|
+
const operation = JIRA_TOOL_OPERATIONS[call.toolId as keyof typeof JIRA_TOOL_OPERATIONS];
|
|
68
|
+
if (!operation) return jiraToolError(`Unknown Jira tool: ${call.toolId}`);
|
|
69
|
+
|
|
70
|
+
const missingParameter = missingRequiredParameter(tool, call.arguments);
|
|
71
|
+
if (missingParameter) {
|
|
72
|
+
return jiraToolError(`Missing required parameter: ${missingParameter}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let response: JiraAgentToolResponse;
|
|
76
|
+
try {
|
|
77
|
+
response = await this.options.jira.request({
|
|
78
|
+
accessToken,
|
|
79
|
+
cloudId,
|
|
80
|
+
method: operation.method,
|
|
81
|
+
path: operation.path(call.arguments),
|
|
82
|
+
...(operation.query === undefined ? {} : {query: operation.query(call.arguments)}),
|
|
83
|
+
...(operation.body === undefined ? {} : {body: operation.body(call.arguments)}),
|
|
84
|
+
operation: call.toolId,
|
|
85
|
+
});
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error instanceof JiraIntegrationProviderError) {
|
|
88
|
+
return jiraToolError(error.message, {
|
|
89
|
+
code: error.reason,
|
|
90
|
+
retryAfterSeconds: error.retryAfterSeconds,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (response.status === 400 || response.status === 404) {
|
|
97
|
+
return jiraRequestError(
|
|
98
|
+
response.body,
|
|
99
|
+
response.status === 404 ? 'Jira resource was not found' : 'Jira request was rejected',
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
if (response.status < 200 || response.status >= 300) {
|
|
103
|
+
return jiraToolError(`Jira request returned HTTP ${response.status}`);
|
|
104
|
+
}
|
|
105
|
+
return jiraToolResult(response.body, response.status);
|
|
106
|
+
},
|
|
107
|
+
close: () => Promise.resolve(),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function missingRequiredParameter(
|
|
113
|
+
tool: AgentToolCatalogEntry<JiraAgentToolRequiredScope>,
|
|
114
|
+
args: Record<string, unknown>,
|
|
115
|
+
): string | undefined {
|
|
116
|
+
const required = tool.inputSchema.required;
|
|
117
|
+
if (!Array.isArray(required)) return undefined;
|
|
118
|
+
return required.find(
|
|
119
|
+
(parameter) => typeof parameter === 'string' && args[parameter] === undefined,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function jiraToolResult(body: unknown, status: number): JiraToolCallResult {
|
|
124
|
+
const structuredContent = body === undefined ? {status} : isRecord(body) ? body : {result: body};
|
|
125
|
+
return {
|
|
126
|
+
content: [{type: 'text', text: JSON.stringify(structuredContent)}],
|
|
127
|
+
structuredContent,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function jiraRequestError(body: unknown, fallbackMessage: string): JiraToolCallResult {
|
|
132
|
+
return jiraToolError(jiraErrorMessage(body, fallbackMessage));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function jiraErrorMessage(body: unknown, fallbackMessage: string): string {
|
|
136
|
+
if (typeof body === 'string' && body.length > 0) return body;
|
|
137
|
+
if (!isRecord(body)) return fallbackMessage;
|
|
138
|
+
|
|
139
|
+
const errorMessages = body.errorMessages;
|
|
140
|
+
if (Array.isArray(errorMessages)) {
|
|
141
|
+
const message = errorMessages.filter((value): value is string => typeof value === 'string');
|
|
142
|
+
if (message.length > 0) return message.join('; ');
|
|
143
|
+
}
|
|
144
|
+
if (typeof body.message === 'string' && body.message.length > 0) return body.message;
|
|
145
|
+
const errors = body.errors;
|
|
146
|
+
if (isRecord(errors)) {
|
|
147
|
+
const messages = Object.entries(errors).filter(
|
|
148
|
+
(entry): entry is [string, string] => typeof entry[1] === 'string',
|
|
149
|
+
);
|
|
150
|
+
if (messages.length > 0)
|
|
151
|
+
return messages.map(([field, message]) => `${field}: ${message}`).join('; ');
|
|
152
|
+
}
|
|
153
|
+
return fallbackMessage;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function jiraToolError(
|
|
157
|
+
message: string,
|
|
158
|
+
options: {code?: string | undefined; retryAfterSeconds?: number | undefined} = {},
|
|
159
|
+
): JiraToolCallResult {
|
|
160
|
+
const structuredContent = {
|
|
161
|
+
...(options.code === undefined ? {} : {code: options.code}),
|
|
162
|
+
...(options.retryAfterSeconds === undefined
|
|
163
|
+
? {}
|
|
164
|
+
: {retryAfterSeconds: options.retryAfterSeconds}),
|
|
165
|
+
};
|
|
166
|
+
return {
|
|
167
|
+
isError: true,
|
|
168
|
+
content: [{type: 'text', text: message}],
|
|
169
|
+
...(Object.keys(structuredContent).length === 0 ? {} : {structuredContent}),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
174
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
175
|
+
}
|