@shipfox/api-integration-jira 12.0.0 → 12.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +17 -2
- package/CHANGELOG.md +10 -0
- package/README.md +58 -2
- package/dist/api/client.d.ts +19 -0
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +78 -0
- package/dist/api/client.js.map +1 -1
- package/dist/core/agent-tools-provider.d.ts +26 -0
- package/dist/core/agent-tools-provider.d.ts.map +1 -0
- package/dist/core/agent-tools-provider.js +130 -0
- package/dist/core/agent-tools-provider.js.map +1 -0
- package/dist/core/agent-tools.d.ts +16 -0
- package/dist/core/agent-tools.d.ts.map +1 -0
- package/dist/core/agent-tools.js +441 -0
- package/dist/core/agent-tools.js.map +1 -0
- package/dist/core/tokens.d.ts +2 -0
- package/dist/core/tokens.d.ts.map +1 -1
- package/dist/core/tokens.js +129 -37
- package/dist/core/tokens.js.map +1 -1
- package/dist/db/db.d.ts +68 -0
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/installations.d.ts +23 -7
- package/dist/db/installations.d.ts.map +1 -1
- package/dist/db/installations.js +42 -19
- package/dist/db/installations.js.map +1 -1
- package/dist/db/schema/installations.d.ts +34 -0
- package/dist/db/schema/installations.d.ts.map +1 -1
- package/dist/db/schema/installations.js +12 -2
- package/dist/db/schema/installations.js.map +1 -1
- package/dist/index.d.ts +20 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -5
- package/dist/index.js.map +1 -1
- package/dist/temporal/activities/index.d.ts +2 -0
- package/dist/temporal/activities/index.d.ts.map +1 -0
- package/dist/temporal/activities/index.js +3 -0
- package/dist/temporal/activities/index.js.map +1 -0
- package/dist/temporal/activities/refresh-jira-tokens.d.ts +20 -0
- package/dist/temporal/activities/refresh-jira-tokens.d.ts.map +1 -0
- package/dist/temporal/activities/refresh-jira-tokens.js +58 -0
- package/dist/temporal/activities/refresh-jira-tokens.js.map +1 -0
- package/dist/temporal/constants.d.ts +6 -0
- package/dist/temporal/constants.d.ts.map +1 -0
- package/dist/temporal/constants.js +8 -0
- package/dist/temporal/constants.js.map +1 -0
- package/dist/temporal/worker.d.ts +9 -0
- package/dist/temporal/worker.d.ts.map +1 -0
- package/dist/temporal/worker.js +26 -0
- package/dist/temporal/worker.js.map +1 -0
- package/dist/temporal/workflows/index.bundle.js +25225 -0
- package/dist/temporal/workflows/index.bundle.meta.json +1 -0
- package/dist/temporal/workflows/index.d.ts +2 -0
- package/dist/temporal/workflows/index.d.ts.map +1 -0
- package/dist/temporal/workflows/index.js +3 -0
- package/dist/temporal/workflows/index.js.map +1 -0
- package/dist/temporal/workflows/refresh-jira-tokens-cron.d.ts +2 -0
- package/dist/temporal/workflows/refresh-jira-tokens-cron.d.ts.map +1 -0
- package/dist/temporal/workflows/refresh-jira-tokens-cron.js +20 -0
- package/dist/temporal/workflows/refresh-jira-tokens-cron.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0001_jira-token-refresh-state.sql +12 -0
- package/drizzle/meta/0001_snapshot.json +245 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +12 -5
- package/src/api/client.test.ts +133 -12
- package/src/api/client.ts +107 -0
- package/src/core/agent-tools-provider.test.ts +299 -0
- package/src/core/agent-tools-provider.ts +175 -0
- package/src/core/agent-tools.ts +450 -0
- package/src/core/tokens-refresh.test.ts +221 -4
- package/src/core/tokens.test.ts +1 -1
- package/src/core/tokens.ts +153 -33
- package/src/db/installations.test.ts +84 -2
- package/src/db/installations.ts +81 -21
- package/src/db/schema/installations.ts +21 -1
- package/src/index.test.ts +34 -0
- package/src/index.ts +47 -3
- package/src/temporal/activities/index.ts +5 -0
- package/src/temporal/activities/refresh-jira-tokens.test.ts +156 -0
- package/src/temporal/activities/refresh-jira-tokens.ts +88 -0
- package/src/temporal/constants.ts +8 -0
- package/src/temporal/worker.ts +39 -0
- package/src/temporal/workflows/index.ts +1 -0
- package/src/temporal/workflows/refresh-jira-tokens-cron.ts +19 -0
- package/test/factories/jira-installation.ts +4 -0
- package/tsconfig.build.tsbuildinfo +1 -1
package/src/db/installations.ts
CHANGED
|
@@ -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
|
|
15
|
-
const
|
|
16
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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:
|
|
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> =
|
|
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 {
|
|
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,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';
|