@shipfox/api-integration-linear 2.0.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 +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +89 -0
- package/LICENSE +21 -0
- package/README.md +88 -0
- package/dist/api/client.d.ts +29 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +189 -0
- package/dist/api/client.js.map +1 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +22 -0
- package/dist/config.js.map +1 -0
- package/dist/core/agent-tools-provider.d.ts +32 -0
- package/dist/core/agent-tools-provider.d.ts.map +1 -0
- package/dist/core/agent-tools-provider.js +63 -0
- package/dist/core/agent-tools-provider.js.map +1 -0
- package/dist/core/agent-tools.d.ts +10 -0
- package/dist/core/agent-tools.d.ts.map +1 -0
- package/dist/core/agent-tools.js +920 -0
- package/dist/core/agent-tools.js.map +1 -0
- package/dist/core/disconnect.d.ts +18 -0
- package/dist/core/disconnect.d.ts.map +1 -0
- package/dist/core/disconnect.js +23 -0
- package/dist/core/disconnect.js.map +1 -0
- package/dist/core/errors.d.ts +35 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +59 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/install.d.ts +47 -0
- package/dist/core/install.d.ts.map +1 -0
- package/dist/core/install.js +102 -0
- package/dist/core/install.js.map +1 -0
- package/dist/core/scopes.d.ts +4 -0
- package/dist/core/scopes.d.ts.map +1 -0
- package/dist/core/scopes.js +17 -0
- package/dist/core/scopes.js.map +1 -0
- package/dist/core/state.d.ts +12 -0
- package/dist/core/state.d.ts.map +1 -0
- package/dist/core/state.js +54 -0
- package/dist/core/state.js.map +1 -0
- package/dist/core/tokens.d.ts +39 -0
- package/dist/core/tokens.d.ts.map +1 -0
- package/dist/core/tokens.js +117 -0
- package/dist/core/tokens.js.map +1 -0
- package/dist/core/webhook.d.ts +16 -0
- package/dist/core/webhook.d.ts.map +1 -0
- package/dist/core/webhook.js +105 -0
- package/dist/core/webhook.js.map +1 -0
- package/dist/db/db.d.ts +369 -0
- package/dist/db/db.d.ts.map +1 -0
- package/dist/db/db.js +18 -0
- package/dist/db/db.js.map +1 -0
- package/dist/db/installations.d.ts +53 -0
- package/dist/db/installations.d.ts.map +1 -0
- package/dist/db/installations.js +125 -0
- package/dist/db/installations.js.map +1 -0
- package/dist/db/migrations.d.ts +2 -0
- package/dist/db/migrations.d.ts.map +1 -0
- package/dist/db/migrations.js +5 -0
- package/dist/db/migrations.js.map +1 -0
- package/dist/db/schema/common.d.ts +2 -0
- package/dist/db/schema/common.d.ts.map +1 -0
- package/dist/db/schema/common.js +4 -0
- package/dist/db/schema/common.js.map +1 -0
- package/dist/db/schema/installations.d.ts +186 -0
- package/dist/db/schema/installations.d.ts.map +1 -0
- package/dist/db/schema/installations.js +40 -0
- package/dist/db/schema/installations.js.map +1 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +56 -0
- package/dist/index.js.map +1 -0
- package/dist/presentation/dto/integrations.d.ts +17 -0
- package/dist/presentation/dto/integrations.d.ts.map +1 -0
- package/dist/presentation/dto/integrations.js +6 -0
- package/dist/presentation/dto/integrations.js.map +1 -0
- package/dist/presentation/e2eRoutes/create-connection.d.ts +16 -0
- package/dist/presentation/e2eRoutes/create-connection.d.ts.map +1 -0
- package/dist/presentation/e2eRoutes/create-connection.js +64 -0
- package/dist/presentation/e2eRoutes/create-connection.js.map +1 -0
- package/dist/presentation/e2eRoutes/index.d.ts +5 -0
- package/dist/presentation/e2eRoutes/index.d.ts.map +1 -0
- package/dist/presentation/e2eRoutes/index.js +11 -0
- package/dist/presentation/e2eRoutes/index.js.map +1 -0
- package/dist/presentation/routes/errors.d.ts +2 -0
- package/dist/presentation/routes/errors.d.ts.map +1 -0
- package/dist/presentation/routes/errors.js +65 -0
- package/dist/presentation/routes/errors.js.map +1 -0
- package/dist/presentation/routes/install.d.ts +19 -0
- package/dist/presentation/routes/install.d.ts.map +1 -0
- package/dist/presentation/routes/install.js +100 -0
- package/dist/presentation/routes/install.js.map +1 -0
- package/dist/presentation/routes/webhooks.d.ts +11 -0
- package/dist/presentation/routes/webhooks.d.ts.map +1 -0
- package/dist/presentation/routes/webhooks.js +136 -0
- package/dist/presentation/routes/webhooks.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/drizzle/0000_initial.sql +15 -0
- package/drizzle/meta/0000_snapshot.json +126 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +7 -0
- package/package.json +72 -0
- package/src/api/client.test.ts +366 -0
- package/src/api/client.ts +281 -0
- package/src/config.test.ts +34 -0
- package/src/config.ts +22 -0
- package/src/connection-external-url.test.ts +64 -0
- package/src/core/agent-tools-provider.test.ts +245 -0
- package/src/core/agent-tools-provider.ts +106 -0
- package/src/core/agent-tools.ts +918 -0
- package/src/core/disconnect.test.ts +45 -0
- package/src/core/disconnect.ts +27 -0
- package/src/core/errors.ts +71 -0
- package/src/core/install.ts +162 -0
- package/src/core/scopes.ts +18 -0
- package/src/core/state.test.ts +51 -0
- package/src/core/state.ts +85 -0
- package/src/core/tokens.test.ts +281 -0
- package/src/core/tokens.ts +219 -0
- package/src/core/webhook.test.ts +318 -0
- package/src/core/webhook.ts +146 -0
- package/src/db/db.ts +18 -0
- package/src/db/installations.test.ts +182 -0
- package/src/db/installations.ts +215 -0
- package/src/db/migrations.ts +4 -0
- package/src/db/schema/common.ts +3 -0
- package/src/db/schema/installations.ts +42 -0
- package/src/index.test.ts +20 -0
- package/src/index.ts +153 -0
- package/src/presentation/dto/integrations.ts +9 -0
- package/src/presentation/e2eRoutes/create-connection.ts +85 -0
- package/src/presentation/e2eRoutes/index.test.ts +193 -0
- package/src/presentation/e2eRoutes/index.ts +14 -0
- package/src/presentation/routes/errors.ts +60 -0
- package/src/presentation/routes/install.test.ts +460 -0
- package/src/presentation/routes/install.ts +132 -0
- package/src/presentation/routes/webhooks.test.ts +407 -0
- package/src/presentation/routes/webhooks.ts +154 -0
- package/test/api-secrets.d.ts +28 -0
- package/test/env.ts +13 -0
- package/test/factories/index.ts +1 -0
- package/test/globalSetup.ts +23 -0
- package/test/index.ts +1 -0
- package/test/setup.ts +17 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +9 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import {randomUUID} from 'node:crypto';
|
|
2
|
+
import type {
|
|
3
|
+
GetIntegrationConnectionByIdFn,
|
|
4
|
+
IntegrationConnection,
|
|
5
|
+
PublishIntegrationEventReceivedFn,
|
|
6
|
+
RecordDeliveryOnlyFn,
|
|
7
|
+
} from '@shipfox/api-integration-core-dto';
|
|
8
|
+
import type {LinearWebhookBaseEnvelopeDto} from '@shipfox/api-integration-linear-dto';
|
|
9
|
+
import {db} from '#db/db.js';
|
|
10
|
+
import {upsertLinearInstallation} from '#db/installations.js';
|
|
11
|
+
import {linearInstallations} from '#db/schema/installations.js';
|
|
12
|
+
import {handleLinearWebhook} from './webhook.js';
|
|
13
|
+
|
|
14
|
+
function fakeConnection(overrides: Partial<IntegrationConnection> = {}): IntegrationConnection {
|
|
15
|
+
return {
|
|
16
|
+
id: randomUUID(),
|
|
17
|
+
workspaceId: randomUUID(),
|
|
18
|
+
provider: 'linear',
|
|
19
|
+
externalAccountId: 'org-1',
|
|
20
|
+
slug: 'Linear_Acme',
|
|
21
|
+
displayName: 'Linear Acme',
|
|
22
|
+
lifecycleStatus: 'active',
|
|
23
|
+
createdAt: new Date(),
|
|
24
|
+
updatedAt: new Date(),
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function payload(overrides: Record<string, unknown> = {}) {
|
|
30
|
+
return {
|
|
31
|
+
action: 'create',
|
|
32
|
+
type: 'Issue',
|
|
33
|
+
organizationId: `org-${randomUUID()}`,
|
|
34
|
+
webhookTimestamp: Date.now(),
|
|
35
|
+
data: {id: 'issue-1'},
|
|
36
|
+
...overrides,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function agentSessionPayload(
|
|
41
|
+
overrides: Record<string, unknown> = {},
|
|
42
|
+
): LinearWebhookBaseEnvelopeDto {
|
|
43
|
+
return {
|
|
44
|
+
action: 'created',
|
|
45
|
+
type: 'AgentSessionEvent',
|
|
46
|
+
organizationId: `org-${randomUUID()}`,
|
|
47
|
+
appUserId: 'app-user-1',
|
|
48
|
+
webhookTimestamp: Date.now(),
|
|
49
|
+
agentSession: {id: 'session-1'},
|
|
50
|
+
...overrides,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function deps(
|
|
55
|
+
options: {
|
|
56
|
+
connection?: IntegrationConnection | undefined;
|
|
57
|
+
publishIntegrationEventReceivedResult?: {published: boolean};
|
|
58
|
+
} = {},
|
|
59
|
+
) {
|
|
60
|
+
return {
|
|
61
|
+
publishIntegrationEventReceived: vi.fn<PublishIntegrationEventReceivedFn>(() =>
|
|
62
|
+
Promise.resolve(options.publishIntegrationEventReceivedResult ?? {published: true}),
|
|
63
|
+
),
|
|
64
|
+
recordDeliveryOnly: vi.fn<RecordDeliveryOnlyFn>(() => Promise.resolve()),
|
|
65
|
+
getIntegrationConnectionById: vi.fn<GetIntegrationConnectionByIdFn>(() =>
|
|
66
|
+
Promise.resolve(options.connection ?? fakeConnection()),
|
|
67
|
+
),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function firstPublishIntegrationEventReceivedCall(publishIntegrationEventReceived: {
|
|
72
|
+
mock: {calls: Array<Parameters<PublishIntegrationEventReceivedFn>>};
|
|
73
|
+
}): Parameters<PublishIntegrationEventReceivedFn>[0] {
|
|
74
|
+
const [call] = publishIntegrationEventReceived.mock.calls;
|
|
75
|
+
if (!call) throw new Error('Expected publishIntegrationEventReceived to be called');
|
|
76
|
+
return call[0];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function firstRecordDeliveryOnlyCall(recordDeliveryOnly: {
|
|
80
|
+
mock: {calls: Array<Parameters<RecordDeliveryOnlyFn>>};
|
|
81
|
+
}): Parameters<RecordDeliveryOnlyFn>[0] {
|
|
82
|
+
const [call] = recordDeliveryOnly.mock.calls;
|
|
83
|
+
if (!call) throw new Error('Expected recordDeliveryOnly to be called');
|
|
84
|
+
return call[0];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function seedInstallation(input: {
|
|
88
|
+
connectionId: string;
|
|
89
|
+
organizationId: string;
|
|
90
|
+
status?: 'installed' | 'revoked' | undefined;
|
|
91
|
+
}): Promise<void> {
|
|
92
|
+
await upsertLinearInstallation({
|
|
93
|
+
connectionId: input.connectionId,
|
|
94
|
+
organizationId: input.organizationId,
|
|
95
|
+
organizationUrlKey: 'acme',
|
|
96
|
+
appUserId: 'app-user-1',
|
|
97
|
+
scopes: ['read', 'write'],
|
|
98
|
+
status: input.status ?? 'installed',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
describe('handleLinearWebhook', () => {
|
|
103
|
+
beforeEach(async () => {
|
|
104
|
+
await db().delete(linearInstallations);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('publishes a supported Linear data event for an active connection', async () => {
|
|
108
|
+
const connection = fakeConnection();
|
|
109
|
+
const deliveryId = randomUUID();
|
|
110
|
+
const rawPayload = payload({organizationId: 'org-active'});
|
|
111
|
+
await seedInstallation({connectionId: connection.id, organizationId: 'org-active'});
|
|
112
|
+
const handlers = deps({connection});
|
|
113
|
+
|
|
114
|
+
const result = await handleLinearWebhook({
|
|
115
|
+
tx: db(),
|
|
116
|
+
deliveryId,
|
|
117
|
+
payload: rawPayload,
|
|
118
|
+
rawPayload,
|
|
119
|
+
...handlers,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect(result.outcome).toBe('published');
|
|
123
|
+
expect(handlers.recordDeliveryOnly).not.toHaveBeenCalled();
|
|
124
|
+
expect(
|
|
125
|
+
firstPublishIntegrationEventReceivedCall(handlers.publishIntegrationEventReceived),
|
|
126
|
+
).toMatchObject({
|
|
127
|
+
event: {
|
|
128
|
+
provider: 'linear',
|
|
129
|
+
source: connection.slug,
|
|
130
|
+
event: 'Issue.create',
|
|
131
|
+
workspaceId: connection.workspaceId,
|
|
132
|
+
connectionId: connection.id,
|
|
133
|
+
connectionName: connection.displayName,
|
|
134
|
+
deliveryId,
|
|
135
|
+
payload: rawPayload,
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('returns duplicate when the delivery was already published', async () => {
|
|
141
|
+
const connection = fakeConnection();
|
|
142
|
+
const rawPayload = payload({organizationId: 'org-duplicate'});
|
|
143
|
+
await seedInstallation({connectionId: connection.id, organizationId: 'org-duplicate'});
|
|
144
|
+
const handlers = deps({
|
|
145
|
+
connection,
|
|
146
|
+
publishIntegrationEventReceivedResult: {published: false},
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const result = await handleLinearWebhook({
|
|
150
|
+
tx: db(),
|
|
151
|
+
deliveryId: randomUUID(),
|
|
152
|
+
payload: rawPayload,
|
|
153
|
+
rawPayload,
|
|
154
|
+
...handlers,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
expect(result.outcome).toBe('duplicate');
|
|
158
|
+
expect(handlers.publishIntegrationEventReceived).toHaveBeenCalledTimes(1);
|
|
159
|
+
expect(handlers.recordDeliveryOnly).not.toHaveBeenCalled();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it.each([
|
|
163
|
+
['created', 'agentSession.created'],
|
|
164
|
+
['prompted', 'agentSession.prompted'],
|
|
165
|
+
] as const)('publishes AgentSessionEvent %s as %s', async (action, event) => {
|
|
166
|
+
const connection = fakeConnection();
|
|
167
|
+
const rawPayload = agentSessionPayload({
|
|
168
|
+
action,
|
|
169
|
+
organizationId: `org-${action}`,
|
|
170
|
+
promptContext: '<issue identifier="ENG-879">Route the complete payload</issue>',
|
|
171
|
+
});
|
|
172
|
+
await seedInstallation({connectionId: connection.id, organizationId: `org-${action}`});
|
|
173
|
+
const handlers = deps({connection});
|
|
174
|
+
|
|
175
|
+
const result = await handleLinearWebhook({
|
|
176
|
+
tx: db(),
|
|
177
|
+
deliveryId: randomUUID(),
|
|
178
|
+
payload: rawPayload,
|
|
179
|
+
rawPayload,
|
|
180
|
+
...handlers,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
expect(result.outcome).toBe('published');
|
|
184
|
+
const published = firstPublishIntegrationEventReceivedCall(
|
|
185
|
+
handlers.publishIntegrationEventReceived,
|
|
186
|
+
);
|
|
187
|
+
expect(published.event.event).toBe(event);
|
|
188
|
+
expect(published.event.payload).toEqual(rawPayload);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('records the delivery only for an unknown organization', async () => {
|
|
192
|
+
const handlers = deps();
|
|
193
|
+
const deliveryId = randomUUID();
|
|
194
|
+
|
|
195
|
+
const result = await handleLinearWebhook({
|
|
196
|
+
tx: db(),
|
|
197
|
+
deliveryId,
|
|
198
|
+
payload: payload({organizationId: 'org-missing'}),
|
|
199
|
+
rawPayload: payload({organizationId: 'org-missing'}),
|
|
200
|
+
...handlers,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
expect(result.outcome).toBe('unknown-organization');
|
|
204
|
+
expect(handlers.getIntegrationConnectionById).not.toHaveBeenCalled();
|
|
205
|
+
expect(handlers.publishIntegrationEventReceived).not.toHaveBeenCalled();
|
|
206
|
+
expect(firstRecordDeliveryOnlyCall(handlers.recordDeliveryOnly)).toMatchObject({
|
|
207
|
+
provider: 'linear',
|
|
208
|
+
deliveryId,
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('records the delivery only for a revoked installation', async () => {
|
|
213
|
+
const connection = fakeConnection();
|
|
214
|
+
const rawPayload = payload({organizationId: 'org-revoked'});
|
|
215
|
+
await seedInstallation({
|
|
216
|
+
connectionId: connection.id,
|
|
217
|
+
organizationId: 'org-revoked',
|
|
218
|
+
status: 'revoked',
|
|
219
|
+
});
|
|
220
|
+
const handlers = deps({connection});
|
|
221
|
+
|
|
222
|
+
const result = await handleLinearWebhook({
|
|
223
|
+
tx: db(),
|
|
224
|
+
deliveryId: randomUUID(),
|
|
225
|
+
payload: rawPayload,
|
|
226
|
+
rawPayload,
|
|
227
|
+
...handlers,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
expect(result.outcome).toBe('revoked-installation');
|
|
231
|
+
expect(handlers.getIntegrationConnectionById).not.toHaveBeenCalled();
|
|
232
|
+
expect(handlers.publishIntegrationEventReceived).not.toHaveBeenCalled();
|
|
233
|
+
expect(handlers.recordDeliveryOnly).toHaveBeenCalledTimes(1);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('records the delivery only when the installation has no connection', async () => {
|
|
237
|
+
const connection = fakeConnection();
|
|
238
|
+
const rawPayload = payload({organizationId: 'org-dangling'});
|
|
239
|
+
await seedInstallation({connectionId: connection.id, organizationId: 'org-dangling'});
|
|
240
|
+
const handlers = deps({connection});
|
|
241
|
+
handlers.getIntegrationConnectionById.mockResolvedValue(undefined);
|
|
242
|
+
|
|
243
|
+
const result = await handleLinearWebhook({
|
|
244
|
+
tx: db(),
|
|
245
|
+
deliveryId: randomUUID(),
|
|
246
|
+
payload: rawPayload,
|
|
247
|
+
rawPayload,
|
|
248
|
+
...handlers,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
expect(result.outcome).toBe('missing-connection');
|
|
252
|
+
expect(handlers.publishIntegrationEventReceived).not.toHaveBeenCalled();
|
|
253
|
+
expect(handlers.recordDeliveryOnly).toHaveBeenCalledTimes(1);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it.each([
|
|
257
|
+
'disabled',
|
|
258
|
+
'error',
|
|
259
|
+
] as const)('records the delivery only when the connection is %s', async (lifecycleStatus) => {
|
|
260
|
+
const connection = fakeConnection({lifecycleStatus});
|
|
261
|
+
const rawPayload = payload({organizationId: `org-${lifecycleStatus}`});
|
|
262
|
+
await seedInstallation({
|
|
263
|
+
connectionId: connection.id,
|
|
264
|
+
organizationId: `org-${lifecycleStatus}`,
|
|
265
|
+
});
|
|
266
|
+
const handlers = deps({connection});
|
|
267
|
+
|
|
268
|
+
const result = await handleLinearWebhook({
|
|
269
|
+
tx: db(),
|
|
270
|
+
deliveryId: randomUUID(),
|
|
271
|
+
payload: rawPayload,
|
|
272
|
+
rawPayload,
|
|
273
|
+
...handlers,
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
expect(result.outcome).toBe('inactive-connection');
|
|
277
|
+
expect(handlers.publishIntegrationEventReceived).not.toHaveBeenCalled();
|
|
278
|
+
expect(handlers.recordDeliveryOnly).toHaveBeenCalledTimes(1);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('records the delivery only for unsupported resources', async () => {
|
|
282
|
+
const connection = fakeConnection();
|
|
283
|
+
const rawPayload = payload({organizationId: 'org-reaction', type: 'Reaction'});
|
|
284
|
+
await seedInstallation({connectionId: connection.id, organizationId: 'org-reaction'});
|
|
285
|
+
const handlers = deps({connection});
|
|
286
|
+
|
|
287
|
+
const result = await handleLinearWebhook({
|
|
288
|
+
tx: db(),
|
|
289
|
+
deliveryId: randomUUID(),
|
|
290
|
+
payload: rawPayload,
|
|
291
|
+
rawPayload,
|
|
292
|
+
...handlers,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
expect(result.outcome).toBe('unsupported-event');
|
|
296
|
+
expect(handlers.publishIntegrationEventReceived).not.toHaveBeenCalled();
|
|
297
|
+
expect(handlers.recordDeliveryOnly).toHaveBeenCalledTimes(1);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('records and drops an unsupported AgentSessionEvent action', async () => {
|
|
301
|
+
const connection = fakeConnection();
|
|
302
|
+
const rawPayload = agentSessionPayload({action: 'resolved', organizationId: 'org-resolved'});
|
|
303
|
+
await seedInstallation({connectionId: connection.id, organizationId: 'org-resolved'});
|
|
304
|
+
const handlers = deps({connection});
|
|
305
|
+
|
|
306
|
+
const result = await handleLinearWebhook({
|
|
307
|
+
tx: db(),
|
|
308
|
+
deliveryId: randomUUID(),
|
|
309
|
+
payload: rawPayload,
|
|
310
|
+
rawPayload,
|
|
311
|
+
...handlers,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
expect(result.outcome).toBe('unsupported-event');
|
|
315
|
+
expect(handlers.publishIntegrationEventReceived).not.toHaveBeenCalled();
|
|
316
|
+
expect(handlers.recordDeliveryOnly).toHaveBeenCalledTimes(1);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
GetIntegrationConnectionByIdFn,
|
|
3
|
+
IntegrationTx,
|
|
4
|
+
PublishIntegrationEventReceivedFn,
|
|
5
|
+
RecordDeliveryOnlyFn,
|
|
6
|
+
} from '@shipfox/api-integration-core-dto';
|
|
7
|
+
import {
|
|
8
|
+
LINEAR_PROVIDER,
|
|
9
|
+
type LinearWebhookBaseEnvelopeDto,
|
|
10
|
+
type LinearWebhookEnvelopeDto,
|
|
11
|
+
type LinearWebhookEventName,
|
|
12
|
+
linearWebhookEnvelopeSchema,
|
|
13
|
+
} from '@shipfox/api-integration-linear-dto';
|
|
14
|
+
import {logger} from '@shipfox/node-opentelemetry';
|
|
15
|
+
import {getLinearInstallationByOrganizationId} from '#db/installations.js';
|
|
16
|
+
|
|
17
|
+
export interface HandleLinearWebhookParams {
|
|
18
|
+
tx: IntegrationTx;
|
|
19
|
+
deliveryId: string;
|
|
20
|
+
payload: LinearWebhookBaseEnvelopeDto;
|
|
21
|
+
rawPayload: unknown;
|
|
22
|
+
publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
|
|
23
|
+
recordDeliveryOnly: RecordDeliveryOnlyFn;
|
|
24
|
+
getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type HandleLinearWebhookOutcome =
|
|
28
|
+
| 'published'
|
|
29
|
+
| 'duplicate'
|
|
30
|
+
| 'unknown-organization'
|
|
31
|
+
| 'revoked-installation'
|
|
32
|
+
| 'missing-connection'
|
|
33
|
+
| 'inactive-connection'
|
|
34
|
+
| 'unsupported-event';
|
|
35
|
+
|
|
36
|
+
export async function handleLinearWebhook(
|
|
37
|
+
params: HandleLinearWebhookParams,
|
|
38
|
+
): Promise<{outcome: HandleLinearWebhookOutcome}> {
|
|
39
|
+
const installation = await getLinearInstallationByOrganizationId(params.payload.organizationId, {
|
|
40
|
+
tx: params.tx,
|
|
41
|
+
});
|
|
42
|
+
if (!installation) {
|
|
43
|
+
logger().warn(
|
|
44
|
+
{deliveryId: params.deliveryId, organizationId: params.payload.organizationId},
|
|
45
|
+
'linear webhook: unknown organization, dropping',
|
|
46
|
+
);
|
|
47
|
+
await recordDeliveryOnly(params);
|
|
48
|
+
return {outcome: 'unknown-organization'};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (installation.status !== 'installed') {
|
|
52
|
+
logger().info(
|
|
53
|
+
{
|
|
54
|
+
deliveryId: params.deliveryId,
|
|
55
|
+
organizationId: params.payload.organizationId,
|
|
56
|
+
connectionId: installation.connectionId,
|
|
57
|
+
status: installation.status,
|
|
58
|
+
},
|
|
59
|
+
'linear webhook: installation is not installed, dropping',
|
|
60
|
+
);
|
|
61
|
+
await recordDeliveryOnly(params);
|
|
62
|
+
return {outcome: 'revoked-installation'};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const connection = await params.getIntegrationConnectionById(installation.connectionId, {
|
|
66
|
+
tx: params.tx,
|
|
67
|
+
});
|
|
68
|
+
if (!connection) {
|
|
69
|
+
logger().warn(
|
|
70
|
+
{
|
|
71
|
+
deliveryId: params.deliveryId,
|
|
72
|
+
organizationId: params.payload.organizationId,
|
|
73
|
+
connectionId: installation.connectionId,
|
|
74
|
+
},
|
|
75
|
+
'linear webhook: installation has no connection, dropping',
|
|
76
|
+
);
|
|
77
|
+
await recordDeliveryOnly(params);
|
|
78
|
+
return {outcome: 'missing-connection'};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (connection.lifecycleStatus !== 'active') {
|
|
82
|
+
const logContext = {
|
|
83
|
+
deliveryId: params.deliveryId,
|
|
84
|
+
organizationId: params.payload.organizationId,
|
|
85
|
+
connectionId: connection.id,
|
|
86
|
+
workspaceId: connection.workspaceId,
|
|
87
|
+
lifecycleStatus: connection.lifecycleStatus,
|
|
88
|
+
};
|
|
89
|
+
if (connection.lifecycleStatus === 'error') {
|
|
90
|
+
logger().warn(logContext, 'linear webhook: connection in error state, dropping');
|
|
91
|
+
} else {
|
|
92
|
+
logger().info(logContext, 'linear webhook: connection disabled, dropping');
|
|
93
|
+
}
|
|
94
|
+
await recordDeliveryOnly(params);
|
|
95
|
+
return {outcome: 'inactive-connection'};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const supported = linearWebhookEnvelopeSchema.safeParse(params.payload);
|
|
99
|
+
if (!supported.success) {
|
|
100
|
+
logger().info(
|
|
101
|
+
{
|
|
102
|
+
deliveryId: params.deliveryId,
|
|
103
|
+
organizationId: params.payload.organizationId,
|
|
104
|
+
type: params.payload.type,
|
|
105
|
+
action: params.payload.action,
|
|
106
|
+
},
|
|
107
|
+
'linear webhook: unsupported event, dropping',
|
|
108
|
+
);
|
|
109
|
+
await recordDeliveryOnly(params);
|
|
110
|
+
return {outcome: 'unsupported-event'};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const result = await params.publishIntegrationEventReceived({
|
|
114
|
+
tx: params.tx,
|
|
115
|
+
event: {
|
|
116
|
+
provider: LINEAR_PROVIDER,
|
|
117
|
+
source: connection.slug,
|
|
118
|
+
event: linearWebhookEventName(supported.data),
|
|
119
|
+
workspaceId: connection.workspaceId,
|
|
120
|
+
connectionId: connection.id,
|
|
121
|
+
connectionName: connection.displayName,
|
|
122
|
+
deliveryId: params.deliveryId,
|
|
123
|
+
receivedAt: new Date().toISOString(),
|
|
124
|
+
payload: params.rawPayload,
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return {outcome: result.published ? 'published' : 'duplicate'};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function linearWebhookEventName(payload: LinearWebhookEnvelopeDto): LinearWebhookEventName {
|
|
132
|
+
if (payload.type === 'AgentSessionEvent') return `agentSession.${payload.action}`;
|
|
133
|
+
return `${payload.type}.${payload.action}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function recordDeliveryOnly(params: {
|
|
137
|
+
tx: IntegrationTx;
|
|
138
|
+
deliveryId: string;
|
|
139
|
+
recordDeliveryOnly: RecordDeliveryOnlyFn;
|
|
140
|
+
}): Promise<void> {
|
|
141
|
+
await params.recordDeliveryOnly({
|
|
142
|
+
tx: params.tx,
|
|
143
|
+
provider: LINEAR_PROVIDER,
|
|
144
|
+
deliveryId: params.deliveryId,
|
|
145
|
+
});
|
|
146
|
+
}
|
package/src/db/db.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import {drizzle, type NodePgDatabase} from '@shipfox/node-drizzle';
|
|
2
|
+
import {pgClient} from '@shipfox/node-postgres';
|
|
3
|
+
import {linearInstallations} from './schema/installations.js';
|
|
4
|
+
|
|
5
|
+
export const schema = {
|
|
6
|
+
linearInstallations,
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
let _db: NodePgDatabase<typeof schema> | undefined;
|
|
10
|
+
|
|
11
|
+
export function db() {
|
|
12
|
+
if (!_db) _db = drizzle(pgClient(), {schema});
|
|
13
|
+
return _db;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function closeDb(): void {
|
|
17
|
+
_db = undefined;
|
|
18
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import {
|
|
2
|
+
LinearConnectionAlreadyLinkedError,
|
|
3
|
+
LinearInstallationAlreadyLinkedError,
|
|
4
|
+
} from '#core/errors.js';
|
|
5
|
+
import {
|
|
6
|
+
getLinearInstallationByConnectionId,
|
|
7
|
+
getLinearInstallationByOrganizationId,
|
|
8
|
+
markLinearInstallationRevoked,
|
|
9
|
+
upsertLinearInstallation,
|
|
10
|
+
withLinearRefreshLock,
|
|
11
|
+
} from './installations.js';
|
|
12
|
+
|
|
13
|
+
describe('linear installations', () => {
|
|
14
|
+
it('upserts and reads an installation by connection id', async () => {
|
|
15
|
+
const connectionId = crypto.randomUUID();
|
|
16
|
+
const organizationId = `org-${crypto.randomUUID()}`;
|
|
17
|
+
const tokenExpiresAt = new Date('2026-08-01T00:00:00.000Z');
|
|
18
|
+
|
|
19
|
+
const installation = await upsertLinearInstallation({
|
|
20
|
+
connectionId,
|
|
21
|
+
organizationId,
|
|
22
|
+
organizationUrlKey: 'acme',
|
|
23
|
+
appUserId: 'app-user-1',
|
|
24
|
+
scopes: ['read', 'write'],
|
|
25
|
+
tokenExpiresAt,
|
|
26
|
+
status: 'installed',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const result = await getLinearInstallationByConnectionId(connectionId);
|
|
30
|
+
expect(result).toEqual(installation);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('updates mutable metadata for an existing connection id', async () => {
|
|
34
|
+
const connectionId = crypto.randomUUID();
|
|
35
|
+
const organizationId = `org-${crypto.randomUUID()}`;
|
|
36
|
+
await upsertLinearInstallation({
|
|
37
|
+
connectionId,
|
|
38
|
+
organizationId,
|
|
39
|
+
organizationUrlKey: 'old',
|
|
40
|
+
appUserId: 'app-user-2',
|
|
41
|
+
scopes: ['read'],
|
|
42
|
+
status: 'installed',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const result = await upsertLinearInstallation({
|
|
46
|
+
connectionId,
|
|
47
|
+
organizationId,
|
|
48
|
+
organizationUrlKey: 'new',
|
|
49
|
+
appUserId: 'app-user-3',
|
|
50
|
+
scopes: ['read', 'write', 'app:assignable'],
|
|
51
|
+
tokenExpiresAt: null,
|
|
52
|
+
status: 'installed',
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(result.organizationUrlKey).toBe('new');
|
|
56
|
+
expect(result.appUserId).toBe('app-user-3');
|
|
57
|
+
expect(result.scopes).toEqual(['read', 'write', 'app:assignable']);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('refuses to claim an organization already linked to another connection', async () => {
|
|
61
|
+
const organizationId = `org-${crypto.randomUUID()}`;
|
|
62
|
+
const firstConnectionId = crypto.randomUUID();
|
|
63
|
+
const secondConnectionId = crypto.randomUUID();
|
|
64
|
+
await upsertLinearInstallation({
|
|
65
|
+
connectionId: firstConnectionId,
|
|
66
|
+
organizationId,
|
|
67
|
+
organizationUrlKey: 'claimed',
|
|
68
|
+
appUserId: 'app-user-claimed',
|
|
69
|
+
scopes: ['read'],
|
|
70
|
+
status: 'installed',
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
let error: unknown;
|
|
74
|
+
try {
|
|
75
|
+
await upsertLinearInstallation({
|
|
76
|
+
connectionId: secondConnectionId,
|
|
77
|
+
organizationId,
|
|
78
|
+
organizationUrlKey: 'claimed',
|
|
79
|
+
appUserId: 'app-user-claimed',
|
|
80
|
+
scopes: ['read', 'write'],
|
|
81
|
+
status: 'installed',
|
|
82
|
+
});
|
|
83
|
+
} catch (caught) {
|
|
84
|
+
error = caught;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const installation = await getLinearInstallationByOrganizationId(organizationId);
|
|
88
|
+
|
|
89
|
+
expect(error).toBeInstanceOf(LinearInstallationAlreadyLinkedError);
|
|
90
|
+
expect(installation?.connectionId).toBe(firstConnectionId);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('refuses to repoint an existing connection to another organization', async () => {
|
|
94
|
+
const connectionId = crypto.randomUUID();
|
|
95
|
+
const firstOrganizationId = `org-${crypto.randomUUID()}`;
|
|
96
|
+
const secondOrganizationId = `org-${crypto.randomUUID()}`;
|
|
97
|
+
await upsertLinearInstallation({
|
|
98
|
+
connectionId,
|
|
99
|
+
organizationId: firstOrganizationId,
|
|
100
|
+
organizationUrlKey: 'first',
|
|
101
|
+
appUserId: 'app-user-first',
|
|
102
|
+
scopes: ['read'],
|
|
103
|
+
status: 'installed',
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
let error: unknown;
|
|
107
|
+
try {
|
|
108
|
+
await upsertLinearInstallation({
|
|
109
|
+
connectionId,
|
|
110
|
+
organizationId: secondOrganizationId,
|
|
111
|
+
organizationUrlKey: 'second',
|
|
112
|
+
appUserId: 'app-user-second',
|
|
113
|
+
scopes: ['read', 'write'],
|
|
114
|
+
status: 'installed',
|
|
115
|
+
});
|
|
116
|
+
} catch (caught) {
|
|
117
|
+
error = caught;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const installation = await getLinearInstallationByConnectionId(connectionId);
|
|
121
|
+
|
|
122
|
+
expect(error).toBeInstanceOf(LinearConnectionAlreadyLinkedError);
|
|
123
|
+
expect(installation?.organizationId).toBe(firstOrganizationId);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('reads an installation by organization id', async () => {
|
|
127
|
+
const connectionId = crypto.randomUUID();
|
|
128
|
+
const organizationId = `org-${crypto.randomUUID()}`;
|
|
129
|
+
await upsertLinearInstallation({
|
|
130
|
+
connectionId,
|
|
131
|
+
organizationId,
|
|
132
|
+
organizationUrlKey: 'lookup',
|
|
133
|
+
appUserId: 'app-user-lookup',
|
|
134
|
+
scopes: ['read'],
|
|
135
|
+
status: 'installed',
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const result = await getLinearInstallationByOrganizationId(organizationId);
|
|
139
|
+
|
|
140
|
+
expect(result?.connectionId).toBe(connectionId);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('marks an installation revoked by connection id', async () => {
|
|
144
|
+
const connectionId = crypto.randomUUID();
|
|
145
|
+
await upsertLinearInstallation({
|
|
146
|
+
connectionId,
|
|
147
|
+
organizationId: `org-${crypto.randomUUID()}`,
|
|
148
|
+
organizationUrlKey: 'revoked',
|
|
149
|
+
appUserId: 'app-user-revoked',
|
|
150
|
+
scopes: ['read'],
|
|
151
|
+
status: 'installed',
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const result = await markLinearInstallationRevoked(connectionId);
|
|
155
|
+
|
|
156
|
+
expect(result?.status).toBe('revoked');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('allows one refresh lock holder per connection and fails contenders fast', async () => {
|
|
160
|
+
const connectionId = crypto.randomUUID();
|
|
161
|
+
let releaseLock: () => void = () => {
|
|
162
|
+
throw new Error('releaseLock was not initialized');
|
|
163
|
+
};
|
|
164
|
+
const holder = withLinearRefreshLock(
|
|
165
|
+
connectionId,
|
|
166
|
+
() =>
|
|
167
|
+
new Promise<string>((resolve) => {
|
|
168
|
+
releaseLock = () => resolve('holder');
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
173
|
+
const contender = await withLinearRefreshLock(connectionId, async () => 'contender');
|
|
174
|
+
const different = await withLinearRefreshLock(crypto.randomUUID(), async () => 'different');
|
|
175
|
+
releaseLock();
|
|
176
|
+
const held = await holder;
|
|
177
|
+
|
|
178
|
+
expect(contender).toEqual({acquired: false});
|
|
179
|
+
expect(different).toEqual({acquired: true, value: 'different'});
|
|
180
|
+
expect(held).toEqual({acquired: true, value: 'holder'});
|
|
181
|
+
});
|
|
182
|
+
});
|