@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.
Files changed (91) hide show
  1. package/.turbo/turbo-build.log +17 -2
  2. package/CHANGELOG.md +26 -0
  3. package/README.md +58 -2
  4. package/dist/api/client.d.ts +19 -0
  5. package/dist/api/client.d.ts.map +1 -1
  6. package/dist/api/client.js +87 -1
  7. package/dist/api/client.js.map +1 -1
  8. package/dist/core/agent-tools-provider.d.ts +26 -0
  9. package/dist/core/agent-tools-provider.d.ts.map +1 -0
  10. package/dist/core/agent-tools-provider.js +130 -0
  11. package/dist/core/agent-tools-provider.js.map +1 -0
  12. package/dist/core/agent-tools.d.ts +16 -0
  13. package/dist/core/agent-tools.d.ts.map +1 -0
  14. package/dist/core/agent-tools.js +441 -0
  15. package/dist/core/agent-tools.js.map +1 -0
  16. package/dist/core/errors.d.ts.map +1 -1
  17. package/dist/core/scopes.d.ts +1 -1
  18. package/dist/core/scopes.d.ts.map +1 -1
  19. package/dist/core/tokens.d.ts +2 -0
  20. package/dist/core/tokens.d.ts.map +1 -1
  21. package/dist/core/tokens.js +129 -37
  22. package/dist/core/tokens.js.map +1 -1
  23. package/dist/db/db.d.ts +72 -4
  24. package/dist/db/db.d.ts.map +1 -1
  25. package/dist/db/installations.d.ts +23 -7
  26. package/dist/db/installations.d.ts.map +1 -1
  27. package/dist/db/installations.js +42 -19
  28. package/dist/db/installations.js.map +1 -1
  29. package/dist/db/schema/installations.d.ts +35 -1
  30. package/dist/db/schema/installations.d.ts.map +1 -1
  31. package/dist/db/schema/installations.js +12 -2
  32. package/dist/db/schema/installations.js.map +1 -1
  33. package/dist/db/schema/pending-selections.d.ts +1 -1
  34. package/dist/index.d.ts +21 -6
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +17 -5
  37. package/dist/index.js.map +1 -1
  38. package/dist/presentation/dto/integrations.d.ts.map +1 -1
  39. package/dist/temporal/activities/index.d.ts +2 -0
  40. package/dist/temporal/activities/index.d.ts.map +1 -0
  41. package/dist/temporal/activities/index.js +3 -0
  42. package/dist/temporal/activities/index.js.map +1 -0
  43. package/dist/temporal/activities/refresh-jira-tokens.d.ts +20 -0
  44. package/dist/temporal/activities/refresh-jira-tokens.d.ts.map +1 -0
  45. package/dist/temporal/activities/refresh-jira-tokens.js +58 -0
  46. package/dist/temporal/activities/refresh-jira-tokens.js.map +1 -0
  47. package/dist/temporal/constants.d.ts +6 -0
  48. package/dist/temporal/constants.d.ts.map +1 -0
  49. package/dist/temporal/constants.js +8 -0
  50. package/dist/temporal/constants.js.map +1 -0
  51. package/dist/temporal/worker.d.ts +9 -0
  52. package/dist/temporal/worker.d.ts.map +1 -0
  53. package/dist/temporal/worker.js +26 -0
  54. package/dist/temporal/worker.js.map +1 -0
  55. package/dist/temporal/workflows/index.bundle.js +25225 -0
  56. package/dist/temporal/workflows/index.bundle.meta.json +1 -0
  57. package/dist/temporal/workflows/index.d.ts +2 -0
  58. package/dist/temporal/workflows/index.d.ts.map +1 -0
  59. package/dist/temporal/workflows/index.js +3 -0
  60. package/dist/temporal/workflows/index.js.map +1 -0
  61. package/dist/temporal/workflows/refresh-jira-tokens-cron.d.ts +2 -0
  62. package/dist/temporal/workflows/refresh-jira-tokens-cron.d.ts.map +1 -0
  63. package/dist/temporal/workflows/refresh-jira-tokens-cron.js +20 -0
  64. package/dist/temporal/workflows/refresh-jira-tokens-cron.js.map +1 -0
  65. package/dist/tsconfig.test.tsbuildinfo +1 -1
  66. package/drizzle/0001_jira-token-refresh-state.sql +12 -0
  67. package/drizzle/meta/0001_snapshot.json +245 -0
  68. package/drizzle/meta/_journal.json +7 -0
  69. package/package.json +12 -5
  70. package/src/api/client.test.ts +304 -12
  71. package/src/api/client.ts +122 -1
  72. package/src/core/agent-tools-provider.test.ts +299 -0
  73. package/src/core/agent-tools-provider.ts +175 -0
  74. package/src/core/agent-tools.ts +450 -0
  75. package/src/core/tokens-refresh.test.ts +221 -4
  76. package/src/core/tokens.test.ts +1 -1
  77. package/src/core/tokens.ts +153 -33
  78. package/src/db/installations.test.ts +84 -2
  79. package/src/db/installations.ts +81 -21
  80. package/src/db/schema/installations.ts +21 -1
  81. package/src/index.test.ts +34 -0
  82. package/src/index.ts +47 -3
  83. package/src/temporal/activities/index.ts +5 -0
  84. package/src/temporal/activities/refresh-jira-tokens.test.ts +156 -0
  85. package/src/temporal/activities/refresh-jira-tokens.ts +88 -0
  86. package/src/temporal/constants.ts +8 -0
  87. package/src/temporal/worker.ts +39 -0
  88. package/src/temporal/workflows/index.ts +1 -0
  89. package/src/temporal/workflows/refresh-jira-tokens-cron.ts +19 -0
  90. package/test/factories/jira-installation.ts +4 -0
  91. 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 && (!Array.isArray(errors) || errors.length > 0)) {
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
+ }