@shipfox/api-integration-github 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 (42) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +34 -0
  3. package/dist/api/shared-installation-token-cache.d.ts.map +1 -1
  4. package/dist/core/agent-tools.d.ts.map +1 -1
  5. package/dist/core/errors.d.ts.map +1 -1
  6. package/dist/core/source-control.d.ts +2 -1
  7. package/dist/core/source-control.d.ts.map +1 -1
  8. package/dist/core/source-control.js +56 -1
  9. package/dist/core/source-control.js.map +1 -1
  10. package/dist/core/webhook-processor.d.ts +2 -1
  11. package/dist/core/webhook-processor.d.ts.map +1 -1
  12. package/dist/core/webhook-processor.js +1 -0
  13. package/dist/core/webhook-processor.js.map +1 -1
  14. package/dist/core/webhook.d.ts +3 -2
  15. package/dist/core/webhook.d.ts.map +1 -1
  16. package/dist/core/webhook.js +103 -1
  17. package/dist/core/webhook.js.map +1 -1
  18. package/dist/db/db.d.ts +2 -2
  19. package/dist/db/schema/installations.d.ts +1 -1
  20. package/dist/index.d.ts +4 -3
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/presentation/routes/webhooks.d.ts +2 -1
  25. package/dist/presentation/routes/webhooks.d.ts.map +1 -1
  26. package/dist/presentation/routes/webhooks.js.map +1 -1
  27. package/dist/tsconfig.test.tsbuildinfo +1 -1
  28. package/package.json +8 -8
  29. package/src/connection-external-url.test.ts +1 -0
  30. package/src/core/agent-tools.test.ts +1 -0
  31. package/src/core/source-control.test.ts +80 -0
  32. package/src/core/source-control.ts +72 -0
  33. package/src/core/webhook-processor.test.ts +4 -0
  34. package/src/core/webhook-processor.ts +3 -0
  35. package/src/core/webhook.test.ts +197 -2
  36. package/src/core/webhook.ts +150 -0
  37. package/src/index.test.ts +1 -0
  38. package/src/index.ts +3 -0
  39. package/src/presentation/routes/install.test.ts +2 -1
  40. package/src/presentation/routes/webhooks.test.ts +49 -1
  41. package/src/presentation/routes/webhooks.ts +2 -0
  42. package/tsconfig.build.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-integration-github",
3
3
  "license": "MIT",
4
- "version": "11.0.0",
4
+ "version": "12.2.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -27,14 +27,14 @@
27
27
  "ky": "^2.0.0",
28
28
  "octokit": "^5.0.3",
29
29
  "zod": "^4.4.3",
30
- "@shipfox/api-auth-context": "11.0.0",
31
- "@shipfox/api-integration-spi": "0.2.2",
32
- "@shipfox/api-integration-github-dto": "9.0.2",
30
+ "@shipfox/api-auth-context": "12.2.0",
31
+ "@shipfox/api-integration-spi": "1.0.1",
32
+ "@shipfox/api-integration-github-dto": "12.2.0",
33
33
  "@shipfox/config": "1.2.4",
34
- "@shipfox/node-drizzle": "0.3.4",
35
- "@shipfox/node-fastify": "0.4.0",
36
- "@shipfox/node-postgres": "0.4.4",
37
- "@shipfox/node-opentelemetry": "0.6.3",
34
+ "@shipfox/node-drizzle": "0.3.5",
35
+ "@shipfox/node-fastify": "0.4.2",
36
+ "@shipfox/node-postgres": "0.5.0",
37
+ "@shipfox/node-opentelemetry": "0.6.4",
38
38
  "@shipfox/node-error-monitoring": "0.3.0"
39
39
  },
40
40
  "imports": {
@@ -40,6 +40,7 @@ function createProvider(lookup: (connectionId: string) => Promise<GithubInstalla
40
40
  connectGithubInstallation: vi.fn() as never,
41
41
  coreDb: vi.fn() as never,
42
42
  publishIntegrationEventReceived: vi.fn(() => Promise.resolve({published: false})),
43
+ publishSourceRepositoryUpdated: vi.fn(() => Promise.resolve({published: false})),
43
44
  publishSourcePush: vi.fn(() => Promise.resolve({published: false})),
44
45
  recordDeliveryOnly: vi.fn(() => Promise.resolve()),
45
46
  getIntegrationConnectionById: vi.fn(() => Promise.resolve(undefined)),
@@ -547,6 +547,7 @@ function createProvider() {
547
547
  connectGithubInstallation: vi.fn() as never,
548
548
  coreDb: vi.fn() as never,
549
549
  publishIntegrationEventReceived: vi.fn(() => Promise.resolve({published: false})),
550
+ publishSourceRepositoryUpdated: vi.fn(() => Promise.resolve({published: false})),
550
551
  publishSourcePush: vi.fn(() => Promise.resolve({published: false})),
551
552
  recordDeliveryOnly: vi.fn(() => Promise.resolve()),
552
553
  getIntegrationConnectionById: vi.fn(() => Promise.resolve(undefined)),
@@ -3,6 +3,8 @@ import {githubInstallationFactory} from '#test/index.js';
3
3
  import {GithubIntegrationProviderError} from './errors.js';
4
4
  import {GithubSourceControlProvider} from './source-control.js';
5
5
 
6
+ const VALID_COMMIT = 'a'.repeat(40);
7
+
6
8
  function githubClient(overrides: Partial<GithubApiClient> = {}): GithubApiClient {
7
9
  return {
8
10
  exchangeOAuthCode: vi.fn(() => Promise.resolve('token')),
@@ -95,6 +97,84 @@ describe('GithubSourceControlProvider', () => {
95
97
  };
96
98
  }
97
99
 
100
+ it('normalizes push trigger references', () => {
101
+ const provider = new GithubSourceControlProvider(githubClient());
102
+
103
+ const result = provider.resolveTriggerReference({
104
+ ref: 'refs/heads/feature/review',
105
+ after: VALID_COMMIT,
106
+ repository: {id: 42},
107
+ sender: {login: 'octocat'},
108
+ });
109
+
110
+ expect(result).toEqual({
111
+ externalRepositoryId: 'github:42',
112
+ ref: 'refs/heads/feature/review',
113
+ commit: VALID_COMMIT,
114
+ actor: 'octocat',
115
+ });
116
+ });
117
+
118
+ it('resolves a null actor when the payload names no sender', () => {
119
+ const provider = new GithubSourceControlProvider(githubClient());
120
+
121
+ const result = provider.resolveTriggerReference({
122
+ ref: 'refs/heads/main',
123
+ after: VALID_COMMIT,
124
+ repository: {id: 42},
125
+ });
126
+
127
+ expect(result).toMatchObject({actor: null});
128
+ });
129
+
130
+ it('normalizes pull-request trigger references from the head repository', () => {
131
+ const provider = new GithubSourceControlProvider(githubClient());
132
+
133
+ const result = provider.resolveTriggerReference({
134
+ repository: {id: 42},
135
+ sender: {login: 'octocat'},
136
+ pull_request: {
137
+ number: 17,
138
+ head: {
139
+ sha: VALID_COMMIT,
140
+ repo: {id: 42},
141
+ },
142
+ base: {repo: {id: 42}},
143
+ },
144
+ });
145
+
146
+ expect(result).toEqual({
147
+ externalRepositoryId: 'github:42',
148
+ ref: 'refs/pull/17/head',
149
+ commit: VALID_COMMIT,
150
+ actor: 'octocat',
151
+ });
152
+ });
153
+
154
+ it.each([
155
+ undefined,
156
+ {},
157
+ {ref: 'refs/heads/main', after: VALID_COMMIT},
158
+ {ref: 'refs/heads/feature branch', after: VALID_COMMIT, repository: {id: 42}},
159
+ {ref: 'refs/heads/feature..branch', after: VALID_COMMIT, repository: {id: 42}},
160
+ {ref: 'refs/heads/main', after: '0'.repeat(40), repository: {id: 42}},
161
+ {ref: 'refs/heads/main', after: 'not-a-commit', repository: {id: 42}},
162
+ {
163
+ repository: {id: 42},
164
+ pull_request: {
165
+ number: 17,
166
+ head: {sha: VALID_COMMIT, repo: {id: 84}},
167
+ base: {repo: {id: 42}},
168
+ },
169
+ },
170
+ ])('returns null for an unresolvable or unsafe trigger payload', (payload) => {
171
+ const provider = new GithubSourceControlProvider(githubClient());
172
+
173
+ const result = provider.resolveTriggerReference(payload);
174
+
175
+ expect(result).toBeNull();
176
+ });
177
+
98
178
  it('lists repositories using installation auth metadata', async () => {
99
179
  await createInstallation();
100
180
  const github = githubClient();
@@ -1,5 +1,6 @@
1
1
  import {Buffer} from 'node:buffer';
2
2
  import {
3
+ asRecord,
3
4
  buildProviderRepositoryId,
4
5
  type CheckoutSpec,
5
6
  type CreateCheckoutSpecInput,
@@ -7,15 +8,21 @@ import {
7
8
  type FilePage,
8
9
  type FileSnapshot,
9
10
  type IntegrationConnection,
11
+ isRecord,
12
+ isValidGitObjectId,
13
+ isValidTriggerRef,
10
14
  type ListFilesInput,
11
15
  type ListRepositoriesInput,
12
16
  MAX_REPOSITORY_FILE_BYTES,
17
+ nonEmptyString,
13
18
  parseProviderRepositoryId,
19
+ positiveInteger,
14
20
  type RepositoryPage,
15
21
  type RepositorySnapshot,
16
22
  type RepositoryVisibility,
17
23
  type ResolveRepositoryInput,
18
24
  type SourceControlProvider,
25
+ type TriggerReference,
19
26
  } from '@shipfox/api-integration-spi';
20
27
  import type {GithubApiClient, GithubRepository} from '#api/client.js';
21
28
  import {config} from '#config.js';
@@ -143,6 +150,48 @@ export class GithubSourceControlProvider
143
150
  };
144
151
  }
145
152
 
153
+ resolveTriggerReference(payload: unknown): TriggerReference | null {
154
+ if (!isRecord(payload)) return null;
155
+
156
+ const actor = githubEventActor(payload);
157
+ const pullRequest = asRecord(payload.pull_request);
158
+ if (pullRequest) {
159
+ const head = asRecord(pullRequest.head);
160
+ const repository = asRecord(head?.repo);
161
+ const base = asRecord(pullRequest.base);
162
+ const baseRepository = asRecord(base?.repo) ?? asRecord(payload.repository);
163
+ if (!repository || !baseRepository || !sameGithubRepository(repository, baseRepository)) {
164
+ return null;
165
+ }
166
+ const repositoryId = githubRepositoryId(repository);
167
+ const number = positiveInteger(pullRequest.number);
168
+ const commit = nonEmptyString(head?.sha);
169
+ const ref = number === null ? null : `refs/pull/${number}/head`;
170
+ if (!repositoryId || !ref || !commit) return null;
171
+ const hasValidReference = isValidGitObjectId(commit) && isValidTriggerRef(ref);
172
+ if (!hasValidReference) return null;
173
+ return {
174
+ externalRepositoryId: buildProviderRepositoryId(GITHUB_PROVIDER, repositoryId),
175
+ ref,
176
+ commit,
177
+ actor,
178
+ };
179
+ }
180
+
181
+ const repositoryId = githubRepositoryId(asRecord(payload.repository));
182
+ const ref = nonEmptyString(payload.ref);
183
+ const commit = nonEmptyString(payload.after);
184
+ if (!repositoryId || !ref || !commit) return null;
185
+ const hasValidReference = isValidGitObjectId(commit) && isValidTriggerRef(ref);
186
+ if (!hasValidReference) return null;
187
+ return {
188
+ externalRepositoryId: buildProviderRepositoryId(GITHUB_PROVIDER, repositoryId),
189
+ ref,
190
+ commit,
191
+ actor,
192
+ };
193
+ }
194
+
146
195
  async createCheckoutSpec(
147
196
  input: CreateCheckoutSpecInput<GithubIntegrationConnection>,
148
197
  ): Promise<CheckoutSpec> {
@@ -178,6 +227,29 @@ export class GithubSourceControlProvider
178
227
  }
179
228
  }
180
229
 
230
+ function githubRepositoryId(repository: Record<string, unknown> | null): string | null {
231
+ return positiveInteger(repository?.id) === null ? null : String(repository?.id);
232
+ }
233
+
234
+ // `sender` is the one actor field every GitHub webhook event carries, so it stays correct
235
+ // for pushes, pull requests, and anything else that reaches a trigger reference.
236
+ function githubEventActor(payload: Record<string, unknown>): string | null {
237
+ return nonEmptyString(asRecord(payload.sender)?.login);
238
+ }
239
+
240
+ function sameGithubRepository(
241
+ first: Record<string, unknown>,
242
+ second: Record<string, unknown>,
243
+ ): boolean {
244
+ const firstId = githubRepositoryId(first);
245
+ const secondId = githubRepositoryId(second);
246
+ if (firstId && secondId) return firstId === secondId;
247
+
248
+ const firstName = nonEmptyString(first.full_name);
249
+ const secondName = nonEmptyString(second.full_name);
250
+ return Boolean(firstName && secondName && firstName.toLowerCase() === secondName.toLowerCase());
251
+ }
252
+
181
253
  function githubAppGitAuthor(): CheckoutSpec['gitAuthor'] {
182
254
  const appUsername = config.GITHUB_APP_USERNAME?.trim();
183
255
  if (!appUsername) return undefined;
@@ -51,6 +51,7 @@ describe('GitHub webhook processor', () => {
51
51
  const processor = createGithubWebhookProcessor({
52
52
  coreDb,
53
53
  publishIntegrationEventReceived: vi.fn(),
54
+ publishSourceRepositoryUpdated: vi.fn(),
54
55
  publishSourcePush: vi.fn(),
55
56
  recordDeliveryOnly: vi.fn(),
56
57
  getIntegrationConnectionById: vi.fn(),
@@ -78,6 +79,7 @@ describe('GitHub webhook processor', () => {
78
79
  const processor = createGithubWebhookProcessor({
79
80
  coreDb: vi.fn(),
80
81
  publishIntegrationEventReceived: vi.fn(),
82
+ publishSourceRepositoryUpdated: vi.fn(),
81
83
  publishSourcePush: vi.fn(),
82
84
  recordDeliveryOnly: vi.fn(),
83
85
  getIntegrationConnectionById: vi.fn(),
@@ -114,6 +116,7 @@ describe('GitHub webhook processor', () => {
114
116
  coreDb: () =>
115
117
  ({transaction: (callback: (tx: unknown) => Promise<unknown>) => callback({})}) as never,
116
118
  publishIntegrationEventReceived: vi.fn(),
119
+ publishSourceRepositoryUpdated: vi.fn(),
117
120
  publishSourcePush: vi.fn(),
118
121
  recordDeliveryOnly: vi.fn(),
119
122
  getIntegrationConnectionById: vi.fn(),
@@ -145,6 +148,7 @@ describe('GitHub webhook processor', () => {
145
148
  const processor = createGithubWebhookProcessor({
146
149
  coreDb: db,
147
150
  publishIntegrationEventReceived,
151
+ publishSourceRepositoryUpdated: vi.fn(),
148
152
  publishSourcePush: vi.fn(),
149
153
  recordDeliveryOnly: vi.fn(),
150
154
  getIntegrationConnectionById: vi.fn(() => Promise.resolve(connection)),
@@ -5,6 +5,7 @@ import {
5
5
  type GetIntegrationConnectionByIdFn,
6
6
  type PublishIntegrationEventReceivedFn,
7
7
  type PublishSourcePushFn,
8
+ type PublishSourceRepositoryUpdatedFn,
8
9
  type RecordDeliveryOnlyFn,
9
10
  type StoredWebhookRequest,
10
11
  type WebhookProcessingResult,
@@ -21,6 +22,7 @@ const SIGNATURE_HEADER = 'x-hub-signature-256';
21
22
  export interface CreateGithubWebhookProcessorOptions {
22
23
  coreDb: () => NodePgDatabase<Record<string, unknown>>;
23
24
  publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
25
+ publishSourceRepositoryUpdated: PublishSourceRepositoryUpdatedFn;
24
26
  publishSourcePush: PublishSourcePushFn;
25
27
  recordDeliveryOnly: RecordDeliveryOnlyFn;
26
28
  getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
@@ -81,6 +83,7 @@ async function processGithubWebhookRequest(
81
83
  event,
82
84
  payload,
83
85
  publishIntegrationEventReceived: options.publishIntegrationEventReceived,
86
+ publishSourceRepositoryUpdated: options.publishSourceRepositoryUpdated,
84
87
  publishSourcePush: options.publishSourcePush,
85
88
  recordDeliveryOnly: options.recordDeliveryOnly,
86
89
  getIntegrationConnectionById: options.getIntegrationConnectionById,
@@ -4,6 +4,7 @@ import type {
4
4
  IntegrationConnection,
5
5
  PublishIntegrationEventReceivedFn,
6
6
  PublishSourcePushFn,
7
+ PublishSourceRepositoryUpdatedFn,
7
8
  RecordDeliveryOnlyFn,
8
9
  } from '@shipfox/api-integration-spi';
9
10
  import {db} from '#db/db.js';
@@ -30,6 +31,7 @@ function deps(
30
31
  options: {
31
32
  connection?: IntegrationConnection | undefined;
32
33
  publishIntegrationEventReceivedResult?: {published: boolean};
34
+ publishSourceRepositoryUpdatedResult?: {published: boolean};
33
35
  publishSourcePushResult?: {published: boolean};
34
36
  } = {},
35
37
  ) {
@@ -37,6 +39,9 @@ function deps(
37
39
  publishIntegrationEventReceived: vi.fn<PublishIntegrationEventReceivedFn>(() =>
38
40
  Promise.resolve(options.publishIntegrationEventReceivedResult ?? {published: true}),
39
41
  ),
42
+ publishSourceRepositoryUpdated: vi.fn<PublishSourceRepositoryUpdatedFn>(() =>
43
+ Promise.resolve(options.publishSourceRepositoryUpdatedResult ?? {published: true}),
44
+ ),
40
45
  publishSourcePush: vi.fn<PublishSourcePushFn>(() =>
41
46
  Promise.resolve(options.publishSourcePushResult ?? {published: true}),
42
47
  ),
@@ -272,7 +277,12 @@ describe('handleGithubEvent', () => {
272
277
  const payload = {
273
278
  action: 'opened',
274
279
  installation: {id: installationId},
275
- pull_request: {number: 17},
280
+ repository: {id: 42, full_name: 'shipfox/platform'},
281
+ pull_request: {
282
+ number: 17,
283
+ head: {repo: {id: 42, full_name: 'shipfox/platform'}},
284
+ base: {repo: {id: 42, full_name: 'shipfox/platform'}},
285
+ },
276
286
  };
277
287
 
278
288
  const result = await handleGithubEvent({
@@ -299,6 +309,186 @@ describe('handleGithubEvent', () => {
299
309
  });
300
310
  });
301
311
 
312
+ it('records and drops pull request deliveries whose head repository is a fork', async () => {
313
+ const handlers = deps();
314
+ const deliveryId = randomUUID();
315
+ const payload = {
316
+ action: 'opened',
317
+ installation: {id: 7787},
318
+ repository: {id: 42, full_name: 'shipfox/platform'},
319
+ pull_request: {
320
+ head: {repo: {id: 84, full_name: 'contributor/platform'}},
321
+ base: {repo: {id: 42, full_name: 'shipfox/platform'}},
322
+ },
323
+ };
324
+
325
+ const result = await handleGithubEvent({
326
+ tx: db(),
327
+ deliveryId,
328
+ event: 'pull_request',
329
+ payload,
330
+ ...handlers,
331
+ });
332
+
333
+ expect(result.outcome).toBe('fork-pull-request');
334
+ expect(handlers.publishSourcePush).not.toHaveBeenCalled();
335
+ expect(handlers.publishIntegrationEventReceived).not.toHaveBeenCalled();
336
+ expect(handlers.getIntegrationConnectionById).not.toHaveBeenCalled();
337
+ expect(firstRecordDeliveryOnlyCall(handlers.recordDeliveryOnly)).toMatchObject({
338
+ provider: 'github',
339
+ deliveryId,
340
+ });
341
+ });
342
+
343
+ it('records and drops pull request deliveries whose head repository is unresolved', async () => {
344
+ const handlers = deps();
345
+ const deliveryId = randomUUID();
346
+ const payload = {
347
+ action: 'synchronize',
348
+ installation: {id: 7789},
349
+ repository: {id: 42, full_name: 'shipfox/platform'},
350
+ pull_request: {
351
+ head: {repo: null},
352
+ base: {repo: {id: 42, full_name: 'shipfox/platform'}},
353
+ },
354
+ };
355
+
356
+ const result = await handleGithubEvent({
357
+ tx: db(),
358
+ deliveryId,
359
+ event: 'pull_request',
360
+ payload,
361
+ ...handlers,
362
+ });
363
+
364
+ expect(result.outcome).toBe('fork-pull-request');
365
+ expect(handlers.publishSourcePush).not.toHaveBeenCalled();
366
+ expect(handlers.publishIntegrationEventReceived).not.toHaveBeenCalled();
367
+ expect(handlers.getIntegrationConnectionById).not.toHaveBeenCalled();
368
+ expect(firstRecordDeliveryOnlyCall(handlers.recordDeliveryOnly)).toMatchObject({
369
+ provider: 'github',
370
+ deliveryId,
371
+ });
372
+ });
373
+
374
+ it('publishes pull request deliveries whose head and base repositories match', async () => {
375
+ const installationId = 7788;
376
+ const connection = fakeConnection();
377
+ await seedInstallation(installationId, connection.id);
378
+ const handlers = deps({connection});
379
+ const payload = {
380
+ action: 'opened',
381
+ installation: {id: installationId},
382
+ repository: {id: 42, full_name: 'shipfox/platform'},
383
+ pull_request: {
384
+ head: {repo: {id: 42, full_name: 'shipfox/platform'}},
385
+ base: {repo: {id: 42, full_name: 'shipfox/platform'}},
386
+ },
387
+ };
388
+
389
+ const result = await handleGithubEvent({
390
+ tx: db(),
391
+ deliveryId: randomUUID(),
392
+ event: 'pull_request',
393
+ payload,
394
+ ...handlers,
395
+ });
396
+
397
+ expect(result.outcome).toBe('published-envelope');
398
+ expect(handlers.recordDeliveryOnly).not.toHaveBeenCalled();
399
+ expect(handlers.publishIntegrationEventReceived).toHaveBeenCalledTimes(1);
400
+ });
401
+
402
+ it('publishes a typed repository update for a repository rename', async () => {
403
+ const installationId = 7784;
404
+ const connection = fakeConnection();
405
+ await seedInstallation(installationId, connection.id);
406
+ const handlers = deps({connection});
407
+ const deliveryId = randomUUID();
408
+ const payload = {
409
+ action: 'renamed',
410
+ installation: {id: installationId},
411
+ repository: {
412
+ id: 42,
413
+ name: 'platform-renamed',
414
+ owner: {login: 'acme'},
415
+ default_branch: 'trunk',
416
+ },
417
+ };
418
+
419
+ const result = await handleGithubEvent({
420
+ tx: db(),
421
+ deliveryId,
422
+ event: 'repository',
423
+ payload,
424
+ ...handlers,
425
+ });
426
+
427
+ expect(result.outcome).toBe('published');
428
+ expect(handlers.publishIntegrationEventReceived).not.toHaveBeenCalled();
429
+ expect(handlers.publishSourceRepositoryUpdated).toHaveBeenCalledWith(
430
+ expect.objectContaining({
431
+ event: 'repository.renamed',
432
+ rawPayload: payload,
433
+ repositories: [
434
+ {
435
+ externalRepositoryId: 'github:42',
436
+ owner: 'acme',
437
+ name: 'platform-renamed',
438
+ defaultBranch: 'trunk',
439
+ },
440
+ ],
441
+ }),
442
+ );
443
+ });
444
+
445
+ it('publishes one typed repository update per installation repository', async () => {
446
+ const installationId = 7785;
447
+ const connection = fakeConnection();
448
+ await seedInstallation(installationId, connection.id);
449
+ const handlers = deps({connection});
450
+ const deliveryId = randomUUID();
451
+ const payload = {
452
+ action: 'added',
453
+ installation: {id: installationId},
454
+ repositories_added: [
455
+ {id: 42, name: 'platform', owner: {login: 'acme'}, default_branch: 'main'},
456
+ ],
457
+ repositories_removed: [
458
+ {id: 43, name: 'runner', owner: {login: 'acme'}, default_branch: 'trunk'},
459
+ ],
460
+ };
461
+
462
+ const result = await handleGithubEvent({
463
+ tx: db(),
464
+ deliveryId,
465
+ event: 'installation_repositories',
466
+ payload,
467
+ ...handlers,
468
+ });
469
+
470
+ expect(result.outcome).toBe('published');
471
+ expect(handlers.publishSourceRepositoryUpdated).toHaveBeenCalledWith(
472
+ expect.objectContaining({
473
+ event: 'installation_repositories.added',
474
+ repositories: [
475
+ {
476
+ externalRepositoryId: 'github:42',
477
+ owner: 'acme',
478
+ name: 'platform',
479
+ defaultBranch: 'main',
480
+ },
481
+ {
482
+ externalRepositoryId: 'github:43',
483
+ owner: 'acme',
484
+ name: 'runner',
485
+ defaultBranch: 'trunk',
486
+ },
487
+ ],
488
+ }),
489
+ );
490
+ });
491
+
302
492
  it('returns the cached installation token cleanup handle when the installation is deleted', async () => {
303
493
  const installationId = 7791;
304
494
  const connection = fakeConnection();
@@ -374,7 +564,12 @@ describe('handleGithubEvent', () => {
374
564
  const payload = {
375
565
  action: null,
376
566
  installation: {id: installationId},
377
- pull_request: {number: 17},
567
+ repository: {id: 42, full_name: 'shipfox/platform'},
568
+ pull_request: {
569
+ number: 17,
570
+ head: {repo: {id: 42, full_name: 'shipfox/platform'}},
571
+ base: {repo: {id: 42, full_name: 'shipfox/platform'}},
572
+ },
378
573
  };
379
574
 
380
575
  const result = await handleGithubEvent({