@shipfox/api-integration-jira 12.0.0 → 12.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/.turbo/turbo-build.log +17 -2
  2. package/CHANGELOG.md +26 -0
  3. package/README.md +58 -2
  4. package/dist/api/client.d.ts +19 -0
  5. package/dist/api/client.d.ts.map +1 -1
  6. package/dist/api/client.js +87 -1
  7. package/dist/api/client.js.map +1 -1
  8. package/dist/core/agent-tools-provider.d.ts +26 -0
  9. package/dist/core/agent-tools-provider.d.ts.map +1 -0
  10. package/dist/core/agent-tools-provider.js +130 -0
  11. package/dist/core/agent-tools-provider.js.map +1 -0
  12. package/dist/core/agent-tools.d.ts +16 -0
  13. package/dist/core/agent-tools.d.ts.map +1 -0
  14. package/dist/core/agent-tools.js +441 -0
  15. package/dist/core/agent-tools.js.map +1 -0
  16. package/dist/core/errors.d.ts.map +1 -1
  17. package/dist/core/scopes.d.ts +1 -1
  18. package/dist/core/scopes.d.ts.map +1 -1
  19. package/dist/core/tokens.d.ts +2 -0
  20. package/dist/core/tokens.d.ts.map +1 -1
  21. package/dist/core/tokens.js +129 -37
  22. package/dist/core/tokens.js.map +1 -1
  23. package/dist/db/db.d.ts +72 -4
  24. package/dist/db/db.d.ts.map +1 -1
  25. package/dist/db/installations.d.ts +23 -7
  26. package/dist/db/installations.d.ts.map +1 -1
  27. package/dist/db/installations.js +42 -19
  28. package/dist/db/installations.js.map +1 -1
  29. package/dist/db/schema/installations.d.ts +35 -1
  30. package/dist/db/schema/installations.d.ts.map +1 -1
  31. package/dist/db/schema/installations.js +12 -2
  32. package/dist/db/schema/installations.js.map +1 -1
  33. package/dist/db/schema/pending-selections.d.ts +1 -1
  34. package/dist/index.d.ts +21 -6
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +17 -5
  37. package/dist/index.js.map +1 -1
  38. package/dist/presentation/dto/integrations.d.ts.map +1 -1
  39. package/dist/temporal/activities/index.d.ts +2 -0
  40. package/dist/temporal/activities/index.d.ts.map +1 -0
  41. package/dist/temporal/activities/index.js +3 -0
  42. package/dist/temporal/activities/index.js.map +1 -0
  43. package/dist/temporal/activities/refresh-jira-tokens.d.ts +20 -0
  44. package/dist/temporal/activities/refresh-jira-tokens.d.ts.map +1 -0
  45. package/dist/temporal/activities/refresh-jira-tokens.js +58 -0
  46. package/dist/temporal/activities/refresh-jira-tokens.js.map +1 -0
  47. package/dist/temporal/constants.d.ts +6 -0
  48. package/dist/temporal/constants.d.ts.map +1 -0
  49. package/dist/temporal/constants.js +8 -0
  50. package/dist/temporal/constants.js.map +1 -0
  51. package/dist/temporal/worker.d.ts +9 -0
  52. package/dist/temporal/worker.d.ts.map +1 -0
  53. package/dist/temporal/worker.js +26 -0
  54. package/dist/temporal/worker.js.map +1 -0
  55. package/dist/temporal/workflows/index.bundle.js +25225 -0
  56. package/dist/temporal/workflows/index.bundle.meta.json +1 -0
  57. package/dist/temporal/workflows/index.d.ts +2 -0
  58. package/dist/temporal/workflows/index.d.ts.map +1 -0
  59. package/dist/temporal/workflows/index.js +3 -0
  60. package/dist/temporal/workflows/index.js.map +1 -0
  61. package/dist/temporal/workflows/refresh-jira-tokens-cron.d.ts +2 -0
  62. package/dist/temporal/workflows/refresh-jira-tokens-cron.d.ts.map +1 -0
  63. package/dist/temporal/workflows/refresh-jira-tokens-cron.js +20 -0
  64. package/dist/temporal/workflows/refresh-jira-tokens-cron.js.map +1 -0
  65. package/dist/tsconfig.test.tsbuildinfo +1 -1
  66. package/drizzle/0001_jira-token-refresh-state.sql +12 -0
  67. package/drizzle/meta/0001_snapshot.json +245 -0
  68. package/drizzle/meta/_journal.json +7 -0
  69. package/package.json +12 -5
  70. package/src/api/client.test.ts +304 -12
  71. package/src/api/client.ts +122 -1
  72. package/src/core/agent-tools-provider.test.ts +299 -0
  73. package/src/core/agent-tools-provider.ts +175 -0
  74. package/src/core/agent-tools.ts +450 -0
  75. package/src/core/tokens-refresh.test.ts +221 -4
  76. package/src/core/tokens.test.ts +1 -1
  77. package/src/core/tokens.ts +153 -33
  78. package/src/db/installations.test.ts +84 -2
  79. package/src/db/installations.ts +81 -21
  80. package/src/db/schema/installations.ts +21 -1
  81. package/src/index.test.ts +34 -0
  82. package/src/index.ts +47 -3
  83. package/src/temporal/activities/index.ts +5 -0
  84. package/src/temporal/activities/refresh-jira-tokens.test.ts +156 -0
  85. package/src/temporal/activities/refresh-jira-tokens.ts +88 -0
  86. package/src/temporal/constants.ts +8 -0
  87. package/src/temporal/worker.ts +39 -0
  88. package/src/temporal/workflows/index.ts +1 -0
  89. package/src/temporal/workflows/refresh-jira-tokens-cron.ts +19 -0
  90. package/test/factories/jira-installation.ts +4 -0
  91. package/tsconfig.build.tsbuildinfo +1 -1
@@ -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.1",
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,24 @@ 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(() => ({
6
+ delete: vi.fn(),
7
+ post: vi.fn(),
8
+ request: vi.fn(),
9
+ warn: vi.fn(),
10
+ }));
11
+
12
+ vi.mock('@shipfox/node-opentelemetry', () => ({
13
+ logger: () => ({
14
+ warn: mocks.warn,
15
+ }),
16
+ }));
6
17
 
7
18
  vi.mock('ky', () => {
8
19
  class MockHTTPError extends Error {
9
- constructor(public response: {status: number; statusText?: string; headers: Headers}) {
20
+ data?: unknown;
21
+
22
+ constructor(public response: Response) {
10
23
  super('http');
11
24
  this.name = 'HTTPError';
12
25
  }
@@ -18,21 +31,23 @@ vi.mock('ky', () => {
18
31
  }
19
32
  }
20
33
  return {
21
- default: {delete: mocks.delete, post: mocks.post},
34
+ default: Object.assign(mocks.request, {delete: mocks.delete, post: mocks.post}),
22
35
  HTTPError: MockHTTPError,
23
36
  TimeoutError: MockTimeoutError,
24
37
  };
25
38
  });
26
39
 
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
- );
40
+ function rejectedRequest(status: number, body?: {error: string}): () => Promise<never> {
41
+ return async () => {
42
+ const response = new Response(body ? JSON.stringify(body) : null, {
43
+ status,
44
+ ...(body ? {headers: {'content-type': 'application/json'}} : {}),
45
+ });
46
+ const error = new HTTPError(response, new Request('https://jira.example.test'), {} as never);
47
+ await response.text();
48
+ error.data = body;
49
+ throw error;
50
+ };
36
51
  }
37
52
 
38
53
  describe('mapJiraError', () => {
@@ -48,6 +63,20 @@ describe('mapJiraError', () => {
48
63
  reason,
49
64
  } satisfies Partial<JiraIntegrationProviderError>);
50
65
  });
66
+
67
+ it.each([
68
+ ['invalid_grant', 'access-denied'],
69
+ ['unauthorized_client', 'access-denied'],
70
+ ['invalid_request', 'malformed-provider-response'],
71
+ [undefined, 'malformed-provider-response'],
72
+ ] as const)('maps refresh HTTP 400 with OAuth error %s to %s', async (errorCode, reason) => {
73
+ const result = mapJiraError(
74
+ 'refresh-access-token',
75
+ rejectedRequest(400, errorCode ? {error: errorCode} : undefined),
76
+ );
77
+
78
+ await expect(result).rejects.toMatchObject({reason});
79
+ });
51
80
  });
52
81
 
53
82
  function resolves(data: unknown) {
@@ -58,6 +87,8 @@ describe('Jira dynamic webhook API', () => {
58
87
  beforeEach(() => {
59
88
  mocks.delete.mockReset();
60
89
  mocks.post.mockReset();
90
+ mocks.request.mockReset();
91
+ mocks.warn.mockReset();
61
92
  });
62
93
 
63
94
  it('registers the six curated events with the access token', async () => {
@@ -114,6 +145,165 @@ describe('Jira dynamic webhook API', () => {
114
145
  ).rejects.toMatchObject({reason: 'malformed-provider-response'});
115
146
  });
116
147
 
148
+ it('logs provider validation errors before rejecting registration', async () => {
149
+ mocks.post.mockReturnValue(
150
+ resolves({
151
+ webhookRegistrationResult: [
152
+ {createdWebhookId: 123, errors: ['Webhook URL is not approved', 'Invalid JQL filter']},
153
+ ],
154
+ }),
155
+ );
156
+ const {createJiraApiClient} = await import('./client.js');
157
+
158
+ await expect(
159
+ createJiraApiClient().registerDynamicWebhook({
160
+ accessToken: 'access-token',
161
+ cloudId: 'cloud-1',
162
+ url: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
163
+ }),
164
+ ).rejects.toMatchObject({reason: 'malformed-provider-response'});
165
+
166
+ expect(mocks.warn).toHaveBeenCalledTimes(1);
167
+ expect(mocks.warn).toHaveBeenCalledWith(
168
+ {
169
+ operation: 'register-dynamic-webhook',
170
+ providerErrors: ['Webhook URL is not approved', 'Invalid JQL filter'],
171
+ providerErrorCount: 2,
172
+ },
173
+ 'Jira dynamic webhook registration rejected',
174
+ );
175
+ });
176
+
177
+ it('limits logged provider errors to five values', async () => {
178
+ mocks.post.mockReturnValue(
179
+ resolves({
180
+ webhookRegistrationResult: [
181
+ {
182
+ errors: ['one', 'two', 'three', 'four', 'five', 'six'],
183
+ },
184
+ ],
185
+ }),
186
+ );
187
+ const {createJiraApiClient} = await import('./client.js');
188
+
189
+ await expect(
190
+ createJiraApiClient().registerDynamicWebhook({
191
+ accessToken: 'access-token',
192
+ cloudId: 'cloud-1',
193
+ url: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
194
+ }),
195
+ ).rejects.toMatchObject({reason: 'malformed-provider-response'});
196
+
197
+ expect(mocks.warn).toHaveBeenCalledWith(
198
+ {
199
+ operation: 'register-dynamic-webhook',
200
+ providerErrors: ['one', 'two', 'three', 'four', 'five'],
201
+ providerErrorCount: 6,
202
+ },
203
+ 'Jira dynamic webhook registration rejected',
204
+ );
205
+ });
206
+
207
+ it('limits each logged provider error to 500 characters', async () => {
208
+ const longError = 'x'.repeat(501);
209
+ mocks.post.mockReturnValue(resolves({webhookRegistrationResult: [{errors: [longError]}]}));
210
+ const {createJiraApiClient} = await import('./client.js');
211
+
212
+ await expect(
213
+ createJiraApiClient().registerDynamicWebhook({
214
+ accessToken: 'access-token',
215
+ cloudId: 'cloud-1',
216
+ url: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
217
+ }),
218
+ ).rejects.toMatchObject({reason: 'malformed-provider-response'});
219
+
220
+ expect(mocks.warn).toHaveBeenCalledWith(
221
+ {
222
+ operation: 'register-dynamic-webhook',
223
+ providerErrors: ['x'.repeat(500)],
224
+ providerErrorCount: 1,
225
+ },
226
+ 'Jira dynamic webhook registration rejected',
227
+ );
228
+ });
229
+
230
+ it('omits non-string provider errors from the warning', async () => {
231
+ mocks.post.mockReturnValue(
232
+ resolves({
233
+ webhookRegistrationResult: [
234
+ {errors: ['valid message', 42, null, {message: 'raw response'}, true]},
235
+ ],
236
+ }),
237
+ );
238
+ const {createJiraApiClient} = await import('./client.js');
239
+
240
+ await expect(
241
+ createJiraApiClient().registerDynamicWebhook({
242
+ accessToken: 'access-token',
243
+ cloudId: 'cloud-1',
244
+ url: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
245
+ }),
246
+ ).rejects.toMatchObject({reason: 'malformed-provider-response'});
247
+
248
+ expect(mocks.warn).toHaveBeenCalledWith(
249
+ {
250
+ operation: 'register-dynamic-webhook',
251
+ providerErrors: ['valid message'],
252
+ providerErrorCount: 5,
253
+ },
254
+ 'Jira dynamic webhook registration rejected',
255
+ );
256
+ });
257
+
258
+ it('accepts an empty provider errors array without logging a rejection', async () => {
259
+ mocks.post.mockReturnValue(
260
+ resolves({webhookRegistrationResult: [{createdWebhookId: 123, errors: []}]}),
261
+ );
262
+ const {createJiraApiClient} = await import('./client.js');
263
+
264
+ await expect(
265
+ createJiraApiClient().registerDynamicWebhook({
266
+ accessToken: 'access-token',
267
+ cloudId: 'cloud-1',
268
+ url: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
269
+ }),
270
+ ).resolves.toEqual({webhookId: 123});
271
+ expect(mocks.warn).not.toHaveBeenCalled();
272
+ });
273
+
274
+ it('rejects a non-array provider errors value without logging it', async () => {
275
+ mocks.post.mockReturnValue(
276
+ resolves({
277
+ webhookRegistrationResult: [{createdWebhookId: 123, errors: {message: 'do not log me'}}],
278
+ }),
279
+ );
280
+ const {createJiraApiClient} = await import('./client.js');
281
+
282
+ await expect(
283
+ createJiraApiClient().registerDynamicWebhook({
284
+ accessToken: 'access-token',
285
+ cloudId: 'cloud-1',
286
+ url: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
287
+ }),
288
+ ).rejects.toMatchObject({reason: 'malformed-provider-response'});
289
+ expect(mocks.warn).not.toHaveBeenCalled();
290
+ });
291
+
292
+ it('keeps the malformed provider response reason for a Jira rejection', async () => {
293
+ mocks.post.mockReturnValue(
294
+ resolves({webhookRegistrationResult: [{errors: ['Provider rejected the webhook']}]}),
295
+ );
296
+ const {createJiraApiClient} = await import('./client.js');
297
+
298
+ await expect(
299
+ createJiraApiClient().registerDynamicWebhook({
300
+ accessToken: 'access-token',
301
+ cloudId: 'cloud-1',
302
+ url: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
303
+ }),
304
+ ).rejects.toMatchObject({reason: 'malformed-provider-response'});
305
+ });
306
+
117
307
  it('deletes a dynamic webhook by id', async () => {
118
308
  mocks.delete.mockResolvedValue(undefined);
119
309
  const {createJiraApiClient} = await import('./client.js');
@@ -133,3 +323,105 @@ describe('Jira dynamic webhook API', () => {
133
323
  );
134
324
  });
135
325
  });
326
+
327
+ describe('Jira agent-tools REST API', () => {
328
+ beforeEach(() => {
329
+ mocks.request.mockReset();
330
+ });
331
+
332
+ it('sends a REST v3 request and parses a JSON response', async () => {
333
+ mocks.request.mockResolvedValue(
334
+ new Response(JSON.stringify({id: '1004', key: 'ENG-1004'}), {status: 200}),
335
+ );
336
+ const {createJiraAgentToolsClient} = await import('./client.js');
337
+
338
+ const result = await createJiraAgentToolsClient().request({
339
+ accessToken: 'access-token',
340
+ cloudId: 'cloud-1',
341
+ method: 'GET',
342
+ path: '/issue/ENG-1004',
343
+ query: {fields: ['summary', 'description'], updateHistory: false},
344
+ operation: 'get_issue',
345
+ });
346
+
347
+ expect(result).toEqual({status: 200, body: {id: '1004', key: 'ENG-1004'}});
348
+ const [url, options] = mocks.request.mock.calls[0] as [
349
+ string,
350
+ {headers: Record<string, string>; searchParams: URLSearchParams},
351
+ ];
352
+ expect(url).toBe('http://127.0.0.1:0/ex/jira/cloud-1/rest/api/3/issue/ENG-1004');
353
+ expect(options.headers).toEqual({authorization: 'Bearer access-token'});
354
+ expect([...options.searchParams.entries()]).toEqual([
355
+ ['fields', 'summary'],
356
+ ['fields', 'description'],
357
+ ['updateHistory', 'false'],
358
+ ]);
359
+ });
360
+
361
+ it('maps HTTP 429 Retry-After responses to a rate-limited provider error', async () => {
362
+ mocks.request.mockRejectedValue(
363
+ new HTTPError(
364
+ new Response(null, {status: 429, headers: {'retry-after': '19'}}),
365
+ new Request('https://jira.example.test'),
366
+ {} as never,
367
+ ),
368
+ );
369
+ const {createJiraAgentToolsClient} = await import('./client.js');
370
+
371
+ await expect(
372
+ createJiraAgentToolsClient().request({
373
+ accessToken: 'access-token',
374
+ cloudId: 'cloud-1',
375
+ method: 'GET',
376
+ path: '/issue/ENG-1004',
377
+ operation: 'get_issue',
378
+ }),
379
+ ).rejects.toMatchObject({reason: 'rate-limited', retryAfterSeconds: 19});
380
+ });
381
+
382
+ it('preserves Jira validation details from HTTP 400 responses', async () => {
383
+ mocks.request.mockRejectedValue(
384
+ new HTTPError(
385
+ new Response(JSON.stringify({errorMessages: ['The JQL query is invalid']}), {status: 400}),
386
+ new Request('https://jira.example.test'),
387
+ {} as never,
388
+ ),
389
+ );
390
+ const {createJiraAgentToolsClient} = await import('./client.js');
391
+
392
+ await expect(
393
+ createJiraAgentToolsClient().request({
394
+ accessToken: 'access-token',
395
+ cloudId: 'cloud-1',
396
+ method: 'POST',
397
+ path: '/search/jql',
398
+ body: {jql: 'not valid'},
399
+ operation: 'search_issues',
400
+ }),
401
+ ).resolves.toEqual({
402
+ status: 400,
403
+ body: {errorMessages: ['The JQL query is invalid']},
404
+ });
405
+ });
406
+
407
+ it('maps HTTP 401 responses to an access-denied provider error', async () => {
408
+ mocks.request.mockRejectedValue(
409
+ new HTTPError(
410
+ new Response(null, {status: 401}),
411
+ new Request('https://jira.example.test'),
412
+ {} as never,
413
+ ),
414
+ );
415
+ const {createJiraAgentToolsClient} = await import('./client.js');
416
+
417
+ await expect(
418
+ createJiraAgentToolsClient().request({
419
+ accessToken: 'access-token',
420
+ cloudId: 'cloud-1',
421
+ method: 'GET',
422
+ path: '/issue/ENG-1004',
423
+ operation: 'get_issue',
424
+ }),
425
+ ).rejects.toMatchObject({reason: 'access-denied'});
426
+ });
427
+ });