@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.
Files changed (151) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/.turbo/turbo-type$colon$emit.log +1 -0
  3. package/.turbo/turbo-type.log +1 -0
  4. package/CHANGELOG.md +89 -0
  5. package/LICENSE +21 -0
  6. package/README.md +88 -0
  7. package/dist/api/client.d.ts +29 -0
  8. package/dist/api/client.d.ts.map +1 -0
  9. package/dist/api/client.js +189 -0
  10. package/dist/api/client.js.map +1 -0
  11. package/dist/config.d.ts +8 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/config.js +22 -0
  14. package/dist/config.js.map +1 -0
  15. package/dist/core/agent-tools-provider.d.ts +32 -0
  16. package/dist/core/agent-tools-provider.d.ts.map +1 -0
  17. package/dist/core/agent-tools-provider.js +63 -0
  18. package/dist/core/agent-tools-provider.js.map +1 -0
  19. package/dist/core/agent-tools.d.ts +10 -0
  20. package/dist/core/agent-tools.d.ts.map +1 -0
  21. package/dist/core/agent-tools.js +920 -0
  22. package/dist/core/agent-tools.js.map +1 -0
  23. package/dist/core/disconnect.d.ts +18 -0
  24. package/dist/core/disconnect.d.ts.map +1 -0
  25. package/dist/core/disconnect.js +23 -0
  26. package/dist/core/disconnect.js.map +1 -0
  27. package/dist/core/errors.d.ts +35 -0
  28. package/dist/core/errors.d.ts.map +1 -0
  29. package/dist/core/errors.js +59 -0
  30. package/dist/core/errors.js.map +1 -0
  31. package/dist/core/install.d.ts +47 -0
  32. package/dist/core/install.d.ts.map +1 -0
  33. package/dist/core/install.js +102 -0
  34. package/dist/core/install.js.map +1 -0
  35. package/dist/core/scopes.d.ts +4 -0
  36. package/dist/core/scopes.d.ts.map +1 -0
  37. package/dist/core/scopes.js +17 -0
  38. package/dist/core/scopes.js.map +1 -0
  39. package/dist/core/state.d.ts +12 -0
  40. package/dist/core/state.d.ts.map +1 -0
  41. package/dist/core/state.js +54 -0
  42. package/dist/core/state.js.map +1 -0
  43. package/dist/core/tokens.d.ts +39 -0
  44. package/dist/core/tokens.d.ts.map +1 -0
  45. package/dist/core/tokens.js +117 -0
  46. package/dist/core/tokens.js.map +1 -0
  47. package/dist/core/webhook.d.ts +16 -0
  48. package/dist/core/webhook.d.ts.map +1 -0
  49. package/dist/core/webhook.js +105 -0
  50. package/dist/core/webhook.js.map +1 -0
  51. package/dist/db/db.d.ts +369 -0
  52. package/dist/db/db.d.ts.map +1 -0
  53. package/dist/db/db.js +18 -0
  54. package/dist/db/db.js.map +1 -0
  55. package/dist/db/installations.d.ts +53 -0
  56. package/dist/db/installations.d.ts.map +1 -0
  57. package/dist/db/installations.js +125 -0
  58. package/dist/db/installations.js.map +1 -0
  59. package/dist/db/migrations.d.ts +2 -0
  60. package/dist/db/migrations.d.ts.map +1 -0
  61. package/dist/db/migrations.js +5 -0
  62. package/dist/db/migrations.js.map +1 -0
  63. package/dist/db/schema/common.d.ts +2 -0
  64. package/dist/db/schema/common.d.ts.map +1 -0
  65. package/dist/db/schema/common.js +4 -0
  66. package/dist/db/schema/common.js.map +1 -0
  67. package/dist/db/schema/installations.d.ts +186 -0
  68. package/dist/db/schema/installations.d.ts.map +1 -0
  69. package/dist/db/schema/installations.js +40 -0
  70. package/dist/db/schema/installations.js.map +1 -0
  71. package/dist/index.d.ts +53 -0
  72. package/dist/index.d.ts.map +1 -0
  73. package/dist/index.js +56 -0
  74. package/dist/index.js.map +1 -0
  75. package/dist/presentation/dto/integrations.d.ts +17 -0
  76. package/dist/presentation/dto/integrations.d.ts.map +1 -0
  77. package/dist/presentation/dto/integrations.js +6 -0
  78. package/dist/presentation/dto/integrations.js.map +1 -0
  79. package/dist/presentation/e2eRoutes/create-connection.d.ts +16 -0
  80. package/dist/presentation/e2eRoutes/create-connection.d.ts.map +1 -0
  81. package/dist/presentation/e2eRoutes/create-connection.js +64 -0
  82. package/dist/presentation/e2eRoutes/create-connection.js.map +1 -0
  83. package/dist/presentation/e2eRoutes/index.d.ts +5 -0
  84. package/dist/presentation/e2eRoutes/index.d.ts.map +1 -0
  85. package/dist/presentation/e2eRoutes/index.js +11 -0
  86. package/dist/presentation/e2eRoutes/index.js.map +1 -0
  87. package/dist/presentation/routes/errors.d.ts +2 -0
  88. package/dist/presentation/routes/errors.d.ts.map +1 -0
  89. package/dist/presentation/routes/errors.js +65 -0
  90. package/dist/presentation/routes/errors.js.map +1 -0
  91. package/dist/presentation/routes/install.d.ts +19 -0
  92. package/dist/presentation/routes/install.d.ts.map +1 -0
  93. package/dist/presentation/routes/install.js +100 -0
  94. package/dist/presentation/routes/install.js.map +1 -0
  95. package/dist/presentation/routes/webhooks.d.ts +11 -0
  96. package/dist/presentation/routes/webhooks.d.ts.map +1 -0
  97. package/dist/presentation/routes/webhooks.js +136 -0
  98. package/dist/presentation/routes/webhooks.js.map +1 -0
  99. package/dist/tsconfig.test.tsbuildinfo +1 -0
  100. package/drizzle/0000_initial.sql +15 -0
  101. package/drizzle/meta/0000_snapshot.json +126 -0
  102. package/drizzle/meta/_journal.json +13 -0
  103. package/drizzle.config.ts +7 -0
  104. package/package.json +72 -0
  105. package/src/api/client.test.ts +366 -0
  106. package/src/api/client.ts +281 -0
  107. package/src/config.test.ts +34 -0
  108. package/src/config.ts +22 -0
  109. package/src/connection-external-url.test.ts +64 -0
  110. package/src/core/agent-tools-provider.test.ts +245 -0
  111. package/src/core/agent-tools-provider.ts +106 -0
  112. package/src/core/agent-tools.ts +918 -0
  113. package/src/core/disconnect.test.ts +45 -0
  114. package/src/core/disconnect.ts +27 -0
  115. package/src/core/errors.ts +71 -0
  116. package/src/core/install.ts +162 -0
  117. package/src/core/scopes.ts +18 -0
  118. package/src/core/state.test.ts +51 -0
  119. package/src/core/state.ts +85 -0
  120. package/src/core/tokens.test.ts +281 -0
  121. package/src/core/tokens.ts +219 -0
  122. package/src/core/webhook.test.ts +318 -0
  123. package/src/core/webhook.ts +146 -0
  124. package/src/db/db.ts +18 -0
  125. package/src/db/installations.test.ts +182 -0
  126. package/src/db/installations.ts +215 -0
  127. package/src/db/migrations.ts +4 -0
  128. package/src/db/schema/common.ts +3 -0
  129. package/src/db/schema/installations.ts +42 -0
  130. package/src/index.test.ts +20 -0
  131. package/src/index.ts +153 -0
  132. package/src/presentation/dto/integrations.ts +9 -0
  133. package/src/presentation/e2eRoutes/create-connection.ts +85 -0
  134. package/src/presentation/e2eRoutes/index.test.ts +193 -0
  135. package/src/presentation/e2eRoutes/index.ts +14 -0
  136. package/src/presentation/routes/errors.ts +60 -0
  137. package/src/presentation/routes/install.test.ts +460 -0
  138. package/src/presentation/routes/install.ts +132 -0
  139. package/src/presentation/routes/webhooks.test.ts +407 -0
  140. package/src/presentation/routes/webhooks.ts +154 -0
  141. package/test/api-secrets.d.ts +28 -0
  142. package/test/env.ts +13 -0
  143. package/test/factories/index.ts +1 -0
  144. package/test/globalSetup.ts +23 -0
  145. package/test/index.ts +1 -0
  146. package/test/setup.ts +17 -0
  147. package/tsconfig.build.json +9 -0
  148. package/tsconfig.build.tsbuildinfo +1 -0
  149. package/tsconfig.json +3 -0
  150. package/tsconfig.test.json +9 -0
  151. package/vitest.config.ts +12 -0
@@ -0,0 +1,215 @@
1
+ import {pgClient} from '@shipfox/node-postgres';
2
+ import {eq} from 'drizzle-orm';
3
+ import {
4
+ LinearConnectionAlreadyLinkedError,
5
+ LinearInstallationAlreadyLinkedError,
6
+ } from '#core/errors.js';
7
+ import {db} from './db.js';
8
+ import {linearInstallations, toLinearInstallation} from './schema/installations.js';
9
+
10
+ export type LinearInstallationStatus = 'installed' | 'revoked';
11
+
12
+ export interface LinearInstallation {
13
+ id: string;
14
+ connectionId: string;
15
+ organizationId: string;
16
+ organizationUrlKey: string;
17
+ appUserId: string;
18
+ scopes: string[];
19
+ tokenExpiresAt: Date | null;
20
+ status: LinearInstallationStatus;
21
+ createdAt: Date;
22
+ updatedAt: Date;
23
+ }
24
+
25
+ export interface UpsertLinearInstallationParams {
26
+ connectionId: string;
27
+ organizationId: string;
28
+ organizationUrlKey: string;
29
+ appUserId: string;
30
+ scopes: string[];
31
+ tokenExpiresAt?: Date | null | undefined;
32
+ status: LinearInstallationStatus;
33
+ }
34
+
35
+ export interface UpdateLinearInstallationTokenExpiryParams {
36
+ connectionId: string;
37
+ tokenExpiresAt: Date | null;
38
+ scopes?: string[] | undefined;
39
+ }
40
+
41
+ type LinearDb = ReturnType<typeof db>;
42
+ type LinearTx = Parameters<Parameters<LinearDb['transaction']>[0]>[0];
43
+
44
+ interface PostgresErrorFields {
45
+ code?: unknown;
46
+ constraint?: unknown;
47
+ cause?: unknown;
48
+ }
49
+
50
+ export async function upsertLinearInstallation(
51
+ params: UpsertLinearInstallationParams,
52
+ options: {tx?: unknown} = {},
53
+ ): Promise<LinearInstallation> {
54
+ const executor = (options.tx ?? db()) as LinearDb | LinearTx;
55
+ const now = new Date();
56
+ let row: typeof linearInstallations.$inferSelect | undefined;
57
+
58
+ try {
59
+ [row] = await executor
60
+ .insert(linearInstallations)
61
+ .values({
62
+ connectionId: params.connectionId,
63
+ organizationId: params.organizationId,
64
+ organizationUrlKey: params.organizationUrlKey,
65
+ appUserId: params.appUserId,
66
+ scopes: params.scopes,
67
+ tokenExpiresAt: params.tokenExpiresAt ?? null,
68
+ status: params.status,
69
+ })
70
+ .onConflictDoUpdate({
71
+ target: linearInstallations.organizationId,
72
+ setWhere: eq(linearInstallations.connectionId, params.connectionId),
73
+ set: {
74
+ connectionId: params.connectionId,
75
+ organizationId: params.organizationId,
76
+ organizationUrlKey: params.organizationUrlKey,
77
+ appUserId: params.appUserId,
78
+ scopes: params.scopes,
79
+ tokenExpiresAt: params.tokenExpiresAt ?? null,
80
+ status: params.status,
81
+ updatedAt: now,
82
+ },
83
+ })
84
+ .returning();
85
+ } catch (error) {
86
+ if (isUniqueViolation(error, 'integrations_linear_installations_connection_unique')) {
87
+ throw new LinearConnectionAlreadyLinkedError(params.connectionId);
88
+ }
89
+ throw error;
90
+ }
91
+
92
+ if (!row) throw new LinearInstallationAlreadyLinkedError(params.organizationId);
93
+ return toLinearInstallation(row);
94
+ }
95
+
96
+ function isUniqueViolation(error: unknown, constraint: string): boolean {
97
+ let current = error;
98
+ while (typeof current === 'object' && current !== null) {
99
+ const postgresError = current as PostgresErrorFields;
100
+ if (postgresError.code === '23505' && postgresError.constraint === constraint) {
101
+ return true;
102
+ }
103
+ current = postgresError.cause;
104
+ }
105
+ return false;
106
+ }
107
+
108
+ export async function getLinearInstallationByConnectionId(
109
+ connectionId: string,
110
+ options: {tx?: unknown} = {},
111
+ ): Promise<LinearInstallation | undefined> {
112
+ const executor = (options.tx ?? db()) as LinearDb | LinearTx;
113
+ const rows = await executor
114
+ .select()
115
+ .from(linearInstallations)
116
+ .where(eq(linearInstallations.connectionId, connectionId))
117
+ .limit(1);
118
+ const row = rows[0];
119
+ if (!row) return undefined;
120
+ return toLinearInstallation(row);
121
+ }
122
+
123
+ export async function getLinearInstallationByOrganizationId(
124
+ organizationId: string,
125
+ options: {tx?: unknown} = {},
126
+ ): Promise<LinearInstallation | undefined> {
127
+ const executor = (options.tx ?? db()) as LinearDb | LinearTx;
128
+ const rows = await executor
129
+ .select()
130
+ .from(linearInstallations)
131
+ .where(eq(linearInstallations.organizationId, organizationId))
132
+ .limit(1);
133
+ const row = rows[0];
134
+ if (!row) return undefined;
135
+ return toLinearInstallation(row);
136
+ }
137
+
138
+ export async function updateLinearInstallationTokenExpiry(
139
+ params: UpdateLinearInstallationTokenExpiryParams,
140
+ options: {tx?: unknown} = {},
141
+ ): Promise<LinearInstallation | undefined> {
142
+ const executor = (options.tx ?? db()) as LinearDb | LinearTx;
143
+ const [row] = await executor
144
+ .update(linearInstallations)
145
+ .set({
146
+ tokenExpiresAt: params.tokenExpiresAt,
147
+ ...(params.scopes === undefined ? {} : {scopes: params.scopes}),
148
+ updatedAt: new Date(),
149
+ })
150
+ .where(eq(linearInstallations.connectionId, params.connectionId))
151
+ .returning();
152
+ if (!row) return undefined;
153
+ return toLinearInstallation(row);
154
+ }
155
+
156
+ export async function markLinearInstallationRevoked(
157
+ connectionId: string,
158
+ options: {tx?: unknown} = {},
159
+ ): Promise<LinearInstallation | undefined> {
160
+ const executor = (options.tx ?? db()) as LinearDb | LinearTx;
161
+ const [row] = await executor
162
+ .update(linearInstallations)
163
+ .set({status: 'revoked', updatedAt: new Date()})
164
+ .where(eq(linearInstallations.connectionId, connectionId))
165
+ .returning();
166
+ if (!row) return undefined;
167
+ return toLinearInstallation(row);
168
+ }
169
+
170
+ export async function deleteLinearInstallationByConnectionId(
171
+ connectionId: string,
172
+ options: {tx?: unknown} = {},
173
+ ): Promise<boolean> {
174
+ const executor = (options.tx ?? db()) as LinearDb | LinearTx;
175
+ const result = await executor
176
+ .delete(linearInstallations)
177
+ .where(eq(linearInstallations.connectionId, connectionId));
178
+ return (result.rowCount ?? 0) > 0;
179
+ }
180
+
181
+ export type LinearRefreshLockResult<T> = {acquired: true; value: T} | {acquired: false};
182
+
183
+ export function withLinearRefreshLock<T>(
184
+ connectionId: string,
185
+ fn: () => Promise<T>,
186
+ ): Promise<LinearRefreshLockResult<T>> {
187
+ return withLinearRefreshLockClient(connectionId, fn);
188
+ }
189
+
190
+ async function withLinearRefreshLockClient<T>(
191
+ connectionId: string,
192
+ fn: () => Promise<T>,
193
+ ): Promise<LinearRefreshLockResult<T>> {
194
+ const client = await pgClient().connect();
195
+ let acquired = false;
196
+ try {
197
+ const lock = await client.query<{acquired: boolean}>(
198
+ 'SELECT pg_try_advisory_lock(hashtext($1)) AS acquired',
199
+ [connectionId],
200
+ );
201
+ acquired = lock.rows[0]?.acquired === true;
202
+ if (!acquired) return {acquired: false};
203
+
204
+ const value = await fn();
205
+ return {acquired: true, value};
206
+ } finally {
207
+ try {
208
+ if (acquired) {
209
+ await client.query('SELECT pg_advisory_unlock(hashtext($1))', [connectionId]);
210
+ }
211
+ } finally {
212
+ client.release();
213
+ }
214
+ }
215
+ }
@@ -0,0 +1,4 @@
1
+ import {dirname, resolve} from 'node:path';
2
+ import {fileURLToPath} from 'node:url';
3
+
4
+ export const migrationsPath = resolve(dirname(fileURLToPath(import.meta.url)), '../../drizzle');
@@ -0,0 +1,3 @@
1
+ import {pgTableCreator} from 'drizzle-orm/pg-core';
2
+
3
+ export const pgTable = pgTableCreator((name) => `integrations_linear_${name}`);
@@ -0,0 +1,42 @@
1
+ import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
2
+ import {jsonb, text, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
3
+ import type {LinearInstallation} from '#db/installations.js';
4
+ import {pgTable} from './common.js';
5
+
6
+ export const linearInstallations = pgTable(
7
+ 'installations',
8
+ {
9
+ id: uuidv7PrimaryKey(),
10
+ connectionId: uuid('connection_id').notNull(),
11
+ organizationId: text('organization_id').notNull(),
12
+ organizationUrlKey: text('organization_url_key').notNull(),
13
+ appUserId: text('app_user_id').notNull(),
14
+ scopes: jsonb('scopes').notNull().$type<string[]>(),
15
+ tokenExpiresAt: timestamp('token_expires_at', {withTimezone: true}),
16
+ status: text('status').notNull().$type<LinearInstallation['status']>(),
17
+ createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
18
+ updatedAt: timestamp('updated_at', {withTimezone: true}).notNull().defaultNow(),
19
+ },
20
+ (table) => [
21
+ uniqueIndex('integrations_linear_installations_connection_unique').on(table.connectionId),
22
+ uniqueIndex('integrations_linear_installations_organization_unique').on(table.organizationId),
23
+ ],
24
+ );
25
+
26
+ export type LinearInstallationDb = typeof linearInstallations.$inferSelect;
27
+ export type LinearInstallationCreateDb = typeof linearInstallations.$inferInsert;
28
+
29
+ export function toLinearInstallation(row: LinearInstallationDb): LinearInstallation {
30
+ return {
31
+ id: row.id,
32
+ connectionId: row.connectionId,
33
+ organizationId: row.organizationId,
34
+ organizationUrlKey: row.organizationUrlKey,
35
+ appUserId: row.appUserId,
36
+ scopes: row.scopes,
37
+ tokenExpiresAt: row.tokenExpiresAt,
38
+ status: row.status,
39
+ createdAt: row.createdAt,
40
+ updatedAt: row.updatedAt,
41
+ };
42
+ }
@@ -0,0 +1,20 @@
1
+ import {linearAgentToolCatalog} from '#core/agent-tools.js';
2
+ import {createLinearIntegrationProvider} from '#index.js';
3
+
4
+ describe('createLinearIntegrationProvider', () => {
5
+ it('does not wire the Linear agent tools adapter without a token store', () => {
6
+ const provider = createLinearIntegrationProvider();
7
+
8
+ expect(provider.adapters.agent_tools).toBeUndefined();
9
+ });
10
+
11
+ it('wires the Linear agent tools adapter when a token store is provided', () => {
12
+ const provider = createLinearIntegrationProvider({
13
+ agentTools: {
14
+ tokenStore: {getAccessToken: async () => 'linear-token'},
15
+ },
16
+ });
17
+
18
+ expect(provider.adapters.agent_tools?.catalog()).toBe(linearAgentToolCatalog);
19
+ });
20
+ });
package/src/index.ts ADDED
@@ -0,0 +1,153 @@
1
+ import {LINEAR_PROVIDER} from '@shipfox/api-integration-linear-dto';
2
+ import {createLinearApiClient, type LinearApiClient} from '#api/client.js';
3
+ import {config} from '#config.js';
4
+ import {LinearAgentToolsProvider} from '#core/agent-tools-provider.js';
5
+ import type {LinearTokenStore} from '#core/tokens.js';
6
+ import {closeDb, db} from '#db/db.js';
7
+ import {getLinearInstallationByConnectionId} from '#db/installations.js';
8
+ import {migrationsPath} from '#db/migrations.js';
9
+ import {
10
+ type CreateLinearIntegrationRoutesOptions,
11
+ createLinearIntegrationRoutes,
12
+ } from '#presentation/routes/install.js';
13
+ import {
14
+ type CreateLinearWebhookRoutesOptions,
15
+ createLinearWebhookRoutes,
16
+ } from '#presentation/routes/webhooks.js';
17
+
18
+ export type {LinearProvider} from '@shipfox/api-integration-linear-dto';
19
+ export type {LinearApiClient, LinearAuthorization, LinearIdentity} from '#api/client.js';
20
+ export {createLinearApiClient} from '#api/client.js';
21
+ export type {
22
+ LinearAgentToolCatalogEntry,
23
+ LinearAgentToolCategory,
24
+ LinearAgentToolId,
25
+ LinearAgentToolRequiredScope,
26
+ } from '#core/agent-tools.js';
27
+ export {
28
+ linearAgentToolCatalog,
29
+ linearAgentToolSelectionCatalog,
30
+ } from '#core/agent-tools.js';
31
+ export {
32
+ type DisconnectLinearInstallationParams,
33
+ disconnectLinearInstallation,
34
+ } from '#core/disconnect.js';
35
+ export {
36
+ LinearAccessTokenMissingError,
37
+ LinearAuthorizationScopeMismatchError,
38
+ LinearConnectionAlreadyLinkedError,
39
+ LinearConnectionNotFoundError,
40
+ LinearInstallationAlreadyLinkedError,
41
+ LinearInstallStateActorMismatchError,
42
+ LinearInstallStateError,
43
+ LinearIntegrationProviderError,
44
+ LinearOAuthCallbackError,
45
+ LinearTokenUnrefreshableError,
46
+ } from '#core/errors.js';
47
+ export type {ConnectLinearInstallationInput, HandleLinearCallbackParams} from '#core/install.js';
48
+ export {handleLinearCallback, handleLinearOAuthCallbackError} from '#core/install.js';
49
+ export {
50
+ assertLinearAuthorizationScopes,
51
+ formatLinearOAuthScopes,
52
+ LINEAR_OAUTH_SCOPES,
53
+ } from '#core/scopes.js';
54
+ export type {LinearInstallStateClaims} from '#core/state.js';
55
+ export {signLinearInstallState, verifyLinearInstallState} from '#core/state.js';
56
+ export type {
57
+ CreateLinearTokenStoreParams,
58
+ GetLinearAccessTokenParams,
59
+ LinearConnectionResolverResult,
60
+ LinearSecretsStore,
61
+ LinearTokenStore,
62
+ StoreLinearTokensParams,
63
+ } from '#core/tokens.js';
64
+ export {
65
+ createLinearTokenStore,
66
+ linearSecretsNamespace,
67
+ } from '#core/tokens.js';
68
+ export type {HandleLinearWebhookOutcome, HandleLinearWebhookParams} from '#core/webhook.js';
69
+ export {handleLinearWebhook} from '#core/webhook.js';
70
+ export type {
71
+ LinearInstallation,
72
+ LinearInstallationStatus,
73
+ UpdateLinearInstallationTokenExpiryParams,
74
+ UpsertLinearInstallationParams,
75
+ } from '#db/installations.js';
76
+ export {
77
+ getLinearInstallationByConnectionId,
78
+ getLinearInstallationByOrganizationId,
79
+ markLinearInstallationRevoked,
80
+ updateLinearInstallationTokenExpiry,
81
+ upsertLinearInstallation,
82
+ withLinearRefreshLock,
83
+ } from '#db/installations.js';
84
+ export {
85
+ type CreateLinearE2eRoutesOptions,
86
+ createLinearE2eRoutes,
87
+ } from '#presentation/e2eRoutes/index.js';
88
+ export {closeDb, config, db, migrationsPath};
89
+
90
+ export interface CreateLinearIntegrationProviderOptions {
91
+ linear?: LinearApiClient | undefined;
92
+ agentTools?:
93
+ | {
94
+ tokenStore: Pick<LinearTokenStore, 'getAccessToken'>;
95
+ endpoint?: string | URL | undefined;
96
+ callTimeoutMs?: number | undefined;
97
+ }
98
+ | undefined;
99
+ getLinearInstallationByConnectionId?: typeof getLinearInstallationByConnectionId | undefined;
100
+ routes?:
101
+ | (Omit<CreateLinearIntegrationRoutesOptions, 'linear' | 'connectionCapabilities'> &
102
+ Partial<CreateLinearWebhookRoutesOptions>)
103
+ | undefined;
104
+ }
105
+
106
+ export function createLinearIntegrationProvider(
107
+ options: CreateLinearIntegrationProviderOptions = {},
108
+ ) {
109
+ const linear = options.linear ?? createLinearApiClient();
110
+ const getInstallationByConnectionId =
111
+ options.getLinearInstallationByConnectionId ?? getLinearInstallationByConnectionId;
112
+ const adapters = options.agentTools
113
+ ? {
114
+ agent_tools: new LinearAgentToolsProvider(options.agentTools),
115
+ }
116
+ : {};
117
+
118
+ const routes = options.routes
119
+ ? [
120
+ createLinearIntegrationRoutes({
121
+ linear,
122
+ connectionCapabilities: adapters.agent_tools ? ['agent_tools'] : [],
123
+ ...options.routes,
124
+ }),
125
+ ]
126
+ : [];
127
+ if (options.routes && hasLinearWebhookRoutesOptions(options.routes)) {
128
+ routes.push(createLinearWebhookRoutes(options.routes));
129
+ }
130
+
131
+ return {
132
+ provider: LINEAR_PROVIDER,
133
+ displayName: 'Linear',
134
+ adapters,
135
+ async connectionExternalUrl(connection: {id: string}): Promise<string | undefined> {
136
+ const installation = await getInstallationByConnectionId(connection.id);
137
+ if (!installation?.organizationUrlKey) return undefined;
138
+ return `https://linear.app/${encodeURIComponent(installation.organizationUrlKey)}/settings`;
139
+ },
140
+ routes,
141
+ };
142
+ }
143
+
144
+ function hasLinearWebhookRoutesOptions(
145
+ routes: Partial<CreateLinearWebhookRoutesOptions>,
146
+ ): routes is CreateLinearWebhookRoutesOptions {
147
+ return (
148
+ routes.coreDb !== undefined &&
149
+ routes.publishIntegrationEventReceived !== undefined &&
150
+ routes.recordDeliveryOnly !== undefined &&
151
+ routes.getIntegrationConnectionById !== undefined
152
+ );
153
+ }
@@ -0,0 +1,9 @@
1
+ import type {IntegrationCapability, IntegrationConnection} from '@shipfox/api-integration-core-dto';
2
+ import {toIntegrationConnectionDto as toCoreIntegrationConnectionDto} from '@shipfox/api-integration-core-dto';
3
+
4
+ export function toIntegrationConnectionDto(
5
+ connection: IntegrationConnection,
6
+ params: {capabilities: IntegrationCapability[]},
7
+ ) {
8
+ return toCoreIntegrationConnectionDto(connection, params);
9
+ }
@@ -0,0 +1,85 @@
1
+ import type {IntegrationCapability, IntegrationConnection} from '@shipfox/api-integration-core-dto';
2
+ import {
3
+ createE2eLinearConnectionBodySchema,
4
+ createE2eLinearConnectionResponseSchema,
5
+ } from '@shipfox/api-integration-linear-dto';
6
+ import {ClientError, defineRoute} from '@shipfox/node-fastify';
7
+ import {logger} from '@shipfox/node-opentelemetry';
8
+ import type {ConnectLinearInstallationInput} from '#core/install.js';
9
+ import type {LinearTokenStore} from '#core/tokens.js';
10
+ import {toIntegrationConnectionDto} from '#presentation/dto/integrations.js';
11
+
12
+ export interface CreateE2eLinearConnectionRouteOptions {
13
+ tokenStore: Pick<LinearTokenStore, 'storeTokens'>;
14
+ getExistingLinearConnection: (input: {
15
+ organizationId: string;
16
+ }) => Promise<IntegrationConnection<'linear'> | undefined>;
17
+ connectLinearInstallation: (
18
+ input: ConnectLinearInstallationInput,
19
+ ) => Promise<IntegrationConnection<'linear'>>;
20
+ disconnectLinearInstallation: (input: {connectionId: string}) => Promise<void>;
21
+ connectionCapabilities: IntegrationCapability[];
22
+ }
23
+
24
+ export function createE2eLinearConnectionRoute(options: CreateE2eLinearConnectionRouteOptions) {
25
+ return defineRoute({
26
+ method: 'POST',
27
+ path: '/linear-connections',
28
+ description: 'Create a synthetic Linear connection for E2E tests.',
29
+ schema: {
30
+ body: createE2eLinearConnectionBodySchema,
31
+ response: {201: createE2eLinearConnectionResponseSchema},
32
+ },
33
+ handler: async (request, reply) => {
34
+ const body = request.body;
35
+ const existing = await options.getExistingLinearConnection({
36
+ organizationId: body.organization_id,
37
+ });
38
+ if (existing && existing.workspaceId !== body.workspace_id) {
39
+ throw new ClientError(
40
+ 'Linear organization is already connected to another workspace',
41
+ 'linear-connection-workspace-mismatch',
42
+ {status: 409},
43
+ );
44
+ }
45
+ const connection =
46
+ existing ??
47
+ (await options.connectLinearInstallation({
48
+ workspaceId: body.workspace_id,
49
+ organizationId: body.organization_id,
50
+ organizationUrlKey: body.organization_url_key,
51
+ appUserId: body.app_user_id,
52
+ scopes: body.scopes,
53
+ tokenExpiresAt: null,
54
+ displayName: body.display_name,
55
+ }));
56
+
57
+ try {
58
+ await options.tokenStore.storeTokens({
59
+ connectionId: connection.id,
60
+ accessToken: body.access_token,
61
+ });
62
+ } catch (error) {
63
+ if (!existing) await bestEffortDisconnectLinearInstallation(options, connection.id);
64
+ throw error;
65
+ }
66
+
67
+ reply.code(201);
68
+ return toIntegrationConnectionDto(connection, {capabilities: options.connectionCapabilities});
69
+ },
70
+ });
71
+ }
72
+
73
+ async function bestEffortDisconnectLinearInstallation(
74
+ options: CreateE2eLinearConnectionRouteOptions,
75
+ connectionId: string,
76
+ ): Promise<void> {
77
+ try {
78
+ await options.disconnectLinearInstallation({connectionId});
79
+ } catch (error) {
80
+ logger().warn(
81
+ {err: error, connectionId},
82
+ 'Linear E2E connection compensation failed after token storage rejection',
83
+ );
84
+ }
85
+ }