@shipfox/api-projects 11.0.0 → 12.2.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 (98) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +53 -0
  3. package/README.md +83 -0
  4. package/dist/core/entities/project.d.ts +4 -0
  5. package/dist/core/entities/project.d.ts.map +1 -1
  6. package/dist/core/entities/project.js.map +1 -1
  7. package/dist/core/errors.d.ts +4 -0
  8. package/dist/core/errors.d.ts.map +1 -1
  9. package/dist/core/errors.js +6 -0
  10. package/dist/core/errors.js.map +1 -1
  11. package/dist/core/index.d.ts +3 -3
  12. package/dist/core/index.d.ts.map +1 -1
  13. package/dist/core/index.js +2 -2
  14. package/dist/core/index.js.map +1 -1
  15. package/dist/core/projects.d.ts +8 -0
  16. package/dist/core/projects.d.ts.map +1 -1
  17. package/dist/core/projects.js +62 -19
  18. package/dist/core/projects.js.map +1 -1
  19. package/dist/db/db.d.ts +144 -6
  20. package/dist/db/db.d.ts.map +1 -1
  21. package/dist/db/db.js.map +1 -1
  22. package/dist/db/index.d.ts +2 -2
  23. package/dist/db/index.d.ts.map +1 -1
  24. package/dist/db/index.js +1 -1
  25. package/dist/db/index.js.map +1 -1
  26. package/dist/db/projects.d.ts +53 -0
  27. package/dist/db/projects.d.ts.map +1 -1
  28. package/dist/db/projects.js +116 -18
  29. package/dist/db/projects.js.map +1 -1
  30. package/dist/db/schema/integration-event-dedup.d.ts +1 -1
  31. package/dist/db/schema/outbox.d.ts +1 -1
  32. package/dist/db/schema/projects.d.ts +69 -1
  33. package/dist/db/schema/projects.d.ts.map +1 -1
  34. package/dist/db/schema/projects.js +12 -1
  35. package/dist/db/schema/projects.js.map +1 -1
  36. package/dist/index.d.ts +1 -1
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +8 -5
  39. package/dist/index.js.map +1 -1
  40. package/dist/presentation/dto/project.d.ts +2 -1
  41. package/dist/presentation/dto/project.d.ts.map +1 -1
  42. package/dist/presentation/dto/project.js +1 -0
  43. package/dist/presentation/dto/project.js.map +1 -1
  44. package/dist/presentation/e2eRoutes/create-project.d.ts.map +1 -1
  45. package/dist/presentation/e2eRoutes/create-project.js +34 -8
  46. package/dist/presentation/e2eRoutes/create-project.js.map +1 -1
  47. package/dist/presentation/inter-module.d.ts +4 -1
  48. package/dist/presentation/inter-module.d.ts.map +1 -1
  49. package/dist/presentation/inter-module.js +63 -3
  50. package/dist/presentation/inter-module.js.map +1 -1
  51. package/dist/presentation/routes/create-project.d.ts.map +1 -1
  52. package/dist/presentation/routes/create-project.js +8 -2
  53. package/dist/presentation/routes/create-project.js.map +1 -1
  54. package/dist/presentation/routes/index.d.ts.map +1 -1
  55. package/dist/presentation/routes/index.js +3 -1
  56. package/dist/presentation/routes/index.js.map +1 -1
  57. package/dist/presentation/routes/update-project.d.ts +2 -0
  58. package/dist/presentation/routes/update-project.d.ts.map +1 -0
  59. package/dist/presentation/routes/update-project.js +53 -0
  60. package/dist/presentation/routes/update-project.js.map +1 -0
  61. package/dist/presentation/subscribers/index.d.ts +1 -0
  62. package/dist/presentation/subscribers/index.d.ts.map +1 -1
  63. package/dist/presentation/subscribers/index.js +1 -0
  64. package/dist/presentation/subscribers/index.js.map +1 -1
  65. package/dist/presentation/subscribers/on-source-repository-updated.d.ts +4 -0
  66. package/dist/presentation/subscribers/on-source-repository-updated.d.ts.map +1 -0
  67. package/dist/presentation/subscribers/on-source-repository-updated.js +39 -0
  68. package/dist/presentation/subscribers/on-source-repository-updated.js.map +1 -0
  69. package/dist/tsconfig.test.tsbuildinfo +1 -1
  70. package/drizzle/0000_initial.sql +6 -0
  71. package/drizzle/meta/0000_snapshot.json +78 -0
  72. package/package.json +13 -12
  73. package/src/core/entities/project.ts +4 -0
  74. package/src/core/errors.ts +7 -0
  75. package/src/core/index.ts +3 -2
  76. package/src/core/projects.test.ts +10 -0
  77. package/src/core/projects.ts +90 -35
  78. package/src/db/db.ts +3 -0
  79. package/src/db/index.ts +6 -0
  80. package/src/db/projects.test.ts +44 -1
  81. package/src/db/projects.ts +242 -33
  82. package/src/db/schema/projects.ts +16 -0
  83. package/src/index.ts +9 -3
  84. package/src/presentation/dto/project.ts +1 -0
  85. package/src/presentation/e2eRoutes/create-project.test.ts +23 -0
  86. package/src/presentation/e2eRoutes/create-project.ts +39 -10
  87. package/src/presentation/inter-module.test.ts +349 -0
  88. package/src/presentation/inter-module.ts +90 -4
  89. package/src/presentation/routes/create-project.ts +10 -2
  90. package/src/presentation/routes/index.ts +7 -1
  91. package/src/presentation/routes/projects.test.ts +300 -4
  92. package/src/presentation/routes/update-project.ts +46 -0
  93. package/src/presentation/subscribers/index.ts +1 -0
  94. package/src/presentation/subscribers/on-source-commit-pushed.test.ts +2 -0
  95. package/src/presentation/subscribers/on-source-repository-updated.test.ts +108 -0
  96. package/src/presentation/subscribers/on-source-repository-updated.ts +50 -0
  97. package/test/factories/project.ts +6 -0
  98. package/tsconfig.build.tsbuildinfo +1 -1
@@ -15,9 +15,12 @@ import {
15
15
  import {createInterModuleKnownError} from '@shipfox/inter-module';
16
16
  import type {AuthMethod} from '@shipfox/node-fastify';
17
17
  import {closeApp, createApp} from '@shipfox/node-fastify';
18
+ import {sql} from 'drizzle-orm';
18
19
  import type {FastifyInstance, FastifyRequest} from 'fastify';
19
20
  import type {Project} from '#core/entities/project.js';
21
+ import {db} from '#db/db.js';
20
22
  import {createProject} from '#db/projects.js';
23
+ import {projectsOutbox} from '#db/schema/outbox.js';
21
24
  import {createProjectRoutes} from './index.js';
22
25
 
23
26
  let authenticatedMemberships: UserContextMembership[] = [];
@@ -95,6 +98,7 @@ describe('project routes', () => {
95
98
  payload: {
96
99
  workspace_id: workspaceId,
97
100
  name: ' Platform ',
101
+ slug: 'platform',
98
102
  source: {
99
103
  connection_id: sourceConnectionId,
100
104
  external_repository_id: 'gitea:gitea-owner/platform',
@@ -104,12 +108,290 @@ describe('project routes', () => {
104
108
 
105
109
  expect(res.statusCode).toBe(201);
106
110
  expect(res.json().name).toBe('Platform');
111
+ expect(res.json().slug).toBe('platform');
107
112
  expect(res.json().source).toEqual({
108
113
  connection_id: sourceConnectionId,
109
114
  external_repository_id: 'gitea:gitea-owner/platform',
110
115
  });
111
116
  });
112
117
 
118
+ test('returns a distinct conflict when a slug is taken in the workspace', async () => {
119
+ await app.inject({
120
+ method: 'POST',
121
+ url: '/projects',
122
+ headers: {authorization: 'Bearer user'},
123
+ payload: {
124
+ workspace_id: workspaceId,
125
+ name: 'Platform',
126
+ slug: 'platform',
127
+ source: {
128
+ connection_id: sourceConnectionId,
129
+ external_repository_id: 'gitea:gitea-owner/platform',
130
+ },
131
+ },
132
+ });
133
+ vi.mocked(integrations.resolveSourceRepository).mockResolvedValueOnce({
134
+ connection: {
135
+ id: crypto.randomUUID(),
136
+ provider: 'gitea',
137
+ slug: 'gitea_owner',
138
+ },
139
+ repository: {
140
+ externalRepositoryId: 'gitea:gitea-owner/other',
141
+ owner: 'gitea-owner',
142
+ name: 'other',
143
+ fullName: 'gitea-owner/other',
144
+ defaultBranch: 'main',
145
+ visibility: 'private',
146
+ cloneUrl: 'https://gitea.local/gitea-owner/other.git',
147
+ htmlUrl: 'https://gitea.local/gitea-owner/other',
148
+ },
149
+ });
150
+
151
+ const res = await app.inject({
152
+ method: 'POST',
153
+ url: '/projects',
154
+ headers: {authorization: 'Bearer user'},
155
+ payload: {
156
+ workspace_id: workspaceId,
157
+ name: 'Other',
158
+ slug: 'platform',
159
+ source: {
160
+ connection_id: crypto.randomUUID(),
161
+ external_repository_id: 'gitea:gitea-owner/other',
162
+ },
163
+ },
164
+ });
165
+
166
+ expect(res.statusCode).toBe(409);
167
+ expect(res.json().code).toBe('slug-conflict');
168
+ expect(res.json().code).not.toBe('project-already-exists');
169
+ });
170
+
171
+ test('allows the same slug in different workspaces', async () => {
172
+ const secondWorkspaceId = crypto.randomUUID();
173
+ authenticatedMemberships = [
174
+ {workspaceId, role: 'admin', workspaceStatus: 'active'},
175
+ {workspaceId: secondWorkspaceId, role: 'admin', workspaceStatus: 'active'},
176
+ ];
177
+ await app.inject({
178
+ method: 'POST',
179
+ url: '/projects',
180
+ headers: {authorization: 'Bearer user'},
181
+ payload: {
182
+ workspace_id: workspaceId,
183
+ name: 'Platform',
184
+ slug: 'platform',
185
+ source: {
186
+ connection_id: sourceConnectionId,
187
+ external_repository_id: 'gitea:gitea-owner/platform',
188
+ },
189
+ },
190
+ });
191
+ vi.mocked(integrations.resolveSourceRepository).mockResolvedValueOnce({
192
+ connection: {
193
+ id: crypto.randomUUID(),
194
+ provider: 'gitea',
195
+ slug: 'gitea_owner',
196
+ },
197
+ repository: {
198
+ externalRepositoryId: 'gitea:gitea-owner/other-workspace',
199
+ owner: 'gitea-owner',
200
+ name: 'other-workspace',
201
+ fullName: 'gitea-owner/other-workspace',
202
+ defaultBranch: 'main',
203
+ visibility: 'private',
204
+ cloneUrl: 'https://gitea.local/gitea-owner/other-workspace.git',
205
+ htmlUrl: 'https://gitea.local/gitea-owner/other-workspace',
206
+ },
207
+ });
208
+
209
+ const res = await app.inject({
210
+ method: 'POST',
211
+ url: '/projects',
212
+ headers: {authorization: 'Bearer user'},
213
+ payload: {
214
+ workspace_id: secondWorkspaceId,
215
+ name: 'Platform',
216
+ slug: 'platform',
217
+ source: {
218
+ connection_id: crypto.randomUUID(),
219
+ external_repository_id: 'gitea:gitea-owner/other-workspace',
220
+ },
221
+ },
222
+ });
223
+
224
+ expect(res.statusCode).toBe(201);
225
+ expect(res.json().slug).toBe('platform');
226
+ });
227
+
228
+ test('accepts a project slug named new', async () => {
229
+ const res = await app.inject({
230
+ method: 'POST',
231
+ url: '/projects',
232
+ headers: {authorization: 'Bearer user'},
233
+ payload: {
234
+ workspace_id: workspaceId,
235
+ name: 'New Project',
236
+ slug: 'new',
237
+ source: {
238
+ connection_id: sourceConnectionId,
239
+ external_repository_id: 'gitea:gitea-owner/platform',
240
+ },
241
+ },
242
+ });
243
+
244
+ expect(res.statusCode).toBe(201);
245
+ expect(res.json().slug).toBe('new');
246
+ });
247
+
248
+ test('renames a project, frees the old slug, and writes an update event', async () => {
249
+ const createRes = await app.inject({
250
+ method: 'POST',
251
+ url: '/projects',
252
+ headers: {authorization: 'Bearer user'},
253
+ payload: {
254
+ workspace_id: workspaceId,
255
+ name: 'Platform',
256
+ slug: 'platform',
257
+ source: {
258
+ connection_id: sourceConnectionId,
259
+ external_repository_id: 'gitea:gitea-owner/platform',
260
+ },
261
+ },
262
+ });
263
+
264
+ const patchRes = await app.inject({
265
+ method: 'PATCH',
266
+ url: `/projects/${createRes.json().id}`,
267
+ headers: {authorization: 'Bearer user'},
268
+ payload: {name: 'Renamed Platform', slug: 'renamed-platform'},
269
+ });
270
+
271
+ expect(patchRes.statusCode).toBe(200);
272
+ expect(patchRes.json()).toMatchObject({
273
+ name: 'Renamed Platform',
274
+ slug: 'renamed-platform',
275
+ });
276
+
277
+ const events = await db()
278
+ .select()
279
+ .from(projectsOutbox)
280
+ .where(sql`${projectsOutbox.payload}->>'projectId' = ${createRes.json().id}`);
281
+ expect(events).toEqual(
282
+ expect.arrayContaining([
283
+ expect.objectContaining({
284
+ eventType: 'projects.project.updated',
285
+ payload: expect.objectContaining({
286
+ name: 'Renamed Platform',
287
+ slug: 'renamed-platform',
288
+ }),
289
+ }),
290
+ ]),
291
+ );
292
+
293
+ vi.mocked(integrations.resolveSourceRepository).mockResolvedValueOnce({
294
+ connection: {
295
+ id: crypto.randomUUID(),
296
+ provider: 'gitea',
297
+ slug: 'gitea_owner',
298
+ },
299
+ repository: {
300
+ externalRepositoryId: 'gitea:gitea-owner/other',
301
+ owner: 'gitea-owner',
302
+ name: 'other',
303
+ fullName: 'gitea-owner/other',
304
+ defaultBranch: 'main',
305
+ visibility: 'private',
306
+ cloneUrl: 'https://gitea.local/gitea-owner/other.git',
307
+ htmlUrl: 'https://gitea.local/gitea-owner/other',
308
+ },
309
+ });
310
+ const reuseRes = await app.inject({
311
+ method: 'POST',
312
+ url: '/projects',
313
+ headers: {authorization: 'Bearer user'},
314
+ payload: {
315
+ workspace_id: workspaceId,
316
+ name: 'Other',
317
+ slug: 'platform',
318
+ source: {
319
+ connection_id: crypto.randomUUID(),
320
+ external_repository_id: 'gitea:gitea-owner/other',
321
+ },
322
+ },
323
+ });
324
+ expect(reuseRes.statusCode).toBe(201);
325
+ });
326
+
327
+ test('treats an empty project update as a no-op', async () => {
328
+ const createRes = await app.inject({
329
+ method: 'POST',
330
+ url: '/projects',
331
+ headers: {authorization: 'Bearer user'},
332
+ payload: {
333
+ workspace_id: workspaceId,
334
+ name: 'Platform',
335
+ slug: 'platform',
336
+ source: {
337
+ connection_id: sourceConnectionId,
338
+ external_repository_id: 'gitea:gitea-owner/platform',
339
+ },
340
+ },
341
+ });
342
+ const created = createRes.json();
343
+ const beforeEvents = await db()
344
+ .select()
345
+ .from(projectsOutbox)
346
+ .where(sql`${projectsOutbox.payload}->>'projectId' = ${created.id}`);
347
+
348
+ const patchRes = await app.inject({
349
+ method: 'PATCH',
350
+ url: `/projects/${created.id}`,
351
+ headers: {authorization: 'Bearer user'},
352
+ payload: {},
353
+ });
354
+
355
+ expect(patchRes.statusCode).toBe(200);
356
+ expect(patchRes.json()).toMatchObject({
357
+ name: 'Platform',
358
+ slug: 'platform',
359
+ updated_at: created.updated_at,
360
+ });
361
+ const afterEvents = await db()
362
+ .select()
363
+ .from(projectsOutbox)
364
+ .where(sql`${projectsOutbox.payload}->>'projectId' = ${created.id}`);
365
+ expect(afterEvents).toHaveLength(beforeEvents.length);
366
+ });
367
+
368
+ test('returns a slug conflict when updating to another project slug', async () => {
369
+ const first = await createProject({
370
+ workspaceId,
371
+ name: 'Platform',
372
+ slug: 'platform',
373
+ sourceConnectionId: crypto.randomUUID(),
374
+ sourceExternalRepositoryId: 'gitea:platform',
375
+ });
376
+ const second = await createProject({
377
+ workspaceId,
378
+ name: 'Other',
379
+ slug: 'other',
380
+ sourceConnectionId: crypto.randomUUID(),
381
+ sourceExternalRepositoryId: 'gitea:other',
382
+ });
383
+
384
+ const res = await app.inject({
385
+ method: 'PATCH',
386
+ url: `/projects/${second.id}`,
387
+ headers: {authorization: 'Bearer user'},
388
+ payload: {slug: first.slug},
389
+ });
390
+
391
+ expect(res.statusCode).toBe(409);
392
+ expect(res.json().code).toBe('slug-conflict');
393
+ });
394
+
113
395
  test.each([
114
396
  ['blank after trimming', ' '],
115
397
  ['with control characters', 'Plat\nform'],
@@ -122,6 +404,7 @@ describe('project routes', () => {
122
404
  payload: {
123
405
  workspace_id: workspaceId,
124
406
  name,
407
+ slug: 'invalid-name',
125
408
  source: {
126
409
  connection_id: sourceConnectionId,
127
410
  external_repository_id: 'gitea:gitea-owner/platform',
@@ -141,6 +424,7 @@ describe('project routes', () => {
141
424
  payload: {
142
425
  workspace_id: workspaceId,
143
426
  name: 'Platform',
427
+ slug: 'platform',
144
428
  source: {
145
429
  connection_id: sourceConnectionId,
146
430
  external_repository_id: 'gitea:gitea-owner/platform',
@@ -162,9 +446,14 @@ describe('project routes', () => {
162
446
  expect(res.json().projects[0].source.connection_id).toBe(sourceConnectionId);
163
447
  });
164
448
 
165
- test('filters projects by `search` (case-insensitive substring on name)', async () => {
166
- const names = ['Platform', 'Runner', 'Notifier'];
167
- for (const [index, name] of names.entries()) {
449
+ test('filters projects by `search` (case-insensitive substring on name or slug)', async () => {
450
+ const projects = [
451
+ {name: 'Platform', slug: 'platform'},
452
+ {name: 'Runner', slug: 'runner'},
453
+ {name: 'Notifier', slug: 'notifier'},
454
+ {name: 'Cloud', slug: 'runnbox'},
455
+ ];
456
+ for (const [index, {name, slug}] of projects.entries()) {
168
457
  vi.mocked(integrations.resolveSourceRepository).mockResolvedValueOnce({
169
458
  connection: {
170
459
  id: sourceConnectionId,
@@ -189,6 +478,7 @@ describe('project routes', () => {
189
478
  payload: {
190
479
  workspace_id: workspaceId,
191
480
  name,
481
+ slug,
192
482
  source: {
193
483
  connection_id: sourceConnectionId,
194
484
  external_repository_id: `gitea:gitea-owner/${name.toLowerCase()}-${index}`,
@@ -205,13 +495,14 @@ describe('project routes', () => {
205
495
 
206
496
  expect(res.statusCode).toBe(200);
207
497
  const returned = res.json().projects.map((project: {name: string}) => project.name);
208
- expect(returned).toEqual(['Runner']);
498
+ expect(returned).toEqual(['Cloud', 'Runner']);
209
499
  });
210
500
 
211
501
  test('returns 409 when the source repository already has a project', async () => {
212
502
  const payload = {
213
503
  workspace_id: workspaceId,
214
504
  name: 'Platform',
505
+ slug: 'platform',
215
506
  source: {
216
507
  connection_id: sourceConnectionId,
217
508
  external_repository_id: 'gitea:gitea-owner/platform',
@@ -252,6 +543,7 @@ describe('project routes', () => {
252
543
  payload: {
253
544
  workspace_id: workspaceId,
254
545
  name: 'Platform',
546
+ slug: 'platform',
255
547
  source: {
256
548
  connection_id: sourceConnectionId,
257
549
  external_repository_id: 'gitea:gitea-owner/platform',
@@ -320,6 +612,7 @@ describe('project routes', () => {
320
612
  await createProject({
321
613
  workspaceId,
322
614
  name,
615
+ slug: name.toLowerCase(),
323
616
  sourceConnectionId: crypto.randomUUID(),
324
617
  sourceExternalRepositoryId: `gitea:${name.toLowerCase()}`,
325
618
  }),
@@ -375,12 +668,14 @@ describe('project routes', () => {
375
668
  const firstProject = await createProject({
376
669
  workspaceId: firstWorkspaceId,
377
670
  name: 'GlobalAdminLookupAlpha',
671
+ slug: 'global-admin-lookup-alpha',
378
672
  sourceConnectionId: crypto.randomUUID(),
379
673
  sourceExternalRepositoryId: 'gitea:global-admin-lookup-alpha',
380
674
  });
381
675
  const secondProject = await createProject({
382
676
  workspaceId: secondWorkspaceId,
383
677
  name: 'GlobalAdminLookupBeta',
678
+ slug: 'global-admin-lookup-beta',
384
679
  sourceConnectionId: crypto.randomUUID(),
385
680
  sourceExternalRepositoryId: 'gitea:global-admin-lookup-beta',
386
681
  });
@@ -404,6 +699,7 @@ describe('project routes', () => {
404
699
  const project = await createProject({
405
700
  workspaceId,
406
701
  name: 'Platform',
702
+ slug: 'platform',
407
703
  sourceConnectionId: crypto.randomUUID(),
408
704
  sourceExternalRepositoryId: 'gitea:platform',
409
705
  });
@@ -0,0 +1,46 @@
1
+ import {AUTH_USER, requireUserContext} from '@shipfox/api-auth-context';
2
+ import {projectResponseSchema, updateProjectBodySchema} from '@shipfox/api-projects-dto';
3
+ import {ClientError, defineRoute} from '@shipfox/node-fastify';
4
+ import {z} from 'zod';
5
+ import {ProjectNotFoundError, ProjectSlugConflictError, updateProjectDetails} from '#core/index.js';
6
+ import {requireProjectAccess} from '#presentation/auth/require-project-access.js';
7
+ import {toProjectDto} from '#presentation/dto/index.js';
8
+
9
+ const projectParamsSchema = z.object({
10
+ projectId: z.string().uuid(),
11
+ });
12
+
13
+ export const updateProjectRoute = defineRoute({
14
+ method: 'PATCH',
15
+ path: '/:projectId',
16
+ auth: AUTH_USER,
17
+ description: 'Update a project.',
18
+ schema: {
19
+ params: projectParamsSchema,
20
+ body: updateProjectBodySchema,
21
+ response: {
22
+ 200: projectResponseSchema,
23
+ },
24
+ },
25
+ errorHandler: (error) => {
26
+ if (error instanceof ProjectSlugConflictError) {
27
+ throw new ClientError('Project slug already exists', 'slug-conflict', {status: 409});
28
+ }
29
+ if (error instanceof ProjectNotFoundError) {
30
+ throw new ClientError('Project not found', 'project-not-found', {status: 404});
31
+ }
32
+ throw error;
33
+ },
34
+ handler: async (request) => {
35
+ const {projectId} = request.params;
36
+ await requireProjectAccess({request, projectId});
37
+ const actor = requireUserContext(request);
38
+ const project = await updateProjectDetails({
39
+ actorId: actor.userId,
40
+ projectId,
41
+ name: request.body.name,
42
+ slug: request.body.slug,
43
+ });
44
+ return toProjectDto(project);
45
+ },
46
+ });
@@ -1 +1,2 @@
1
1
  export {onSourceCommitPushed} from './on-source-commit-pushed.js';
2
+ export {onSourceRepositoryUpdated} from './on-source-repository-updated.js';
@@ -1,6 +1,7 @@
1
1
  import type {AuthInterModuleClient} from '@shipfox/api-auth-dto/inter-module';
2
2
  import {
3
3
  INTEGRATION_SOURCE_COMMIT_PUSHED,
4
+ INTEGRATION_SOURCE_REPOSITORY_UPDATED,
4
5
  type IntegrationSourceCommitPushedEvent,
5
6
  type SourcePushPayload,
6
7
  } from '@shipfox/api-integration-core-dto';
@@ -150,5 +151,6 @@ describe('onSourceCommitPushed', () => {
150
151
  const events = module.subscribers?.map((subscriber) => subscriber.event);
151
152
 
152
153
  expect(events).toContain(INTEGRATION_SOURCE_COMMIT_PUSHED);
154
+ expect(events).toContain(INTEGRATION_SOURCE_REPOSITORY_UPDATED);
153
155
  });
154
156
  });
@@ -0,0 +1,108 @@
1
+ import {
2
+ INTEGRATION_SOURCE_REPOSITORY_UPDATED,
3
+ type IntegrationSourceRepositoryUpdatedEvent,
4
+ } from '@shipfox/api-integration-core-dto';
5
+ import type {DomainEvent} from '@shipfox/node-outbox';
6
+ import {getProjectBySource} from '#db/index.js';
7
+ import {projectFactory} from '#test/index.js';
8
+ import {onSourceRepositoryUpdated} from './on-source-repository-updated.js';
9
+
10
+ function buildEvent(params: {
11
+ workspaceId: string;
12
+ connectionId: string;
13
+ externalRepositoryId: string;
14
+ }): DomainEvent<IntegrationSourceRepositoryUpdatedEvent> {
15
+ return {
16
+ id: crypto.randomUUID(),
17
+ type: INTEGRATION_SOURCE_REPOSITORY_UPDATED,
18
+ createdAt: new Date(),
19
+ payload: {
20
+ provider: 'github',
21
+ workspaceId: params.workspaceId,
22
+ connectionId: params.connectionId,
23
+ deliveryId: crypto.randomUUID(),
24
+ receivedAt: new Date().toISOString(),
25
+ repository: {
26
+ externalRepositoryId: params.externalRepositoryId,
27
+ owner: 'acme',
28
+ name: 'platform-renamed',
29
+ defaultBranch: 'trunk',
30
+ },
31
+ },
32
+ };
33
+ }
34
+
35
+ describe('onSourceRepositoryUpdated', () => {
36
+ it('refreshes the denormalized repository identity by stable external id', async () => {
37
+ const workspaceId = crypto.randomUUID();
38
+ const connectionId = crypto.randomUUID();
39
+ const externalRepositoryId = `github:${crypto.randomUUID()}`;
40
+ const project = await projectFactory.create({
41
+ workspaceId,
42
+ sourceConnectionId: connectionId,
43
+ sourceExternalRepositoryId: externalRepositoryId,
44
+ sourceRepositoryOwner: 'old-owner',
45
+ sourceRepositoryName: 'old-name',
46
+ sourceDefaultBranch: 'main',
47
+ });
48
+ const event = buildEvent({workspaceId, connectionId, externalRepositoryId});
49
+
50
+ await onSourceRepositoryUpdated(event.payload, event);
51
+
52
+ const refreshed = await getProjectBySource({
53
+ workspaceId,
54
+ sourceConnectionId: connectionId,
55
+ sourceExternalRepositoryId: externalRepositoryId,
56
+ });
57
+ expect(refreshed).toMatchObject({
58
+ id: project.id,
59
+ sourceRepositoryOwner: 'acme',
60
+ sourceRepositoryName: 'platform-renamed',
61
+ sourceDefaultBranch: 'trunk',
62
+ });
63
+ });
64
+
65
+ it('ignores updates for repositories without a bound project', async () => {
66
+ const event = buildEvent({
67
+ workspaceId: crypto.randomUUID(),
68
+ connectionId: crypto.randomUUID(),
69
+ externalRepositoryId: `github:${crypto.randomUUID()}`,
70
+ });
71
+
72
+ await expect(onSourceRepositoryUpdated(event.payload, event)).resolves.toBeUndefined();
73
+ });
74
+
75
+ it('deduplicates the same repository update event for a project', async () => {
76
+ const workspaceId = crypto.randomUUID();
77
+ const connectionId = crypto.randomUUID();
78
+ const externalRepositoryId = `github:${crypto.randomUUID()}`;
79
+ await projectFactory.create({
80
+ workspaceId,
81
+ sourceConnectionId: connectionId,
82
+ sourceExternalRepositoryId: externalRepositoryId,
83
+ sourceRepositoryOwner: 'old-owner',
84
+ sourceRepositoryName: 'old-name',
85
+ sourceDefaultBranch: 'main',
86
+ });
87
+ const event = buildEvent({workspaceId, connectionId, externalRepositoryId});
88
+
89
+ await onSourceRepositoryUpdated(event.payload, event);
90
+ const duplicate = {
91
+ ...event,
92
+ payload: {
93
+ ...event.payload,
94
+ repository: {...event.payload.repository, name: 'stale-name'},
95
+ },
96
+ };
97
+ await onSourceRepositoryUpdated(duplicate.payload, duplicate);
98
+
99
+ const refreshed = await getProjectBySource({
100
+ workspaceId,
101
+ sourceConnectionId: connectionId,
102
+ sourceExternalRepositoryId: externalRepositoryId,
103
+ });
104
+ expect(refreshed).toMatchObject({
105
+ sourceRepositoryName: 'platform-renamed',
106
+ });
107
+ });
108
+ });
@@ -0,0 +1,50 @@
1
+ import type {IntegrationSourceRepositoryUpdatedEvent} from '@shipfox/api-integration-core-dto';
2
+ import {logger} from '@shipfox/node-opentelemetry';
3
+ import type {DomainEvent} from '@shipfox/node-outbox';
4
+ import {db} from '#db/db.js';
5
+ import {recordIntegrationEventForProject} from '#db/integration-event-dedup.js';
6
+ import {getProjectBySource, updateProjectSourceRepository} from '#db/projects.js';
7
+
8
+ export async function onSourceRepositoryUpdated(
9
+ payload: IntegrationSourceRepositoryUpdatedEvent,
10
+ event: DomainEvent<IntegrationSourceRepositoryUpdatedEvent>,
11
+ ): Promise<void> {
12
+ const project = await getProjectBySource({
13
+ workspaceId: payload.workspaceId,
14
+ sourceConnectionId: payload.connectionId,
15
+ sourceExternalRepositoryId: payload.repository.externalRepositoryId,
16
+ });
17
+
18
+ if (project) {
19
+ const outcome = await db().transaction(async (tx) => {
20
+ const {firstSeen} = await recordIntegrationEventForProject({
21
+ tx,
22
+ integrationEventId: event.id,
23
+ projectId: project.id,
24
+ });
25
+ if (!firstSeen) return 'duplicate';
26
+
27
+ const updated = await updateProjectSourceRepository({
28
+ tx,
29
+ workspaceId: payload.workspaceId,
30
+ sourceConnectionId: payload.connectionId,
31
+ sourceExternalRepositoryId: payload.repository.externalRepositoryId,
32
+ sourceRepositoryOwner: payload.repository.owner,
33
+ sourceRepositoryName: payload.repository.name,
34
+ sourceDefaultBranch: payload.repository.defaultBranch,
35
+ });
36
+ return updated ? 'updated' : 'missing';
37
+ });
38
+
39
+ if (outcome !== 'missing') return;
40
+ }
41
+
42
+ logger().info(
43
+ {
44
+ eventId: event.id,
45
+ connectionId: payload.connectionId,
46
+ externalRepositoryId: payload.repository.externalRepositoryId,
47
+ },
48
+ 'source repository updated: no project bound to source, dropping',
49
+ );
50
+ }
@@ -1,3 +1,4 @@
1
+ import {withSlugSuffix} from '@shipfox/api-common-dto';
1
2
  import {Factory} from 'fishery';
2
3
  import type {Project} from '#core/entities/index.js';
3
4
  import {createProject} from '#db/index.js';
@@ -9,6 +10,7 @@ export const projectFactory = Factory.define<Project>(({sequence, onCreate}) =>
9
10
  sourceConnectionId: project.sourceConnectionId,
10
11
  sourceExternalRepositoryId: project.sourceExternalRepositoryId,
11
12
  name: project.name,
13
+ slug: project.slug,
12
14
  }),
13
15
  );
14
16
 
@@ -17,7 +19,11 @@ export const projectFactory = Factory.define<Project>(({sequence, onCreate}) =>
17
19
  workspaceId: crypto.randomUUID(),
18
20
  sourceConnectionId: crypto.randomUUID(),
19
21
  sourceExternalRepositoryId: `repository-${sequence}`,
22
+ sourceRepositoryOwner: null,
23
+ sourceRepositoryName: null,
24
+ sourceDefaultBranch: null,
20
25
  name: `Project ${sequence}`,
26
+ slug: withSlugSuffix('project', sequence + 1),
21
27
  createdAt: new Date(),
22
28
  updatedAt: new Date(),
23
29
  };