@shipfox/api-integration-github 11.0.0 → 12.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 (37) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +24 -0
  3. package/dist/core/source-control.d.ts +2 -1
  4. package/dist/core/source-control.d.ts.map +1 -1
  5. package/dist/core/source-control.js +56 -1
  6. package/dist/core/source-control.js.map +1 -1
  7. package/dist/core/webhook-processor.d.ts +2 -1
  8. package/dist/core/webhook-processor.d.ts.map +1 -1
  9. package/dist/core/webhook-processor.js +1 -0
  10. package/dist/core/webhook-processor.js.map +1 -1
  11. package/dist/core/webhook.d.ts +3 -2
  12. package/dist/core/webhook.d.ts.map +1 -1
  13. package/dist/core/webhook.js +103 -1
  14. package/dist/core/webhook.js.map +1 -1
  15. package/dist/index.d.ts +2 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +1 -0
  18. package/dist/index.js.map +1 -1
  19. package/dist/presentation/routes/webhooks.d.ts +2 -1
  20. package/dist/presentation/routes/webhooks.d.ts.map +1 -1
  21. package/dist/presentation/routes/webhooks.js.map +1 -1
  22. package/dist/tsconfig.test.tsbuildinfo +1 -1
  23. package/package.json +7 -7
  24. package/src/connection-external-url.test.ts +1 -0
  25. package/src/core/agent-tools.test.ts +1 -0
  26. package/src/core/source-control.test.ts +80 -0
  27. package/src/core/source-control.ts +72 -0
  28. package/src/core/webhook-processor.test.ts +4 -0
  29. package/src/core/webhook-processor.ts +3 -0
  30. package/src/core/webhook.test.ts +197 -2
  31. package/src/core/webhook.ts +150 -0
  32. package/src/index.test.ts +1 -0
  33. package/src/index.ts +3 -0
  34. package/src/presentation/routes/install.test.ts +2 -1
  35. package/src/presentation/routes/webhooks.test.ts +49 -1
  36. package/src/presentation/routes/webhooks.ts +2 -0
  37. package/tsconfig.build.tsbuildinfo +1 -1
@@ -1,6 +1,8 @@
1
1
  import {
2
2
  type GithubPushPayloadDto,
3
+ githubInstallationRepositoriesPayloadSchema,
3
4
  githubPushPayloadSchema,
5
+ githubRepositoryRenamedPayloadSchema,
4
6
  githubWebhookActionSchema,
5
7
  githubWebhookInstallationSchema,
6
8
  } from '@shipfox/api-integration-github-dto';
@@ -10,8 +12,10 @@ import {
10
12
  type IntegrationTx,
11
13
  type PublishIntegrationEventReceivedFn,
12
14
  type PublishSourcePushFn,
15
+ type PublishSourceRepositoryUpdatedFn,
13
16
  type RecordDeliveryOnlyFn,
14
17
  type SourcePushPayload,
18
+ type SourceRepositoryIdentity,
15
19
  } from '@shipfox/api-integration-spi';
16
20
  import {logger} from '@shipfox/node-opentelemetry';
17
21
  import {getGithubInstallationByInstallationId} from '#db/installations.js';
@@ -27,6 +31,7 @@ export interface HandleGithubEventParams {
27
31
  event: string;
28
32
  payload: unknown;
29
33
  publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
34
+ publishSourceRepositoryUpdated: PublishSourceRepositoryUpdatedFn;
30
35
  publishSourcePush: PublishSourcePushFn;
31
36
  recordDeliveryOnly: RecordDeliveryOnlyFn;
32
37
  getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
@@ -39,6 +44,7 @@ export type HandleGithubEventOutcome =
39
44
  | 'duplicate-envelope'
40
45
  | 'published-push-envelope-only'
41
46
  | 'duplicate-push-envelope-only'
47
+ | 'fork-pull-request'
42
48
  | 'unknown-installation'
43
49
  | 'missing-connection'
44
50
  | 'inactive-connection'
@@ -53,11 +59,88 @@ function isBranchDeletion(after: string): boolean {
53
59
  return after === DELETED_BRANCH_SHA;
54
60
  }
55
61
 
62
+ interface GithubRepositoryIdentity {
63
+ id?: string;
64
+ fullName?: string;
65
+ }
66
+
67
+ function isRecord(value: unknown): value is Record<string, unknown> {
68
+ return typeof value === 'object' && value !== null;
69
+ }
70
+
71
+ function repositoryIdentity(value: unknown): GithubRepositoryIdentity | undefined {
72
+ if (!isRecord(value)) return undefined;
73
+
74
+ const id =
75
+ (typeof value.id === 'number' && Number.isInteger(value.id) ? String(value.id) : undefined) ??
76
+ (typeof value.id === 'string' && value.id.trim() ? value.id.trim() : undefined);
77
+ const fullName =
78
+ typeof value.full_name === 'string' && value.full_name.trim()
79
+ ? value.full_name.trim().toLowerCase()
80
+ : undefined;
81
+ if (!id && !fullName) return undefined;
82
+
83
+ return {...(id ? {id} : {}), ...(fullName ? {fullName} : {})};
84
+ }
85
+
86
+ function areSameRepositories(
87
+ head: GithubRepositoryIdentity,
88
+ base: GithubRepositoryIdentity,
89
+ ): boolean {
90
+ if (head.id && base.id) return head.id === base.id;
91
+ if (head.fullName && base.fullName) return head.fullName === base.fullName;
92
+ return false;
93
+ }
94
+
95
+ function pullRequestRepositories(
96
+ payload: unknown,
97
+ ):
98
+ | {head: GithubRepositoryIdentity | undefined; base: GithubRepositoryIdentity | undefined}
99
+ | undefined {
100
+ if (!isRecord(payload) || !isRecord(payload.pull_request)) return undefined;
101
+
102
+ const pullRequest = payload.pull_request;
103
+ const head = isRecord(pullRequest.head) ? repositoryIdentity(pullRequest.head.repo) : undefined;
104
+ const base =
105
+ (isRecord(pullRequest.base) ? repositoryIdentity(pullRequest.base.repo) : undefined) ??
106
+ repositoryIdentity(payload.repository);
107
+ return {head, base};
108
+ }
109
+
56
110
  export async function handleGithubEvent(
57
111
  params: HandleGithubEventParams,
58
112
  ): Promise<HandleGithubEventResult> {
59
113
  const actionEnvelope = githubWebhookActionSchema.safeParse(params.payload);
60
114
  const action = actionEnvelope.success ? actionEnvelope.data.action : undefined;
115
+
116
+ const pullRequestRepos = pullRequestRepositories(params.payload);
117
+ if (
118
+ pullRequestRepos &&
119
+ (!pullRequestRepos.head ||
120
+ !pullRequestRepos.base ||
121
+ !areSameRepositories(pullRequestRepos.head, pullRequestRepos.base))
122
+ ) {
123
+ logger().info(
124
+ {
125
+ deliveryId: params.deliveryId,
126
+ event: params.event,
127
+ reason:
128
+ !pullRequestRepos.head || !pullRequestRepos.base
129
+ ? 'repository_unresolved'
130
+ : 'repositories_differ',
131
+ headRepository: pullRequestRepos.head,
132
+ baseRepository: pullRequestRepos.base,
133
+ },
134
+ 'github webhook: fork or indeterminate pull request, dropping',
135
+ );
136
+ await params.recordDeliveryOnly({
137
+ tx: params.tx,
138
+ provider: GITHUB_SOURCE,
139
+ deliveryId: params.deliveryId,
140
+ });
141
+ return {outcome: 'fork-pull-request'};
142
+ }
143
+
61
144
  const installationEnvelope = githubWebhookInstallationSchema.safeParse(params.payload);
62
145
  const installationId = installationEnvelope.success
63
146
  ? installationEnvelope.data.installation?.id
@@ -157,6 +240,30 @@ export async function handleGithubEvent(
157
240
  }
158
241
 
159
242
  const eventName = action ? `${params.event}.${action}` : params.event;
243
+ const repositories = normalizeRepositoryUpdates(params.event, action, params.payload);
244
+ if (repositories) {
245
+ const result = await params.publishSourceRepositoryUpdated({
246
+ tx: params.tx,
247
+ provider: GITHUB_SOURCE,
248
+ source: connection.slug,
249
+ workspaceId: connection.workspaceId,
250
+ connectionId: connection.id,
251
+ connectionName: connection.displayName,
252
+ deliveryId: params.deliveryId,
253
+ receivedAt: new Date().toISOString(),
254
+ rawPayload: params.payload,
255
+ event: eventName,
256
+ repositories,
257
+ });
258
+ return withInstallationTokenCleanup(
259
+ {outcome: result.published ? 'published' : 'duplicate'},
260
+ params.event,
261
+ action,
262
+ connection.workspaceId,
263
+ installationId,
264
+ );
265
+ }
266
+
160
267
  const result = await publishGithubEnvelopeOnly({
161
268
  tx: params.tx,
162
269
  deliveryId: params.deliveryId,
@@ -174,6 +281,49 @@ export async function handleGithubEvent(
174
281
  );
175
282
  }
176
283
 
284
+ function normalizeRepositoryUpdates(
285
+ event: string,
286
+ action: string | undefined,
287
+ payload: unknown,
288
+ ): SourceRepositoryIdentity[] | undefined {
289
+ if (event === 'repository' && action === 'renamed') {
290
+ const parsed = githubRepositoryRenamedPayloadSchema.safeParse(payload);
291
+ if (!parsed.success) return undefined;
292
+ return [toSourceRepositoryIdentity(parsed.data.repository)];
293
+ }
294
+
295
+ if (event !== 'installation_repositories' || (action !== 'added' && action !== 'removed')) {
296
+ return undefined;
297
+ }
298
+
299
+ const parsed = githubInstallationRepositoriesPayloadSchema.safeParse(payload);
300
+ if (!parsed.success) return undefined;
301
+
302
+ const repositories = new Map<number, SourceRepositoryIdentity>();
303
+ for (const repository of [
304
+ ...parsed.data.repositories_added,
305
+ ...parsed.data.repositories_removed,
306
+ ]) {
307
+ const normalized = toSourceRepositoryIdentity(repository);
308
+ repositories.set(repository.id, normalized);
309
+ }
310
+ return repositories.size > 0 ? [...repositories.values()] : undefined;
311
+ }
312
+
313
+ function toSourceRepositoryIdentity(repository: {
314
+ id: number;
315
+ name: string;
316
+ owner: {login: string};
317
+ default_branch: string;
318
+ }): SourceRepositoryIdentity {
319
+ return {
320
+ externalRepositoryId: buildProviderRepositoryId(GITHUB_SOURCE, String(repository.id)),
321
+ owner: repository.owner.login,
322
+ name: repository.name,
323
+ defaultBranch: repository.default_branch,
324
+ };
325
+ }
326
+
177
327
  function shouldDeleteInstallationTokenSecret(event: string, action: string | undefined): boolean {
178
328
  return event === 'installation' && (action === 'deleted' || action === 'suspend');
179
329
  }
package/src/index.test.ts CHANGED
@@ -25,6 +25,7 @@ describe('createGithubIntegrationProvider', () => {
25
25
  connectGithubInstallation: vi.fn() as never,
26
26
  coreDb: vi.fn() as never,
27
27
  publishIntegrationEventReceived: vi.fn(() => Promise.resolve({published: false})),
28
+ publishSourceRepositoryUpdated: vi.fn(() => Promise.resolve({published: false})),
28
29
  publishSourcePush: vi.fn(() => Promise.resolve({published: false})),
29
30
  recordDeliveryOnly: vi.fn(() => Promise.resolve()),
30
31
  getIntegrationConnectionById: vi.fn(() => Promise.resolve(undefined)),
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ import type {
2
2
  GetIntegrationConnectionByIdFn,
3
3
  PublishIntegrationEventReceivedFn,
4
4
  PublishSourcePushFn,
5
+ PublishSourceRepositoryUpdatedFn,
5
6
  RecordDeliveryOnlyFn,
6
7
  } from '@shipfox/api-integration-spi';
7
8
  import type {NodePgDatabase} from 'drizzle-orm/node-postgres';
@@ -69,6 +70,7 @@ export interface CreateGithubIntegrationProviderOptions
69
70
  github?: GithubApiClient | undefined;
70
71
  coreDb: () => NodePgDatabase<Record<string, unknown>>;
71
72
  publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
73
+ publishSourceRepositoryUpdated: PublishSourceRepositoryUpdatedFn;
72
74
  publishSourcePush: PublishSourcePushFn;
73
75
  recordDeliveryOnly: RecordDeliveryOnlyFn;
74
76
  getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
@@ -129,6 +131,7 @@ export function createGithubIntegrationProvider(options: CreateGithubIntegration
129
131
  createGithubWebhookRoutes({
130
132
  coreDb: options.coreDb,
131
133
  publishIntegrationEventReceived: options.publishIntegrationEventReceived,
134
+ publishSourceRepositoryUpdated: options.publishSourceRepositoryUpdated,
132
135
  publishSourcePush: options.publishSourcePush,
133
136
  recordDeliveryOnly: options.recordDeliveryOnly,
134
137
  getIntegrationConnectionById: options.getIntegrationConnectionById,
@@ -100,9 +100,10 @@ async function createTestApp(options: CreateTestAppOptions = {}): Promise<Fastif
100
100
 
101
101
  return Promise.resolve(connection);
102
102
  }),
103
- // Webhook receiver dependencies install/OAuth tests don't exercise them.
103
+ // Webhook receiver dependencies: install/OAuth tests don't exercise them.
104
104
  coreDb: vi.fn() as never,
105
105
  publishIntegrationEventReceived: vi.fn(() => Promise.resolve({published: false})),
106
+ publishSourceRepositoryUpdated: vi.fn(() => Promise.resolve({published: false})),
106
107
  publishSourcePush: vi.fn(() => Promise.resolve({published: false})),
107
108
  recordDeliveryOnly: vi.fn(() => Promise.resolve()),
108
109
  getIntegrationConnectionById: vi.fn(() => Promise.resolve(undefined)),
@@ -11,6 +11,7 @@ import {createGithubWebhookRoutes} from './webhooks.js';
11
11
  const WEBHOOK_SECRET = 'test-webhook-secret';
12
12
 
13
13
  // The route persists through injected core functions (publishSourcePush,
14
+ // publishSourceRepositoryUpdated,
14
15
  // recordDeliveryOnly, getIntegrationConnectionById) that @shipfox/api-integration-core
15
16
  // owns and wires in production. github only orchestrates them, so here we fake that
16
17
  // interface with spies and assert the route's own behavior: signature/payload
@@ -34,6 +35,7 @@ function fakeConnection(overrides: Partial<IntegrationConnection> = {}): Integra
34
35
  interface TestApp {
35
36
  app: FastifyInstance;
36
37
  publishIntegrationEventReceived: ReturnType<typeof vi.fn>;
38
+ publishSourceRepositoryUpdated: ReturnType<typeof vi.fn>;
37
39
  publishSourcePush: ReturnType<typeof vi.fn>;
38
40
  recordDeliveryOnly: ReturnType<typeof vi.fn>;
39
41
  getIntegrationConnectionById: ReturnType<typeof vi.fn>;
@@ -41,6 +43,7 @@ interface TestApp {
41
43
 
42
44
  async function createTestApp(options: {connection?: IntegrationConnection} = {}): Promise<TestApp> {
43
45
  const publishIntegrationEventReceived = vi.fn(() => Promise.resolve({published: true}));
46
+ const publishSourceRepositoryUpdated = vi.fn(() => Promise.resolve({published: true}));
44
47
  const publishSourcePush = vi.fn(() => Promise.resolve({published: true}));
45
48
  const recordDeliveryOnly = vi.fn(() => Promise.resolve());
46
49
  const getIntegrationConnectionById = vi.fn(() =>
@@ -49,6 +52,7 @@ async function createTestApp(options: {connection?: IntegrationConnection} = {})
49
52
  const routes = createGithubWebhookRoutes({
50
53
  coreDb: db,
51
54
  publishIntegrationEventReceived,
55
+ publishSourceRepositoryUpdated,
52
56
  publishSourcePush,
53
57
  recordDeliveryOnly,
54
58
  getIntegrationConnectionById,
@@ -58,6 +62,7 @@ async function createTestApp(options: {connection?: IntegrationConnection} = {})
58
62
  return {
59
63
  app,
60
64
  publishIntegrationEventReceived,
65
+ publishSourceRepositoryUpdated,
61
66
  publishSourcePush,
62
67
  recordDeliveryOnly,
63
68
  getIntegrationConnectionById,
@@ -250,7 +255,12 @@ describe('GitHub webhook route', () => {
250
255
  const rawPayload = {
251
256
  action: 'opened',
252
257
  installation: {id: installationId},
253
- pull_request: {number: 17},
258
+ repository: {id: 42, full_name: 'shipfox/platform'},
259
+ pull_request: {
260
+ number: 17,
261
+ head: {repo: {id: 42, full_name: 'shipfox/platform'}},
262
+ base: {repo: {id: 42, full_name: 'shipfox/platform'}},
263
+ },
254
264
  };
255
265
  const body = JSON.stringify(rawPayload);
256
266
 
@@ -278,6 +288,44 @@ describe('GitHub webhook route', () => {
278
288
  });
279
289
  });
280
290
 
291
+ it('records and drops a pull request opened from a fork', async () => {
292
+ const installationId = 7787;
293
+ const connection = fakeConnection();
294
+ await seedInstallation(installationId, connection.id);
295
+ const {
296
+ app,
297
+ publishIntegrationEventReceived,
298
+ publishSourcePush,
299
+ recordDeliveryOnly,
300
+ getIntegrationConnectionById,
301
+ } = await createTestApp({connection});
302
+ const deliveryId = randomUUID();
303
+ const rawPayload = {
304
+ action: 'opened',
305
+ installation: {id: installationId},
306
+ repository: {id: 42, full_name: 'shipfox/platform'},
307
+ pull_request: {
308
+ head: {repo: {id: 84, full_name: 'contributor/platform'}},
309
+ base: {repo: {id: 42, full_name: 'shipfox/platform'}},
310
+ },
311
+ };
312
+ const body = JSON.stringify(rawPayload);
313
+
314
+ const res = await app.inject({
315
+ method: 'POST',
316
+ url: '/webhooks/integrations/github',
317
+ headers: signedHeaders(body, 'pull_request', deliveryId),
318
+ payload: body,
319
+ });
320
+
321
+ expect(res.statusCode).toBe(204);
322
+ expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
323
+ expect(publishSourcePush).not.toHaveBeenCalled();
324
+ expect(recordDeliveryOnly).toHaveBeenCalledTimes(1);
325
+ expect(recordDeliveryOnly.mock.calls[0]?.[0]).toMatchObject({provider: 'github', deliveryId});
326
+ expect(getIntegrationConnectionById).not.toHaveBeenCalled();
327
+ });
328
+
281
329
  it('publishes a generic envelope for a non-push event without an action', async () => {
282
330
  const installationId = 7785;
283
331
  const connection = fakeConnection();
@@ -3,6 +3,7 @@ import type {
3
3
  GetIntegrationConnectionByIdFn,
4
4
  PublishIntegrationEventReceivedFn,
5
5
  PublishSourcePushFn,
6
+ PublishSourceRepositoryUpdatedFn,
6
7
  RecordDeliveryOnlyFn,
7
8
  StoredWebhookRequest,
8
9
  WebhookProcessingResult,
@@ -28,6 +29,7 @@ const DELIVERY_HEADER = 'x-github-delivery';
28
29
  export interface CreateGithubWebhookRoutesOptions {
29
30
  coreDb: () => NodePgDatabase<Record<string, unknown>>;
30
31
  publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
32
+ publishSourceRepositoryUpdated: PublishSourceRepositoryUpdatedFn;
31
33
  publishSourcePush: PublishSourcePushFn;
32
34
  recordDeliveryOnly: RecordDeliveryOnlyFn;
33
35
  getIntegrationConnectionById: GetIntegrationConnectionByIdFn;