@shipfox/api-integration-jira 11.0.0 → 12.1.0
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 +36 -0
- package/README.md +58 -2
- package/dist/api/client.d.ts +34 -0
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +131 -0
- package/dist/api/client.js.map +1 -1
- package/dist/config.d.ts +0 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -4
- package/dist/config.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/install.d.ts +22 -0
- package/dist/core/install.d.ts.map +1 -1
- package/dist/core/install.js +68 -20
- package/dist/core/install.js.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/core/webhook-processor.d.ts +15 -0
- package/dist/core/webhook-processor.d.ts.map +1 -0
- package/dist/core/webhook-processor.js +137 -0
- package/dist/core/webhook-processor.js.map +1 -0
- package/dist/core/webhook-registration.d.ts +26 -0
- package/dist/core/webhook-registration.d.ts.map +1 -0
- package/dist/core/webhook-registration.js +165 -0
- package/dist/core/webhook-registration.js.map +1 -0
- package/dist/core/webhook.d.ts +18 -0
- package/dist/core/webhook.d.ts.map +1 -0
- package/dist/core/webhook.js +38 -0
- package/dist/core/webhook.js.map +1 -0
- package/dist/db/db.d.ts +68 -0
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/installations.d.ts +33 -6
- package/dist/db/installations.d.ts.map +1 -1
- package/dist/db/installations.js +79 -7
- package/dist/db/installations.js.map +1 -1
- package/dist/db/schema/installations.d.ts +34 -0
- 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/index.d.ts +34 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +76 -8
- package/dist/index.js.map +1 -1
- package/dist/presentation/routes/install.d.ts +22 -0
- package/dist/presentation/routes/install.d.ts.map +1 -1
- package/dist/presentation/routes/install.js.map +1 -1
- package/dist/presentation/routes/webhooks.d.ts +8 -0
- package/dist/presentation/routes/webhooks.d.ts.map +1 -0
- package/dist/presentation/routes/webhooks.js +108 -0
- package/dist/presentation/routes/webhooks.js.map +1 -0
- 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 +17 -8
- package/src/api/client.test.ts +236 -9
- package/src/api/client.ts +192 -0
- package/src/config.test.ts +0 -1
- package/src/config.ts +1 -4
- 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/install.test.ts +179 -0
- package/src/core/install.ts +85 -19
- package/src/core/tokens-refresh.test.ts +223 -4
- package/src/core/tokens.test.ts +1 -1
- package/src/core/tokens.ts +153 -33
- package/src/core/webhook-processor.test.ts +418 -0
- package/src/core/webhook-processor.ts +166 -0
- package/src/core/webhook-registration.test.ts +375 -0
- package/src/core/webhook-registration.ts +216 -0
- package/src/core/webhook.ts +69 -0
- package/src/db/installations.test.ts +148 -2
- package/src/db/installations.ts +133 -7
- package/src/db/schema/installations.ts +21 -1
- package/src/index.test.ts +42 -0
- package/src/index.ts +148 -5
- package/src/presentation/routes/install.ts +12 -0
- package/src/presentation/routes/webhooks.test.ts +132 -0
- package/src/presentation/routes/webhooks.ts +115 -0
- 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/env.ts +0 -1
- package/test/factories/jira-installation.ts +4 -0
- package/tsconfig.build.tsbuildinfo +1 -1
package/src/api/client.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {jiraWebhookEventNames} from '@shipfox/api-integration-jira-dto';
|
|
1
2
|
import {logger} from '@shipfox/node-opentelemetry';
|
|
2
3
|
import ky, {HTTPError, TimeoutError} from 'ky';
|
|
3
4
|
import {config} from '#config.js';
|
|
@@ -5,6 +6,9 @@ import {JiraIntegrationProviderError} from '#core/errors.js';
|
|
|
5
6
|
|
|
6
7
|
const JIRA_API_TIMEOUT_MS = 10_000;
|
|
7
8
|
const SCOPE_SEPARATOR_RE = /[,\s]+/;
|
|
9
|
+
const TRAILING_SLASHES_RE = /\/+$/;
|
|
10
|
+
export const JIRA_DYNAMIC_WEBHOOK_EVENTS = jiraWebhookEventNames;
|
|
11
|
+
export const JIRA_DYNAMIC_WEBHOOK_JQL = '';
|
|
8
12
|
|
|
9
13
|
export interface JiraAuthorization {
|
|
10
14
|
accessToken: string;
|
|
@@ -24,11 +28,53 @@ export interface JiraIdentity {
|
|
|
24
28
|
accountId: string;
|
|
25
29
|
}
|
|
26
30
|
|
|
31
|
+
export interface JiraDynamicWebhookRegistration {
|
|
32
|
+
webhookId: number;
|
|
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
|
+
|
|
27
63
|
export interface JiraApiClient {
|
|
28
64
|
exchangeAuthorizationCode(input: {code: string}): Promise<JiraAuthorization>;
|
|
29
65
|
refreshAccessToken(input: {refreshToken: string}): Promise<JiraAuthorization>;
|
|
30
66
|
getAccessibleResources(input: {accessToken: string}): Promise<JiraAccessibleResource[]>;
|
|
31
67
|
getMyself(input: {accessToken: string; cloudId: string}): Promise<JiraIdentity>;
|
|
68
|
+
registerDynamicWebhook(input: {
|
|
69
|
+
accessToken: string;
|
|
70
|
+
cloudId: string;
|
|
71
|
+
url: string;
|
|
72
|
+
}): Promise<JiraDynamicWebhookRegistration>;
|
|
73
|
+
deleteDynamicWebhook(input: {
|
|
74
|
+
accessToken: string;
|
|
75
|
+
cloudId: string;
|
|
76
|
+
webhookId: number;
|
|
77
|
+
}): Promise<void>;
|
|
32
78
|
}
|
|
33
79
|
|
|
34
80
|
interface JiraTokenResponse {
|
|
@@ -49,6 +95,12 @@ interface JiraMyselfResponse {
|
|
|
49
95
|
accountId?: unknown;
|
|
50
96
|
}
|
|
51
97
|
|
|
98
|
+
interface JiraDynamicWebhookRegistrationResponse {
|
|
99
|
+
webhookRegistrationResult?: unknown;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const JIRA_REFRESH_TOKEN_TERMINAL_ERROR_CODES = new Set(['invalid_grant', 'unauthorized_client']);
|
|
103
|
+
|
|
52
104
|
export function createJiraApiClient(): JiraApiClient {
|
|
53
105
|
return {
|
|
54
106
|
async exchangeAuthorizationCode(input) {
|
|
@@ -115,7 +167,132 @@ export function createJiraApiClient(): JiraApiClient {
|
|
|
115
167
|
}
|
|
116
168
|
return {accountId: body.accountId};
|
|
117
169
|
},
|
|
170
|
+
|
|
171
|
+
async registerDynamicWebhook(input) {
|
|
172
|
+
const body = await mapJiraError('register-dynamic-webhook', () =>
|
|
173
|
+
ky
|
|
174
|
+
.post(`${config.JIRA_API_BASE_URL}/ex/jira/${input.cloudId}/rest/api/3/webhook`, {
|
|
175
|
+
headers: {authorization: `Bearer ${input.accessToken}`},
|
|
176
|
+
json: {
|
|
177
|
+
url: input.url,
|
|
178
|
+
webhooks: [
|
|
179
|
+
{
|
|
180
|
+
events: JIRA_DYNAMIC_WEBHOOK_EVENTS,
|
|
181
|
+
jqlFilter: JIRA_DYNAMIC_WEBHOOK_JQL,
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
},
|
|
185
|
+
timeout: JIRA_API_TIMEOUT_MS,
|
|
186
|
+
})
|
|
187
|
+
.json<JiraDynamicWebhookRegistrationResponse>(),
|
|
188
|
+
);
|
|
189
|
+
return parseDynamicWebhookRegistration(body);
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
async deleteDynamicWebhook(input) {
|
|
193
|
+
await mapJiraError('delete-dynamic-webhook', () =>
|
|
194
|
+
ky.delete(`${config.JIRA_API_BASE_URL}/ex/jira/${input.cloudId}/rest/api/3/webhook`, {
|
|
195
|
+
headers: {authorization: `Bearer ${input.accessToken}`},
|
|
196
|
+
json: {webhookIds: [input.webhookId]},
|
|
197
|
+
timeout: JIRA_API_TIMEOUT_MS,
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
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
|
+
|
|
265
|
+
function parseDynamicWebhookRegistration(
|
|
266
|
+
body: JiraDynamicWebhookRegistrationResponse,
|
|
267
|
+
): JiraDynamicWebhookRegistration {
|
|
268
|
+
if (
|
|
269
|
+
!body ||
|
|
270
|
+
typeof body !== 'object' ||
|
|
271
|
+
!Array.isArray(body.webhookRegistrationResult) ||
|
|
272
|
+
body.webhookRegistrationResult.length !== 1
|
|
273
|
+
) {
|
|
274
|
+
throw malformed('Jira webhook registration response did not contain exactly one result');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const result = body.webhookRegistrationResult[0];
|
|
278
|
+
if (!result || typeof result !== 'object') {
|
|
279
|
+
throw malformed('Jira webhook registration result was malformed');
|
|
280
|
+
}
|
|
281
|
+
const {createdWebhookId, errors} = result as {
|
|
282
|
+
createdWebhookId?: unknown;
|
|
283
|
+
errors?: unknown;
|
|
118
284
|
};
|
|
285
|
+
if (errors !== undefined && (!Array.isArray(errors) || errors.length > 0)) {
|
|
286
|
+
throw malformed('Jira webhook registration returned errors');
|
|
287
|
+
}
|
|
288
|
+
if (
|
|
289
|
+
typeof createdWebhookId !== 'number' ||
|
|
290
|
+
!Number.isSafeInteger(createdWebhookId) ||
|
|
291
|
+
createdWebhookId <= 0
|
|
292
|
+
) {
|
|
293
|
+
throw malformed('Jira webhook registration did not return a created webhook id');
|
|
294
|
+
}
|
|
295
|
+
return {webhookId: createdWebhookId};
|
|
119
296
|
}
|
|
120
297
|
|
|
121
298
|
function parseAuthorization(body: JiraTokenResponse): JiraAuthorization {
|
|
@@ -187,6 +364,15 @@ export async function mapJiraError<T>(operation: string, request: () => Promise<
|
|
|
187
364
|
if (status === 401 || status === 403) {
|
|
188
365
|
throw new JiraIntegrationProviderError('access-denied', 'Jira request was rejected');
|
|
189
366
|
}
|
|
367
|
+
if (status === 400 && operation === 'refresh-access-token') {
|
|
368
|
+
const errorCode = readJiraOAuthErrorCode(error.data);
|
|
369
|
+
if (errorCode && JIRA_REFRESH_TOKEN_TERMINAL_ERROR_CODES.has(errorCode)) {
|
|
370
|
+
throw new JiraIntegrationProviderError(
|
|
371
|
+
'access-denied',
|
|
372
|
+
'Jira refresh token was rejected; reconnect is required',
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
190
376
|
throw malformed('Jira request was rejected');
|
|
191
377
|
}
|
|
192
378
|
if (error instanceof TimeoutError) {
|
|
@@ -205,6 +391,12 @@ function malformed(message: string): JiraIntegrationProviderError {
|
|
|
205
391
|
return new JiraIntegrationProviderError('malformed-provider-response', message);
|
|
206
392
|
}
|
|
207
393
|
|
|
394
|
+
function readJiraOAuthErrorCode(data: unknown): string | undefined {
|
|
395
|
+
if (!data || typeof data !== 'object') return undefined;
|
|
396
|
+
const errorCode = (data as {error?: unknown}).error;
|
|
397
|
+
return typeof errorCode === 'string' ? errorCode : undefined;
|
|
398
|
+
}
|
|
399
|
+
|
|
208
400
|
function retryAfterSeconds(headers: Headers): number | undefined {
|
|
209
401
|
const value = headers.get('retry-after');
|
|
210
402
|
if (!value) return undefined;
|
package/src/config.test.ts
CHANGED
package/src/config.ts
CHANGED
|
@@ -5,14 +5,11 @@ export const config = createConfig({
|
|
|
5
5
|
desc: 'OAuth client ID of the Jira app, used to start and complete the Jira connect flow. Required.',
|
|
6
6
|
}),
|
|
7
7
|
JIRA_OAUTH_CLIENT_SECRET: str({
|
|
8
|
-
desc: 'OAuth client secret of the Jira app, used
|
|
8
|
+
desc: 'OAuth client secret of the Jira app, used for authorization-code exchange and inbound webhook JWT verification. Required.',
|
|
9
9
|
}),
|
|
10
10
|
JIRA_OAUTH_REDIRECT_URL: url({
|
|
11
11
|
desc: 'Public client callback URL Jira redirects to after OAuth authorization, such as https://shipfox.example.com/integrations/jira/callback. Required.',
|
|
12
12
|
}),
|
|
13
|
-
JIRA_WEBHOOK_SIGNING_SECRET: str({
|
|
14
|
-
desc: 'Secret used to verify incoming Jira webhooks. It must match the webhook signing secret configured for the Jira app. Required.',
|
|
15
|
-
}),
|
|
16
13
|
JIRA_WEBHOOK_BASE_URL: url({
|
|
17
14
|
desc: 'Public base URL Jira sends webhooks to. Its origin must be added to the Jira app webhook allowlist. Required.',
|
|
18
15
|
}),
|
|
@@ -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
|
+
});
|