@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.
Files changed (86) hide show
  1. package/.turbo/turbo-build.log +17 -2
  2. package/CHANGELOG.md +10 -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 +78 -0
  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/tokens.d.ts +2 -0
  17. package/dist/core/tokens.d.ts.map +1 -1
  18. package/dist/core/tokens.js +129 -37
  19. package/dist/core/tokens.js.map +1 -1
  20. package/dist/db/db.d.ts +68 -0
  21. package/dist/db/db.d.ts.map +1 -1
  22. package/dist/db/installations.d.ts +23 -7
  23. package/dist/db/installations.d.ts.map +1 -1
  24. package/dist/db/installations.js +42 -19
  25. package/dist/db/installations.js.map +1 -1
  26. package/dist/db/schema/installations.d.ts +34 -0
  27. package/dist/db/schema/installations.d.ts.map +1 -1
  28. package/dist/db/schema/installations.js +12 -2
  29. package/dist/db/schema/installations.js.map +1 -1
  30. package/dist/index.d.ts +20 -5
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +17 -5
  33. package/dist/index.js.map +1 -1
  34. package/dist/temporal/activities/index.d.ts +2 -0
  35. package/dist/temporal/activities/index.d.ts.map +1 -0
  36. package/dist/temporal/activities/index.js +3 -0
  37. package/dist/temporal/activities/index.js.map +1 -0
  38. package/dist/temporal/activities/refresh-jira-tokens.d.ts +20 -0
  39. package/dist/temporal/activities/refresh-jira-tokens.d.ts.map +1 -0
  40. package/dist/temporal/activities/refresh-jira-tokens.js +58 -0
  41. package/dist/temporal/activities/refresh-jira-tokens.js.map +1 -0
  42. package/dist/temporal/constants.d.ts +6 -0
  43. package/dist/temporal/constants.d.ts.map +1 -0
  44. package/dist/temporal/constants.js +8 -0
  45. package/dist/temporal/constants.js.map +1 -0
  46. package/dist/temporal/worker.d.ts +9 -0
  47. package/dist/temporal/worker.d.ts.map +1 -0
  48. package/dist/temporal/worker.js +26 -0
  49. package/dist/temporal/worker.js.map +1 -0
  50. package/dist/temporal/workflows/index.bundle.js +25225 -0
  51. package/dist/temporal/workflows/index.bundle.meta.json +1 -0
  52. package/dist/temporal/workflows/index.d.ts +2 -0
  53. package/dist/temporal/workflows/index.d.ts.map +1 -0
  54. package/dist/temporal/workflows/index.js +3 -0
  55. package/dist/temporal/workflows/index.js.map +1 -0
  56. package/dist/temporal/workflows/refresh-jira-tokens-cron.d.ts +2 -0
  57. package/dist/temporal/workflows/refresh-jira-tokens-cron.d.ts.map +1 -0
  58. package/dist/temporal/workflows/refresh-jira-tokens-cron.js +20 -0
  59. package/dist/temporal/workflows/refresh-jira-tokens-cron.js.map +1 -0
  60. package/dist/tsconfig.test.tsbuildinfo +1 -1
  61. package/drizzle/0001_jira-token-refresh-state.sql +12 -0
  62. package/drizzle/meta/0001_snapshot.json +245 -0
  63. package/drizzle/meta/_journal.json +7 -0
  64. package/package.json +12 -5
  65. package/src/api/client.test.ts +133 -12
  66. package/src/api/client.ts +107 -0
  67. package/src/core/agent-tools-provider.test.ts +299 -0
  68. package/src/core/agent-tools-provider.ts +175 -0
  69. package/src/core/agent-tools.ts +450 -0
  70. package/src/core/tokens-refresh.test.ts +221 -4
  71. package/src/core/tokens.test.ts +1 -1
  72. package/src/core/tokens.ts +153 -33
  73. package/src/db/installations.test.ts +84 -2
  74. package/src/db/installations.ts +81 -21
  75. package/src/db/schema/installations.ts +21 -1
  76. package/src/index.test.ts +34 -0
  77. package/src/index.ts +47 -3
  78. package/src/temporal/activities/index.ts +5 -0
  79. package/src/temporal/activities/refresh-jira-tokens.test.ts +156 -0
  80. package/src/temporal/activities/refresh-jira-tokens.ts +88 -0
  81. package/src/temporal/constants.ts +8 -0
  82. package/src/temporal/worker.ts +39 -0
  83. package/src/temporal/workflows/index.ts +1 -0
  84. package/src/temporal/workflows/refresh-jira-tokens-cron.ts +19 -0
  85. package/test/factories/jira-installation.ts +4 -0
  86. 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
+ }
@@ -8,6 +8,13 @@
8
8
  "when": 1784385935754,
9
9
  "tag": "0000_whole_master_chief",
10
10
  "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "7",
15
+ "when": 1785832279528,
16
+ "tag": "0001_jira-token-refresh-state",
17
+ "breakpoints": true
11
18
  }
12
19
  ]
13
20
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-integration-jira",
3
3
  "license": "MIT",
4
- "version": "12.0.0",
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",
@@ -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
- constructor(public response: {status: number; statusText?: string; headers: Headers}) {
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
- Promise.reject(
30
- new HTTPError(
31
- new Response(null, {status}),
32
- new Request('https://jira.example.test'),
33
- {} as never,
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;