@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.
Files changed (126) hide show
  1. package/.turbo/turbo-build.log +17 -2
  2. package/CHANGELOG.md +36 -0
  3. package/README.md +58 -2
  4. package/dist/api/client.d.ts +34 -0
  5. package/dist/api/client.d.ts.map +1 -1
  6. package/dist/api/client.js +131 -0
  7. package/dist/api/client.js.map +1 -1
  8. package/dist/config.d.ts +0 -1
  9. package/dist/config.d.ts.map +1 -1
  10. package/dist/config.js +1 -4
  11. package/dist/config.js.map +1 -1
  12. package/dist/core/agent-tools-provider.d.ts +26 -0
  13. package/dist/core/agent-tools-provider.d.ts.map +1 -0
  14. package/dist/core/agent-tools-provider.js +130 -0
  15. package/dist/core/agent-tools-provider.js.map +1 -0
  16. package/dist/core/agent-tools.d.ts +16 -0
  17. package/dist/core/agent-tools.d.ts.map +1 -0
  18. package/dist/core/agent-tools.js +441 -0
  19. package/dist/core/agent-tools.js.map +1 -0
  20. package/dist/core/install.d.ts +22 -0
  21. package/dist/core/install.d.ts.map +1 -1
  22. package/dist/core/install.js +68 -20
  23. package/dist/core/install.js.map +1 -1
  24. package/dist/core/tokens.d.ts +2 -0
  25. package/dist/core/tokens.d.ts.map +1 -1
  26. package/dist/core/tokens.js +129 -37
  27. package/dist/core/tokens.js.map +1 -1
  28. package/dist/core/webhook-processor.d.ts +15 -0
  29. package/dist/core/webhook-processor.d.ts.map +1 -0
  30. package/dist/core/webhook-processor.js +137 -0
  31. package/dist/core/webhook-processor.js.map +1 -0
  32. package/dist/core/webhook-registration.d.ts +26 -0
  33. package/dist/core/webhook-registration.d.ts.map +1 -0
  34. package/dist/core/webhook-registration.js +165 -0
  35. package/dist/core/webhook-registration.js.map +1 -0
  36. package/dist/core/webhook.d.ts +18 -0
  37. package/dist/core/webhook.d.ts.map +1 -0
  38. package/dist/core/webhook.js +38 -0
  39. package/dist/core/webhook.js.map +1 -0
  40. package/dist/db/db.d.ts +68 -0
  41. package/dist/db/db.d.ts.map +1 -1
  42. package/dist/db/installations.d.ts +33 -6
  43. package/dist/db/installations.d.ts.map +1 -1
  44. package/dist/db/installations.js +79 -7
  45. package/dist/db/installations.js.map +1 -1
  46. package/dist/db/schema/installations.d.ts +34 -0
  47. package/dist/db/schema/installations.d.ts.map +1 -1
  48. package/dist/db/schema/installations.js +12 -2
  49. package/dist/db/schema/installations.js.map +1 -1
  50. package/dist/index.d.ts +34 -7
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +76 -8
  53. package/dist/index.js.map +1 -1
  54. package/dist/presentation/routes/install.d.ts +22 -0
  55. package/dist/presentation/routes/install.d.ts.map +1 -1
  56. package/dist/presentation/routes/install.js.map +1 -1
  57. package/dist/presentation/routes/webhooks.d.ts +8 -0
  58. package/dist/presentation/routes/webhooks.d.ts.map +1 -0
  59. package/dist/presentation/routes/webhooks.js +108 -0
  60. package/dist/presentation/routes/webhooks.js.map +1 -0
  61. package/dist/temporal/activities/index.d.ts +2 -0
  62. package/dist/temporal/activities/index.d.ts.map +1 -0
  63. package/dist/temporal/activities/index.js +3 -0
  64. package/dist/temporal/activities/index.js.map +1 -0
  65. package/dist/temporal/activities/refresh-jira-tokens.d.ts +20 -0
  66. package/dist/temporal/activities/refresh-jira-tokens.d.ts.map +1 -0
  67. package/dist/temporal/activities/refresh-jira-tokens.js +58 -0
  68. package/dist/temporal/activities/refresh-jira-tokens.js.map +1 -0
  69. package/dist/temporal/constants.d.ts +6 -0
  70. package/dist/temporal/constants.d.ts.map +1 -0
  71. package/dist/temporal/constants.js +8 -0
  72. package/dist/temporal/constants.js.map +1 -0
  73. package/dist/temporal/worker.d.ts +9 -0
  74. package/dist/temporal/worker.d.ts.map +1 -0
  75. package/dist/temporal/worker.js +26 -0
  76. package/dist/temporal/worker.js.map +1 -0
  77. package/dist/temporal/workflows/index.bundle.js +25225 -0
  78. package/dist/temporal/workflows/index.bundle.meta.json +1 -0
  79. package/dist/temporal/workflows/index.d.ts +2 -0
  80. package/dist/temporal/workflows/index.d.ts.map +1 -0
  81. package/dist/temporal/workflows/index.js +3 -0
  82. package/dist/temporal/workflows/index.js.map +1 -0
  83. package/dist/temporal/workflows/refresh-jira-tokens-cron.d.ts +2 -0
  84. package/dist/temporal/workflows/refresh-jira-tokens-cron.d.ts.map +1 -0
  85. package/dist/temporal/workflows/refresh-jira-tokens-cron.js +20 -0
  86. package/dist/temporal/workflows/refresh-jira-tokens-cron.js.map +1 -0
  87. package/dist/tsconfig.test.tsbuildinfo +1 -1
  88. package/drizzle/0001_jira-token-refresh-state.sql +12 -0
  89. package/drizzle/meta/0001_snapshot.json +245 -0
  90. package/drizzle/meta/_journal.json +7 -0
  91. package/package.json +17 -8
  92. package/src/api/client.test.ts +236 -9
  93. package/src/api/client.ts +192 -0
  94. package/src/config.test.ts +0 -1
  95. package/src/config.ts +1 -4
  96. package/src/core/agent-tools-provider.test.ts +299 -0
  97. package/src/core/agent-tools-provider.ts +175 -0
  98. package/src/core/agent-tools.ts +450 -0
  99. package/src/core/install.test.ts +179 -0
  100. package/src/core/install.ts +85 -19
  101. package/src/core/tokens-refresh.test.ts +223 -4
  102. package/src/core/tokens.test.ts +1 -1
  103. package/src/core/tokens.ts +153 -33
  104. package/src/core/webhook-processor.test.ts +418 -0
  105. package/src/core/webhook-processor.ts +166 -0
  106. package/src/core/webhook-registration.test.ts +375 -0
  107. package/src/core/webhook-registration.ts +216 -0
  108. package/src/core/webhook.ts +69 -0
  109. package/src/db/installations.test.ts +148 -2
  110. package/src/db/installations.ts +133 -7
  111. package/src/db/schema/installations.ts +21 -1
  112. package/src/index.test.ts +42 -0
  113. package/src/index.ts +148 -5
  114. package/src/presentation/routes/install.ts +12 -0
  115. package/src/presentation/routes/webhooks.test.ts +132 -0
  116. package/src/presentation/routes/webhooks.ts +115 -0
  117. package/src/temporal/activities/index.ts +5 -0
  118. package/src/temporal/activities/refresh-jira-tokens.test.ts +156 -0
  119. package/src/temporal/activities/refresh-jira-tokens.ts +88 -0
  120. package/src/temporal/constants.ts +8 -0
  121. package/src/temporal/worker.ts +39 -0
  122. package/src/temporal/workflows/index.ts +1 -0
  123. package/src/temporal/workflows/refresh-jira-tokens-cron.ts +19 -0
  124. package/test/env.ts +0 -1
  125. package/test/factories/jira-installation.ts +4 -0
  126. 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": "11.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,19 +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
- "@shipfox/api-auth-context": "11.0.0",
24
- "@shipfox/api-integration-spi": "0.2.2",
25
- "@shipfox/api-integration-jira-dto": "9.0.2",
29
+ "zod": "^4.4.3",
30
+ "@shipfox/api-auth-context": "12.0.0",
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
- "@shipfox/node-drizzle": "0.3.4",
28
- "@shipfox/node-fastify": "0.4.0",
34
+ "@shipfox/node-drizzle": "0.3.5",
35
+ "@shipfox/node-fastify": "0.4.1",
36
+ "@shipfox/node-jwt": "0.4.0",
37
+ "@shipfox/node-module": "1.0.5",
29
38
  "@shipfox/node-opentelemetry": "0.6.3",
30
- "@shipfox/node-postgres": "0.4.4"
39
+ "@shipfox/node-postgres": "0.5.0"
31
40
  },
32
41
  "imports": {
33
42
  "#*": "./dist/*"
@@ -41,7 +50,7 @@
41
50
  }
42
51
  },
43
52
  "scripts": {
44
- "build": "shipfox-swc",
53
+ "build": "shipfox-swc && shipfox-temporal-bundle dist/temporal/workflows/index.js",
45
54
  "check": "shipfox-biome-check",
46
55
  "check:fix": "shipfox-biome-check --write",
47
56
  "depcruise": "shipfox-depcruise",
@@ -2,15 +2,41 @@ import {HTTPError} from 'ky';
2
2
  import type {JiraIntegrationProviderError} from '#core/errors.js';
3
3
  import {mapJiraError} from './client.js';
4
4
 
5
- function rejectedRequest(status: number): () => Promise<never> {
6
- return () =>
7
- Promise.reject(
8
- new HTTPError(
9
- new Response(null, {status}),
10
- new Request('https://jira.example.test'),
11
- {} as never,
12
- ),
13
- );
5
+ const mocks = vi.hoisted(() => ({delete: vi.fn(), post: vi.fn(), request: vi.fn()}));
6
+
7
+ vi.mock('ky', () => {
8
+ class MockHTTPError extends Error {
9
+ data?: unknown;
10
+
11
+ constructor(public response: Response) {
12
+ super('http');
13
+ this.name = 'HTTPError';
14
+ }
15
+ }
16
+ class MockTimeoutError extends Error {
17
+ constructor() {
18
+ super('timeout');
19
+ this.name = 'TimeoutError';
20
+ }
21
+ }
22
+ return {
23
+ default: Object.assign(mocks.request, {delete: mocks.delete, post: mocks.post}),
24
+ HTTPError: MockHTTPError,
25
+ TimeoutError: MockTimeoutError,
26
+ };
27
+ });
28
+
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
+ };
14
40
  }
15
41
 
16
42
  describe('mapJiraError', () => {
@@ -26,4 +52,205 @@ describe('mapJiraError', () => {
26
52
  reason,
27
53
  } satisfies Partial<JiraIntegrationProviderError>);
28
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
+ });
69
+ });
70
+
71
+ function resolves(data: unknown) {
72
+ return {json: () => Promise.resolve(data)};
73
+ }
74
+
75
+ describe('Jira dynamic webhook API', () => {
76
+ beforeEach(() => {
77
+ mocks.delete.mockReset();
78
+ mocks.post.mockReset();
79
+ mocks.request.mockReset();
80
+ });
81
+
82
+ it('registers the six curated events with the access token', async () => {
83
+ mocks.post.mockReturnValue(
84
+ resolves({webhookRegistrationResult: [{createdWebhookId: 123, errors: []}]}),
85
+ );
86
+
87
+ const {createJiraApiClient} = await import('./client.js');
88
+ const result = await createJiraApiClient().registerDynamicWebhook({
89
+ accessToken: 'access-token',
90
+ cloudId: 'cloud-1',
91
+ url: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
92
+ });
93
+
94
+ const [url, options] = mocks.post.mock.calls[0] as [
95
+ string,
96
+ {headers: Record<string, string>; json: Record<string, unknown>},
97
+ ];
98
+ expect(url).toBe('http://127.0.0.1:0/ex/jira/cloud-1/rest/api/3/webhook');
99
+ expect(options.headers).toEqual({authorization: 'Bearer access-token'});
100
+ expect(options.json).toEqual({
101
+ url: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
102
+ webhooks: [
103
+ {
104
+ events: [
105
+ 'jira:issue_created',
106
+ 'jira:issue_updated',
107
+ 'jira:issue_deleted',
108
+ 'comment_created',
109
+ 'comment_updated',
110
+ 'comment_deleted',
111
+ ],
112
+ jqlFilter: '',
113
+ },
114
+ ],
115
+ });
116
+ expect(result).toEqual({webhookId: 123});
117
+ });
118
+
119
+ it.each([
120
+ {webhookRegistrationResult: []},
121
+ {webhookRegistrationResult: [{createdWebhookId: 123, errors: ['rejected']}]},
122
+ {webhookRegistrationResult: [{errors: []}]},
123
+ ])('rejects malformed or errored registration responses', async (response) => {
124
+ mocks.post.mockReturnValue(resolves(response));
125
+ const {createJiraApiClient} = await import('./client.js');
126
+
127
+ await expect(
128
+ createJiraApiClient().registerDynamicWebhook({
129
+ accessToken: 'access-token',
130
+ cloudId: 'cloud-1',
131
+ url: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
132
+ }),
133
+ ).rejects.toMatchObject({reason: 'malformed-provider-response'});
134
+ });
135
+
136
+ it('deletes a dynamic webhook by id', async () => {
137
+ mocks.delete.mockResolvedValue(undefined);
138
+ const {createJiraApiClient} = await import('./client.js');
139
+
140
+ await createJiraApiClient().deleteDynamicWebhook({
141
+ accessToken: 'access-token',
142
+ cloudId: 'cloud-1',
143
+ webhookId: 123,
144
+ });
145
+
146
+ expect(mocks.delete).toHaveBeenCalledWith(
147
+ 'http://127.0.0.1:0/ex/jira/cloud-1/rest/api/3/webhook',
148
+ expect.objectContaining({
149
+ headers: {authorization: 'Bearer access-token'},
150
+ json: {webhookIds: [123]},
151
+ }),
152
+ );
153
+ });
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
+ });
29
256
  });