@shipfox/api-integration-github 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 (179) 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 +138 -0
  5. package/LICENSE +21 -0
  6. package/dist/api/client.d.ts +90 -0
  7. package/dist/api/client.d.ts.map +1 -0
  8. package/dist/api/client.js +306 -0
  9. package/dist/api/client.js.map +1 -0
  10. package/dist/api/installation-token-envelope.d.ts +36 -0
  11. package/dist/api/installation-token-envelope.d.ts.map +1 -0
  12. package/dist/api/installation-token-envelope.js +123 -0
  13. package/dist/api/installation-token-envelope.js.map +1 -0
  14. package/dist/api/installation-token-provider.d.ts +23 -0
  15. package/dist/api/installation-token-provider.d.ts.map +1 -0
  16. package/dist/api/installation-token-provider.js +130 -0
  17. package/dist/api/installation-token-provider.js.map +1 -0
  18. package/dist/api/shared-installation-token-cache.d.ts +43 -0
  19. package/dist/api/shared-installation-token-cache.d.ts.map +1 -0
  20. package/dist/api/shared-installation-token-cache.js +224 -0
  21. package/dist/api/shared-installation-token-cache.js.map +1 -0
  22. package/dist/config.d.ts +15 -0
  23. package/dist/config.d.ts.map +1 -0
  24. package/dist/config.js +44 -0
  25. package/dist/config.js.map +1 -0
  26. package/dist/core/actions-logs.d.ts +47 -0
  27. package/dist/core/actions-logs.d.ts.map +1 -0
  28. package/dist/core/actions-logs.js +134 -0
  29. package/dist/core/actions-logs.js.map +1 -0
  30. package/dist/core/agent-tool-selection.d.ts +4 -0
  31. package/dist/core/agent-tool-selection.d.ts.map +1 -0
  32. package/dist/core/agent-tool-selection.js +38 -0
  33. package/dist/core/agent-tool-selection.js.map +1 -0
  34. package/dist/core/agent-tools.d.ts +50 -0
  35. package/dist/core/agent-tools.d.ts.map +1 -0
  36. package/dist/core/agent-tools.js +1122 -0
  37. package/dist/core/agent-tools.js.map +1 -0
  38. package/dist/core/errors.d.ts +15 -0
  39. package/dist/core/errors.d.ts.map +1 -0
  40. package/dist/core/errors.js +22 -0
  41. package/dist/core/errors.js.map +1 -0
  42. package/dist/core/install.d.ts +38 -0
  43. package/dist/core/install.d.ts.map +1 -0
  44. package/dist/core/install.js +64 -0
  45. package/dist/core/install.js.map +1 -0
  46. package/dist/core/source-control.d.ts +15 -0
  47. package/dist/core/source-control.d.ts.map +1 -0
  48. package/dist/core/source-control.js +175 -0
  49. package/dist/core/source-control.js.map +1 -0
  50. package/dist/core/state.d.ts +12 -0
  51. package/dist/core/state.d.ts.map +1 -0
  52. package/dist/core/state.js +54 -0
  53. package/dist/core/state.js.map +1 -0
  54. package/dist/core/webhook.d.ts +20 -0
  55. package/dist/core/webhook.d.ts.map +1 -0
  56. package/dist/core/webhook.js +216 -0
  57. package/dist/core/webhook.js.map +1 -0
  58. package/dist/db/db.d.ts +433 -0
  59. package/dist/db/db.d.ts.map +1 -0
  60. package/dist/db/db.js +18 -0
  61. package/dist/db/db.js.map +1 -0
  62. package/dist/db/installation-token-lock.d.ts +8 -0
  63. package/dist/db/installation-token-lock.d.ts.map +1 -0
  64. package/dist/db/installation-token-lock.js +37 -0
  65. package/dist/db/installation-token-lock.js.map +1 -0
  66. package/dist/db/installations.d.ts +33 -0
  67. package/dist/db/installations.d.ts.map +1 -0
  68. package/dist/db/installations.js +56 -0
  69. package/dist/db/installations.js.map +1 -0
  70. package/dist/db/migrations.d.ts +2 -0
  71. package/dist/db/migrations.d.ts.map +1 -0
  72. package/dist/db/migrations.js +5 -0
  73. package/dist/db/migrations.js.map +1 -0
  74. package/dist/db/schema/common.d.ts +2 -0
  75. package/dist/db/schema/common.d.ts.map +1 -0
  76. package/dist/db/schema/common.js +4 -0
  77. package/dist/db/schema/common.js.map +1 -0
  78. package/dist/db/schema/installations.d.ts +218 -0
  79. package/dist/db/schema/installations.d.ts.map +1 -0
  80. package/dist/db/schema/installations.js +46 -0
  81. package/dist/db/schema/installations.js.map +1 -0
  82. package/dist/index.d.ts +53 -0
  83. package/dist/index.d.ts.map +1 -0
  84. package/dist/index.js +66 -0
  85. package/dist/index.js.map +1 -0
  86. package/dist/metrics/index.d.ts +2 -0
  87. package/dist/metrics/index.d.ts.map +1 -0
  88. package/dist/metrics/index.js +3 -0
  89. package/dist/metrics/index.js.map +1 -0
  90. package/dist/metrics/instance.d.ts +14 -0
  91. package/dist/metrics/instance.d.ts.map +1 -0
  92. package/dist/metrics/instance.js +75 -0
  93. package/dist/metrics/instance.js.map +1 -0
  94. package/dist/presentation/dto/integrations.d.ts +15 -0
  95. package/dist/presentation/dto/integrations.d.ts.map +1 -0
  96. package/dist/presentation/dto/integrations.js +10 -0
  97. package/dist/presentation/dto/integrations.js.map +1 -0
  98. package/dist/presentation/e2eRoutes/create-connection.d.ts +11 -0
  99. package/dist/presentation/e2eRoutes/create-connection.d.ts.map +1 -0
  100. package/dist/presentation/e2eRoutes/create-connection.js +57 -0
  101. package/dist/presentation/e2eRoutes/create-connection.js.map +1 -0
  102. package/dist/presentation/e2eRoutes/index.d.ts +5 -0
  103. package/dist/presentation/e2eRoutes/index.d.ts.map +1 -0
  104. package/dist/presentation/e2eRoutes/index.js +11 -0
  105. package/dist/presentation/e2eRoutes/index.js.map +1 -0
  106. package/dist/presentation/routes/errors.d.ts +2 -0
  107. package/dist/presentation/routes/errors.d.ts.map +1 -0
  108. package/dist/presentation/routes/errors.js +46 -0
  109. package/dist/presentation/routes/errors.js.map +1 -0
  110. package/dist/presentation/routes/install.d.ts +13 -0
  111. package/dist/presentation/routes/install.d.ts.map +1 -0
  112. package/dist/presentation/routes/install.js +77 -0
  113. package/dist/presentation/routes/install.js.map +1 -0
  114. package/dist/presentation/routes/webhooks.d.ts +16 -0
  115. package/dist/presentation/routes/webhooks.d.ts.map +1 -0
  116. package/dist/presentation/routes/webhooks.js +110 -0
  117. package/dist/presentation/routes/webhooks.js.map +1 -0
  118. package/dist/tsconfig.test.tsbuildinfo +1 -0
  119. package/drizzle/0000_initial.sql +17 -0
  120. package/drizzle/meta/_journal.json +13 -0
  121. package/drizzle.config.ts +7 -0
  122. package/package.json +71 -0
  123. package/src/api/client.test.ts +94 -0
  124. package/src/api/client.ts +525 -0
  125. package/src/api/installation-token-envelope.ts +171 -0
  126. package/src/api/installation-token-provider.test.ts +241 -0
  127. package/src/api/installation-token-provider.ts +224 -0
  128. package/src/api/shared-installation-token-cache.test.ts +294 -0
  129. package/src/api/shared-installation-token-cache.ts +346 -0
  130. package/src/config.test.ts +14 -0
  131. package/src/config.ts +47 -0
  132. package/src/connection-external-url.test.ts +78 -0
  133. package/src/core/actions-logs.test.ts +156 -0
  134. package/src/core/actions-logs.ts +186 -0
  135. package/src/core/agent-tool-selection.test.ts +41 -0
  136. package/src/core/agent-tool-selection.ts +42 -0
  137. package/src/core/agent-tools.test.ts +539 -0
  138. package/src/core/agent-tools.ts +1269 -0
  139. package/src/core/errors.ts +23 -0
  140. package/src/core/install.test.ts +238 -0
  141. package/src/core/install.ts +119 -0
  142. package/src/core/source-control.test.ts +327 -0
  143. package/src/core/source-control.ts +223 -0
  144. package/src/core/state.test.ts +43 -0
  145. package/src/core/state.ts +85 -0
  146. package/src/core/webhook.test.ts +522 -0
  147. package/src/core/webhook.ts +296 -0
  148. package/src/db/db.ts +18 -0
  149. package/src/db/installation-token-lock.test.ts +68 -0
  150. package/src/db/installation-token-lock.ts +39 -0
  151. package/src/db/installations.test.ts +67 -0
  152. package/src/db/installations.ts +108 -0
  153. package/src/db/migrations.ts +4 -0
  154. package/src/db/schema/common.ts +3 -0
  155. package/src/db/schema/installations.ts +46 -0
  156. package/src/index.ts +124 -0
  157. package/src/metrics/index.ts +1 -0
  158. package/src/metrics/instance.ts +82 -0
  159. package/src/presentation/dto/integrations.ts +8 -0
  160. package/src/presentation/e2eRoutes/create-connection.ts +70 -0
  161. package/src/presentation/e2eRoutes/index.test.ts +126 -0
  162. package/src/presentation/e2eRoutes/index.ts +14 -0
  163. package/src/presentation/routes/errors.ts +43 -0
  164. package/src/presentation/routes/install.test.ts +279 -0
  165. package/src/presentation/routes/install.ts +93 -0
  166. package/src/presentation/routes/webhooks.test.ts +560 -0
  167. package/src/presentation/routes/webhooks.ts +115 -0
  168. package/test/env.ts +16 -0
  169. package/test/factories/github-installation.ts +35 -0
  170. package/test/factories/index.ts +1 -0
  171. package/test/fixtures/github-webhook.ts +20 -0
  172. package/test/globalSetup.ts +14 -0
  173. package/test/index.ts +2 -0
  174. package/test/setup.ts +17 -0
  175. package/tsconfig.build.json +9 -0
  176. package/tsconfig.build.tsbuildinfo +1 -0
  177. package/tsconfig.json +3 -0
  178. package/tsconfig.test.json +9 -0
  179. package/vitest.config.ts +12 -0
@@ -0,0 +1,296 @@
1
+ import {
2
+ buildProviderRepositoryId,
3
+ type GetIntegrationConnectionByIdFn,
4
+ type IntegrationTx,
5
+ type PublishIntegrationEventReceivedFn,
6
+ type PublishSourcePushFn,
7
+ type RecordDeliveryOnlyFn,
8
+ type SourcePushPayload,
9
+ } from '@shipfox/api-integration-core-dto';
10
+ import {
11
+ type GithubPushPayloadDto,
12
+ githubPushPayloadSchema,
13
+ githubWebhookActionSchema,
14
+ githubWebhookInstallationSchema,
15
+ } from '@shipfox/api-integration-github-dto';
16
+ import {logger} from '@shipfox/node-opentelemetry';
17
+ import {getGithubInstallationByInstallationId} from '#db/installations.js';
18
+
19
+ const REFS_HEADS_PREFIX = 'refs/heads/';
20
+ const GITHUB_SOURCE = 'github';
21
+ // GitHub sends a `push` webhook for a branch deletion with `after` set to this all-zero SHA.
22
+ const DELETED_BRANCH_SHA = '0'.repeat(40);
23
+
24
+ export interface HandleGithubEventParams {
25
+ tx: IntegrationTx;
26
+ deliveryId: string;
27
+ event: string;
28
+ payload: unknown;
29
+ publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
30
+ publishSourcePush: PublishSourcePushFn;
31
+ recordDeliveryOnly: RecordDeliveryOnlyFn;
32
+ getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
33
+ deleteInstallationTokenSecret?:
34
+ | ((params: {workspaceId: string; installationId: number}) => Promise<unknown>)
35
+ | undefined;
36
+ }
37
+
38
+ export type HandleGithubEventOutcome =
39
+ | 'published'
40
+ | 'duplicate'
41
+ | 'published-envelope'
42
+ | 'duplicate-envelope'
43
+ | 'published-push-envelope-only'
44
+ | 'duplicate-push-envelope-only'
45
+ | 'unknown-installation'
46
+ | 'missing-connection'
47
+ | 'inactive-connection'
48
+ | 'no-installation-id';
49
+
50
+ function isBranchDeletion(after: string): boolean {
51
+ return after === DELETED_BRANCH_SHA;
52
+ }
53
+
54
+ export async function handleGithubEvent(
55
+ params: HandleGithubEventParams,
56
+ ): Promise<{outcome: HandleGithubEventOutcome}> {
57
+ const actionEnvelope = githubWebhookActionSchema.safeParse(params.payload);
58
+ const action = actionEnvelope.success ? actionEnvelope.data.action : undefined;
59
+ const installationEnvelope = githubWebhookInstallationSchema.safeParse(params.payload);
60
+ const installationId = installationEnvelope.success
61
+ ? installationEnvelope.data.installation?.id
62
+ : undefined;
63
+ if (installationId === undefined) {
64
+ await params.recordDeliveryOnly({
65
+ tx: params.tx,
66
+ provider: GITHUB_SOURCE,
67
+ deliveryId: params.deliveryId,
68
+ });
69
+ return {outcome: 'no-installation-id'};
70
+ }
71
+
72
+ const installation = await getGithubInstallationByInstallationId(String(installationId), {
73
+ tx: params.tx,
74
+ });
75
+ if (!installation) {
76
+ logger().warn(
77
+ {deliveryId: params.deliveryId, installationId},
78
+ 'github webhook: unknown installation, dropping',
79
+ );
80
+ await params.recordDeliveryOnly({
81
+ tx: params.tx,
82
+ provider: GITHUB_SOURCE,
83
+ deliveryId: params.deliveryId,
84
+ });
85
+ return {outcome: 'unknown-installation'};
86
+ }
87
+
88
+ const connection = await params.getIntegrationConnectionById(installation.connectionId, {
89
+ tx: params.tx,
90
+ });
91
+ if (!connection) {
92
+ logger().warn(
93
+ {deliveryId: params.deliveryId, installationId, connectionId: installation.connectionId},
94
+ 'github webhook: installation has no connection, dropping',
95
+ );
96
+ await params.recordDeliveryOnly({
97
+ tx: params.tx,
98
+ provider: GITHUB_SOURCE,
99
+ deliveryId: params.deliveryId,
100
+ });
101
+ return {outcome: 'missing-connection'};
102
+ }
103
+
104
+ if (connection.lifecycleStatus !== 'active') {
105
+ const logContext = {
106
+ deliveryId: params.deliveryId,
107
+ installationId,
108
+ connectionId: connection.id,
109
+ workspaceId: connection.workspaceId,
110
+ lifecycleStatus: connection.lifecycleStatus,
111
+ };
112
+ // `disabled` is an expected steady state; only `error` is anomalous.
113
+ if (connection.lifecycleStatus === 'error') {
114
+ logger().warn(logContext, 'github webhook: connection in error state, dropping');
115
+ } else {
116
+ logger().info(logContext, 'github webhook: connection disabled, dropping');
117
+ }
118
+ await params.recordDeliveryOnly({
119
+ tx: params.tx,
120
+ provider: GITHUB_SOURCE,
121
+ deliveryId: params.deliveryId,
122
+ });
123
+ return {outcome: 'inactive-connection'};
124
+ }
125
+
126
+ if (params.event === 'push') {
127
+ const validated = githubPushPayloadSchema.safeParse(params.payload);
128
+ if (!validated.success) {
129
+ logger().warn(
130
+ {deliveryId: params.deliveryId, issues: validated.error.issues},
131
+ 'github webhook push payload failed schema validation; publishing generic envelope only',
132
+ );
133
+ return publishGithubEnvelopeOnly({
134
+ tx: params.tx,
135
+ deliveryId: params.deliveryId,
136
+ payload: params.payload,
137
+ publishIntegrationEventReceived: params.publishIntegrationEventReceived,
138
+ connection,
139
+ event: 'push',
140
+ });
141
+ }
142
+
143
+ return publishGithubPush({
144
+ ...params,
145
+ eventPayload: validated.data,
146
+ rawPayload: params.payload,
147
+ connection,
148
+ });
149
+ }
150
+
151
+ const eventName = action ? `${params.event}.${action}` : params.event;
152
+ const result = await publishGithubEnvelopeOnly({
153
+ tx: params.tx,
154
+ deliveryId: params.deliveryId,
155
+ payload: params.payload,
156
+ publishIntegrationEventReceived: params.publishIntegrationEventReceived,
157
+ connection,
158
+ event: eventName,
159
+ });
160
+ if (result.outcome === 'published-envelope') {
161
+ await deleteInstallationTokenSecretBestEffort({
162
+ deleteInstallationTokenSecret: params.deleteInstallationTokenSecret,
163
+ event: params.event,
164
+ action,
165
+ deliveryId: params.deliveryId,
166
+ workspaceId: connection.workspaceId,
167
+ installationId,
168
+ });
169
+ }
170
+ return result;
171
+ }
172
+
173
+ function shouldDeleteInstallationTokenSecret(event: string, action: string | undefined): boolean {
174
+ return event === 'installation' && (action === 'deleted' || action === 'suspend');
175
+ }
176
+
177
+ async function deleteInstallationTokenSecretBestEffort(params: {
178
+ deleteInstallationTokenSecret:
179
+ | ((params: {workspaceId: string; installationId: number}) => Promise<unknown>)
180
+ | undefined;
181
+ event: string;
182
+ action: string | undefined;
183
+ deliveryId: string;
184
+ workspaceId: string;
185
+ installationId: number;
186
+ }): Promise<void> {
187
+ if (!shouldDeleteInstallationTokenSecret(params.event, params.action)) return;
188
+
189
+ try {
190
+ await params.deleteInstallationTokenSecret?.({
191
+ workspaceId: params.workspaceId,
192
+ installationId: params.installationId,
193
+ });
194
+ } catch (error) {
195
+ logger().warn(
196
+ {
197
+ deliveryId: params.deliveryId,
198
+ installationId: params.installationId,
199
+ workspaceId: params.workspaceId,
200
+ error,
201
+ },
202
+ 'github webhook installation token cleanup failed',
203
+ );
204
+ }
205
+ }
206
+
207
+ async function publishGithubPush(params: {
208
+ tx: IntegrationTx;
209
+ deliveryId: string;
210
+ publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
211
+ publishSourcePush: PublishSourcePushFn;
212
+ eventPayload: GithubPushPayloadDto;
213
+ rawPayload: unknown;
214
+ connection: {
215
+ id: string;
216
+ workspaceId: string;
217
+ slug: string;
218
+ displayName: string;
219
+ };
220
+ }): Promise<{outcome: HandleGithubEventOutcome}> {
221
+ if (isBranchDeletion(params.eventPayload.after)) {
222
+ const result = await params.publishIntegrationEventReceived({
223
+ tx: params.tx,
224
+ event: {
225
+ provider: GITHUB_SOURCE,
226
+ source: params.connection.slug,
227
+ event: 'push',
228
+ workspaceId: params.connection.workspaceId,
229
+ connectionId: params.connection.id,
230
+ connectionName: params.connection.displayName,
231
+ deliveryId: params.deliveryId,
232
+ receivedAt: new Date().toISOString(),
233
+ payload: params.rawPayload,
234
+ },
235
+ });
236
+ return {
237
+ outcome: result.published ? 'published-push-envelope-only' : 'duplicate-push-envelope-only',
238
+ };
239
+ }
240
+
241
+ const ref = stripRefsHeads(params.eventPayload.ref);
242
+ const defaultBranch = params.eventPayload.repository.default_branch;
243
+ const push: SourcePushPayload = {
244
+ externalRepositoryId: buildProviderRepositoryId(
245
+ GITHUB_SOURCE,
246
+ String(params.eventPayload.repository.id),
247
+ ),
248
+ ref,
249
+ headCommitSha: params.eventPayload.after,
250
+ defaultBranch,
251
+ isDefaultBranch: ref === defaultBranch,
252
+ };
253
+ const result = await params.publishSourcePush({
254
+ tx: params.tx,
255
+ provider: GITHUB_SOURCE,
256
+ source: params.connection.slug,
257
+ workspaceId: params.connection.workspaceId,
258
+ connectionId: params.connection.id,
259
+ connectionName: params.connection.displayName,
260
+ deliveryId: params.deliveryId,
261
+ receivedAt: new Date().toISOString(),
262
+ rawPayload: params.rawPayload,
263
+ push,
264
+ });
265
+
266
+ return {outcome: result.published ? 'published' : 'duplicate'};
267
+ }
268
+
269
+ async function publishGithubEnvelopeOnly(params: {
270
+ tx: IntegrationTx;
271
+ deliveryId: string;
272
+ payload: unknown;
273
+ publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
274
+ connection: {id: string; workspaceId: string; slug: string; displayName: string};
275
+ event: string;
276
+ }): Promise<{outcome: HandleGithubEventOutcome}> {
277
+ const result = await params.publishIntegrationEventReceived({
278
+ tx: params.tx,
279
+ event: {
280
+ provider: GITHUB_SOURCE,
281
+ source: params.connection.slug,
282
+ event: params.event,
283
+ workspaceId: params.connection.workspaceId,
284
+ connectionId: params.connection.id,
285
+ connectionName: params.connection.displayName,
286
+ deliveryId: params.deliveryId,
287
+ receivedAt: new Date().toISOString(),
288
+ payload: params.payload,
289
+ },
290
+ });
291
+ return {outcome: result.published ? 'published-envelope' : 'duplicate-envelope'};
292
+ }
293
+
294
+ function stripRefsHeads(ref: string): string {
295
+ return ref.startsWith(REFS_HEADS_PREFIX) ? ref.slice(REFS_HEADS_PREFIX.length) : ref;
296
+ }
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 {githubInstallations} from './schema/installations.js';
4
+
5
+ export const schema = {
6
+ githubInstallations,
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,68 @@
1
+ import {withInstallationTokenLock} from './installation-token-lock.js';
2
+
3
+ describe('withInstallationTokenLock', () => {
4
+ it('allows one holder per installation and fails contenders fast', async () => {
5
+ const first = holdInstallationTokenLock(9001, 'winner');
6
+
7
+ await first.ready;
8
+ const contender = await withInstallationTokenLock(9001, async () => 'contender');
9
+ const different = await withInstallationTokenLock(9002, async () => 'different');
10
+ first.release();
11
+ const winner = await first.result;
12
+
13
+ expect(contender).toEqual({acquired: false});
14
+ expect(different).toEqual({acquired: true, value: 'different'});
15
+ expect(winner).toEqual({acquired: true, value: 'winner'});
16
+ });
17
+
18
+ it('does not collide installations that differ outside the 32-bit range', async () => {
19
+ const holder = holdInstallationTokenLock(1, 'holder');
20
+
21
+ await holder.ready;
22
+ const different = await withInstallationTokenLock(1 + 2 ** 32, async () => 'different');
23
+ holder.release();
24
+ const held = await holder.result;
25
+
26
+ expect(different).toEqual({acquired: true, value: 'different'});
27
+ expect(held).toEqual({acquired: true, value: 'holder'});
28
+ });
29
+
30
+ it('does not exhaust a small pool when many contenders miss the try-lock', async () => {
31
+ const holder = holdInstallationTokenLock(9010, 'holder');
32
+
33
+ await holder.ready;
34
+ const contenders = await Promise.all(
35
+ Array.from({length: 20}, () => withInstallationTokenLock(9010, async () => 'contender')),
36
+ );
37
+ holder.release();
38
+ const held = await holder.result;
39
+
40
+ expect(contenders).toEqual(Array.from({length: 20}, () => ({acquired: false})));
41
+ expect(held).toEqual({acquired: true, value: 'holder'});
42
+ });
43
+ });
44
+
45
+ function holdInstallationTokenLock(installationId: number, value: string) {
46
+ let releaseLock: (() => void) | undefined;
47
+ let markReady: () => void = () => undefined;
48
+ const ready = new Promise<void>((resolve) => {
49
+ markReady = resolve;
50
+ });
51
+ const result = withInstallationTokenLock(
52
+ installationId,
53
+ () =>
54
+ new Promise<string>((resolve) => {
55
+ releaseLock = () => resolve(value);
56
+ markReady();
57
+ }),
58
+ );
59
+
60
+ return {
61
+ ready,
62
+ release: () => {
63
+ if (!releaseLock) throw new Error('releaseLock was not initialized');
64
+ releaseLock();
65
+ },
66
+ result,
67
+ };
68
+ }
@@ -0,0 +1,39 @@
1
+ import {sql} from 'drizzle-orm';
2
+ import {recordInstallationTokenLockWait} from '#metrics/index.js';
3
+ import {db} from './db.js';
4
+
5
+ export type InstallationTokenLockResult<T> = {acquired: true; value: T} | {acquired: false};
6
+
7
+ export function withInstallationTokenLock<T>(
8
+ installationId: number,
9
+ fn: () => Promise<T>,
10
+ ): Promise<InstallationTokenLockResult<T>> {
11
+ const lockKey = installationTokenLockKey(installationId);
12
+ return db().transaction(async (tx) => {
13
+ const startedAt = Date.now();
14
+ const lock = await tx.execute<{acquired: boolean}>(sql`
15
+ SELECT pg_try_advisory_xact_lock(${lockKey}::bigint) AS acquired
16
+ `);
17
+ const acquired = lock.rows[0]?.acquired === true;
18
+ if (!acquired) {
19
+ recordInstallationTokenLockWait(Date.now() - startedAt);
20
+ return {acquired: false};
21
+ }
22
+
23
+ try {
24
+ const value = await fn();
25
+ return {acquired: true, value};
26
+ } finally {
27
+ recordInstallationTokenLockWait(Date.now() - startedAt);
28
+ }
29
+ });
30
+ }
31
+
32
+ function installationTokenLockKey(installationId: number): string {
33
+ if (!Number.isSafeInteger(installationId) || installationId < 0) {
34
+ throw new Error(`Invalid GitHub installation id for advisory lock: ${installationId}`);
35
+ }
36
+
37
+ // Keep these exact per-installation keys away from positive advisory-lock ids.
38
+ return String(-BigInt(installationId) - 1n);
39
+ }
@@ -0,0 +1,67 @@
1
+ import {randomUUID} from 'node:crypto';
2
+ import {GithubInstallationAlreadyLinkedError} from '#core/errors.js';
3
+ import {db} from './db.js';
4
+ import {
5
+ getGithubInstallationByInstallationId,
6
+ type UpsertGithubInstallationParams,
7
+ upsertGithubInstallation,
8
+ } from './installations.js';
9
+ import {githubInstallations} from './schema/installations.js';
10
+
11
+ function installationParams(
12
+ overrides: Partial<UpsertGithubInstallationParams> = {},
13
+ ): UpsertGithubInstallationParams {
14
+ return {
15
+ connectionId: randomUUID(),
16
+ installationId: `${Math.floor(Math.random() * 1_000_000)}`,
17
+ accountLogin: 'shipfox',
18
+ accountType: 'Organization',
19
+ repositorySelection: 'all',
20
+ latestEvent: {id: 1},
21
+ ...overrides,
22
+ };
23
+ }
24
+
25
+ describe('github installations persistence', () => {
26
+ beforeEach(async () => {
27
+ await db().delete(githubInstallations);
28
+ });
29
+
30
+ test('upsert updates in place when the same connection reconnects, without duplicating', async () => {
31
+ const installationId = `${Date.now()}`;
32
+ const connectionId = randomUUID();
33
+ await upsertGithubInstallation(installationParams({connectionId, installationId}));
34
+
35
+ const updated = await upsertGithubInstallation(
36
+ installationParams({connectionId, installationId, accountLogin: 'shipfox-renamed'}),
37
+ );
38
+
39
+ expect(updated.connectionId).toBe(connectionId);
40
+ expect(updated.accountLogin).toBe('shipfox-renamed');
41
+ const fetched = await getGithubInstallationByInstallationId(installationId);
42
+ expect(fetched?.accountLogin).toBe('shipfox-renamed');
43
+ });
44
+
45
+ test('upsert rejects repointing an installation to a different connection (TOCTOU guard)', async () => {
46
+ const installationId = `${Date.now()}`;
47
+ const firstConnectionId = randomUUID();
48
+ const secondConnectionId = randomUUID();
49
+ await upsertGithubInstallation(
50
+ installationParams({connectionId: firstConnectionId, installationId}),
51
+ );
52
+
53
+ const repoint = upsertGithubInstallation(
54
+ installationParams({connectionId: secondConnectionId, installationId}),
55
+ );
56
+
57
+ await expect(repoint).rejects.toBeInstanceOf(GithubInstallationAlreadyLinkedError);
58
+ const fetched = await getGithubInstallationByInstallationId(installationId);
59
+ expect(fetched?.connectionId).toBe(firstConnectionId);
60
+ });
61
+
62
+ test('getGithubInstallationByInstallationId returns undefined for a miss', async () => {
63
+ const result = await getGithubInstallationByInstallationId('missing');
64
+
65
+ expect(result).toBeUndefined();
66
+ });
67
+ });
@@ -0,0 +1,108 @@
1
+ import {eq} from 'drizzle-orm';
2
+ import {GithubInstallationAlreadyLinkedError} from '#core/errors.js';
3
+ import {db} from './db.js';
4
+ import {githubInstallations, toGithubInstallation} from './schema/installations.js';
5
+
6
+ export interface GithubInstallation {
7
+ id: string;
8
+ connectionId: string;
9
+ installationId: string;
10
+ accountLogin: string;
11
+ accountType: string;
12
+ repositorySelection: string;
13
+ suspendedAt: Date | null;
14
+ deletedAt: Date | null;
15
+ latestEvent: Record<string, unknown>;
16
+ installerUserId: string | null;
17
+ createdAt: Date;
18
+ updatedAt: Date;
19
+ }
20
+
21
+ export interface UpsertGithubInstallationParams {
22
+ connectionId: string;
23
+ installationId: string;
24
+ accountLogin: string;
25
+ accountType: string;
26
+ repositorySelection: string;
27
+ suspendedAt?: Date | null | undefined;
28
+ deletedAt?: Date | null | undefined;
29
+ latestEvent: Record<string, unknown>;
30
+ installerUserId?: string | null | undefined;
31
+ }
32
+
33
+ type GithubDb = ReturnType<typeof db>;
34
+ type GithubTx = Parameters<Parameters<GithubDb['transaction']>[0]>[0];
35
+
36
+ export async function upsertGithubInstallation(
37
+ params: UpsertGithubInstallationParams,
38
+ options: {tx?: unknown} = {},
39
+ ): Promise<GithubInstallation> {
40
+ const executor = (options.tx ?? db()) as GithubDb | GithubTx;
41
+ const now = new Date();
42
+ const [row] = await executor
43
+ .insert(githubInstallations)
44
+ .values({
45
+ connectionId: params.connectionId,
46
+ installationId: params.installationId,
47
+ accountLogin: params.accountLogin,
48
+ accountType: params.accountType,
49
+ repositorySelection: params.repositorySelection,
50
+ suspendedAt: params.suspendedAt ?? null,
51
+ deletedAt: params.deletedAt ?? null,
52
+ latestEvent: params.latestEvent,
53
+ installerUserId: params.installerUserId ?? null,
54
+ })
55
+ .onConflictDoUpdate({
56
+ target: githubInstallations.installationId,
57
+ // TOCTOU guard: only (re)point this installation at the connection that
58
+ // already owns it. A concurrent connect of the same installation to a
59
+ // different workspace inserts its own connection row, so its connectionId
60
+ // differs here; the predicate is false, Postgres updates nothing, and the
61
+ // empty RETURNING below rolls the losing transaction back instead of
62
+ // silently repointing the installation (cross-tenant event misroute).
63
+ setWhere: eq(githubInstallations.connectionId, params.connectionId),
64
+ set: {
65
+ connectionId: params.connectionId,
66
+ accountLogin: params.accountLogin,
67
+ accountType: params.accountType,
68
+ repositorySelection: params.repositorySelection,
69
+ suspendedAt: params.suspendedAt ?? null,
70
+ deletedAt: params.deletedAt ?? null,
71
+ latestEvent: params.latestEvent,
72
+ installerUserId: params.installerUserId ?? null,
73
+ updatedAt: now,
74
+ },
75
+ })
76
+ .returning();
77
+
78
+ if (!row) throw new GithubInstallationAlreadyLinkedError(params.installationId);
79
+ return toGithubInstallation(row);
80
+ }
81
+
82
+ export async function getGithubInstallationByConnectionId(
83
+ connectionId: string,
84
+ ): Promise<GithubInstallation | undefined> {
85
+ const rows = await db()
86
+ .select()
87
+ .from(githubInstallations)
88
+ .where(eq(githubInstallations.connectionId, connectionId))
89
+ .limit(1);
90
+ const row = rows[0];
91
+ if (!row) return undefined;
92
+ return toGithubInstallation(row);
93
+ }
94
+
95
+ export async function getGithubInstallationByInstallationId(
96
+ installationId: string,
97
+ options: {tx?: unknown} = {},
98
+ ): Promise<GithubInstallation | undefined> {
99
+ const executor = (options.tx ?? db()) as GithubDb | GithubTx;
100
+ const rows = await executor
101
+ .select()
102
+ .from(githubInstallations)
103
+ .where(eq(githubInstallations.installationId, installationId))
104
+ .limit(1);
105
+ const row = rows[0];
106
+ if (!row) return undefined;
107
+ return toGithubInstallation(row);
108
+ }
@@ -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_github_${name}`);
@@ -0,0 +1,46 @@
1
+ import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
2
+ import {jsonb, text, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
3
+ import type {GithubInstallation} from '#db/installations.js';
4
+ import {pgTable} from './common.js';
5
+
6
+ export const githubInstallations = pgTable(
7
+ 'installations',
8
+ {
9
+ id: uuidv7PrimaryKey(),
10
+ connectionId: uuid('connection_id').notNull(),
11
+ installationId: text('installation_id').notNull(),
12
+ accountLogin: text('account_login').notNull(),
13
+ accountType: text('account_type').notNull(),
14
+ repositorySelection: text('repository_selection').notNull(),
15
+ suspendedAt: timestamp('suspended_at', {withTimezone: true}),
16
+ deletedAt: timestamp('deleted_at', {withTimezone: true}),
17
+ latestEvent: jsonb('latest_event').notNull().$type<Record<string, unknown>>(),
18
+ installerUserId: uuid('installer_user_id'),
19
+ createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
20
+ updatedAt: timestamp('updated_at', {withTimezone: true}).notNull().defaultNow(),
21
+ },
22
+ (table) => [
23
+ uniqueIndex('integrations_github_installations_connection_unique').on(table.connectionId),
24
+ uniqueIndex('integrations_github_installations_installation_unique').on(table.installationId),
25
+ ],
26
+ );
27
+
28
+ export type GithubInstallationDb = typeof githubInstallations.$inferSelect;
29
+ export type GithubInstallationCreateDb = typeof githubInstallations.$inferInsert;
30
+
31
+ export function toGithubInstallation(row: GithubInstallationDb): GithubInstallation {
32
+ return {
33
+ id: row.id,
34
+ connectionId: row.connectionId,
35
+ installationId: row.installationId,
36
+ accountLogin: row.accountLogin,
37
+ accountType: row.accountType,
38
+ repositorySelection: row.repositorySelection,
39
+ suspendedAt: row.suspendedAt,
40
+ deletedAt: row.deletedAt,
41
+ latestEvent: row.latestEvent,
42
+ installerUserId: row.installerUserId,
43
+ createdAt: row.createdAt,
44
+ updatedAt: row.updatedAt,
45
+ };
46
+ }