@shipfox/api-integration-github 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/.turbo/turbo-type$colon$emit.log +1 -0
  3. package/.turbo/turbo-type.log +1 -0
  4. package/CHANGELOG.md +138 -0
  5. package/LICENSE +21 -0
  6. package/dist/api/client.d.ts +90 -0
  7. package/dist/api/client.d.ts.map +1 -0
  8. package/dist/api/client.js +306 -0
  9. package/dist/api/client.js.map +1 -0
  10. package/dist/api/installation-token-envelope.d.ts +36 -0
  11. package/dist/api/installation-token-envelope.d.ts.map +1 -0
  12. package/dist/api/installation-token-envelope.js +123 -0
  13. package/dist/api/installation-token-envelope.js.map +1 -0
  14. package/dist/api/installation-token-provider.d.ts +23 -0
  15. package/dist/api/installation-token-provider.d.ts.map +1 -0
  16. package/dist/api/installation-token-provider.js +130 -0
  17. package/dist/api/installation-token-provider.js.map +1 -0
  18. package/dist/api/shared-installation-token-cache.d.ts +43 -0
  19. package/dist/api/shared-installation-token-cache.d.ts.map +1 -0
  20. package/dist/api/shared-installation-token-cache.js +224 -0
  21. package/dist/api/shared-installation-token-cache.js.map +1 -0
  22. package/dist/config.d.ts +15 -0
  23. package/dist/config.d.ts.map +1 -0
  24. package/dist/config.js +44 -0
  25. package/dist/config.js.map +1 -0
  26. package/dist/core/actions-logs.d.ts +47 -0
  27. package/dist/core/actions-logs.d.ts.map +1 -0
  28. package/dist/core/actions-logs.js +134 -0
  29. package/dist/core/actions-logs.js.map +1 -0
  30. package/dist/core/agent-tool-selection.d.ts +4 -0
  31. package/dist/core/agent-tool-selection.d.ts.map +1 -0
  32. package/dist/core/agent-tool-selection.js +38 -0
  33. package/dist/core/agent-tool-selection.js.map +1 -0
  34. package/dist/core/agent-tools.d.ts +50 -0
  35. package/dist/core/agent-tools.d.ts.map +1 -0
  36. package/dist/core/agent-tools.js +1122 -0
  37. package/dist/core/agent-tools.js.map +1 -0
  38. package/dist/core/errors.d.ts +15 -0
  39. package/dist/core/errors.d.ts.map +1 -0
  40. package/dist/core/errors.js +22 -0
  41. package/dist/core/errors.js.map +1 -0
  42. package/dist/core/install.d.ts +38 -0
  43. package/dist/core/install.d.ts.map +1 -0
  44. package/dist/core/install.js +64 -0
  45. package/dist/core/install.js.map +1 -0
  46. package/dist/core/source-control.d.ts +15 -0
  47. package/dist/core/source-control.d.ts.map +1 -0
  48. package/dist/core/source-control.js +175 -0
  49. package/dist/core/source-control.js.map +1 -0
  50. package/dist/core/state.d.ts +12 -0
  51. package/dist/core/state.d.ts.map +1 -0
  52. package/dist/core/state.js +54 -0
  53. package/dist/core/state.js.map +1 -0
  54. package/dist/core/webhook.d.ts +20 -0
  55. package/dist/core/webhook.d.ts.map +1 -0
  56. package/dist/core/webhook.js +216 -0
  57. package/dist/core/webhook.js.map +1 -0
  58. package/dist/db/db.d.ts +433 -0
  59. package/dist/db/db.d.ts.map +1 -0
  60. package/dist/db/db.js +18 -0
  61. package/dist/db/db.js.map +1 -0
  62. package/dist/db/installation-token-lock.d.ts +8 -0
  63. package/dist/db/installation-token-lock.d.ts.map +1 -0
  64. package/dist/db/installation-token-lock.js +37 -0
  65. package/dist/db/installation-token-lock.js.map +1 -0
  66. package/dist/db/installations.d.ts +33 -0
  67. package/dist/db/installations.d.ts.map +1 -0
  68. package/dist/db/installations.js +56 -0
  69. package/dist/db/installations.js.map +1 -0
  70. package/dist/db/migrations.d.ts +2 -0
  71. package/dist/db/migrations.d.ts.map +1 -0
  72. package/dist/db/migrations.js +5 -0
  73. package/dist/db/migrations.js.map +1 -0
  74. package/dist/db/schema/common.d.ts +2 -0
  75. package/dist/db/schema/common.d.ts.map +1 -0
  76. package/dist/db/schema/common.js +4 -0
  77. package/dist/db/schema/common.js.map +1 -0
  78. package/dist/db/schema/installations.d.ts +218 -0
  79. package/dist/db/schema/installations.d.ts.map +1 -0
  80. package/dist/db/schema/installations.js +46 -0
  81. package/dist/db/schema/installations.js.map +1 -0
  82. package/dist/index.d.ts +53 -0
  83. package/dist/index.d.ts.map +1 -0
  84. package/dist/index.js +66 -0
  85. package/dist/index.js.map +1 -0
  86. package/dist/metrics/index.d.ts +2 -0
  87. package/dist/metrics/index.d.ts.map +1 -0
  88. package/dist/metrics/index.js +3 -0
  89. package/dist/metrics/index.js.map +1 -0
  90. package/dist/metrics/instance.d.ts +14 -0
  91. package/dist/metrics/instance.d.ts.map +1 -0
  92. package/dist/metrics/instance.js +75 -0
  93. package/dist/metrics/instance.js.map +1 -0
  94. package/dist/presentation/dto/integrations.d.ts +15 -0
  95. package/dist/presentation/dto/integrations.d.ts.map +1 -0
  96. package/dist/presentation/dto/integrations.js +10 -0
  97. package/dist/presentation/dto/integrations.js.map +1 -0
  98. package/dist/presentation/e2eRoutes/create-connection.d.ts +11 -0
  99. package/dist/presentation/e2eRoutes/create-connection.d.ts.map +1 -0
  100. package/dist/presentation/e2eRoutes/create-connection.js +57 -0
  101. package/dist/presentation/e2eRoutes/create-connection.js.map +1 -0
  102. package/dist/presentation/e2eRoutes/index.d.ts +5 -0
  103. package/dist/presentation/e2eRoutes/index.d.ts.map +1 -0
  104. package/dist/presentation/e2eRoutes/index.js +11 -0
  105. package/dist/presentation/e2eRoutes/index.js.map +1 -0
  106. package/dist/presentation/routes/errors.d.ts +2 -0
  107. package/dist/presentation/routes/errors.d.ts.map +1 -0
  108. package/dist/presentation/routes/errors.js +46 -0
  109. package/dist/presentation/routes/errors.js.map +1 -0
  110. package/dist/presentation/routes/install.d.ts +13 -0
  111. package/dist/presentation/routes/install.d.ts.map +1 -0
  112. package/dist/presentation/routes/install.js +77 -0
  113. package/dist/presentation/routes/install.js.map +1 -0
  114. package/dist/presentation/routes/webhooks.d.ts +16 -0
  115. package/dist/presentation/routes/webhooks.d.ts.map +1 -0
  116. package/dist/presentation/routes/webhooks.js +110 -0
  117. package/dist/presentation/routes/webhooks.js.map +1 -0
  118. package/dist/tsconfig.test.tsbuildinfo +1 -0
  119. package/drizzle/0000_initial.sql +17 -0
  120. package/drizzle/meta/_journal.json +13 -0
  121. package/drizzle.config.ts +7 -0
  122. package/package.json +71 -0
  123. package/src/api/client.test.ts +94 -0
  124. package/src/api/client.ts +525 -0
  125. package/src/api/installation-token-envelope.ts +171 -0
  126. package/src/api/installation-token-provider.test.ts +241 -0
  127. package/src/api/installation-token-provider.ts +224 -0
  128. package/src/api/shared-installation-token-cache.test.ts +294 -0
  129. package/src/api/shared-installation-token-cache.ts +346 -0
  130. package/src/config.test.ts +14 -0
  131. package/src/config.ts +47 -0
  132. package/src/connection-external-url.test.ts +78 -0
  133. package/src/core/actions-logs.test.ts +156 -0
  134. package/src/core/actions-logs.ts +186 -0
  135. package/src/core/agent-tool-selection.test.ts +41 -0
  136. package/src/core/agent-tool-selection.ts +42 -0
  137. package/src/core/agent-tools.test.ts +539 -0
  138. package/src/core/agent-tools.ts +1269 -0
  139. package/src/core/errors.ts +23 -0
  140. package/src/core/install.test.ts +238 -0
  141. package/src/core/install.ts +119 -0
  142. package/src/core/source-control.test.ts +327 -0
  143. package/src/core/source-control.ts +223 -0
  144. package/src/core/state.test.ts +43 -0
  145. package/src/core/state.ts +85 -0
  146. package/src/core/webhook.test.ts +522 -0
  147. package/src/core/webhook.ts +296 -0
  148. package/src/db/db.ts +18 -0
  149. package/src/db/installation-token-lock.test.ts +68 -0
  150. package/src/db/installation-token-lock.ts +39 -0
  151. package/src/db/installations.test.ts +67 -0
  152. package/src/db/installations.ts +108 -0
  153. package/src/db/migrations.ts +4 -0
  154. package/src/db/schema/common.ts +3 -0
  155. package/src/db/schema/installations.ts +46 -0
  156. package/src/index.ts +124 -0
  157. package/src/metrics/index.ts +1 -0
  158. package/src/metrics/instance.ts +82 -0
  159. package/src/presentation/dto/integrations.ts +8 -0
  160. package/src/presentation/e2eRoutes/create-connection.ts +70 -0
  161. package/src/presentation/e2eRoutes/index.test.ts +126 -0
  162. package/src/presentation/e2eRoutes/index.ts +14 -0
  163. package/src/presentation/routes/errors.ts +43 -0
  164. package/src/presentation/routes/install.test.ts +279 -0
  165. package/src/presentation/routes/install.ts +93 -0
  166. package/src/presentation/routes/webhooks.test.ts +560 -0
  167. package/src/presentation/routes/webhooks.ts +115 -0
  168. package/test/env.ts +16 -0
  169. package/test/factories/github-installation.ts +35 -0
  170. package/test/factories/index.ts +1 -0
  171. package/test/fixtures/github-webhook.ts +20 -0
  172. package/test/globalSetup.ts +14 -0
  173. package/test/index.ts +2 -0
  174. package/test/setup.ts +17 -0
  175. package/tsconfig.build.json +9 -0
  176. package/tsconfig.build.tsbuildinfo +1 -0
  177. package/tsconfig.json +3 -0
  178. package/tsconfig.test.json +9 -0
  179. package/vitest.config.ts +12 -0
package/src/index.ts ADDED
@@ -0,0 +1,124 @@
1
+ import type {
2
+ GetIntegrationConnectionByIdFn,
3
+ PublishIntegrationEventReceivedFn,
4
+ PublishSourcePushFn,
5
+ RecordDeliveryOnlyFn,
6
+ } from '@shipfox/api-integration-core-dto';
7
+ import type {NodePgDatabase} from 'drizzle-orm/node-postgres';
8
+ import {createGithubApiClient, type GithubApiClient} from '#api/client.js';
9
+ import type {GithubInstallationTokenProvider} from '#api/installation-token-provider.js';
10
+ import {deleteGithubInstallationTokenSecret} from '#api/installation-token-provider.js';
11
+ import {GithubAgentToolsProvider} from '#core/agent-tools.js';
12
+ import {GithubSourceControlProvider} from '#core/source-control.js';
13
+ import {closeDb, db} from '#db/db.js';
14
+ import {getGithubInstallationByConnectionId} from '#db/installations.js';
15
+ import {migrationsPath} from '#db/migrations.js';
16
+ import {
17
+ type CreateGithubE2eRoutesOptions,
18
+ createGithubE2eRoutes,
19
+ } from '#presentation/e2eRoutes/index.js';
20
+ import {
21
+ type CreateGithubIntegrationRoutesOptions,
22
+ createGithubIntegrationRoutes,
23
+ } from '#presentation/routes/install.js';
24
+ import {createGithubWebhookRoutes} from '#presentation/routes/webhooks.js';
25
+
26
+ export type {GithubApiClient} from '#api/client.js';
27
+ export {
28
+ encodeInstallationTokenEnvelope,
29
+ githubInstallationTokenNamespace,
30
+ } from '#api/installation-token-envelope.js';
31
+ export {
32
+ createGithubInstallationTokenProvider,
33
+ type GithubInstallationTokenProvider,
34
+ } from '#api/installation-token-provider.js';
35
+ export {
36
+ type GithubAgentToolCatalogEntry,
37
+ type GithubAgentToolCategory,
38
+ type GithubAgentToolId,
39
+ type GithubAgentToolPermission,
40
+ type GithubAgentToolPermissionAccess,
41
+ type GithubAgentToolRequiredPermission,
42
+ type GithubAgentToolRequiredScope,
43
+ GithubAgentToolsProvider,
44
+ githubAgentToolCatalog,
45
+ } from '#core/agent-tools.js';
46
+ export {GithubIntegrationProviderError} from '#core/errors.js';
47
+ export type {ConnectGithubInstallationInput} from '#core/install.js';
48
+ export {handleGithubCallback} from '#core/install.js';
49
+ export {signGithubInstallState, verifyGithubInstallState} from '#core/state.js';
50
+ export type {HandleGithubEventOutcome} from '#core/webhook.js';
51
+ export {handleGithubEvent} from '#core/webhook.js';
52
+ export type {GithubInstallation, UpsertGithubInstallationParams} from '#db/installations.js';
53
+ export {
54
+ getGithubInstallationByConnectionId,
55
+ getGithubInstallationByInstallationId,
56
+ upsertGithubInstallation,
57
+ } from '#db/installations.js';
58
+ export {type CreateGithubE2eRoutesOptions, closeDb, createGithubE2eRoutes, db, migrationsPath};
59
+
60
+ export interface CreateGithubIntegrationProviderOptions
61
+ extends Omit<CreateGithubIntegrationRoutesOptions, 'github'> {
62
+ github?: GithubApiClient | undefined;
63
+ coreDb: () => NodePgDatabase<Record<string, unknown>>;
64
+ publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
65
+ publishSourcePush: PublishSourcePushFn;
66
+ recordDeliveryOnly: RecordDeliveryOnlyFn;
67
+ getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
68
+ getGithubInstallationByConnectionId?: typeof getGithubInstallationByConnectionId | undefined;
69
+ deleteSecrets?:
70
+ | ((params: {workspaceId: string; namespace: string}) => Promise<number>)
71
+ | undefined;
72
+ agentTools?: {tokenProvider: GithubInstallationTokenProvider} | undefined;
73
+ }
74
+
75
+ export function createGithubIntegrationProvider(options: CreateGithubIntegrationProviderOptions) {
76
+ const github = options.github ?? createGithubApiClient();
77
+ const getInstallationByConnectionId =
78
+ options.getGithubInstallationByConnectionId ?? getGithubInstallationByConnectionId;
79
+ const deleteSecrets = options.deleteSecrets;
80
+
81
+ return {
82
+ provider: 'github' as const,
83
+ displayName: 'GitHub',
84
+ adapters: {
85
+ source_control: new GithubSourceControlProvider(github),
86
+ agent_tools: new GithubAgentToolsProvider({
87
+ getInstallationByConnectionId: getInstallationByConnectionId,
88
+ tokenProvider: options.agentTools?.tokenProvider,
89
+ }),
90
+ },
91
+ async connectionExternalUrl(connection: {id: string}): Promise<string | undefined> {
92
+ const installation = await getInstallationByConnectionId(connection.id);
93
+ if (!installation) return undefined;
94
+ const installationId = encodeURIComponent(installation.installationId);
95
+ if (installation.accountType === 'Organization') {
96
+ const login = encodeURIComponent(installation.accountLogin);
97
+ return `https://github.com/organizations/${login}/settings/installations/${installationId}`;
98
+ }
99
+ return `https://github.com/settings/installations/${installationId}`;
100
+ },
101
+ routes: [
102
+ createGithubIntegrationRoutes({
103
+ github,
104
+ getExistingGithubConnection: options.getExistingGithubConnection,
105
+ connectGithubInstallation: options.connectGithubInstallation,
106
+ }),
107
+ createGithubWebhookRoutes({
108
+ coreDb: options.coreDb,
109
+ publishIntegrationEventReceived: options.publishIntegrationEventReceived,
110
+ publishSourcePush: options.publishSourcePush,
111
+ recordDeliveryOnly: options.recordDeliveryOnly,
112
+ getIntegrationConnectionById: options.getIntegrationConnectionById,
113
+ deleteInstallationTokenSecret: deleteSecrets
114
+ ? (params) =>
115
+ deleteGithubInstallationTokenSecret({
116
+ workspaceId: params.workspaceId,
117
+ installationId: params.installationId,
118
+ deleteSecrets,
119
+ })
120
+ : undefined,
121
+ }),
122
+ ],
123
+ };
124
+ }
@@ -0,0 +1 @@
1
+ export * from './instance.js';
@@ -0,0 +1,82 @@
1
+ import type {IntegrationProviderErrorReason} from '@shipfox/api-integration-core-dto';
2
+ import {instanceMetrics} from '@shipfox/node-opentelemetry';
3
+ import type {MintErrorClass} from '#api/installation-token-envelope.js';
4
+
5
+ const meter = instanceMetrics.getMeter('github');
6
+
7
+ export type GithubInstallationTokenLookupOutcome =
8
+ | 'ram-hit'
9
+ | 'db-hit'
10
+ | 'minted'
11
+ | 'served-stale'
12
+ | 'backoff'
13
+ | 'contended-poll';
14
+
15
+ const installationTokenLookupCount = meter.createCounter<{
16
+ outcome: GithubInstallationTokenLookupOutcome;
17
+ }>('github_installation_token_lookup', {
18
+ description: 'GitHub installation token cache lookups by serving outcome',
19
+ });
20
+
21
+ const installationTokenMintCount = meter.createCounter<{outcome: 'success' | 'failure'}>(
22
+ 'github_installation_token_mint',
23
+ {description: 'GitHub installation token mint attempts by outcome'},
24
+ );
25
+
26
+ const installationTokenMintDuration = meter.createHistogram<Record<string, never>>(
27
+ 'github_installation_token_mint_duration',
28
+ {
29
+ description: 'GitHub installation token mint duration',
30
+ unit: 'ms',
31
+ advice: {explicitBucketBoundaries: [10, 50, 100, 250, 500, 1000, 2500, 5000, 10000]},
32
+ },
33
+ );
34
+
35
+ const installationTokenLockWaitDuration = meter.createHistogram<Record<string, never>>(
36
+ 'github_installation_token_lock_wait_duration',
37
+ {
38
+ description: 'GitHub installation token advisory lock acquire and hold duration',
39
+ unit: 'ms',
40
+ advice: {explicitBucketBoundaries: [0, 1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000]},
41
+ },
42
+ );
43
+
44
+ const installationTokenBackoffCount = meter.createCounter<{
45
+ reason: IntegrationProviderErrorReason;
46
+ class: MintErrorClass;
47
+ }>('github_installation_token_backoff', {
48
+ description: 'GitHub installation token mint backoff activations by reason and class',
49
+ });
50
+
51
+ function recordMetric(record: () => void): void {
52
+ try {
53
+ record();
54
+ } catch {
55
+ // Metrics must not affect GitHub provider outcomes.
56
+ }
57
+ }
58
+
59
+ export function recordInstallationTokenLookup(outcome: GithubInstallationTokenLookupOutcome): void {
60
+ recordMetric(() => installationTokenLookupCount.add(1, {outcome}));
61
+ }
62
+
63
+ export function recordInstallationTokenMint(params: {
64
+ outcome: 'success' | 'failure';
65
+ durationMs: number;
66
+ }): void {
67
+ recordMetric(() => {
68
+ installationTokenMintCount.add(1, {outcome: params.outcome});
69
+ installationTokenMintDuration.record(params.durationMs);
70
+ });
71
+ }
72
+
73
+ export function recordInstallationTokenLockWait(durationMs: number): void {
74
+ recordMetric(() => installationTokenLockWaitDuration.record(durationMs));
75
+ }
76
+
77
+ export function recordInstallationTokenBackoff(params: {
78
+ reason: IntegrationProviderErrorReason;
79
+ class: MintErrorClass;
80
+ }): void {
81
+ recordMetric(() => installationTokenBackoffCount.add(1, params));
82
+ }
@@ -0,0 +1,8 @@
1
+ import {
2
+ type IntegrationConnection,
3
+ toIntegrationConnectionDto as toCoreIntegrationConnectionDto,
4
+ } from '@shipfox/api-integration-core-dto';
5
+
6
+ export function toIntegrationConnectionDto(connection: IntegrationConnection<'github'>) {
7
+ return toCoreIntegrationConnectionDto(connection, {capabilities: ['source_control']});
8
+ }
@@ -0,0 +1,70 @@
1
+ import type {IntegrationCapability, IntegrationConnection} from '@shipfox/api-integration-core-dto';
2
+ import {toIntegrationConnectionDto} from '@shipfox/api-integration-core-dto';
3
+ import {
4
+ createE2eGithubConnectionBodySchema,
5
+ createE2eGithubConnectionResponseSchema,
6
+ } from '@shipfox/api-integration-github-dto';
7
+ import {ClientError, defineRoute} from '@shipfox/node-fastify';
8
+ import type {ConnectGithubInstallationInput} from '#core/install.js';
9
+
10
+ export interface CreateE2eGithubConnectionRouteOptions {
11
+ getExistingGithubConnection: (input: {
12
+ installationId: string;
13
+ }) => Promise<IntegrationConnection<'github'> | undefined>;
14
+ connectGithubInstallation: (
15
+ input: ConnectGithubInstallationInput,
16
+ ) => Promise<IntegrationConnection<'github'>>;
17
+ connectionCapabilities: IntegrationCapability[];
18
+ }
19
+
20
+ export function createE2eGithubConnectionRoute(options: CreateE2eGithubConnectionRouteOptions) {
21
+ return defineRoute({
22
+ method: 'POST',
23
+ path: '/github-connections',
24
+ description: 'Create a synthetic GitHub connection for E2E tests.',
25
+ schema: {
26
+ body: createE2eGithubConnectionBodySchema,
27
+ response: {201: createE2eGithubConnectionResponseSchema},
28
+ },
29
+ handler: async (request, reply) => {
30
+ const body = request.body;
31
+ const installationId = String(body.installation_id);
32
+ const existing = await options.getExistingGithubConnection({installationId});
33
+ if (existing && existing.workspaceId !== body.workspace_id) {
34
+ throw new ClientError(
35
+ 'GitHub installation is already connected to another workspace',
36
+ 'github-connection-workspace-mismatch',
37
+ {status: 409},
38
+ );
39
+ }
40
+
41
+ const connection =
42
+ existing ??
43
+ (await options.connectGithubInstallation({
44
+ workspaceId: body.workspace_id,
45
+ installationId,
46
+ displayName: body.display_name,
47
+ installerUserId: body.installer_user_id,
48
+ installation: {
49
+ installationId,
50
+ accountLogin: body.account_login,
51
+ accountType: 'Organization',
52
+ repositorySelection: 'all',
53
+ suspendedAt: null,
54
+ deletedAt: null,
55
+ latestEvent: {
56
+ id: body.installation_id,
57
+ account: {login: body.account_login, type: 'Organization'},
58
+ repository_selection: 'all',
59
+ },
60
+ installerUserId: body.installer_user_id,
61
+ },
62
+ }));
63
+
64
+ reply.code(201);
65
+ return toIntegrationConnectionDto(connection, {
66
+ capabilities: options.connectionCapabilities,
67
+ });
68
+ },
69
+ });
70
+ }
@@ -0,0 +1,126 @@
1
+ import type {IntegrationConnection} from '@shipfox/api-integration-core-dto';
2
+ import {closeApp, createApp} from '@shipfox/node-fastify';
3
+ import {createGithubE2eRoutes} from './index.js';
4
+
5
+ function connection(
6
+ overrides: Partial<IntegrationConnection<'github'>> = {},
7
+ ): IntegrationConnection<'github'> {
8
+ return {
9
+ id: '00000000-0000-4000-8000-000000000001',
10
+ workspaceId: '00000000-0000-4000-8000-000000000002',
11
+ provider: 'github',
12
+ externalAccountId: '1234',
13
+ slug: 'github_shipfox_e2e',
14
+ displayName: 'GitHub Shipfox E2E',
15
+ lifecycleStatus: 'active',
16
+ createdAt: new Date(),
17
+ updatedAt: new Date(),
18
+ ...overrides,
19
+ };
20
+ }
21
+
22
+ describe('GitHub E2E routes', () => {
23
+ afterEach(async () => {
24
+ await closeApp();
25
+ });
26
+
27
+ it('creates a synthetic GitHub connection and installation', async () => {
28
+ const connectGithubInstallation = vi.fn(() => Promise.resolve(connection()));
29
+ const app = await createApp({
30
+ routes: [
31
+ createGithubE2eRoutes({
32
+ getExistingGithubConnection: vi.fn(() => Promise.resolve(undefined)),
33
+ connectGithubInstallation,
34
+ connectionCapabilities: ['source_control', 'agent_tools'],
35
+ }),
36
+ ],
37
+ swagger: false,
38
+ });
39
+
40
+ const res = await app.inject({
41
+ method: 'POST',
42
+ url: '/integrations/github-connections',
43
+ payload: {
44
+ workspace_id: '00000000-0000-4000-8000-000000000002',
45
+ installation_id: 1234,
46
+ account_login: 'shipfox-e2e',
47
+ display_name: 'GitHub Shipfox E2E',
48
+ installer_user_id: '00000000-0000-4000-8000-000000000004',
49
+ },
50
+ });
51
+
52
+ expect(res.statusCode).toBe(201);
53
+ expect(res.json()).toMatchObject({
54
+ id: '00000000-0000-4000-8000-000000000001',
55
+ provider: 'github',
56
+ capabilities: ['source_control', 'agent_tools'],
57
+ });
58
+ expect(connectGithubInstallation).toHaveBeenCalledWith({
59
+ workspaceId: '00000000-0000-4000-8000-000000000002',
60
+ installationId: '1234',
61
+ displayName: 'GitHub Shipfox E2E',
62
+ installerUserId: '00000000-0000-4000-8000-000000000004',
63
+ installation: expect.objectContaining({
64
+ installationId: '1234',
65
+ accountLogin: 'shipfox-e2e',
66
+ accountType: 'Organization',
67
+ repositorySelection: 'all',
68
+ installerUserId: '00000000-0000-4000-8000-000000000004',
69
+ }),
70
+ });
71
+ });
72
+
73
+ it('rejects malformed connection bodies', async () => {
74
+ const app = await createApp({
75
+ routes: [
76
+ createGithubE2eRoutes({
77
+ getExistingGithubConnection: vi.fn(() => Promise.resolve(undefined)),
78
+ connectGithubInstallation: vi.fn(() => Promise.resolve(connection())),
79
+ connectionCapabilities: ['source_control', 'agent_tools'],
80
+ }),
81
+ ],
82
+ swagger: false,
83
+ });
84
+
85
+ const res = await app.inject({
86
+ method: 'POST',
87
+ url: '/integrations/github-connections',
88
+ payload: {workspace_id: 'not-a-uuid'},
89
+ });
90
+
91
+ expect(res.statusCode).toBe(400);
92
+ expect(res.json()).toMatchObject({code: 'validation-error'});
93
+ });
94
+
95
+ it('rejects an installation connected to another workspace', async () => {
96
+ const connectGithubInstallation = vi.fn(() => Promise.resolve(connection()));
97
+ const app = await createApp({
98
+ routes: [
99
+ createGithubE2eRoutes({
100
+ getExistingGithubConnection: vi.fn(() =>
101
+ Promise.resolve(connection({workspaceId: '00000000-0000-4000-8000-000000000003'})),
102
+ ),
103
+ connectGithubInstallation,
104
+ connectionCapabilities: ['source_control', 'agent_tools'],
105
+ }),
106
+ ],
107
+ swagger: false,
108
+ });
109
+
110
+ const res = await app.inject({
111
+ method: 'POST',
112
+ url: '/integrations/github-connections',
113
+ payload: {
114
+ workspace_id: '00000000-0000-4000-8000-000000000002',
115
+ installation_id: 1234,
116
+ account_login: 'shipfox-e2e',
117
+ display_name: 'GitHub Shipfox E2E',
118
+ installer_user_id: '00000000-0000-4000-8000-000000000004',
119
+ },
120
+ });
121
+
122
+ expect(res.statusCode).toBe(409);
123
+ expect(res.json()).toMatchObject({code: 'github-connection-workspace-mismatch'});
124
+ expect(connectGithubInstallation).not.toHaveBeenCalled();
125
+ });
126
+ });
@@ -0,0 +1,14 @@
1
+ import type {RouteGroup} from '@shipfox/node-fastify';
2
+ import {
3
+ type CreateE2eGithubConnectionRouteOptions,
4
+ createE2eGithubConnectionRoute,
5
+ } from './create-connection.js';
6
+
7
+ export type CreateGithubE2eRoutesOptions = CreateE2eGithubConnectionRouteOptions;
8
+
9
+ export function createGithubE2eRoutes(options: CreateGithubE2eRoutesOptions): RouteGroup {
10
+ return {
11
+ prefix: '/integrations',
12
+ routes: [createE2eGithubConnectionRoute(options)],
13
+ };
14
+ }
@@ -0,0 +1,43 @@
1
+ import {
2
+ ConnectionSlugConflictError,
3
+ type IntegrationProviderErrorReason,
4
+ } from '@shipfox/api-integration-core-dto';
5
+ import {ClientError} from '@shipfox/node-fastify';
6
+ import {
7
+ GithubInstallationAlreadyLinkedError,
8
+ GithubInstallationNotAuthorizedError,
9
+ GithubInstallStateActorMismatchError,
10
+ GithubInstallStateError,
11
+ GithubIntegrationProviderError,
12
+ } from '#core/errors.js';
13
+
14
+ function providerStatus(reason: IntegrationProviderErrorReason): number {
15
+ if (reason === 'rate-limited') return 429;
16
+ if (reason === 'timeout' || reason === 'provider-unavailable') return 503;
17
+ return 422;
18
+ }
19
+
20
+ export function githubRouteErrorHandler(error: unknown): never {
21
+ if (error instanceof GithubInstallStateError) {
22
+ throw new ClientError(error.message, 'invalid-github-install-state', {status: 400});
23
+ }
24
+ if (error instanceof GithubInstallStateActorMismatchError) {
25
+ throw new ClientError(error.message, 'github-install-state-actor-mismatch', {status: 403});
26
+ }
27
+ if (error instanceof GithubInstallationNotAuthorizedError) {
28
+ throw new ClientError(error.message, 'github-installation-not-authorized', {status: 403});
29
+ }
30
+ if (error instanceof GithubInstallationAlreadyLinkedError) {
31
+ throw new ClientError(error.message, 'github-installation-already-linked', {status: 409});
32
+ }
33
+ if (error instanceof ConnectionSlugConflictError) {
34
+ throw new ClientError(error.message, 'slug-conflict', {status: 409});
35
+ }
36
+ if (error instanceof GithubIntegrationProviderError) {
37
+ throw new ClientError(error.message, error.reason, {
38
+ details: {retry_after_seconds: error.retryAfterSeconds},
39
+ status: providerStatus(error.reason),
40
+ });
41
+ }
42
+ throw error;
43
+ }