@shipfox/api-integration-jira 12.2.0 → 12.4.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 +8 -7
- package/.turbo/turbo-type$colon$emit.log +0 -1
- package/CHANGELOG.md +17 -0
- package/README.md +5 -1
- package/dist/api/client.d.ts +11 -1
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +57 -12
- package/dist/api/client.js.map +1 -1
- package/dist/core/disconnect.d.ts +12 -0
- package/dist/core/disconnect.d.ts.map +1 -1
- package/dist/core/disconnect.js +45 -1
- package/dist/core/disconnect.js.map +1 -1
- package/dist/core/install.d.ts +1 -0
- package/dist/core/install.d.ts.map +1 -1
- package/dist/core/install.js +2 -1
- package/dist/core/install.js.map +1 -1
- package/dist/core/tokens.d.ts +2 -0
- package/dist/core/tokens.d.ts.map +1 -1
- package/dist/core/tokens.js +2 -2
- package/dist/core/tokens.js.map +1 -1
- package/dist/core/webhook-registration.d.ts +1 -0
- package/dist/core/webhook-registration.d.ts.map +1 -1
- package/dist/core/webhook-registration.js +3 -1
- package/dist/core/webhook-registration.js.map +1 -1
- package/dist/core/webhook-url.d.ts +3 -0
- package/dist/core/webhook-url.d.ts.map +1 -0
- package/dist/core/webhook-url.js +8 -0
- package/dist/core/webhook-url.js.map +1 -0
- package/dist/db/installations.d.ts +13 -0
- package/dist/db/installations.d.ts.map +1 -1
- package/dist/db/installations.js +18 -1
- package/dist/db/installations.js.map +1 -1
- package/dist/index.d.ts +17 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -7
- package/dist/index.js.map +1 -1
- package/dist/presentation/routes/webhooks.d.ts +2 -1
- package/dist/presentation/routes/webhooks.d.ts.map +1 -1
- package/dist/presentation/routes/webhooks.js +2 -1
- package/dist/presentation/routes/webhooks.js.map +1 -1
- package/dist/temporal/activities/index.d.ts +8 -1
- package/dist/temporal/activities/index.d.ts.map +1 -1
- package/dist/temporal/activities/index.js +8 -1
- package/dist/temporal/activities/index.js.map +1 -1
- package/dist/temporal/activities/renew-jira-webhooks.d.ts +26 -0
- package/dist/temporal/activities/renew-jira-webhooks.d.ts.map +1 -0
- package/dist/temporal/activities/renew-jira-webhooks.js +96 -0
- package/dist/temporal/activities/renew-jira-webhooks.js.map +1 -0
- package/dist/temporal/constants.d.ts +2 -0
- package/dist/temporal/constants.d.ts.map +1 -1
- package/dist/temporal/constants.js +2 -0
- package/dist/temporal/constants.js.map +1 -1
- package/dist/temporal/worker.d.ts +3 -0
- package/dist/temporal/worker.d.ts.map +1 -1
- package/dist/temporal/worker.js +5 -0
- package/dist/temporal/worker.js.map +1 -1
- package/dist/temporal/workflows/index.bundle.js +38 -2
- package/dist/temporal/workflows/index.d.ts +1 -0
- package/dist/temporal/workflows/index.d.ts.map +1 -1
- package/dist/temporal/workflows/index.js +1 -0
- package/dist/temporal/workflows/index.js.map +1 -1
- package/dist/temporal/workflows/renew-jira-webhooks-cron.d.ts +2 -0
- package/dist/temporal/workflows/renew-jira-webhooks-cron.d.ts.map +1 -0
- package/dist/temporal/workflows/renew-jira-webhooks-cron.js +18 -0
- package/dist/temporal/workflows/renew-jira-webhooks-cron.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/api/client.test.ts +83 -4
- package/src/api/client.ts +70 -8
- package/src/core/disconnect.test.ts +107 -1
- package/src/core/disconnect.ts +64 -1
- package/src/core/install.test.ts +23 -0
- package/src/core/install.ts +5 -2
- package/src/core/tokens-refresh.test.ts +39 -0
- package/src/core/tokens.ts +4 -2
- package/src/core/webhook-registration.test.ts +32 -0
- package/src/core/webhook-registration.ts +4 -1
- package/src/core/webhook-url.ts +9 -0
- package/src/db/installations.test.ts +82 -0
- package/src/db/installations.ts +54 -1
- package/src/index.test.ts +21 -1
- package/src/index.ts +21 -9
- package/src/presentation/routes/webhooks.ts +3 -1
- package/src/temporal/activities/index.ts +20 -3
- package/src/temporal/activities/renew-jira-webhooks.test.ts +215 -0
- package/src/temporal/activities/renew-jira-webhooks.ts +144 -0
- package/src/temporal/constants.ts +2 -0
- package/src/temporal/worker.ts +11 -0
- package/src/temporal/workflows/index.ts +1 -0
- package/src/temporal/workflows/renew-jira-webhooks-cron.ts +17 -0
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -8,10 +8,12 @@ import {
|
|
|
8
8
|
getJiraInstallationByConnectionId,
|
|
9
9
|
getJiraInstallationByWebhookId,
|
|
10
10
|
listJiraInstallationsDueForTokenRefresh,
|
|
11
|
+
listJiraInstallationsDueForWebhookRenewal,
|
|
11
12
|
markJiraInstallationRevoked,
|
|
12
13
|
markJiraInstallationTokenRefreshAttempt,
|
|
13
14
|
updateJiraInstallationTokenExpiry,
|
|
14
15
|
updateJiraInstallationWebhook,
|
|
16
|
+
updateJiraInstallationWebhookIfUnchanged,
|
|
15
17
|
upsertJiraInstallation,
|
|
16
18
|
withJiraWebhookRegistrationLock,
|
|
17
19
|
} from './installations.js';
|
|
@@ -96,6 +98,53 @@ describe('jira installations', () => {
|
|
|
96
98
|
expect(result).toMatchObject({webhookIds: [654], webhookExpiresAt});
|
|
97
99
|
});
|
|
98
100
|
|
|
101
|
+
it('only updates webhook metadata when the refreshed ids are still current', async () => {
|
|
102
|
+
const currentWebhookExpiresAt = new Date('2026-08-05T00:00:00.000Z');
|
|
103
|
+
const input = createInstallationInput({
|
|
104
|
+
webhookIds: [123],
|
|
105
|
+
webhookExpiresAt: currentWebhookExpiresAt,
|
|
106
|
+
});
|
|
107
|
+
await upsertJiraInstallation(input);
|
|
108
|
+
|
|
109
|
+
await expect(
|
|
110
|
+
updateJiraInstallationWebhookIfUnchanged({
|
|
111
|
+
connectionId: input.connectionId,
|
|
112
|
+
expectedWebhookIds: [456],
|
|
113
|
+
expectedWebhookExpiresAt: currentWebhookExpiresAt,
|
|
114
|
+
webhookIds: [789],
|
|
115
|
+
webhookExpiresAt: new Date('2026-08-31T00:00:00.000Z'),
|
|
116
|
+
}),
|
|
117
|
+
).resolves.toBeUndefined();
|
|
118
|
+
await expect(getJiraInstallationByConnectionId(input.connectionId)).resolves.toMatchObject({
|
|
119
|
+
webhookIds: [123],
|
|
120
|
+
webhookExpiresAt: currentWebhookExpiresAt,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await expect(
|
|
124
|
+
updateJiraInstallationWebhookIfUnchanged({
|
|
125
|
+
connectionId: input.connectionId,
|
|
126
|
+
expectedWebhookIds: [123],
|
|
127
|
+
expectedWebhookExpiresAt: new Date('2026-08-04T00:00:00.000Z'),
|
|
128
|
+
webhookIds: [789],
|
|
129
|
+
webhookExpiresAt: new Date('2026-08-31T00:00:00.000Z'),
|
|
130
|
+
}),
|
|
131
|
+
).resolves.toBeUndefined();
|
|
132
|
+
await expect(getJiraInstallationByConnectionId(input.connectionId)).resolves.toMatchObject({
|
|
133
|
+
webhookIds: [123],
|
|
134
|
+
webhookExpiresAt: currentWebhookExpiresAt,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await expect(
|
|
138
|
+
updateJiraInstallationWebhookIfUnchanged({
|
|
139
|
+
connectionId: input.connectionId,
|
|
140
|
+
expectedWebhookIds: [123],
|
|
141
|
+
expectedWebhookExpiresAt: currentWebhookExpiresAt,
|
|
142
|
+
webhookIds: [789],
|
|
143
|
+
webhookExpiresAt: new Date('2026-08-31T00:00:00.000Z'),
|
|
144
|
+
}),
|
|
145
|
+
).resolves.toMatchObject({webhookIds: [789]});
|
|
146
|
+
});
|
|
147
|
+
|
|
99
148
|
it('refuses to repoint a connection to a different Jira site', async () => {
|
|
100
149
|
const first = createInstallationInput();
|
|
101
150
|
await upsertJiraInstallation(first);
|
|
@@ -186,6 +235,39 @@ describe('jira installations', () => {
|
|
|
186
235
|
);
|
|
187
236
|
});
|
|
188
237
|
|
|
238
|
+
it('lists only installed installations whose webhooks are nearing expiry', async () => {
|
|
239
|
+
const due = createInstallationInput({
|
|
240
|
+
webhookExpiresAt: new Date('2026-08-05T00:00:00.000Z'),
|
|
241
|
+
});
|
|
242
|
+
const fresh = createInstallationInput({
|
|
243
|
+
webhookExpiresAt: new Date('2026-08-20T00:00:00.000Z'),
|
|
244
|
+
});
|
|
245
|
+
const missingExpiry = createInstallationInput({webhookExpiresAt: null});
|
|
246
|
+
const revoked = createInstallationInput({
|
|
247
|
+
webhookExpiresAt: new Date('2026-08-05T00:00:00.000Z'),
|
|
248
|
+
status: 'revoked',
|
|
249
|
+
});
|
|
250
|
+
await upsertJiraInstallation(due);
|
|
251
|
+
await upsertJiraInstallation(fresh);
|
|
252
|
+
await upsertJiraInstallation(missingExpiry);
|
|
253
|
+
await upsertJiraInstallation(revoked);
|
|
254
|
+
|
|
255
|
+
const result = await listJiraInstallationsDueForWebhookRenewal({
|
|
256
|
+
before: new Date('2026-08-07T00:00:00.000Z'),
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
expect(result.map((installation) => installation.connectionId)).toContain(due.connectionId);
|
|
260
|
+
expect(result.map((installation) => installation.connectionId)).not.toContain(
|
|
261
|
+
fresh.connectionId,
|
|
262
|
+
);
|
|
263
|
+
expect(result.map((installation) => installation.connectionId)).not.toContain(
|
|
264
|
+
missingExpiry.connectionId,
|
|
265
|
+
);
|
|
266
|
+
expect(result.map((installation) => installation.connectionId)).not.toContain(
|
|
267
|
+
revoked.connectionId,
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
|
|
189
271
|
it('orders due installations by their latest refresh attempt', async () => {
|
|
190
272
|
const refreshTokenLastUsedAt = new Date('1970-01-01T00:00:00.000Z');
|
|
191
273
|
const attemptBase = Date.now();
|
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 {and, asc, eq, lte, sql} from 'drizzle-orm';
|
|
3
|
+
import {and, asc, eq, isNotNull, isNull, lte, sql} from 'drizzle-orm';
|
|
4
4
|
import {
|
|
5
5
|
JiraConnectionAlreadyLinkedError,
|
|
6
6
|
JiraInstallationAlreadyLinkedError,
|
|
@@ -83,6 +83,12 @@ export interface UpdateJiraInstallationWebhookParams {
|
|
|
83
83
|
webhookExpiresAt: Date | null;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
export interface UpdateJiraInstallationWebhookIfUnchangedParams
|
|
87
|
+
extends UpdateJiraInstallationWebhookParams {
|
|
88
|
+
expectedWebhookIds: number[];
|
|
89
|
+
expectedWebhookExpiresAt: Date | null;
|
|
90
|
+
}
|
|
91
|
+
|
|
86
92
|
type JiraDb = ReturnType<typeof db>;
|
|
87
93
|
type JiraTx = Parameters<Parameters<JiraDb['transaction']>[0]>[0];
|
|
88
94
|
|
|
@@ -211,6 +217,26 @@ export async function listJiraInstallationsDueForTokenRefresh(
|
|
|
211
217
|
return rows.map(toJiraInstallation);
|
|
212
218
|
}
|
|
213
219
|
|
|
220
|
+
export async function listJiraInstallationsDueForWebhookRenewal(
|
|
221
|
+
params: {before: Date; limit?: number | undefined},
|
|
222
|
+
options: {tx?: unknown} = {},
|
|
223
|
+
): Promise<JiraInstallation[]> {
|
|
224
|
+
const executor = (options.tx ?? db()) as JiraDb | JiraTx;
|
|
225
|
+
const rows = await executor
|
|
226
|
+
.select()
|
|
227
|
+
.from(jiraInstallations)
|
|
228
|
+
.where(
|
|
229
|
+
and(
|
|
230
|
+
eq(jiraInstallations.status, 'installed'),
|
|
231
|
+
isNotNull(jiraInstallations.webhookExpiresAt),
|
|
232
|
+
lte(jiraInstallations.webhookExpiresAt, params.before),
|
|
233
|
+
),
|
|
234
|
+
)
|
|
235
|
+
.orderBy(asc(jiraInstallations.webhookExpiresAt))
|
|
236
|
+
.limit(params.limit ?? 100);
|
|
237
|
+
return rows.map(toJiraInstallation);
|
|
238
|
+
}
|
|
239
|
+
|
|
214
240
|
export async function updateJiraInstallationWebhook(
|
|
215
241
|
params: UpdateJiraInstallationWebhookParams,
|
|
216
242
|
options: {tx?: unknown} = {},
|
|
@@ -228,6 +254,33 @@ export async function updateJiraInstallationWebhook(
|
|
|
228
254
|
return row ? toJiraInstallation(row) : undefined;
|
|
229
255
|
}
|
|
230
256
|
|
|
257
|
+
export async function updateJiraInstallationWebhookIfUnchanged(
|
|
258
|
+
params: UpdateJiraInstallationWebhookIfUnchangedParams,
|
|
259
|
+
options: {tx?: unknown} = {},
|
|
260
|
+
): Promise<JiraInstallation | undefined> {
|
|
261
|
+
const executor = (options.tx ?? db()) as JiraDb | JiraTx;
|
|
262
|
+
const expectedWebhookIds = JSON.stringify([...new Set(params.expectedWebhookIds)]);
|
|
263
|
+
const [row] = await executor
|
|
264
|
+
.update(jiraInstallations)
|
|
265
|
+
.set({
|
|
266
|
+
webhookIds: params.webhookIds,
|
|
267
|
+
webhookExpiresAt: params.webhookExpiresAt,
|
|
268
|
+
updatedAt: new Date(),
|
|
269
|
+
})
|
|
270
|
+
.where(
|
|
271
|
+
and(
|
|
272
|
+
eq(jiraInstallations.connectionId, params.connectionId),
|
|
273
|
+
sql`${jiraInstallations.webhookIds} @> ${expectedWebhookIds}::jsonb`,
|
|
274
|
+
sql`${jiraInstallations.webhookIds} <@ ${expectedWebhookIds}::jsonb`,
|
|
275
|
+
params.expectedWebhookExpiresAt === null
|
|
276
|
+
? isNull(jiraInstallations.webhookExpiresAt)
|
|
277
|
+
: eq(jiraInstallations.webhookExpiresAt, params.expectedWebhookExpiresAt),
|
|
278
|
+
),
|
|
279
|
+
)
|
|
280
|
+
.returning();
|
|
281
|
+
return row ? toJiraInstallation(row) : undefined;
|
|
282
|
+
}
|
|
283
|
+
|
|
231
284
|
export async function deleteJiraInstallationByConnectionId(
|
|
232
285
|
connectionId: string,
|
|
233
286
|
options: {tx?: unknown} = {},
|
package/src/index.test.ts
CHANGED
|
@@ -34,11 +34,17 @@ describe('createJiraIntegrationProvider', () => {
|
|
|
34
34
|
|
|
35
35
|
it('exposes explicit connection cleanup without requiring routes', () => {
|
|
36
36
|
const deleteConnectionRecords = vi.fn(() => Promise.resolve());
|
|
37
|
+
const deleteConnectionRemoteResources = vi.fn(() => Promise.resolve(undefined));
|
|
37
38
|
const deleteConnectionSecrets = vi.fn(() => Promise.resolve());
|
|
38
39
|
const provider = createJiraIntegrationProvider({
|
|
39
|
-
cleanup: {
|
|
40
|
+
cleanup: {
|
|
41
|
+
deleteConnectionRemoteResources,
|
|
42
|
+
deleteConnectionRecords,
|
|
43
|
+
deleteConnectionSecrets,
|
|
44
|
+
},
|
|
40
45
|
});
|
|
41
46
|
|
|
47
|
+
expect(provider.deleteConnectionRemoteResources).toBe(deleteConnectionRemoteResources);
|
|
42
48
|
expect(provider.deleteConnectionRecords).toBe(deleteConnectionRecords);
|
|
43
49
|
expect(provider.deleteConnectionSecrets).toBe(deleteConnectionSecrets);
|
|
44
50
|
});
|
|
@@ -48,6 +54,12 @@ describe('createJiraMaintenanceWorker', () => {
|
|
|
48
54
|
it('describes the Jira proactive token refresh worker', () => {
|
|
49
55
|
const worker = createJiraMaintenanceWorker({
|
|
50
56
|
tokenStore: {getAccessToken: vi.fn()},
|
|
57
|
+
jira: {
|
|
58
|
+
refreshDynamicWebhooks: vi.fn(),
|
|
59
|
+
registerDynamicWebhook: vi.fn(),
|
|
60
|
+
deleteDynamicWebhook: vi.fn(),
|
|
61
|
+
},
|
|
62
|
+
webhookUrlForConnection: (connectionId) => `https://example.test/${connectionId}`,
|
|
51
63
|
resolveConnection: vi.fn(),
|
|
52
64
|
});
|
|
53
65
|
|
|
@@ -56,12 +68,20 @@ describe('createJiraMaintenanceWorker', () => {
|
|
|
56
68
|
expect(Object.keys(worker.activities({outboxRegistry: createOutboxRegistry()}))).toContain(
|
|
57
69
|
'refreshJiraTokensActivity',
|
|
58
70
|
);
|
|
71
|
+
expect(Object.keys(worker.activities({outboxRegistry: createOutboxRegistry()}))).toContain(
|
|
72
|
+
'renewJiraWebhooksActivity',
|
|
73
|
+
);
|
|
59
74
|
expect(worker.workflows).toEqual([
|
|
60
75
|
{
|
|
61
76
|
name: 'refreshJiraTokensCron',
|
|
62
77
|
id: 'jira-refresh-tokens',
|
|
63
78
|
cronSchedule: '0 */6 * * *',
|
|
64
79
|
},
|
|
80
|
+
{
|
|
81
|
+
name: 'renewJiraWebhooksCron',
|
|
82
|
+
id: 'jira-renew-webhooks',
|
|
83
|
+
cronSchedule: '0 */6 * * *',
|
|
84
|
+
},
|
|
65
85
|
]);
|
|
66
86
|
});
|
|
67
87
|
});
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {JiraAgentToolsProvider} from '#core/agent-tools-provider.js';
|
|
|
10
10
|
import type {JiraTokenStore} from '#core/tokens.js';
|
|
11
11
|
import {createJiraWebhookProcessor} from '#core/webhook-processor.js';
|
|
12
12
|
import {registerJiraWebhook} from '#core/webhook-registration.js';
|
|
13
|
+
import {jiraWebhookUrl} from '#core/webhook-url.js';
|
|
13
14
|
import {closeDb, db} from '#db/db.js';
|
|
14
15
|
import {getJiraInstallationByConnectionId} from '#db/installations.js';
|
|
15
16
|
import {migrationsPath} from '#db/migrations.js';
|
|
@@ -20,11 +21,8 @@ import {
|
|
|
20
21
|
import {
|
|
21
22
|
type CreateJiraWebhookRoutesOptions,
|
|
22
23
|
createJiraWebhookRoutes,
|
|
23
|
-
JIRA_WEBHOOK_ROUTE_PREFIX,
|
|
24
24
|
} from '#presentation/routes/webhooks.js';
|
|
25
25
|
|
|
26
|
-
const TRAILING_SLASHES_RE = /\/+$/;
|
|
27
|
-
|
|
28
26
|
export type {JiraProvider} from '@shipfox/api-integration-jira-dto';
|
|
29
27
|
export type {
|
|
30
28
|
JiraAccessibleResource,
|
|
@@ -60,8 +58,15 @@ export type {
|
|
|
60
58
|
JiraToolCallResult,
|
|
61
59
|
} from '#core/agent-tools-provider.js';
|
|
62
60
|
export {JiraAgentToolsProvider} from '#core/agent-tools-provider.js';
|
|
63
|
-
export type {
|
|
64
|
-
|
|
61
|
+
export type {
|
|
62
|
+
DeregisterJiraWebhooksParams,
|
|
63
|
+
DisconnectJiraInstallationParams,
|
|
64
|
+
} from '#core/disconnect.js';
|
|
65
|
+
export {
|
|
66
|
+
deregisterJiraWebhooks,
|
|
67
|
+
disconnectJiraInstallation,
|
|
68
|
+
prepareJiraWebhookDeregistration,
|
|
69
|
+
} from '#core/disconnect.js';
|
|
65
70
|
export {
|
|
66
71
|
JiraAccessTokenMissingError,
|
|
67
72
|
JiraAuthorizationScopeMismatchError,
|
|
@@ -108,10 +113,12 @@ export type {
|
|
|
108
113
|
export {createJiraWebhookProcessor} from '#core/webhook-processor.js';
|
|
109
114
|
export type {RegisterJiraWebhookParams} from '#core/webhook-registration.js';
|
|
110
115
|
export {JIRA_WEBHOOK_TTL_MS, registerJiraWebhook} from '#core/webhook-registration.js';
|
|
116
|
+
export {JIRA_WEBHOOK_ROUTE_PREFIX, jiraWebhookUrl} from '#core/webhook-url.js';
|
|
111
117
|
export type {
|
|
112
118
|
JiraInstallation,
|
|
113
119
|
JiraInstallationLock,
|
|
114
120
|
JiraInstallationStatus,
|
|
121
|
+
UpdateJiraInstallationWebhookIfUnchangedParams,
|
|
115
122
|
UpdateJiraInstallationWebhookParams,
|
|
116
123
|
UpsertJiraInstallationParams,
|
|
117
124
|
} from '#db/installations.js';
|
|
@@ -121,9 +128,11 @@ export {
|
|
|
121
128
|
getJiraInstallationByConnectionId,
|
|
122
129
|
getJiraInstallationByWebhookId,
|
|
123
130
|
listJiraInstallationsDueForTokenRefresh,
|
|
131
|
+
listJiraInstallationsDueForWebhookRenewal,
|
|
124
132
|
markJiraInstallationRevoked,
|
|
125
133
|
updateJiraInstallationTokenExpiry,
|
|
126
134
|
updateJiraInstallationWebhook,
|
|
135
|
+
updateJiraInstallationWebhookIfUnchanged,
|
|
127
136
|
upsertJiraInstallation,
|
|
128
137
|
withJiraRefreshLock,
|
|
129
138
|
withJiraRefreshLockAndWait,
|
|
@@ -145,6 +154,13 @@ export interface CreateJiraIntegrationProviderOptions {
|
|
|
145
154
|
getJiraInstallationByConnectionId?: typeof getJiraInstallationByConnectionId | undefined;
|
|
146
155
|
cleanup?:
|
|
147
156
|
| {
|
|
157
|
+
deleteConnectionRemoteResources?: (connection: {
|
|
158
|
+
id: string;
|
|
159
|
+
}) => Promise<(() => Promise<void>) | undefined>;
|
|
160
|
+
withConnectionDeletionLock?: (
|
|
161
|
+
connection: {id: string},
|
|
162
|
+
fn: () => Promise<void>,
|
|
163
|
+
) => Promise<void>;
|
|
148
164
|
deleteConnectionRecords?: (
|
|
149
165
|
connection: {id: string},
|
|
150
166
|
options: {tx: unknown},
|
|
@@ -228,10 +244,6 @@ export function createJiraIntegrationProvider(options: CreateJiraIntegrationProv
|
|
|
228
244
|
};
|
|
229
245
|
}
|
|
230
246
|
|
|
231
|
-
function jiraWebhookUrl(connectionId: string): string {
|
|
232
|
-
return `${config.JIRA_WEBHOOK_BASE_URL.replace(TRAILING_SLASHES_RE, '')}${JIRA_WEBHOOK_ROUTE_PREFIX}/${connectionId}`;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
247
|
function toJiraWebhookOptions(
|
|
236
248
|
routes: CreateJiraIntegrationProviderOptions['routes'],
|
|
237
249
|
): CreateJiraWebhookRoutesOptions | undefined {
|
|
@@ -14,9 +14,11 @@ import {
|
|
|
14
14
|
createJiraWebhookProcessor,
|
|
15
15
|
type JiraWebhookProcessor,
|
|
16
16
|
} from '#core/webhook-processor.js';
|
|
17
|
+
import {JIRA_WEBHOOK_ROUTE_PREFIX} from '#core/webhook-url.js';
|
|
17
18
|
|
|
18
19
|
const jiraWebhookParamsSchema = z.object({connectionId: z.string().uuid()});
|
|
19
|
-
|
|
20
|
+
|
|
21
|
+
export {JIRA_WEBHOOK_ROUTE_PREFIX};
|
|
20
22
|
|
|
21
23
|
export interface CreateJiraWebhookRoutesOptions
|
|
22
24
|
extends Omit<CreateJiraWebhookProcessorOptions, 'getJiraInstallationByConnectionId'> {
|
|
@@ -1,5 +1,22 @@
|
|
|
1
|
-
|
|
2
|
-
type CreateJiraMaintenanceActivitiesOptions,
|
|
3
|
-
createJiraMaintenanceActivities,
|
|
1
|
+
import {
|
|
2
|
+
type CreateJiraMaintenanceActivitiesOptions as CreateJiraTokenMaintenanceActivitiesOptions,
|
|
3
|
+
createJiraMaintenanceActivities as createJiraTokenMaintenanceActivities,
|
|
4
4
|
type JiraTokenRefreshActivityResult,
|
|
5
5
|
} from './refresh-jira-tokens.js';
|
|
6
|
+
import {
|
|
7
|
+
type CreateJiraWebhookRenewalActivitiesOptions,
|
|
8
|
+
createJiraWebhookRenewalActivities,
|
|
9
|
+
type JiraWebhookRenewalActivityResult,
|
|
10
|
+
} from './renew-jira-webhooks.js';
|
|
11
|
+
|
|
12
|
+
export type CreateJiraMaintenanceActivitiesOptions = CreateJiraTokenMaintenanceActivitiesOptions &
|
|
13
|
+
CreateJiraWebhookRenewalActivitiesOptions;
|
|
14
|
+
|
|
15
|
+
export function createJiraMaintenanceActivities(options: CreateJiraMaintenanceActivitiesOptions) {
|
|
16
|
+
return {
|
|
17
|
+
...createJiraTokenMaintenanceActivities(options),
|
|
18
|
+
...createJiraWebhookRenewalActivities(options),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type {JiraTokenRefreshActivityResult, JiraWebhookRenewalActivityResult};
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import type {JiraInstallation} from '#db/installations.js';
|
|
2
|
+
import {JIRA_WEBHOOK_RENEWAL_THRESHOLD_MS} from '#temporal/constants.js';
|
|
3
|
+
import {createJiraWebhookRenewalActivities} from './renew-jira-webhooks.js';
|
|
4
|
+
|
|
5
|
+
function installation(
|
|
6
|
+
connectionId: string,
|
|
7
|
+
overrides: Partial<JiraInstallation> = {},
|
|
8
|
+
): JiraInstallation {
|
|
9
|
+
const now = new Date('2026-08-01T00:00:00.000Z');
|
|
10
|
+
return {
|
|
11
|
+
id: crypto.randomUUID(),
|
|
12
|
+
connectionId,
|
|
13
|
+
cloudId: crypto.randomUUID(),
|
|
14
|
+
siteUrl: 'https://acme.atlassian.net',
|
|
15
|
+
siteName: 'Acme',
|
|
16
|
+
authorizingAccountId: crypto.randomUUID(),
|
|
17
|
+
scopes: ['manage:jira-webhook'],
|
|
18
|
+
webhookIds: [123],
|
|
19
|
+
webhookExpiresAt: new Date('2026-08-05T00:00:00.000Z'),
|
|
20
|
+
status: 'installed',
|
|
21
|
+
tokenExpiresAt: now,
|
|
22
|
+
refreshTokenLastUsedAt: now,
|
|
23
|
+
refreshTokenLastAttemptedAt: now,
|
|
24
|
+
createdAt: now,
|
|
25
|
+
updatedAt: now,
|
|
26
|
+
...overrides,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('Jira webhook renewal activity', () => {
|
|
31
|
+
it('refreshes due active connections and skips outside-threshold or inactive connections', async () => {
|
|
32
|
+
const now = new Date('2026-08-01T00:00:00.000Z');
|
|
33
|
+
const dueConnectionId = crypto.randomUUID();
|
|
34
|
+
const outsideConnectionId = crypto.randomUUID();
|
|
35
|
+
const inactiveConnectionId = crypto.randomUUID();
|
|
36
|
+
const due = installation(dueConnectionId);
|
|
37
|
+
const outside = installation(outsideConnectionId, {
|
|
38
|
+
webhookExpiresAt: new Date(now.getTime() + JIRA_WEBHOOK_RENEWAL_THRESHOLD_MS + 1),
|
|
39
|
+
});
|
|
40
|
+
const inactive = installation(inactiveConnectionId);
|
|
41
|
+
const expirationDate = new Date('2026-08-31T00:00:00.000Z');
|
|
42
|
+
const getAccessToken = vi.fn().mockResolvedValue('fresh-access-token');
|
|
43
|
+
const refreshDynamicWebhooks = vi.fn().mockResolvedValue(expirationDate);
|
|
44
|
+
const updateInstallation = vi.fn().mockResolvedValue({id: 'installation-1'});
|
|
45
|
+
const resolveConnection = vi
|
|
46
|
+
.fn()
|
|
47
|
+
.mockImplementation(async (connectionId: string) =>
|
|
48
|
+
connectionId === inactiveConnectionId
|
|
49
|
+
? {lifecycleStatus: 'error'}
|
|
50
|
+
: {lifecycleStatus: 'active'},
|
|
51
|
+
);
|
|
52
|
+
const listInstallations = vi.fn().mockResolvedValue([due, outside, inactive]);
|
|
53
|
+
const activities = createJiraWebhookRenewalActivities({
|
|
54
|
+
tokenStore: {getAccessToken},
|
|
55
|
+
jira: {
|
|
56
|
+
refreshDynamicWebhooks,
|
|
57
|
+
registerDynamicWebhook: vi.fn(),
|
|
58
|
+
deleteDynamicWebhook: vi.fn(),
|
|
59
|
+
},
|
|
60
|
+
webhookUrlForConnection: (connectionId) =>
|
|
61
|
+
`https://shipfox.example.test/webhooks/integrations/jira/${connectionId}`,
|
|
62
|
+
resolveConnection,
|
|
63
|
+
listInstallations,
|
|
64
|
+
updateInstallation,
|
|
65
|
+
now: () => now,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const result = await activities.renewJiraWebhooksActivity();
|
|
69
|
+
|
|
70
|
+
expect(listInstallations).toHaveBeenCalledWith({
|
|
71
|
+
before: new Date(now.getTime() + JIRA_WEBHOOK_RENEWAL_THRESHOLD_MS),
|
|
72
|
+
limit: 20,
|
|
73
|
+
});
|
|
74
|
+
expect(getAccessToken).toHaveBeenCalledWith({connectionId: dueConnectionId});
|
|
75
|
+
expect(refreshDynamicWebhooks).toHaveBeenCalledWith({
|
|
76
|
+
accessToken: 'fresh-access-token',
|
|
77
|
+
cloudId: due.cloudId,
|
|
78
|
+
webhookIds: due.webhookIds,
|
|
79
|
+
});
|
|
80
|
+
expect(updateInstallation).toHaveBeenCalledWith({
|
|
81
|
+
connectionId: dueConnectionId,
|
|
82
|
+
webhookIds: due.webhookIds,
|
|
83
|
+
webhookExpiresAt: expirationDate,
|
|
84
|
+
expectedWebhookIds: due.webhookIds,
|
|
85
|
+
expectedWebhookExpiresAt: due.webhookExpiresAt,
|
|
86
|
+
});
|
|
87
|
+
expect(result).toEqual({renewed: 1, reregistered: 0, skipped: 2, failed: 0});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('re-registers and replaces webhook metadata when Jira no longer refreshes the ids', async () => {
|
|
91
|
+
const connectionId = crypto.randomUUID();
|
|
92
|
+
const due = installation(connectionId);
|
|
93
|
+
const registerWebhook = vi.fn().mockResolvedValue({
|
|
94
|
+
webhookId: 456,
|
|
95
|
+
webhookExpiresAt: new Date('2026-08-31T00:00:00.000Z'),
|
|
96
|
+
});
|
|
97
|
+
const now = new Date('2026-08-01T00:00:00.000Z');
|
|
98
|
+
const jira = {
|
|
99
|
+
refreshDynamicWebhooks: vi.fn().mockResolvedValue(undefined),
|
|
100
|
+
registerDynamicWebhook: vi.fn(),
|
|
101
|
+
deleteDynamicWebhook: vi.fn(),
|
|
102
|
+
};
|
|
103
|
+
const activities = createJiraWebhookRenewalActivities({
|
|
104
|
+
tokenStore: {getAccessToken: vi.fn().mockResolvedValue('fresh-access-token')},
|
|
105
|
+
jira,
|
|
106
|
+
webhookUrlForConnection: (id) =>
|
|
107
|
+
`https://shipfox.example.test/webhooks/integrations/jira/${id}`,
|
|
108
|
+
resolveConnection: vi.fn().mockResolvedValue({lifecycleStatus: 'active'}),
|
|
109
|
+
listInstallations: vi.fn().mockResolvedValue([due]),
|
|
110
|
+
registerWebhook,
|
|
111
|
+
now: () => now,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const result = await activities.renewJiraWebhooksActivity();
|
|
115
|
+
|
|
116
|
+
expect(registerWebhook).toHaveBeenCalledWith(
|
|
117
|
+
expect.objectContaining({
|
|
118
|
+
connectionId,
|
|
119
|
+
cloudId: due.cloudId,
|
|
120
|
+
accessToken: 'fresh-access-token',
|
|
121
|
+
webhookUrl: `https://shipfox.example.test/webhooks/integrations/jira/${connectionId}`,
|
|
122
|
+
replaceExistingWebhooks: true,
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
expect(result).toEqual({renewed: 0, reregistered: 1, skipped: 0, failed: 0});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('counts a failure and keeps sweeping when one connection cannot be renewed', async () => {
|
|
129
|
+
const brokenConnectionId = crypto.randomUUID();
|
|
130
|
+
const healthyConnectionId = crypto.randomUUID();
|
|
131
|
+
const expirationDate = new Date('2026-08-31T00:00:00.000Z');
|
|
132
|
+
const broken = installation(brokenConnectionId);
|
|
133
|
+
const healthy = installation(healthyConnectionId);
|
|
134
|
+
const refreshDynamicWebhooks = vi
|
|
135
|
+
.fn()
|
|
136
|
+
.mockImplementation(({cloudId}: {cloudId: string}) =>
|
|
137
|
+
cloudId === broken.cloudId
|
|
138
|
+
? Promise.reject(new Error('Jira rejected'))
|
|
139
|
+
: Promise.resolve(expirationDate),
|
|
140
|
+
);
|
|
141
|
+
const updateInstallation = vi.fn().mockResolvedValue({id: 'installation-1'});
|
|
142
|
+
const activities = createJiraWebhookRenewalActivities({
|
|
143
|
+
tokenStore: {getAccessToken: vi.fn().mockResolvedValue('fresh-access-token')},
|
|
144
|
+
jira: {
|
|
145
|
+
refreshDynamicWebhooks,
|
|
146
|
+
registerDynamicWebhook: vi.fn(),
|
|
147
|
+
deleteDynamicWebhook: vi.fn(),
|
|
148
|
+
},
|
|
149
|
+
webhookUrlForConnection: (id) =>
|
|
150
|
+
`https://shipfox.example.test/webhooks/integrations/jira/${id}`,
|
|
151
|
+
resolveConnection: vi.fn().mockResolvedValue({lifecycleStatus: 'active'}),
|
|
152
|
+
listInstallations: vi.fn().mockResolvedValue([broken, healthy]),
|
|
153
|
+
updateInstallation,
|
|
154
|
+
now: () => new Date('2026-08-01T00:00:00.000Z'),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const result = await activities.renewJiraWebhooksActivity();
|
|
158
|
+
|
|
159
|
+
expect(updateInstallation).toHaveBeenCalledOnce();
|
|
160
|
+
expect(updateInstallation).toHaveBeenCalledWith(
|
|
161
|
+
expect.objectContaining({connectionId: healthyConnectionId}),
|
|
162
|
+
);
|
|
163
|
+
expect(result).toEqual({renewed: 1, reregistered: 0, skipped: 0, failed: 1});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('fails the sweep entry when webhook renewal dependencies are not configured', async () => {
|
|
167
|
+
const due = installation(crypto.randomUUID());
|
|
168
|
+
const activities = createJiraWebhookRenewalActivities({
|
|
169
|
+
tokenStore: {getAccessToken: vi.fn()},
|
|
170
|
+
resolveConnection: vi.fn().mockResolvedValue({lifecycleStatus: 'active'}),
|
|
171
|
+
listInstallations: vi.fn().mockResolvedValue([due]),
|
|
172
|
+
now: () => new Date('2026-08-01T00:00:00.000Z'),
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
await expect(activities.renewJiraWebhooksActivity()).resolves.toEqual({
|
|
176
|
+
renewed: 0,
|
|
177
|
+
reregistered: 0,
|
|
178
|
+
skipped: 0,
|
|
179
|
+
failed: 1,
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('skips a stale refresh result when webhook metadata changed during the request', async () => {
|
|
184
|
+
const connectionId = crypto.randomUUID();
|
|
185
|
+
const due = installation(connectionId);
|
|
186
|
+
const updateInstallation = vi.fn().mockResolvedValue(undefined);
|
|
187
|
+
const activities = createJiraWebhookRenewalActivities({
|
|
188
|
+
tokenStore: {getAccessToken: vi.fn().mockResolvedValue('fresh-access-token')},
|
|
189
|
+
jira: {
|
|
190
|
+
refreshDynamicWebhooks: vi.fn().mockResolvedValue(new Date('2026-08-31T00:00:00.000Z')),
|
|
191
|
+
registerDynamicWebhook: vi.fn(),
|
|
192
|
+
deleteDynamicWebhook: vi.fn(),
|
|
193
|
+
},
|
|
194
|
+
webhookUrlForConnection: (id) => `https://shipfox.example.test/${id}`,
|
|
195
|
+
resolveConnection: vi.fn().mockResolvedValue({lifecycleStatus: 'active'}),
|
|
196
|
+
listInstallations: vi.fn().mockResolvedValue([due]),
|
|
197
|
+
updateInstallation,
|
|
198
|
+
now: () => new Date('2026-08-01T00:00:00.000Z'),
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
await expect(activities.renewJiraWebhooksActivity()).resolves.toEqual({
|
|
202
|
+
renewed: 0,
|
|
203
|
+
reregistered: 0,
|
|
204
|
+
skipped: 1,
|
|
205
|
+
failed: 0,
|
|
206
|
+
});
|
|
207
|
+
expect(updateInstallation).toHaveBeenCalledWith({
|
|
208
|
+
connectionId,
|
|
209
|
+
webhookIds: due.webhookIds,
|
|
210
|
+
webhookExpiresAt: new Date('2026-08-31T00:00:00.000Z'),
|
|
211
|
+
expectedWebhookIds: due.webhookIds,
|
|
212
|
+
expectedWebhookExpiresAt: due.webhookExpiresAt,
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
});
|