@shipfox/api-integration-jira 12.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 +10 -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 +78 -0
- 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/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 +68 -0
- 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 +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 +20 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -5
- package/dist/index.js.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 +133 -12
- package/src/api/client.ts +107 -0
- 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
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
ALTER TABLE "integrations_jira_installations" ADD COLUMN "refresh_token_last_used_at" timestamp with time zone;--> statement-breakpoint
|
|
2
|
+
ALTER TABLE "integrations_jira_installations" ADD COLUMN "refresh_token_last_attempted_at" timestamp with time zone;--> statement-breakpoint
|
|
3
|
+
UPDATE "integrations_jira_installations"
|
|
4
|
+
SET
|
|
5
|
+
"refresh_token_last_used_at" = "created_at",
|
|
6
|
+
"refresh_token_last_attempted_at" = "created_at";--> statement-breakpoint
|
|
7
|
+
ALTER TABLE "integrations_jira_installations" ALTER COLUMN "refresh_token_last_used_at" SET DEFAULT now();--> statement-breakpoint
|
|
8
|
+
ALTER TABLE "integrations_jira_installations" ALTER COLUMN "refresh_token_last_used_at" SET NOT NULL;--> statement-breakpoint
|
|
9
|
+
ALTER TABLE "integrations_jira_installations" ALTER COLUMN "refresh_token_last_attempted_at" SET DEFAULT now();--> statement-breakpoint
|
|
10
|
+
ALTER TABLE "integrations_jira_installations" ALTER COLUMN "refresh_token_last_attempted_at" SET NOT NULL;--> statement-breakpoint
|
|
11
|
+
CREATE INDEX "integrations_jira_installations_token_refresh_due_idx" ON "integrations_jira_installations" USING btree ("status","refresh_token_last_used_at");--> statement-breakpoint
|
|
12
|
+
CREATE INDEX "integrations_jira_installations_token_refresh_attempt_idx" ON "integrations_jira_installations" USING btree ("status","refresh_token_last_attempted_at");
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "9ddf6898-1a59-4d58-a3c1-7a8fc1587c00",
|
|
3
|
+
"prevId": "5903a3ad-8572-4443-9fb4-8eb0298fab14",
|
|
4
|
+
"version": "7",
|
|
5
|
+
"dialect": "postgresql",
|
|
6
|
+
"tables": {
|
|
7
|
+
"public.integrations_jira_installations": {
|
|
8
|
+
"name": "integrations_jira_installations",
|
|
9
|
+
"schema": "",
|
|
10
|
+
"columns": {
|
|
11
|
+
"id": {
|
|
12
|
+
"name": "id",
|
|
13
|
+
"type": "uuid",
|
|
14
|
+
"primaryKey": true,
|
|
15
|
+
"notNull": true,
|
|
16
|
+
"default": "uuidv7()"
|
|
17
|
+
},
|
|
18
|
+
"connection_id": {
|
|
19
|
+
"name": "connection_id",
|
|
20
|
+
"type": "uuid",
|
|
21
|
+
"primaryKey": false,
|
|
22
|
+
"notNull": true
|
|
23
|
+
},
|
|
24
|
+
"cloud_id": {
|
|
25
|
+
"name": "cloud_id",
|
|
26
|
+
"type": "text",
|
|
27
|
+
"primaryKey": false,
|
|
28
|
+
"notNull": true
|
|
29
|
+
},
|
|
30
|
+
"site_url": {
|
|
31
|
+
"name": "site_url",
|
|
32
|
+
"type": "text",
|
|
33
|
+
"primaryKey": false,
|
|
34
|
+
"notNull": true
|
|
35
|
+
},
|
|
36
|
+
"site_name": {
|
|
37
|
+
"name": "site_name",
|
|
38
|
+
"type": "text",
|
|
39
|
+
"primaryKey": false,
|
|
40
|
+
"notNull": true
|
|
41
|
+
},
|
|
42
|
+
"authorizing_account_id": {
|
|
43
|
+
"name": "authorizing_account_id",
|
|
44
|
+
"type": "text",
|
|
45
|
+
"primaryKey": false,
|
|
46
|
+
"notNull": true
|
|
47
|
+
},
|
|
48
|
+
"scopes": {
|
|
49
|
+
"name": "scopes",
|
|
50
|
+
"type": "jsonb",
|
|
51
|
+
"primaryKey": false,
|
|
52
|
+
"notNull": true
|
|
53
|
+
},
|
|
54
|
+
"webhook_ids": {
|
|
55
|
+
"name": "webhook_ids",
|
|
56
|
+
"type": "jsonb",
|
|
57
|
+
"primaryKey": false,
|
|
58
|
+
"notNull": true,
|
|
59
|
+
"default": "'[]'::jsonb"
|
|
60
|
+
},
|
|
61
|
+
"webhook_expires_at": {
|
|
62
|
+
"name": "webhook_expires_at",
|
|
63
|
+
"type": "timestamp with time zone",
|
|
64
|
+
"primaryKey": false,
|
|
65
|
+
"notNull": false
|
|
66
|
+
},
|
|
67
|
+
"status": {
|
|
68
|
+
"name": "status",
|
|
69
|
+
"type": "text",
|
|
70
|
+
"primaryKey": false,
|
|
71
|
+
"notNull": true
|
|
72
|
+
},
|
|
73
|
+
"token_expires_at": {
|
|
74
|
+
"name": "token_expires_at",
|
|
75
|
+
"type": "timestamp with time zone",
|
|
76
|
+
"primaryKey": false,
|
|
77
|
+
"notNull": false
|
|
78
|
+
},
|
|
79
|
+
"refresh_token_last_used_at": {
|
|
80
|
+
"name": "refresh_token_last_used_at",
|
|
81
|
+
"type": "timestamp with time zone",
|
|
82
|
+
"primaryKey": false,
|
|
83
|
+
"notNull": true,
|
|
84
|
+
"default": "now()"
|
|
85
|
+
},
|
|
86
|
+
"refresh_token_last_attempted_at": {
|
|
87
|
+
"name": "refresh_token_last_attempted_at",
|
|
88
|
+
"type": "timestamp with time zone",
|
|
89
|
+
"primaryKey": false,
|
|
90
|
+
"notNull": true,
|
|
91
|
+
"default": "now()"
|
|
92
|
+
},
|
|
93
|
+
"created_at": {
|
|
94
|
+
"name": "created_at",
|
|
95
|
+
"type": "timestamp with time zone",
|
|
96
|
+
"primaryKey": false,
|
|
97
|
+
"notNull": true,
|
|
98
|
+
"default": "now()"
|
|
99
|
+
},
|
|
100
|
+
"updated_at": {
|
|
101
|
+
"name": "updated_at",
|
|
102
|
+
"type": "timestamp with time zone",
|
|
103
|
+
"primaryKey": false,
|
|
104
|
+
"notNull": true,
|
|
105
|
+
"default": "now()"
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
"indexes": {
|
|
109
|
+
"integrations_jira_installations_connection_unique": {
|
|
110
|
+
"name": "integrations_jira_installations_connection_unique",
|
|
111
|
+
"columns": [
|
|
112
|
+
{
|
|
113
|
+
"expression": "connection_id",
|
|
114
|
+
"isExpression": false,
|
|
115
|
+
"asc": true,
|
|
116
|
+
"nulls": "last"
|
|
117
|
+
}
|
|
118
|
+
],
|
|
119
|
+
"isUnique": true,
|
|
120
|
+
"concurrently": false,
|
|
121
|
+
"method": "btree",
|
|
122
|
+
"with": {}
|
|
123
|
+
},
|
|
124
|
+
"integrations_jira_installations_cloud_id_unique": {
|
|
125
|
+
"name": "integrations_jira_installations_cloud_id_unique",
|
|
126
|
+
"columns": [
|
|
127
|
+
{
|
|
128
|
+
"expression": "cloud_id",
|
|
129
|
+
"isExpression": false,
|
|
130
|
+
"asc": true,
|
|
131
|
+
"nulls": "last"
|
|
132
|
+
}
|
|
133
|
+
],
|
|
134
|
+
"isUnique": true,
|
|
135
|
+
"concurrently": false,
|
|
136
|
+
"method": "btree",
|
|
137
|
+
"with": {}
|
|
138
|
+
},
|
|
139
|
+
"integrations_jira_installations_token_refresh_due_idx": {
|
|
140
|
+
"name": "integrations_jira_installations_token_refresh_due_idx",
|
|
141
|
+
"columns": [
|
|
142
|
+
{
|
|
143
|
+
"expression": "status",
|
|
144
|
+
"isExpression": false,
|
|
145
|
+
"asc": true,
|
|
146
|
+
"nulls": "last"
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
"expression": "refresh_token_last_used_at",
|
|
150
|
+
"isExpression": false,
|
|
151
|
+
"asc": true,
|
|
152
|
+
"nulls": "last"
|
|
153
|
+
}
|
|
154
|
+
],
|
|
155
|
+
"isUnique": false,
|
|
156
|
+
"concurrently": false,
|
|
157
|
+
"method": "btree",
|
|
158
|
+
"with": {}
|
|
159
|
+
},
|
|
160
|
+
"integrations_jira_installations_token_refresh_attempt_idx": {
|
|
161
|
+
"name": "integrations_jira_installations_token_refresh_attempt_idx",
|
|
162
|
+
"columns": [
|
|
163
|
+
{
|
|
164
|
+
"expression": "status",
|
|
165
|
+
"isExpression": false,
|
|
166
|
+
"asc": true,
|
|
167
|
+
"nulls": "last"
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
"expression": "refresh_token_last_attempted_at",
|
|
171
|
+
"isExpression": false,
|
|
172
|
+
"asc": true,
|
|
173
|
+
"nulls": "last"
|
|
174
|
+
}
|
|
175
|
+
],
|
|
176
|
+
"isUnique": false,
|
|
177
|
+
"concurrently": false,
|
|
178
|
+
"method": "btree",
|
|
179
|
+
"with": {}
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
"foreignKeys": {},
|
|
183
|
+
"compositePrimaryKeys": {},
|
|
184
|
+
"uniqueConstraints": {},
|
|
185
|
+
"policies": {},
|
|
186
|
+
"checkConstraints": {},
|
|
187
|
+
"isRLSEnabled": false
|
|
188
|
+
},
|
|
189
|
+
"public.integrations_jira_pending_selections": {
|
|
190
|
+
"name": "integrations_jira_pending_selections",
|
|
191
|
+
"schema": "",
|
|
192
|
+
"columns": {
|
|
193
|
+
"state_hash": {
|
|
194
|
+
"name": "state_hash",
|
|
195
|
+
"type": "text",
|
|
196
|
+
"primaryKey": true,
|
|
197
|
+
"notNull": true
|
|
198
|
+
},
|
|
199
|
+
"workspace_id": {
|
|
200
|
+
"name": "workspace_id",
|
|
201
|
+
"type": "uuid",
|
|
202
|
+
"primaryKey": false,
|
|
203
|
+
"notNull": true
|
|
204
|
+
},
|
|
205
|
+
"expires_at": {
|
|
206
|
+
"name": "expires_at",
|
|
207
|
+
"type": "timestamp with time zone",
|
|
208
|
+
"primaryKey": false,
|
|
209
|
+
"notNull": true
|
|
210
|
+
},
|
|
211
|
+
"sites": {
|
|
212
|
+
"name": "sites",
|
|
213
|
+
"type": "jsonb",
|
|
214
|
+
"primaryKey": false,
|
|
215
|
+
"notNull": true
|
|
216
|
+
},
|
|
217
|
+
"created_at": {
|
|
218
|
+
"name": "created_at",
|
|
219
|
+
"type": "timestamp with time zone",
|
|
220
|
+
"primaryKey": false,
|
|
221
|
+
"notNull": true,
|
|
222
|
+
"default": "now()"
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
"indexes": {},
|
|
226
|
+
"foreignKeys": {},
|
|
227
|
+
"compositePrimaryKeys": {},
|
|
228
|
+
"uniqueConstraints": {},
|
|
229
|
+
"policies": {},
|
|
230
|
+
"checkConstraints": {},
|
|
231
|
+
"isRLSEnabled": false
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
"enums": {},
|
|
235
|
+
"schemas": {},
|
|
236
|
+
"sequences": {},
|
|
237
|
+
"roles": {},
|
|
238
|
+
"policies": {},
|
|
239
|
+
"views": {},
|
|
240
|
+
"_meta": {
|
|
241
|
+
"columns": {},
|
|
242
|
+
"schemas": {},
|
|
243
|
+
"tables": {}
|
|
244
|
+
}
|
|
245
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/api-integration-jira",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "12.
|
|
4
|
+
"version": "12.1.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -15,21 +15,28 @@
|
|
|
15
15
|
".": {
|
|
16
16
|
"types": "./dist/index.d.ts",
|
|
17
17
|
"default": "./dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./agent-tools": {
|
|
20
|
+
"types": "./dist/core/agent-tools.d.ts",
|
|
21
|
+
"default": "./dist/core/agent-tools.js"
|
|
18
22
|
}
|
|
19
23
|
},
|
|
20
24
|
"dependencies": {
|
|
25
|
+
"@temporalio/activity": "1.18.1",
|
|
26
|
+
"@temporalio/workflow": "1.18.1",
|
|
21
27
|
"drizzle-orm": "^0.45.2",
|
|
22
28
|
"ky": "^2.0.0",
|
|
23
29
|
"zod": "^4.4.3",
|
|
24
30
|
"@shipfox/api-auth-context": "12.0.0",
|
|
25
31
|
"@shipfox/api-integration-spi": "1.0.0",
|
|
32
|
+
"@shipfox/api-integration-jira-dto": "12.0.0",
|
|
26
33
|
"@shipfox/config": "1.2.4",
|
|
27
34
|
"@shipfox/node-drizzle": "0.3.5",
|
|
28
|
-
"@shipfox/api-integration-jira-dto": "12.0.0",
|
|
29
35
|
"@shipfox/node-fastify": "0.4.1",
|
|
36
|
+
"@shipfox/node-jwt": "0.4.0",
|
|
37
|
+
"@shipfox/node-module": "1.0.5",
|
|
30
38
|
"@shipfox/node-opentelemetry": "0.6.3",
|
|
31
|
-
"@shipfox/node-postgres": "0.5.0"
|
|
32
|
-
"@shipfox/node-jwt": "0.4.0"
|
|
39
|
+
"@shipfox/node-postgres": "0.5.0"
|
|
33
40
|
},
|
|
34
41
|
"imports": {
|
|
35
42
|
"#*": "./dist/*"
|
|
@@ -43,7 +50,7 @@
|
|
|
43
50
|
}
|
|
44
51
|
},
|
|
45
52
|
"scripts": {
|
|
46
|
-
"build": "shipfox-swc",
|
|
53
|
+
"build": "shipfox-swc && shipfox-temporal-bundle dist/temporal/workflows/index.js",
|
|
47
54
|
"check": "shipfox-biome-check",
|
|
48
55
|
"check:fix": "shipfox-biome-check --write",
|
|
49
56
|
"depcruise": "shipfox-depcruise",
|
package/src/api/client.test.ts
CHANGED
|
@@ -2,11 +2,13 @@ import {HTTPError} from 'ky';
|
|
|
2
2
|
import type {JiraIntegrationProviderError} from '#core/errors.js';
|
|
3
3
|
import {mapJiraError} from './client.js';
|
|
4
4
|
|
|
5
|
-
const mocks = vi.hoisted(() => ({delete: vi.fn(), post: vi.fn()}));
|
|
5
|
+
const mocks = vi.hoisted(() => ({delete: vi.fn(), post: vi.fn(), request: vi.fn()}));
|
|
6
6
|
|
|
7
7
|
vi.mock('ky', () => {
|
|
8
8
|
class MockHTTPError extends Error {
|
|
9
|
-
|
|
9
|
+
data?: unknown;
|
|
10
|
+
|
|
11
|
+
constructor(public response: Response) {
|
|
10
12
|
super('http');
|
|
11
13
|
this.name = 'HTTPError';
|
|
12
14
|
}
|
|
@@ -18,21 +20,23 @@ vi.mock('ky', () => {
|
|
|
18
20
|
}
|
|
19
21
|
}
|
|
20
22
|
return {
|
|
21
|
-
default: {delete: mocks.delete, post: mocks.post},
|
|
23
|
+
default: Object.assign(mocks.request, {delete: mocks.delete, post: mocks.post}),
|
|
22
24
|
HTTPError: MockHTTPError,
|
|
23
25
|
TimeoutError: MockTimeoutError,
|
|
24
26
|
};
|
|
25
27
|
});
|
|
26
28
|
|
|
27
|
-
function rejectedRequest(status: number): () => Promise<never> {
|
|
28
|
-
return () =>
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
29
|
+
function rejectedRequest(status: number, body?: {error: string}): () => Promise<never> {
|
|
30
|
+
return async () => {
|
|
31
|
+
const response = new Response(body ? JSON.stringify(body) : null, {
|
|
32
|
+
status,
|
|
33
|
+
...(body ? {headers: {'content-type': 'application/json'}} : {}),
|
|
34
|
+
});
|
|
35
|
+
const error = new HTTPError(response, new Request('https://jira.example.test'), {} as never);
|
|
36
|
+
await response.text();
|
|
37
|
+
error.data = body;
|
|
38
|
+
throw error;
|
|
39
|
+
};
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
describe('mapJiraError', () => {
|
|
@@ -48,6 +52,20 @@ describe('mapJiraError', () => {
|
|
|
48
52
|
reason,
|
|
49
53
|
} satisfies Partial<JiraIntegrationProviderError>);
|
|
50
54
|
});
|
|
55
|
+
|
|
56
|
+
it.each([
|
|
57
|
+
['invalid_grant', 'access-denied'],
|
|
58
|
+
['unauthorized_client', 'access-denied'],
|
|
59
|
+
['invalid_request', 'malformed-provider-response'],
|
|
60
|
+
[undefined, 'malformed-provider-response'],
|
|
61
|
+
] as const)('maps refresh HTTP 400 with OAuth error %s to %s', async (errorCode, reason) => {
|
|
62
|
+
const result = mapJiraError(
|
|
63
|
+
'refresh-access-token',
|
|
64
|
+
rejectedRequest(400, errorCode ? {error: errorCode} : undefined),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
await expect(result).rejects.toMatchObject({reason});
|
|
68
|
+
});
|
|
51
69
|
});
|
|
52
70
|
|
|
53
71
|
function resolves(data: unknown) {
|
|
@@ -58,6 +76,7 @@ describe('Jira dynamic webhook API', () => {
|
|
|
58
76
|
beforeEach(() => {
|
|
59
77
|
mocks.delete.mockReset();
|
|
60
78
|
mocks.post.mockReset();
|
|
79
|
+
mocks.request.mockReset();
|
|
61
80
|
});
|
|
62
81
|
|
|
63
82
|
it('registers the six curated events with the access token', async () => {
|
|
@@ -133,3 +152,105 @@ describe('Jira dynamic webhook API', () => {
|
|
|
133
152
|
);
|
|
134
153
|
});
|
|
135
154
|
});
|
|
155
|
+
|
|
156
|
+
describe('Jira agent-tools REST API', () => {
|
|
157
|
+
beforeEach(() => {
|
|
158
|
+
mocks.request.mockReset();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('sends a REST v3 request and parses a JSON response', async () => {
|
|
162
|
+
mocks.request.mockResolvedValue(
|
|
163
|
+
new Response(JSON.stringify({id: '1004', key: 'ENG-1004'}), {status: 200}),
|
|
164
|
+
);
|
|
165
|
+
const {createJiraAgentToolsClient} = await import('./client.js');
|
|
166
|
+
|
|
167
|
+
const result = await createJiraAgentToolsClient().request({
|
|
168
|
+
accessToken: 'access-token',
|
|
169
|
+
cloudId: 'cloud-1',
|
|
170
|
+
method: 'GET',
|
|
171
|
+
path: '/issue/ENG-1004',
|
|
172
|
+
query: {fields: ['summary', 'description'], updateHistory: false},
|
|
173
|
+
operation: 'get_issue',
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
expect(result).toEqual({status: 200, body: {id: '1004', key: 'ENG-1004'}});
|
|
177
|
+
const [url, options] = mocks.request.mock.calls[0] as [
|
|
178
|
+
string,
|
|
179
|
+
{headers: Record<string, string>; searchParams: URLSearchParams},
|
|
180
|
+
];
|
|
181
|
+
expect(url).toBe('http://127.0.0.1:0/ex/jira/cloud-1/rest/api/3/issue/ENG-1004');
|
|
182
|
+
expect(options.headers).toEqual({authorization: 'Bearer access-token'});
|
|
183
|
+
expect([...options.searchParams.entries()]).toEqual([
|
|
184
|
+
['fields', 'summary'],
|
|
185
|
+
['fields', 'description'],
|
|
186
|
+
['updateHistory', 'false'],
|
|
187
|
+
]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('maps HTTP 429 Retry-After responses to a rate-limited provider error', async () => {
|
|
191
|
+
mocks.request.mockRejectedValue(
|
|
192
|
+
new HTTPError(
|
|
193
|
+
new Response(null, {status: 429, headers: {'retry-after': '19'}}),
|
|
194
|
+
new Request('https://jira.example.test'),
|
|
195
|
+
{} as never,
|
|
196
|
+
),
|
|
197
|
+
);
|
|
198
|
+
const {createJiraAgentToolsClient} = await import('./client.js');
|
|
199
|
+
|
|
200
|
+
await expect(
|
|
201
|
+
createJiraAgentToolsClient().request({
|
|
202
|
+
accessToken: 'access-token',
|
|
203
|
+
cloudId: 'cloud-1',
|
|
204
|
+
method: 'GET',
|
|
205
|
+
path: '/issue/ENG-1004',
|
|
206
|
+
operation: 'get_issue',
|
|
207
|
+
}),
|
|
208
|
+
).rejects.toMatchObject({reason: 'rate-limited', retryAfterSeconds: 19});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('preserves Jira validation details from HTTP 400 responses', async () => {
|
|
212
|
+
mocks.request.mockRejectedValue(
|
|
213
|
+
new HTTPError(
|
|
214
|
+
new Response(JSON.stringify({errorMessages: ['The JQL query is invalid']}), {status: 400}),
|
|
215
|
+
new Request('https://jira.example.test'),
|
|
216
|
+
{} as never,
|
|
217
|
+
),
|
|
218
|
+
);
|
|
219
|
+
const {createJiraAgentToolsClient} = await import('./client.js');
|
|
220
|
+
|
|
221
|
+
await expect(
|
|
222
|
+
createJiraAgentToolsClient().request({
|
|
223
|
+
accessToken: 'access-token',
|
|
224
|
+
cloudId: 'cloud-1',
|
|
225
|
+
method: 'POST',
|
|
226
|
+
path: '/search/jql',
|
|
227
|
+
body: {jql: 'not valid'},
|
|
228
|
+
operation: 'search_issues',
|
|
229
|
+
}),
|
|
230
|
+
).resolves.toEqual({
|
|
231
|
+
status: 400,
|
|
232
|
+
body: {errorMessages: ['The JQL query is invalid']},
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('maps HTTP 401 responses to an access-denied provider error', async () => {
|
|
237
|
+
mocks.request.mockRejectedValue(
|
|
238
|
+
new HTTPError(
|
|
239
|
+
new Response(null, {status: 401}),
|
|
240
|
+
new Request('https://jira.example.test'),
|
|
241
|
+
{} as never,
|
|
242
|
+
),
|
|
243
|
+
);
|
|
244
|
+
const {createJiraAgentToolsClient} = await import('./client.js');
|
|
245
|
+
|
|
246
|
+
await expect(
|
|
247
|
+
createJiraAgentToolsClient().request({
|
|
248
|
+
accessToken: 'access-token',
|
|
249
|
+
cloudId: 'cloud-1',
|
|
250
|
+
method: 'GET',
|
|
251
|
+
path: '/issue/ENG-1004',
|
|
252
|
+
operation: 'get_issue',
|
|
253
|
+
}),
|
|
254
|
+
).rejects.toMatchObject({reason: 'access-denied'});
|
|
255
|
+
});
|
|
256
|
+
});
|
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 {
|
|
@@ -272,6 +364,15 @@ export async function mapJiraError<T>(operation: string, request: () => Promise<
|
|
|
272
364
|
if (status === 401 || status === 403) {
|
|
273
365
|
throw new JiraIntegrationProviderError('access-denied', 'Jira request was rejected');
|
|
274
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
|
+
}
|
|
275
376
|
throw malformed('Jira request was rejected');
|
|
276
377
|
}
|
|
277
378
|
if (error instanceof TimeoutError) {
|
|
@@ -290,6 +391,12 @@ function malformed(message: string): JiraIntegrationProviderError {
|
|
|
290
391
|
return new JiraIntegrationProviderError('malformed-provider-response', message);
|
|
291
392
|
}
|
|
292
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
|
+
|
|
293
400
|
function retryAfterSeconds(headers: Headers): number | undefined {
|
|
294
401
|
const value = headers.get('retry-after');
|
|
295
402
|
if (!value) return undefined;
|