@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
@@ -1,6 +1,6 @@
1
1
  import {isUniqueViolation} from '@shipfox/node-drizzle';
2
2
  import {pgClient, withPostgresSession} from '@shipfox/node-postgres';
3
- import {eq, sql} from 'drizzle-orm';
3
+ import {and, asc, eq, lte, sql} from 'drizzle-orm';
4
4
  import {
5
5
  JiraConnectionAlreadyLinkedError,
6
6
  JiraInstallationAlreadyLinkedError,
@@ -11,17 +11,16 @@ import {jiraInstallations, toJiraInstallation} from './schema/installations.js';
11
11
 
12
12
  export type JiraInstallationLock = <T>(lockKey: string, fn: () => Promise<T>) => Promise<T>;
13
13
 
14
- const JIRA_INSTALLATION_LOCK_RETRY_DELAY_MS = 100;
15
- const JIRA_INSTALLATION_LOCK_MAX_RETRY_DELAY_MS = 1_000;
16
- const JIRA_INSTALLATION_LOCK_TIMEOUT_MS = 30_000;
14
+ const JIRA_ADVISORY_LOCK_RETRY_DELAY_MS = 100;
15
+ const JIRA_ADVISORY_LOCK_MAX_RETRY_DELAY_MS = 1_000;
16
+ const JIRA_ADVISORY_LOCK_TIMEOUT_MS = 30_000;
17
17
 
18
- export async function withJiraInstallationLock<T>(lockKey: string, fn: () => Promise<T>) {
19
- const advisoryKey = `jira-installation:${lockKey}`;
20
- const deadline = Date.now() + JIRA_INSTALLATION_LOCK_TIMEOUT_MS;
21
- let retryDelayMs = JIRA_INSTALLATION_LOCK_RETRY_DELAY_MS;
18
+ type JiraAdvisoryLockAttempt<T> = {acquired: true; value: T} | {acquired: false};
22
19
 
23
- while (true) {
24
- const attempt = await withPostgresSession(async (client) => {
20
+ export function withJiraInstallationLock<T>(lockKey: string, fn: () => Promise<T>) {
21
+ const advisoryKey = `jira-installation:${lockKey}`;
22
+ return withJiraAdvisoryLockWait(`Jira installation lock: ${lockKey}`, () =>
23
+ withPostgresSession(async (client) => {
25
24
  const lock = await client.query<{acquired: boolean}>(
26
25
  'SELECT pg_try_advisory_lock(hashtext($1)) AS acquired',
27
26
  [advisoryKey],
@@ -33,15 +32,8 @@ export async function withJiraInstallationLock<T>(lockKey: string, fn: () => Pro
33
32
  } finally {
34
33
  await client.query('SELECT pg_advisory_unlock(hashtext($1))', [advisoryKey]);
35
34
  }
36
- });
37
-
38
- if (attempt.acquired) return attempt.value;
39
- if (Date.now() >= deadline) {
40
- throw new Error(`Timed out waiting for Jira installation lock: ${lockKey}`);
41
- }
42
- await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
43
- retryDelayMs = Math.min(retryDelayMs * 2, JIRA_INSTALLATION_LOCK_MAX_RETRY_DELAY_MS);
44
- }
35
+ }),
36
+ );
45
37
  }
46
38
 
47
39
  export type JiraInstallationStatus = 'installed' | 'revoked';
@@ -58,6 +50,8 @@ export interface JiraInstallation {
58
50
  webhookExpiresAt: Date | null;
59
51
  status: JiraInstallationStatus;
60
52
  tokenExpiresAt: Date | null;
53
+ refreshTokenLastUsedAt: Date;
54
+ refreshTokenLastAttemptedAt: Date;
61
55
  createdAt: Date;
62
56
  updatedAt: Date;
63
57
  }
@@ -73,6 +67,8 @@ export interface UpsertJiraInstallationParams {
73
67
  webhookExpiresAt?: Date | null | undefined;
74
68
  status: JiraInstallationStatus;
75
69
  tokenExpiresAt?: Date | null | undefined;
70
+ refreshTokenLastUsedAt?: Date | undefined;
71
+ refreshTokenLastAttemptedAt?: Date | undefined;
76
72
  }
77
73
 
78
74
  export interface UpdateJiraInstallationTokenExpiryParams {
@@ -111,6 +107,8 @@ export async function upsertJiraInstallation(
111
107
  webhookExpiresAt: params.webhookExpiresAt ?? null,
112
108
  status: params.status,
113
109
  tokenExpiresAt: params.tokenExpiresAt ?? null,
110
+ refreshTokenLastUsedAt: params.refreshTokenLastUsedAt ?? now,
111
+ refreshTokenLastAttemptedAt: params.refreshTokenLastAttemptedAt ?? now,
114
112
  })
115
113
  .onConflictDoUpdate({
116
114
  target: jiraInstallations.connectionId,
@@ -127,6 +125,8 @@ export async function upsertJiraInstallation(
127
125
  : {webhookExpiresAt: params.webhookExpiresAt}),
128
126
  status: params.status,
129
127
  tokenExpiresAt: params.tokenExpiresAt ?? null,
128
+ refreshTokenLastUsedAt: params.refreshTokenLastUsedAt ?? now,
129
+ refreshTokenLastAttemptedAt: params.refreshTokenLastAttemptedAt ?? now,
130
130
  updatedAt: now,
131
131
  },
132
132
  })
@@ -164,18 +164,53 @@ export async function updateJiraInstallationTokenExpiry(
164
164
  options: {tx?: unknown} = {},
165
165
  ): Promise<JiraInstallation | undefined> {
166
166
  const executor = (options.tx ?? db()) as JiraDb | JiraTx;
167
+ const now = new Date();
167
168
  const [row] = await executor
168
169
  .update(jiraInstallations)
169
170
  .set({
170
171
  tokenExpiresAt: params.tokenExpiresAt,
172
+ refreshTokenLastUsedAt: now,
173
+ refreshTokenLastAttemptedAt: now,
171
174
  ...(params.scopes === undefined ? {} : {scopes: params.scopes}),
172
- updatedAt: new Date(),
175
+ updatedAt: now,
173
176
  })
174
177
  .where(eq(jiraInstallations.connectionId, params.connectionId))
175
178
  .returning();
176
179
  return row ? toJiraInstallation(row) : undefined;
177
180
  }
178
181
 
182
+ export async function markJiraInstallationTokenRefreshAttempt(
183
+ connectionId: string,
184
+ options: {tx?: unknown} = {},
185
+ ): Promise<JiraInstallation | undefined> {
186
+ const executor = (options.tx ?? db()) as JiraDb | JiraTx;
187
+ const [row] = await executor
188
+ .update(jiraInstallations)
189
+ .set({refreshTokenLastAttemptedAt: new Date()})
190
+ .where(eq(jiraInstallations.connectionId, connectionId))
191
+ .returning();
192
+ return row ? toJiraInstallation(row) : undefined;
193
+ }
194
+
195
+ export async function listJiraInstallationsDueForTokenRefresh(
196
+ params: {before: Date; limit?: number | undefined},
197
+ options: {tx?: unknown} = {},
198
+ ): Promise<JiraInstallation[]> {
199
+ const executor = (options.tx ?? db()) as JiraDb | JiraTx;
200
+ const rows = await executor
201
+ .select()
202
+ .from(jiraInstallations)
203
+ .where(
204
+ and(
205
+ eq(jiraInstallations.status, 'installed'),
206
+ lte(jiraInstallations.refreshTokenLastUsedAt, params.before),
207
+ ),
208
+ )
209
+ .orderBy(asc(jiraInstallations.refreshTokenLastAttemptedAt))
210
+ .limit(params.limit ?? 100);
211
+ return rows.map(toJiraInstallation);
212
+ }
213
+
179
214
  export async function updateJiraInstallationWebhook(
180
215
  params: UpdateJiraInstallationWebhookParams,
181
216
  options: {tx?: unknown} = {},
@@ -204,7 +239,7 @@ export async function deleteJiraInstallationByConnectionId(
204
239
  return (result.rowCount ?? 0) > 0;
205
240
  }
206
241
 
207
- export type JiraRefreshLockResult<T> = {acquired: true; value: T} | {acquired: false};
242
+ export type JiraRefreshLockResult<T> = JiraAdvisoryLockAttempt<T>;
208
243
 
209
244
  export function withJiraRefreshLock<T>(
210
245
  connectionId: string,
@@ -213,6 +248,15 @@ export function withJiraRefreshLock<T>(
213
248
  return withJiraRefreshLockClient(connectionId, fn);
214
249
  }
215
250
 
251
+ export function withJiraRefreshLockAndWait<T>(
252
+ connectionId: string,
253
+ fn: () => Promise<T>,
254
+ ): Promise<T> {
255
+ return withJiraAdvisoryLockWait(`Jira refresh lock: ${connectionId}`, () =>
256
+ withJiraRefreshLock(connectionId, fn),
257
+ );
258
+ }
259
+
216
260
  export function withJiraWebhookRegistrationLock<T>(
217
261
  lockKey: string,
218
262
  fn: (tx?: unknown) => Promise<T>,
@@ -243,6 +287,22 @@ async function withJiraRefreshLockClient<T>(
243
287
  }
244
288
  }
245
289
 
290
+ async function withJiraAdvisoryLockWait<T>(
291
+ lockDescription: string,
292
+ attemptLock: () => Promise<JiraAdvisoryLockAttempt<T>>,
293
+ ): Promise<T> {
294
+ const deadline = Date.now() + JIRA_ADVISORY_LOCK_TIMEOUT_MS;
295
+ let retryDelayMs = JIRA_ADVISORY_LOCK_RETRY_DELAY_MS;
296
+
297
+ while (true) {
298
+ const attempt = await attemptLock();
299
+ if (attempt.acquired) return attempt.value;
300
+ if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${lockDescription}`);
301
+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
302
+ retryDelayMs = Math.min(retryDelayMs * 2, JIRA_ADVISORY_LOCK_MAX_RETRY_DELAY_MS);
303
+ }
304
+ }
305
+
246
306
  export async function getJiraInstallationByConnectionId(
247
307
  connectionId: string,
248
308
  options: {tx?: unknown} = {},
@@ -1,5 +1,5 @@
1
1
  import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
2
- import {jsonb, text, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
2
+ import {index, jsonb, text, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
3
3
  import type {JiraInstallation} from '#db/installations.js';
4
4
  import {pgTable} from './common.js';
5
5
 
@@ -17,12 +17,30 @@ export const jiraInstallations = pgTable(
17
17
  webhookExpiresAt: timestamp('webhook_expires_at', {withTimezone: true}),
18
18
  status: text('status').notNull().$type<JiraInstallation['status']>(),
19
19
  tokenExpiresAt: timestamp('token_expires_at', {withTimezone: true}),
20
+ refreshTokenLastUsedAt: timestamp('refresh_token_last_used_at', {
21
+ withTimezone: true,
22
+ })
23
+ .notNull()
24
+ .defaultNow(),
25
+ refreshTokenLastAttemptedAt: timestamp('refresh_token_last_attempted_at', {
26
+ withTimezone: true,
27
+ })
28
+ .notNull()
29
+ .defaultNow(),
20
30
  createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
21
31
  updatedAt: timestamp('updated_at', {withTimezone: true}).notNull().defaultNow(),
22
32
  },
23
33
  (table) => [
24
34
  uniqueIndex('integrations_jira_installations_connection_unique').on(table.connectionId),
25
35
  uniqueIndex('integrations_jira_installations_cloud_id_unique').on(table.cloudId),
36
+ index('integrations_jira_installations_token_refresh_due_idx').on(
37
+ table.status,
38
+ table.refreshTokenLastUsedAt,
39
+ ),
40
+ index('integrations_jira_installations_token_refresh_attempt_idx').on(
41
+ table.status,
42
+ table.refreshTokenLastAttemptedAt,
43
+ ),
26
44
  ],
27
45
  );
28
46
 
@@ -42,6 +60,8 @@ export function toJiraInstallation(row: JiraInstallationDb): JiraInstallation {
42
60
  webhookExpiresAt: row.webhookExpiresAt,
43
61
  status: row.status,
44
62
  tokenExpiresAt: row.tokenExpiresAt,
63
+ refreshTokenLastUsedAt: row.refreshTokenLastUsedAt,
64
+ refreshTokenLastAttemptedAt: row.refreshTokenLastAttemptedAt,
45
65
  createdAt: row.createdAt,
46
66
  updatedAt: row.updatedAt,
47
67
  };
package/src/index.test.ts CHANGED
@@ -1,4 +1,6 @@
1
+ import {createOutboxRegistry} from '@shipfox/node-module';
1
2
  import {createJiraIntegrationProvider} from '#index.js';
3
+ import {createJiraMaintenanceWorker} from '#temporal/worker.js';
2
4
 
3
5
  describe('createJiraIntegrationProvider', () => {
4
6
  it('creates the Jira provider', () => {
@@ -12,6 +14,16 @@ describe('createJiraIntegrationProvider', () => {
12
14
  });
13
15
  });
14
16
 
17
+ it('mounts the in-process agent-tools adapter and advertises its capability', () => {
18
+ const getAccessToken = vi.fn().mockResolvedValue('access-token');
19
+ const provider = createJiraIntegrationProvider({
20
+ agentTools: {tokenStore: {getAccessToken}},
21
+ });
22
+
23
+ expect(provider.adapters.agent_tools).toBeDefined();
24
+ expect(provider.routes).toEqual([]);
25
+ });
26
+
15
27
  it('rejects incomplete receiver wiring instead of mounting registration without a receiver', () => {
16
28
  expect(() =>
17
29
  createJiraIntegrationProvider({
@@ -20,3 +32,25 @@ describe('createJiraIntegrationProvider', () => {
20
32
  ).toThrow('requires all webhook receiver dependencies');
21
33
  });
22
34
  });
35
+
36
+ describe('createJiraMaintenanceWorker', () => {
37
+ it('describes the Jira proactive token refresh worker', () => {
38
+ const worker = createJiraMaintenanceWorker({
39
+ tokenStore: {getAccessToken: vi.fn()},
40
+ resolveConnection: vi.fn(),
41
+ });
42
+
43
+ expect(worker.taskQueue).toBe('integrations-jira-maintenance');
44
+ expect(worker.workflowsPath.endsWith('dist/temporal/workflows/index.js')).toBe(true);
45
+ expect(Object.keys(worker.activities({outboxRegistry: createOutboxRegistry()}))).toContain(
46
+ 'refreshJiraTokensActivity',
47
+ );
48
+ expect(worker.workflows).toEqual([
49
+ {
50
+ name: 'refreshJiraTokensCron',
51
+ id: 'jira-refresh-tokens',
52
+ cronSchedule: '0 */6 * * *',
53
+ },
54
+ ]);
55
+ });
56
+ });
package/src/index.ts CHANGED
@@ -1,6 +1,13 @@
1
1
  import {JIRA_PROVIDER} from '@shipfox/api-integration-jira-dto';
2
- import {createJiraApiClient, type JiraApiClient} from '#api/client.js';
2
+ import {
3
+ createJiraAgentToolsClient,
4
+ createJiraApiClient,
5
+ type JiraAgentToolsClient,
6
+ type JiraApiClient,
7
+ } from '#api/client.js';
3
8
  import {config} from '#config.js';
9
+ import {JiraAgentToolsProvider} from '#core/agent-tools-provider.js';
10
+ import type {JiraTokenStore} from '#core/tokens.js';
4
11
  import {createJiraWebhookProcessor} from '#core/webhook-processor.js';
5
12
  import {registerJiraWebhook} from '#core/webhook-registration.js';
6
13
  import {closeDb, db} from '#db/db.js';
@@ -21,17 +28,38 @@ const TRAILING_SLASHES_RE = /\/+$/;
21
28
  export type {JiraProvider} from '@shipfox/api-integration-jira-dto';
22
29
  export type {
23
30
  JiraAccessibleResource,
31
+ JiraAgentToolHttpMethod,
32
+ JiraAgentToolQueryValue,
33
+ JiraAgentToolRequest,
34
+ JiraAgentToolResponse,
35
+ JiraAgentToolsClient,
24
36
  JiraApiClient,
25
37
  JiraAuthorization,
26
38
  JiraDynamicWebhookRegistration,
27
39
  JiraIdentity,
28
40
  } from '#api/client.js';
29
41
  export {
42
+ createJiraAgentToolsClient,
30
43
  createJiraApiClient,
31
44
  JIRA_DYNAMIC_WEBHOOK_EVENTS,
32
45
  JIRA_DYNAMIC_WEBHOOK_JQL,
33
46
  mapJiraError,
34
47
  } from '#api/client.js';
48
+ export type {
49
+ JiraAgentToolCatalogEntry,
50
+ JiraAgentToolId,
51
+ JiraAgentToolRequiredScope,
52
+ } from '#core/agent-tools.js';
53
+ export {
54
+ jiraAgentToolCatalog,
55
+ jiraAgentToolSelectionCatalog,
56
+ jiraPlainTextToAdf,
57
+ } from '#core/agent-tools.js';
58
+ export type {
59
+ JiraAgentToolsProviderOptions,
60
+ JiraToolCallResult,
61
+ } from '#core/agent-tools-provider.js';
62
+ export {JiraAgentToolsProvider} from '#core/agent-tools-provider.js';
35
63
  export type {DisconnectJiraInstallationParams} from '#core/disconnect.js';
36
64
  export {disconnectJiraInstallation} from '#core/disconnect.js';
37
65
  export {
@@ -92,6 +120,7 @@ export {
92
120
  getJiraInstallationByCloudId,
93
121
  getJiraInstallationByConnectionId,
94
122
  getJiraInstallationByWebhookId,
123
+ listJiraInstallationsDueForTokenRefresh,
95
124
  markJiraInstallationRevoked,
96
125
  updateJiraInstallationTokenExpiry,
97
126
  updateJiraInstallationWebhook,
@@ -101,10 +130,17 @@ export {
101
130
  } from '#db/installations.js';
102
131
  export type {CreateJiraWebhookRoutesOptions} from '#presentation/routes/webhooks.js';
103
132
  export {createJiraWebhookRoutes} from '#presentation/routes/webhooks.js';
133
+ export {createJiraMaintenanceWorker} from '#temporal/worker.js';
104
134
  export {closeDb, config, db, migrationsPath};
105
135
 
106
136
  export interface CreateJiraIntegrationProviderOptions {
107
137
  jira?: JiraApiClient | undefined;
138
+ agentTools?:
139
+ | {
140
+ tokenStore: Pick<JiraTokenStore, 'getAccessToken'>;
141
+ jira?: JiraAgentToolsClient | undefined;
142
+ }
143
+ | undefined;
108
144
  getJiraInstallationByConnectionId?: typeof getJiraInstallationByConnectionId | undefined;
109
145
  routes?: JiraIntegrationProviderRoutesOptions | undefined;
110
146
  }
@@ -119,6 +155,14 @@ export function createJiraIntegrationProvider(options: CreateJiraIntegrationProv
119
155
  const jira = options.jira ?? createJiraApiClient();
120
156
  const getInstallationByConnectionId =
121
157
  options.getJiraInstallationByConnectionId ?? getJiraInstallationByConnectionId;
158
+ const adapters = options.agentTools
159
+ ? {
160
+ agent_tools: new JiraAgentToolsProvider({
161
+ jira: options.agentTools.jira ?? createJiraAgentToolsClient(),
162
+ tokenStore: options.agentTools.tokenStore,
163
+ }),
164
+ }
165
+ : {};
122
166
  const webhookOptions = toJiraWebhookOptions(options.routes);
123
167
  const webhookProcessor = webhookOptions ? createJiraWebhookProcessor(webhookOptions) : undefined;
124
168
  const webhookRoutes = webhookOptions
@@ -136,7 +180,7 @@ export function createJiraIntegrationProvider(options: CreateJiraIntegrationProv
136
180
  ? [
137
181
  createJiraIntegrationRoutes({
138
182
  jira,
139
- connectionCapabilities: [],
183
+ connectionCapabilities: adapters.agent_tools ? ['agent_tools'] : [],
140
184
  ...options.routes,
141
185
  registerJiraWebhook: (input) =>
142
186
  registerJiraWebhook({
@@ -162,7 +206,7 @@ export function createJiraIntegrationProvider(options: CreateJiraIntegrationProv
162
206
  return {
163
207
  provider: JIRA_PROVIDER,
164
208
  displayName: 'Jira',
165
- adapters: {},
209
+ adapters,
166
210
  async connectionExternalUrl(connection: {id: string}): Promise<string | undefined> {
167
211
  return (await getInstallationByConnectionId(connection.id))?.siteUrl;
168
212
  },
@@ -0,0 +1,5 @@
1
+ export {
2
+ type CreateJiraMaintenanceActivitiesOptions,
3
+ createJiraMaintenanceActivities,
4
+ type JiraTokenRefreshActivityResult,
5
+ } from './refresh-jira-tokens.js';
@@ -0,0 +1,156 @@
1
+ import type {JiraInstallation} from '#db/installations.js';
2
+ import {JIRA_REFRESH_TOKEN_PROACTIVE_REFRESH_AFTER_MS} from '#temporal/constants.js';
3
+ import {createJiraMaintenanceActivities} from './index.js';
4
+
5
+ function installation(connectionId: string): JiraInstallation {
6
+ const now = new Date('2026-01-01T00:00:00.000Z');
7
+ return {
8
+ id: crypto.randomUUID(),
9
+ connectionId,
10
+ cloudId: crypto.randomUUID(),
11
+ siteUrl: 'https://acme.atlassian.net',
12
+ siteName: 'Acme',
13
+ authorizingAccountId: crypto.randomUUID(),
14
+ scopes: ['offline_access'],
15
+ webhookIds: [],
16
+ webhookExpiresAt: null,
17
+ status: 'installed',
18
+ tokenExpiresAt: now,
19
+ refreshTokenLastUsedAt: now,
20
+ refreshTokenLastAttemptedAt: now,
21
+ createdAt: now,
22
+ updatedAt: now,
23
+ };
24
+ }
25
+
26
+ describe('Jira proactive token refresh activity', () => {
27
+ it('force-refreshes due active connections and skips inactive ones', async () => {
28
+ const activeConnectionId = crypto.randomUUID();
29
+ const inactiveConnectionId = crypto.randomUUID();
30
+ const failedConnectionId = crypto.randomUUID();
31
+ const installations = [
32
+ installation(activeConnectionId),
33
+ installation(inactiveConnectionId),
34
+ installation(failedConnectionId),
35
+ ];
36
+ const listInstallations = vi.fn().mockResolvedValue(installations);
37
+ const resolveConnection = vi
38
+ .fn()
39
+ .mockImplementation(async (connectionId: string) =>
40
+ connectionId === activeConnectionId
41
+ ? {lifecycleStatus: 'active'}
42
+ : connectionId === failedConnectionId
43
+ ? {lifecycleStatus: 'active'}
44
+ : {lifecycleStatus: 'error'},
45
+ );
46
+ const getAccessToken = vi.fn().mockImplementation(({connectionId}: {connectionId: string}) => {
47
+ if (connectionId === failedConnectionId) throw new Error('refresh failed');
48
+ return 'fresh-access-token';
49
+ });
50
+ const heartbeat = vi.fn();
51
+ const markAttempted = vi.fn().mockResolvedValue(undefined);
52
+ const now = new Date('2026-08-04T00:00:00.000Z');
53
+ const activities = createJiraMaintenanceActivities({
54
+ tokenStore: {getAccessToken},
55
+ resolveConnection,
56
+ listInstallations,
57
+ markAttempted,
58
+ now: () => now,
59
+ heartbeat,
60
+ });
61
+
62
+ const result = await activities.refreshJiraTokensActivity();
63
+
64
+ expect(listInstallations).toHaveBeenCalledWith({
65
+ before: new Date(now.getTime() - JIRA_REFRESH_TOKEN_PROACTIVE_REFRESH_AFTER_MS),
66
+ limit: 20,
67
+ });
68
+ expect(getAccessToken).toHaveBeenCalledWith({
69
+ connectionId: activeConnectionId,
70
+ forceRefresh: true,
71
+ });
72
+ expect(getAccessToken).toHaveBeenCalledTimes(2);
73
+ expect(heartbeat).toHaveBeenCalledTimes(6);
74
+ expect(markAttempted).toHaveBeenCalledTimes(3);
75
+ expect(result).toEqual({refreshed: 1, skipped: 1, failed: 1});
76
+ });
77
+
78
+ it('heartbeats while a token refresh is in flight', async () => {
79
+ vi.useFakeTimers();
80
+ try {
81
+ const connectionId = crypto.randomUUID();
82
+ let resolveToken!: (value: string | PromiseLike<string>) => void;
83
+ const getAccessToken = vi.fn(
84
+ () =>
85
+ new Promise<string>((resolve) => {
86
+ resolveToken = resolve;
87
+ }),
88
+ );
89
+ const heartbeat = vi.fn();
90
+ const activities = createJiraMaintenanceActivities({
91
+ tokenStore: {getAccessToken},
92
+ resolveConnection: vi.fn().mockResolvedValue({lifecycleStatus: 'active'}),
93
+ listInstallations: vi.fn().mockResolvedValue([installation(connectionId)]),
94
+ markAttempted: vi.fn().mockResolvedValue(undefined),
95
+ heartbeat,
96
+ });
97
+
98
+ const result = activities.refreshJiraTokensActivity();
99
+ await vi.waitFor(() => expect(getAccessToken).toHaveBeenCalledOnce());
100
+ const heartbeatCountBeforeRefresh = heartbeat.mock.calls.length;
101
+
102
+ await vi.advanceTimersByTimeAsync(30_000);
103
+
104
+ expect(heartbeat.mock.calls.length).toBeGreaterThan(heartbeatCountBeforeRefresh);
105
+ resolveToken('fresh-access-token');
106
+ await expect(result).resolves.toEqual({refreshed: 1, skipped: 0, failed: 0});
107
+ } finally {
108
+ vi.useRealTimers();
109
+ }
110
+ });
111
+
112
+ it('counts an attempt failure and continues with the next installation', async () => {
113
+ const failedConnectionId = crypto.randomUUID();
114
+ const activeConnectionId = crypto.randomUUID();
115
+ const installations = [installation(failedConnectionId), installation(activeConnectionId)];
116
+ const activities = createJiraMaintenanceActivities({
117
+ tokenStore: {getAccessToken: vi.fn().mockResolvedValue('fresh-access-token')},
118
+ resolveConnection: vi.fn().mockResolvedValue({lifecycleStatus: 'active'}),
119
+ listInstallations: vi.fn().mockResolvedValue(installations),
120
+ markAttempted: vi
121
+ .fn()
122
+ .mockRejectedValueOnce(new Error('attempt write failed'))
123
+ .mockResolvedValue(undefined),
124
+ heartbeat: vi.fn(),
125
+ });
126
+
127
+ const result = await activities.refreshJiraTokensActivity();
128
+
129
+ expect(result).toEqual({refreshed: 1, skipped: 0, failed: 1});
130
+ });
131
+
132
+ it('counts a connection lookup failure and continues with the next installation', async () => {
133
+ const failedConnectionId = crypto.randomUUID();
134
+ const activeConnectionId = crypto.randomUUID();
135
+ const getAccessToken = vi.fn().mockResolvedValue('fresh-access-token');
136
+ const activities = createJiraMaintenanceActivities({
137
+ tokenStore: {getAccessToken},
138
+ resolveConnection: vi
139
+ .fn()
140
+ .mockRejectedValueOnce(new Error('connection lookup failed'))
141
+ .mockResolvedValue({lifecycleStatus: 'active'}),
142
+ listInstallations: vi
143
+ .fn()
144
+ .mockResolvedValue([installation(failedConnectionId), installation(activeConnectionId)]),
145
+ markAttempted: vi.fn().mockResolvedValue(undefined),
146
+ });
147
+
148
+ const result = await activities.refreshJiraTokensActivity();
149
+
150
+ expect(getAccessToken).toHaveBeenCalledWith({
151
+ connectionId: activeConnectionId,
152
+ forceRefresh: true,
153
+ });
154
+ expect(result).toEqual({refreshed: 1, skipped: 0, failed: 1});
155
+ });
156
+ });
@@ -0,0 +1,88 @@
1
+ import type {IntegrationConnection} from '@shipfox/api-integration-spi';
2
+ import {logger} from '@shipfox/node-opentelemetry';
3
+ import type {JiraTokenStore} from '#core/tokens.js';
4
+ import {
5
+ type JiraInstallation,
6
+ listJiraInstallationsDueForTokenRefresh,
7
+ markJiraInstallationTokenRefreshAttempt,
8
+ } from '#db/installations.js';
9
+ import {
10
+ JIRA_REFRESH_TOKEN_PROACTIVE_REFRESH_AFTER_MS,
11
+ JIRA_REFRESH_TOKEN_SWEEP_BATCH_SIZE,
12
+ } from '#temporal/constants.js';
13
+
14
+ const JIRA_REFRESH_TOKEN_HEARTBEAT_INTERVAL_MS = 15_000;
15
+
16
+ export interface JiraTokenRefreshActivityResult {
17
+ refreshed: number;
18
+ skipped: number;
19
+ failed: number;
20
+ }
21
+
22
+ export interface CreateJiraMaintenanceActivitiesOptions {
23
+ tokenStore: Pick<JiraTokenStore, 'getAccessToken'>;
24
+ resolveConnection(
25
+ connectionId: string,
26
+ ): Promise<Pick<IntegrationConnection, 'lifecycleStatus'> | undefined>;
27
+ listInstallations?: typeof listJiraInstallationsDueForTokenRefresh | undefined;
28
+ markAttempted?: typeof markJiraInstallationTokenRefreshAttempt | undefined;
29
+ now?: (() => Date) | undefined;
30
+ heartbeat?: (() => void) | undefined;
31
+ }
32
+
33
+ export function createJiraMaintenanceActivities(options: CreateJiraMaintenanceActivitiesOptions) {
34
+ const listInstallations = options.listInstallations ?? listJiraInstallationsDueForTokenRefresh;
35
+ const markAttempted = options.markAttempted ?? markJiraInstallationTokenRefreshAttempt;
36
+ const now = options.now ?? (() => new Date());
37
+ const heartbeat = options.heartbeat ?? (() => undefined);
38
+
39
+ return {
40
+ async refreshJiraTokensActivity(): Promise<JiraTokenRefreshActivityResult> {
41
+ const before = new Date(now().getTime() - JIRA_REFRESH_TOKEN_PROACTIVE_REFRESH_AFTER_MS);
42
+ const installations = await listInstallations({
43
+ before,
44
+ limit: JIRA_REFRESH_TOKEN_SWEEP_BATCH_SIZE,
45
+ });
46
+ let refreshed = 0;
47
+ let skipped = 0;
48
+ let failed = 0;
49
+
50
+ for (const installation of installations) {
51
+ const heartbeatInterval = setInterval(heartbeat, JIRA_REFRESH_TOKEN_HEARTBEAT_INTERVAL_MS);
52
+ try {
53
+ heartbeat();
54
+ await markAttempted(installation.connectionId);
55
+ const connection = await options.resolveConnection(installation.connectionId);
56
+ if (connection?.lifecycleStatus !== 'active') {
57
+ skipped += 1;
58
+ continue;
59
+ }
60
+
61
+ await options.tokenStore.getAccessToken({
62
+ connectionId: installation.connectionId,
63
+ forceRefresh: true,
64
+ });
65
+ refreshed += 1;
66
+ } catch (error) {
67
+ failed += 1;
68
+ logRefreshFailure(installation, error);
69
+ } finally {
70
+ clearInterval(heartbeatInterval);
71
+ heartbeat();
72
+ }
73
+ }
74
+
75
+ return {refreshed, skipped, failed};
76
+ },
77
+ };
78
+ }
79
+
80
+ function logRefreshFailure(installation: JiraInstallation, error: unknown): void {
81
+ logger().warn(
82
+ {
83
+ connectionId: installation.connectionId,
84
+ err: error,
85
+ },
86
+ 'Jira proactive token refresh failed',
87
+ );
88
+ }
@@ -0,0 +1,8 @@
1
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
2
+
3
+ export const JIRA_REFRESH_TOKEN_IDLE_WINDOW_MS = 90 * MS_PER_DAY;
4
+ export const JIRA_REFRESH_TOKEN_REFRESH_LEAD_TIME_MS = 14 * MS_PER_DAY;
5
+ export const JIRA_REFRESH_TOKEN_PROACTIVE_REFRESH_AFTER_MS =
6
+ JIRA_REFRESH_TOKEN_IDLE_WINDOW_MS - JIRA_REFRESH_TOKEN_REFRESH_LEAD_TIME_MS;
7
+ export const JIRA_REFRESH_TOKEN_SWEEP_BATCH_SIZE = 20;
8
+ export const JIRA_MAINTENANCE_TASK_QUEUE = 'integrations-jira-maintenance';