@shipfox/api-integration-gitea 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 (119) 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 +96 -0
  5. package/LICENSE +21 -0
  6. package/README.md +61 -0
  7. package/dist/api/client.d.ts +58 -0
  8. package/dist/api/client.d.ts.map +1 -0
  9. package/dist/api/client.js +225 -0
  10. package/dist/api/client.js.map +1 -0
  11. package/dist/config.d.ts +11 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/config.js +38 -0
  14. package/dist/config.js.map +1 -0
  15. package/dist/core/connect.d.ts +18 -0
  16. package/dist/core/connect.d.ts.map +1 -0
  17. package/dist/core/connect.js +32 -0
  18. package/dist/core/connect.js.map +1 -0
  19. package/dist/core/connection-url.d.ts +2 -0
  20. package/dist/core/connection-url.d.ts.map +1 -0
  21. package/dist/core/connection-url.js +8 -0
  22. package/dist/core/connection-url.js.map +1 -0
  23. package/dist/core/errors.d.ts +10 -0
  24. package/dist/core/errors.d.ts.map +1 -0
  25. package/dist/core/errors.js +17 -0
  26. package/dist/core/errors.js.map +1 -0
  27. package/dist/core/source-control.d.ts +14 -0
  28. package/dist/core/source-control.d.ts.map +1 -0
  29. package/dist/core/source-control.js +177 -0
  30. package/dist/core/source-control.js.map +1 -0
  31. package/dist/core/webhook.d.ts +38 -0
  32. package/dist/core/webhook.d.ts.map +1 -0
  33. package/dist/core/webhook.js +162 -0
  34. package/dist/core/webhook.js.map +1 -0
  35. package/dist/db/connections.d.ts +19 -0
  36. package/dist/db/connections.d.ts.map +1 -0
  37. package/dist/db/connections.js +42 -0
  38. package/dist/db/connections.js.map +1 -0
  39. package/dist/db/db.d.ts +191 -0
  40. package/dist/db/db.d.ts.map +1 -0
  41. package/dist/db/db.js +18 -0
  42. package/dist/db/db.js.map +1 -0
  43. package/dist/db/migrations.d.ts +2 -0
  44. package/dist/db/migrations.d.ts.map +1 -0
  45. package/dist/db/migrations.js +5 -0
  46. package/dist/db/migrations.js.map +1 -0
  47. package/dist/db/schema/common.d.ts +2 -0
  48. package/dist/db/schema/common.d.ts.map +1 -0
  49. package/dist/db/schema/common.js +4 -0
  50. package/dist/db/schema/common.js.map +1 -0
  51. package/dist/db/schema/connections.d.ts +97 -0
  52. package/dist/db/schema/connections.d.ts.map +1 -0
  53. package/dist/db/schema/connections.js +28 -0
  54. package/dist/db/schema/connections.js.map +1 -0
  55. package/dist/index.d.ts +39 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +42 -0
  58. package/dist/index.js.map +1 -0
  59. package/dist/presentation/dto/integrations.d.ts +17 -0
  60. package/dist/presentation/dto/integrations.d.ts.map +1 -0
  61. package/dist/presentation/dto/integrations.js +11 -0
  62. package/dist/presentation/dto/integrations.js.map +1 -0
  63. package/dist/presentation/routes/connections.d.ts +13 -0
  64. package/dist/presentation/routes/connections.d.ts.map +1 -0
  65. package/dist/presentation/routes/connections.js +47 -0
  66. package/dist/presentation/routes/connections.js.map +1 -0
  67. package/dist/presentation/routes/errors.d.ts +2 -0
  68. package/dist/presentation/routes/errors.d.ts.map +1 -0
  69. package/dist/presentation/routes/errors.js +36 -0
  70. package/dist/presentation/routes/errors.js.map +1 -0
  71. package/dist/presentation/routes/webhooks.d.ts +11 -0
  72. package/dist/presentation/routes/webhooks.d.ts.map +1 -0
  73. package/dist/presentation/routes/webhooks.js +109 -0
  74. package/dist/presentation/routes/webhooks.js.map +1 -0
  75. package/dist/tsconfig.test.tsbuildinfo +1 -0
  76. package/drizzle/0000_initial.sql +10 -0
  77. package/drizzle/meta/0000_snapshot.json +96 -0
  78. package/drizzle/meta/_journal.json +13 -0
  79. package/drizzle.config.ts +7 -0
  80. package/package.json +66 -0
  81. package/src/api/client.test.ts +367 -0
  82. package/src/api/client.ts +364 -0
  83. package/src/config.ts +42 -0
  84. package/src/connection-external-url.test.ts +36 -0
  85. package/src/core/connect.test.ts +149 -0
  86. package/src/core/connect.ts +52 -0
  87. package/src/core/connection-url.ts +8 -0
  88. package/src/core/errors.ts +17 -0
  89. package/src/core/source-control-clone-url.test.ts +83 -0
  90. package/src/core/source-control.test.ts +349 -0
  91. package/src/core/source-control.ts +231 -0
  92. package/src/core/webhook.test.ts +396 -0
  93. package/src/core/webhook.ts +203 -0
  94. package/src/db/connections.test.ts +69 -0
  95. package/src/db/connections.ts +80 -0
  96. package/src/db/db.ts +18 -0
  97. package/src/db/migrations.ts +4 -0
  98. package/src/db/schema/common.ts +3 -0
  99. package/src/db/schema/connections.ts +32 -0
  100. package/src/index.test.ts +23 -0
  101. package/src/index.ts +83 -0
  102. package/src/presentation/dto/integrations.ts +14 -0
  103. package/src/presentation/routes/connections.test.ts +200 -0
  104. package/src/presentation/routes/connections.ts +63 -0
  105. package/src/presentation/routes/errors.ts +35 -0
  106. package/src/presentation/routes/webhooks.test.ts +188 -0
  107. package/src/presentation/routes/webhooks.ts +111 -0
  108. package/test/env.ts +11 -0
  109. package/test/factories/gitea-connection.ts +19 -0
  110. package/test/factories/index.ts +1 -0
  111. package/test/fixtures/gitea-webhook.ts +237 -0
  112. package/test/globalSetup.ts +14 -0
  113. package/test/index.ts +6 -0
  114. package/test/setup.ts +17 -0
  115. package/tsconfig.build.json +9 -0
  116. package/tsconfig.build.tsbuildinfo +1 -0
  117. package/tsconfig.json +3 -0
  118. package/tsconfig.test.json +9 -0
  119. package/vitest.config.ts +12 -0
@@ -0,0 +1,396 @@
1
+ import {randomUUID} from 'node:crypto';
2
+ import type {
3
+ GetIntegrationConnectionByIdFn,
4
+ IntegrationConnection,
5
+ PublishSourcePushFn,
6
+ RecordDeliveryOnlyFn,
7
+ } from '@shipfox/api-integration-core-dto';
8
+ import {db} from '#db/db.js';
9
+ import {giteaConnections} from '#db/schema/connections.js';
10
+ import {capturedGiteaPushPayload, giteaConnectionFactory, giteaPushPayload} from '#test/index.js';
11
+ import {
12
+ GiteaWebhookMalformedJsonError,
13
+ GiteaWebhookMalformedPushPayloadError,
14
+ handleGiteaWebhook,
15
+ } from './webhook.js';
16
+
17
+ function fakeConnection(overrides: Partial<IntegrationConnection> = {}): IntegrationConnection {
18
+ return {
19
+ id: randomUUID(),
20
+ workspaceId: randomUUID(),
21
+ provider: 'gitea',
22
+ externalAccountId: 'shipfox',
23
+ slug: 'gitea_shipfox',
24
+ displayName: 'Gitea shipfox',
25
+ lifecycleStatus: 'active',
26
+ createdAt: new Date(),
27
+ updatedAt: new Date(),
28
+ ...overrides,
29
+ };
30
+ }
31
+
32
+ function deps(options: {connection?: IntegrationConnection} = {}) {
33
+ return {
34
+ publishSourcePush: vi.fn<PublishSourcePushFn>(() => Promise.resolve({published: true})),
35
+ recordDeliveryOnly: vi.fn<RecordDeliveryOnlyFn>(() => Promise.resolve()),
36
+ getIntegrationConnectionById: vi.fn<GetIntegrationConnectionByIdFn>(() =>
37
+ Promise.resolve(options.connection ?? fakeConnection()),
38
+ ),
39
+ };
40
+ }
41
+
42
+ function firstPublishSourcePushCall(publishSourcePush: {
43
+ mock: {calls: Array<Parameters<PublishSourcePushFn>>};
44
+ }): Parameters<PublishSourcePushFn>[0] {
45
+ const [call] = publishSourcePush.mock.calls;
46
+ if (!call) {
47
+ throw new Error('Expected publishSourcePush to be called');
48
+ }
49
+
50
+ return call[0];
51
+ }
52
+
53
+ function firstRecordDeliveryOnlyCall(recordDeliveryOnly: {
54
+ mock: {calls: Array<Parameters<RecordDeliveryOnlyFn>>};
55
+ }): Parameters<RecordDeliveryOnlyFn>[0] {
56
+ const [call] = recordDeliveryOnly.mock.calls;
57
+ if (!call) {
58
+ throw new Error('Expected recordDeliveryOnly to be called');
59
+ }
60
+
61
+ return call[0];
62
+ }
63
+
64
+ async function seedConnection(org: string, connectionId?: string): Promise<void> {
65
+ await giteaConnectionFactory.create({
66
+ org,
67
+ ...(connectionId !== undefined && {connectionId}),
68
+ });
69
+ }
70
+
71
+ describe('handleGiteaWebhook', () => {
72
+ beforeEach(async () => {
73
+ await db().delete(giteaConnections);
74
+ });
75
+
76
+ it('publishes a mapped event for a valid push from a connected org', async () => {
77
+ const connection = fakeConnection();
78
+ await seedConnection('shipfox', connection.id);
79
+ const deliveryId = randomUUID();
80
+ const handlers = deps({connection});
81
+ const payload = giteaPushPayload({
82
+ owner: 'shipfox',
83
+ repo: 'api',
84
+ ref: 'refs/heads/main',
85
+ defaultBranch: 'main',
86
+ sha: 'abc123',
87
+ });
88
+ const rawBody = JSON.stringify(payload);
89
+
90
+ const result = await handleGiteaWebhook({
91
+ tx: db(),
92
+ deliveryId,
93
+ event: 'push',
94
+ rawBody,
95
+ ...handlers,
96
+ });
97
+
98
+ expect(result.outcome).toBe('published');
99
+ expect(handlers.recordDeliveryOnly).not.toHaveBeenCalled();
100
+ expect(handlers.getIntegrationConnectionById).toHaveBeenCalledTimes(1);
101
+ expect(handlers.getIntegrationConnectionById).toHaveBeenCalledWith(
102
+ connection.id,
103
+ expect.objectContaining({tx: expect.anything()}),
104
+ );
105
+ expect(handlers.publishSourcePush).toHaveBeenCalledTimes(1);
106
+ const call = firstPublishSourcePushCall(handlers.publishSourcePush);
107
+ expect(call.tx).toBeDefined();
108
+ expect(call).toMatchObject({
109
+ provider: 'gitea',
110
+ source: connection.slug,
111
+ deliveryId,
112
+ workspaceId: connection.workspaceId,
113
+ connectionId: connection.id,
114
+ connectionName: connection.displayName,
115
+ rawPayload: payload,
116
+ push: {
117
+ externalRepositoryId: 'gitea:shipfox/api',
118
+ ref: 'main',
119
+ headCommitSha: 'abc123',
120
+ isDefaultBranch: true,
121
+ },
122
+ });
123
+ });
124
+
125
+ it('accepts the captured local Gitea push payload shape', async () => {
126
+ const connection = fakeConnection();
127
+ await seedConnection('shipfox', connection.id);
128
+ const handlers = deps({connection});
129
+ const deliveryId = randomUUID();
130
+
131
+ const result = await handleGiteaWebhook({
132
+ tx: db(),
133
+ deliveryId,
134
+ event: 'push',
135
+ rawBody: JSON.stringify(capturedGiteaPushPayload),
136
+ ...handlers,
137
+ });
138
+
139
+ expect(result.outcome).toBe('published');
140
+ expect(handlers.publishSourcePush).toHaveBeenCalledTimes(1);
141
+ expect(firstPublishSourcePushCall(handlers.publishSourcePush)).toMatchObject({
142
+ provider: 'gitea',
143
+ source: connection.slug,
144
+ deliveryId,
145
+ workspaceId: connection.workspaceId,
146
+ connectionId: connection.id,
147
+ rawPayload: capturedGiteaPushPayload,
148
+ push: {
149
+ externalRepositoryId: 'gitea:shipfox/api',
150
+ ref: 'main',
151
+ headCommitSha: capturedGiteaPushPayload.after,
152
+ defaultBranch: 'main',
153
+ isDefaultBranch: true,
154
+ },
155
+ });
156
+ });
157
+
158
+ it('publishes with isDefaultBranch=false when ref is not the default branch', async () => {
159
+ await seedConnection('shipfox');
160
+ const handlers = deps();
161
+ const rawBody = JSON.stringify(
162
+ giteaPushPayload({
163
+ owner: 'shipfox',
164
+ repo: 'api',
165
+ ref: 'refs/heads/feature/x',
166
+ defaultBranch: 'main',
167
+ sha: 'feat1',
168
+ }),
169
+ );
170
+
171
+ const result = await handleGiteaWebhook({
172
+ tx: db(),
173
+ deliveryId: randomUUID(),
174
+ event: 'push',
175
+ rawBody,
176
+ ...handlers,
177
+ });
178
+
179
+ expect(result.outcome).toBe('published');
180
+ expect(handlers.publishSourcePush).toHaveBeenCalledTimes(1);
181
+ expect(firstPublishSourcePushCall(handlers.publishSourcePush).push).toMatchObject({
182
+ ref: 'feature/x',
183
+ isDefaultBranch: false,
184
+ });
185
+ });
186
+
187
+ it('resolves a mixed-case owner against the lower-cased stored org', async () => {
188
+ const connection = fakeConnection();
189
+ await seedConnection('shipfox', connection.id);
190
+ const handlers = deps({connection});
191
+ const rawBody = JSON.stringify(
192
+ giteaPushPayload({
193
+ owner: 'ShipFox',
194
+ repo: 'API',
195
+ ref: 'refs/heads/main',
196
+ defaultBranch: 'main',
197
+ sha: 'casing',
198
+ }),
199
+ );
200
+
201
+ const result = await handleGiteaWebhook({
202
+ tx: db(),
203
+ deliveryId: randomUUID(),
204
+ event: 'push',
205
+ rawBody,
206
+ ...handlers,
207
+ });
208
+
209
+ expect(result.outcome).toBe('published');
210
+ expect(handlers.publishSourcePush).toHaveBeenCalledTimes(1);
211
+ // Repo id keeps the payload's verbatim casing to match the source-control adapter.
212
+ expect(firstPublishSourcePushCall(handlers.publishSourcePush).push.externalRepositoryId).toBe(
213
+ 'gitea:ShipFox/API',
214
+ );
215
+ });
216
+
217
+ it('ignores a branch deletion without publishing', async () => {
218
+ const connection = fakeConnection();
219
+ await seedConnection('shipfox', connection.id);
220
+ const handlers = deps({connection});
221
+ const rawBody = JSON.stringify(
222
+ giteaPushPayload({
223
+ owner: 'shipfox',
224
+ repo: 'api',
225
+ ref: 'refs/heads/main',
226
+ defaultBranch: 'main',
227
+ sha: '0000000000000000000000000000000000000000',
228
+ }),
229
+ );
230
+
231
+ const result = await handleGiteaWebhook({
232
+ tx: db(),
233
+ deliveryId: randomUUID(),
234
+ event: 'push',
235
+ rawBody,
236
+ ...handlers,
237
+ });
238
+
239
+ expect(result.outcome).toBe('deleted');
240
+ expect(handlers.publishSourcePush).not.toHaveBeenCalled();
241
+ expect(handlers.recordDeliveryOnly).not.toHaveBeenCalled();
242
+ });
243
+
244
+ it('records the delivery only for non-push events', async () => {
245
+ const handlers = deps();
246
+ const deliveryId = randomUUID();
247
+
248
+ const result = await handleGiteaWebhook({
249
+ tx: db(),
250
+ deliveryId,
251
+ event: 'create',
252
+ rawBody: JSON.stringify({hello: 'world'}),
253
+ ...handlers,
254
+ });
255
+
256
+ expect(result.outcome).toBe('recorded-only');
257
+ expect(handlers.publishSourcePush).not.toHaveBeenCalled();
258
+ expect(handlers.recordDeliveryOnly).toHaveBeenCalledTimes(1);
259
+ expect(firstRecordDeliveryOnlyCall(handlers.recordDeliveryOnly)).toMatchObject({
260
+ provider: 'gitea',
261
+ deliveryId,
262
+ });
263
+ });
264
+
265
+ it('records the delivery only for pushes from an unknown org', async () => {
266
+ const handlers = deps();
267
+ const deliveryId = randomUUID();
268
+ const rawBody = JSON.stringify(
269
+ giteaPushPayload({
270
+ owner: 'ghost',
271
+ repo: 'api',
272
+ ref: 'refs/heads/main',
273
+ defaultBranch: 'main',
274
+ sha: 'orphan',
275
+ }),
276
+ );
277
+
278
+ const result = await handleGiteaWebhook({
279
+ tx: db(),
280
+ deliveryId,
281
+ event: 'push',
282
+ rawBody,
283
+ ...handlers,
284
+ });
285
+
286
+ expect(result.outcome).toBe('unknown-org');
287
+ expect(handlers.publishSourcePush).not.toHaveBeenCalled();
288
+ expect(handlers.getIntegrationConnectionById).not.toHaveBeenCalled();
289
+ expect(handlers.recordDeliveryOnly).toHaveBeenCalledTimes(1);
290
+ expect(firstRecordDeliveryOnlyCall(handlers.recordDeliveryOnly)).toMatchObject({
291
+ provider: 'gitea',
292
+ deliveryId,
293
+ });
294
+ });
295
+
296
+ it('records the delivery only when the org has no core connection', async () => {
297
+ const handlers = deps();
298
+ handlers.getIntegrationConnectionById.mockResolvedValue(undefined);
299
+ await seedConnection('shipfox');
300
+ const deliveryId = randomUUID();
301
+ const rawBody = JSON.stringify(
302
+ giteaPushPayload({
303
+ owner: 'shipfox',
304
+ repo: 'api',
305
+ ref: 'refs/heads/main',
306
+ defaultBranch: 'main',
307
+ sha: 'dangling',
308
+ }),
309
+ );
310
+
311
+ const result = await handleGiteaWebhook({
312
+ tx: db(),
313
+ deliveryId,
314
+ event: 'push',
315
+ rawBody,
316
+ ...handlers,
317
+ });
318
+
319
+ expect(result.outcome).toBe('unknown-org');
320
+ expect(handlers.getIntegrationConnectionById).toHaveBeenCalledTimes(1);
321
+ expect(handlers.publishSourcePush).not.toHaveBeenCalled();
322
+ expect(handlers.recordDeliveryOnly).toHaveBeenCalledTimes(1);
323
+ expect(firstRecordDeliveryOnlyCall(handlers.recordDeliveryOnly)).toMatchObject({
324
+ provider: 'gitea',
325
+ deliveryId,
326
+ });
327
+ });
328
+
329
+ it.each([
330
+ 'disabled',
331
+ 'error',
332
+ ] as const)('records the delivery only when the connection is %s', async (lifecycleStatus) => {
333
+ const connection = fakeConnection({lifecycleStatus});
334
+ await seedConnection('shipfox', connection.id);
335
+ const handlers = deps({connection});
336
+ const deliveryId = randomUUID();
337
+ const rawBody = JSON.stringify(
338
+ giteaPushPayload({
339
+ owner: 'shipfox',
340
+ repo: 'api',
341
+ ref: 'refs/heads/main',
342
+ defaultBranch: 'main',
343
+ sha: 'inactive',
344
+ }),
345
+ );
346
+
347
+ const result = await handleGiteaWebhook({
348
+ tx: db(),
349
+ deliveryId,
350
+ event: 'push',
351
+ rawBody,
352
+ ...handlers,
353
+ });
354
+
355
+ expect(result.outcome).toBe('inactive-connection');
356
+ expect(handlers.getIntegrationConnectionById).toHaveBeenCalledTimes(1);
357
+ expect(handlers.publishSourcePush).not.toHaveBeenCalled();
358
+ expect(handlers.recordDeliveryOnly).toHaveBeenCalledTimes(1);
359
+ expect(firstRecordDeliveryOnlyCall(handlers.recordDeliveryOnly)).toMatchObject({
360
+ provider: 'gitea',
361
+ deliveryId,
362
+ });
363
+ });
364
+
365
+ it('rejects malformed JSON', async () => {
366
+ const handlers = deps();
367
+
368
+ const result = handleGiteaWebhook({
369
+ tx: db(),
370
+ deliveryId: randomUUID(),
371
+ event: 'push',
372
+ rawBody: '{not valid json',
373
+ ...handlers,
374
+ });
375
+
376
+ await expect(result).rejects.toBeInstanceOf(GiteaWebhookMalformedJsonError);
377
+ expect(handlers.publishSourcePush).not.toHaveBeenCalled();
378
+ expect(handlers.recordDeliveryOnly).not.toHaveBeenCalled();
379
+ });
380
+
381
+ it('rejects push payloads that fail schema validation', async () => {
382
+ const handlers = deps();
383
+
384
+ const result = handleGiteaWebhook({
385
+ tx: db(),
386
+ deliveryId: randomUUID(),
387
+ event: 'push',
388
+ rawBody: JSON.stringify({ref: 'refs/heads/main'}),
389
+ ...handlers,
390
+ });
391
+
392
+ await expect(result).rejects.toBeInstanceOf(GiteaWebhookMalformedPushPayloadError);
393
+ expect(handlers.publishSourcePush).not.toHaveBeenCalled();
394
+ expect(handlers.recordDeliveryOnly).not.toHaveBeenCalled();
395
+ });
396
+ });
@@ -0,0 +1,203 @@
1
+ import {
2
+ buildProviderRepositoryId,
3
+ type GetIntegrationConnectionByIdFn,
4
+ type IntegrationTx,
5
+ type PublishSourcePushFn,
6
+ type RecordDeliveryOnlyFn,
7
+ type SourcePushPayload,
8
+ } from '@shipfox/api-integration-core-dto';
9
+ import {
10
+ type GiteaPushPayloadDto,
11
+ giteaProviderKind,
12
+ giteaPushPayloadSchema,
13
+ } from '@shipfox/api-integration-gitea-dto';
14
+ import {logger} from '@shipfox/node-opentelemetry';
15
+ import {getGiteaConnectionByOrg} from '#db/connections.js';
16
+
17
+ const REFS_HEADS_PREFIX = 'refs/heads/';
18
+ // Gitea sends a `push` webhook for a branch deletion with `after` set to this all-zero SHA.
19
+ const DELETED_BRANCH_SHA = '0'.repeat(40);
20
+
21
+ export class GiteaWebhookMalformedJsonError extends Error {
22
+ constructor(options: {cause: unknown}) {
23
+ super('Gitea webhook payload is not valid JSON', {cause: options.cause});
24
+ this.name = 'GiteaWebhookMalformedJsonError';
25
+ }
26
+ }
27
+
28
+ export class GiteaWebhookMalformedPushPayloadError extends Error {
29
+ constructor(public readonly issues: unknown) {
30
+ super('Gitea webhook push payload failed schema validation');
31
+ this.name = 'GiteaWebhookMalformedPushPayloadError';
32
+ }
33
+ }
34
+
35
+ export interface HandleGiteaWebhookParams {
36
+ tx: IntegrationTx;
37
+ deliveryId: string;
38
+ event: string;
39
+ rawBody: string;
40
+ publishSourcePush: PublishSourcePushFn;
41
+ recordDeliveryOnly: RecordDeliveryOnlyFn;
42
+ getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
43
+ }
44
+
45
+ export interface HandleGiteaPushParams {
46
+ tx: IntegrationTx;
47
+ deliveryId: string;
48
+ payload: GiteaPushPayloadDto;
49
+ rawPayload: unknown;
50
+ publishSourcePush: PublishSourcePushFn;
51
+ recordDeliveryOnly: RecordDeliveryOnlyFn;
52
+ getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
53
+ }
54
+
55
+ export type HandleGiteaPushOutcome =
56
+ | 'published'
57
+ | 'duplicate'
58
+ | 'deleted'
59
+ | 'unknown-org'
60
+ | 'inactive-connection';
61
+ export type HandleGiteaWebhookOutcome = HandleGiteaPushOutcome | 'recorded-only';
62
+
63
+ function isBranchDeletion(after: string): boolean {
64
+ return after === DELETED_BRANCH_SHA;
65
+ }
66
+
67
+ export async function handleGiteaWebhook(
68
+ params: HandleGiteaWebhookParams,
69
+ ): Promise<{outcome: HandleGiteaWebhookOutcome}> {
70
+ if (params.event !== 'push') {
71
+ await params.recordDeliveryOnly({
72
+ tx: params.tx,
73
+ provider: giteaProviderKind,
74
+ deliveryId: params.deliveryId,
75
+ });
76
+ return {outcome: 'recorded-only'};
77
+ }
78
+
79
+ const {payload, rawPayload} = parseGiteaPushPayload(params.rawBody);
80
+ return handleGiteaPush({
81
+ tx: params.tx,
82
+ deliveryId: params.deliveryId,
83
+ payload,
84
+ rawPayload,
85
+ publishSourcePush: params.publishSourcePush,
86
+ recordDeliveryOnly: params.recordDeliveryOnly,
87
+ getIntegrationConnectionById: params.getIntegrationConnectionById,
88
+ });
89
+ }
90
+
91
+ export async function handleGiteaPush(
92
+ params: HandleGiteaPushParams,
93
+ ): Promise<{outcome: HandleGiteaPushOutcome}> {
94
+ // Dropping deletes before `publishSourcePush` avoids project and trigger fan-out.
95
+ if (isBranchDeletion(params.payload.after)) {
96
+ return {outcome: 'deleted'};
97
+ }
98
+
99
+ const owner = params.payload.repository.owner.username;
100
+ // Gitea org routes are case-insensitive, and the connect flow stores them lower-cased.
101
+ const giteaConnection = await getGiteaConnectionByOrg(owner.toLowerCase(), {tx: params.tx});
102
+ if (!giteaConnection) {
103
+ logger().warn(
104
+ {deliveryId: params.deliveryId, org: owner},
105
+ 'gitea webhook: unknown org, dropping',
106
+ );
107
+ await params.recordDeliveryOnly({
108
+ tx: params.tx,
109
+ provider: giteaProviderKind,
110
+ deliveryId: params.deliveryId,
111
+ });
112
+ return {outcome: 'unknown-org'};
113
+ }
114
+
115
+ const connection = await params.getIntegrationConnectionById(giteaConnection.connectionId, {
116
+ tx: params.tx,
117
+ });
118
+ if (!connection) {
119
+ logger().warn(
120
+ {deliveryId: params.deliveryId, org: owner, connectionId: giteaConnection.connectionId},
121
+ 'gitea webhook: org has no connection, dropping',
122
+ );
123
+ await params.recordDeliveryOnly({
124
+ tx: params.tx,
125
+ provider: giteaProviderKind,
126
+ deliveryId: params.deliveryId,
127
+ });
128
+ return {outcome: 'unknown-org'};
129
+ }
130
+
131
+ if (connection.lifecycleStatus !== 'active') {
132
+ const logContext = {
133
+ deliveryId: params.deliveryId,
134
+ org: owner,
135
+ connectionId: connection.id,
136
+ workspaceId: connection.workspaceId,
137
+ lifecycleStatus: connection.lifecycleStatus,
138
+ };
139
+ // `disabled` is an expected steady state; only `error` is anomalous.
140
+ if (connection.lifecycleStatus === 'error') {
141
+ logger().warn(logContext, 'gitea webhook: connection in error state, dropping');
142
+ } else {
143
+ logger().info(logContext, 'gitea webhook: connection disabled, dropping');
144
+ }
145
+ await params.recordDeliveryOnly({
146
+ tx: params.tx,
147
+ provider: giteaProviderKind,
148
+ deliveryId: params.deliveryId,
149
+ });
150
+ return {outcome: 'inactive-connection'};
151
+ }
152
+
153
+ const ref = stripRefsHeads(params.payload.ref);
154
+ const defaultBranch = params.payload.repository.default_branch;
155
+ const push: SourcePushPayload = {
156
+ // Match the source-control adapter's owner/name repository id.
157
+ externalRepositoryId: buildProviderRepositoryId(
158
+ giteaProviderKind,
159
+ `${owner}/${params.payload.repository.name}`,
160
+ ),
161
+ ref,
162
+ headCommitSha: params.payload.after,
163
+ defaultBranch,
164
+ isDefaultBranch: ref === defaultBranch,
165
+ };
166
+ const result = await params.publishSourcePush({
167
+ tx: params.tx,
168
+ provider: giteaProviderKind,
169
+ source: connection.slug,
170
+ workspaceId: connection.workspaceId,
171
+ connectionId: connection.id,
172
+ connectionName: connection.displayName,
173
+ deliveryId: params.deliveryId,
174
+ receivedAt: new Date().toISOString(),
175
+ rawPayload: params.rawPayload,
176
+ push,
177
+ });
178
+
179
+ return {outcome: result.published ? 'published' : 'duplicate'};
180
+ }
181
+
182
+ function stripRefsHeads(ref: string): string {
183
+ return ref.startsWith(REFS_HEADS_PREFIX) ? ref.slice(REFS_HEADS_PREFIX.length) : ref;
184
+ }
185
+
186
+ function parseGiteaPushPayload(rawBody: string): {
187
+ payload: GiteaPushPayloadDto;
188
+ rawPayload: unknown;
189
+ } {
190
+ let parsedJson: unknown;
191
+ try {
192
+ parsedJson = JSON.parse(rawBody);
193
+ } catch (error) {
194
+ throw new GiteaWebhookMalformedJsonError({cause: error});
195
+ }
196
+
197
+ const validated = giteaPushPayloadSchema.safeParse(parsedJson);
198
+ if (!validated.success) {
199
+ throw new GiteaWebhookMalformedPushPayloadError(validated.error.issues);
200
+ }
201
+
202
+ return {payload: validated.data, rawPayload: parsedJson};
203
+ }
@@ -0,0 +1,69 @@
1
+ import {randomUUID} from 'node:crypto';
2
+ import {GiteaOrgAlreadyLinkedError} from '#core/errors.js';
3
+ import {
4
+ getGiteaConnectionByConnectionId,
5
+ getGiteaConnectionByOrg,
6
+ type UpsertGiteaConnectionParams,
7
+ upsertGiteaConnection,
8
+ } from './connections.js';
9
+ import {db} from './db.js';
10
+ import {giteaConnections} from './schema/connections.js';
11
+
12
+ function connectionParams(
13
+ overrides: Partial<UpsertGiteaConnectionParams> = {},
14
+ ): UpsertGiteaConnectionParams {
15
+ return {
16
+ connectionId: randomUUID(),
17
+ org: `org-${Math.floor(Math.random() * 1_000_000)}`,
18
+ ...overrides,
19
+ };
20
+ }
21
+
22
+ describe('gitea connections persistence', () => {
23
+ beforeEach(async () => {
24
+ await db().delete(giteaConnections);
25
+ });
26
+
27
+ test('upsert updates in place when the same connection reconnects, without duplicating', async () => {
28
+ const org = `org-${Date.now()}`;
29
+ const connectionId = randomUUID();
30
+ await upsertGiteaConnection(connectionParams({connectionId, org}));
31
+
32
+ const updated = await upsertGiteaConnection(connectionParams({connectionId, org}));
33
+
34
+ expect(updated.connectionId).toBe(connectionId);
35
+ const fetched = await getGiteaConnectionByOrg(org);
36
+ expect(fetched?.connectionId).toBe(connectionId);
37
+ });
38
+
39
+ test('upsert rejects linking an org already owned by another connection (TOCTOU guard)', async () => {
40
+ const org = `org-${Date.now()}`;
41
+ const firstConnectionId = randomUUID();
42
+ const secondConnectionId = randomUUID();
43
+ await upsertGiteaConnection(connectionParams({connectionId: firstConnectionId, org}));
44
+
45
+ const takeover = upsertGiteaConnection(
46
+ connectionParams({connectionId: secondConnectionId, org}),
47
+ );
48
+
49
+ await expect(takeover).rejects.toBeInstanceOf(GiteaOrgAlreadyLinkedError);
50
+ const fetched = await getGiteaConnectionByOrg(org);
51
+ expect(fetched?.connectionId).toBe(firstConnectionId);
52
+ });
53
+
54
+ test('getGiteaConnectionByConnectionId returns the row for a known connection', async () => {
55
+ const connectionId = randomUUID();
56
+ const org = `org-${Date.now()}`;
57
+ await upsertGiteaConnection(connectionParams({connectionId, org}));
58
+
59
+ const fetched = await getGiteaConnectionByConnectionId(connectionId);
60
+
61
+ expect(fetched?.org).toBe(org);
62
+ });
63
+
64
+ test('getGiteaConnectionByOrg returns undefined for a miss', async () => {
65
+ const result = await getGiteaConnectionByOrg('missing');
66
+
67
+ expect(result).toBeUndefined();
68
+ });
69
+ });