@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
@@ -0,0 +1,23 @@
1
+ import {IntegrationProviderError} from '@shipfox/api-integration-core-dto';
2
+
3
+ export class GithubIntegrationProviderError extends IntegrationProviderError {}
4
+
5
+ export class GithubInstallStateError extends Error {}
6
+
7
+ export class GithubInstallStateActorMismatchError extends Error {
8
+ constructor() {
9
+ super('GitHub install state was issued for a different user');
10
+ }
11
+ }
12
+
13
+ export class GithubInstallationNotAuthorizedError extends Error {
14
+ constructor(installationId: number) {
15
+ super(`GitHub installation is not accessible to the installing user: ${installationId}`);
16
+ }
17
+ }
18
+
19
+ export class GithubInstallationAlreadyLinkedError extends Error {
20
+ constructor(installationId: number | string) {
21
+ super(`GitHub installation is already linked to another Shipfox workspace: ${installationId}`);
22
+ }
23
+ }
@@ -0,0 +1,238 @@
1
+ import type {IntegrationConnection} from '@shipfox/api-integration-core-dto';
2
+ import type {GithubApiClient} from '#api/client.js';
3
+ import {
4
+ GithubInstallationAlreadyLinkedError,
5
+ GithubInstallationNotAuthorizedError,
6
+ GithubInstallStateActorMismatchError,
7
+ } from './errors.js';
8
+ import {handleGithubCallback} from './install.js';
9
+ import {signGithubInstallState} from './state.js';
10
+
11
+ function githubClient(overrides: Partial<GithubApiClient> = {}): GithubApiClient {
12
+ return {
13
+ exchangeOAuthCode: vi.fn(() => Promise.resolve('user-token')),
14
+ listUserInstallations: vi.fn(({cursor}) =>
15
+ Promise.resolve({
16
+ installationIds: cursor ? [123] : [999],
17
+ nextCursor: cursor ? null : '2',
18
+ }),
19
+ ),
20
+ getInstallation: vi.fn(() =>
21
+ Promise.resolve({
22
+ id: 123,
23
+ account: {login: 'shipfox', type: 'Organization'},
24
+ repositorySelection: 'all',
25
+ suspendedAt: null,
26
+ htmlUrl: 'https://github.com/apps/shipfox/installations/123',
27
+ raw: {id: 123},
28
+ }),
29
+ ),
30
+ listInstallationRepositories: vi.fn(() =>
31
+ Promise.resolve({repositories: [], nextCursor: null}),
32
+ ),
33
+ getRepository: vi.fn(() => {
34
+ throw new Error('not used');
35
+ }),
36
+ listRepositoryFiles: vi.fn(() => Promise.resolve({files: [], nextCursor: null})),
37
+ fetchRepositoryFile: vi.fn(() => {
38
+ throw new Error('not used');
39
+ }),
40
+ createInstallationAccessToken: vi.fn(() =>
41
+ Promise.resolve({
42
+ token: 'ghs_installationtoken',
43
+ expiresAt: new Date('2026-06-10T12:00:00.000Z'),
44
+ }),
45
+ ),
46
+ ...overrides,
47
+ };
48
+ }
49
+
50
+ function githubConnection(
51
+ overrides: Partial<IntegrationConnection<'github'>> = {},
52
+ ): IntegrationConnection<'github'> {
53
+ return {
54
+ id: crypto.randomUUID(),
55
+ workspaceId: crypto.randomUUID(),
56
+ provider: 'github',
57
+ externalAccountId: '123',
58
+ slug: 'github_shipfox',
59
+ displayName: 'GitHub shipfox',
60
+ lifecycleStatus: 'active',
61
+ createdAt: new Date(),
62
+ updatedAt: new Date(),
63
+ ...overrides,
64
+ };
65
+ }
66
+
67
+ describe('handleGithubCallback', () => {
68
+ it('paginates user installations before creating a connection', async () => {
69
+ const workspaceId = crypto.randomUUID();
70
+ const userId = crypto.randomUUID();
71
+ const github = githubClient();
72
+ const state = signGithubInstallState({workspaceId, userId});
73
+ const requireWorkspaceMembership = vi.fn(() => Promise.resolve());
74
+ const getExistingGithubConnection = vi.fn(() => Promise.resolve(undefined));
75
+ const connectGithubInstallation = vi.fn(() => Promise.resolve(githubConnection({workspaceId})));
76
+
77
+ const result = await handleGithubCallback({
78
+ github,
79
+ code: 'code',
80
+ installationId: 123,
81
+ state,
82
+ sessionUserId: userId,
83
+ sessionMemberships: [],
84
+ requireWorkspaceMembership,
85
+ getExistingGithubConnection,
86
+ connectGithubInstallation,
87
+ });
88
+
89
+ expect(result.externalAccountId).toBe('123');
90
+ expect(github.listUserInstallations).toHaveBeenCalledTimes(2);
91
+ expect(requireWorkspaceMembership).toHaveBeenCalledWith({
92
+ workspaceId,
93
+ userId,
94
+ memberships: [],
95
+ });
96
+ expect(getExistingGithubConnection).toHaveBeenCalledWith({installationId: '123'});
97
+ expect(connectGithubInstallation).toHaveBeenCalledWith(
98
+ expect.objectContaining({
99
+ workspaceId,
100
+ installationId: '123',
101
+ displayName: 'GitHub shipfox',
102
+ installerUserId: userId,
103
+ installation: expect.objectContaining({installerUserId: userId}),
104
+ }),
105
+ );
106
+ });
107
+
108
+ it('rejects spoofed installation ids', async () => {
109
+ const userId = crypto.randomUUID();
110
+ const state = signGithubInstallState({workspaceId: crypto.randomUUID(), userId});
111
+ const github = githubClient({
112
+ listUserInstallations: vi.fn(() =>
113
+ Promise.resolve({installationIds: [999], nextCursor: null}),
114
+ ),
115
+ });
116
+
117
+ const result = handleGithubCallback({
118
+ github,
119
+ code: 'code',
120
+ installationId: 123,
121
+ state,
122
+ sessionUserId: userId,
123
+ sessionMemberships: [],
124
+ requireWorkspaceMembership: vi.fn(() => Promise.resolve()),
125
+ getExistingGithubConnection: vi.fn(() => Promise.resolve(undefined)),
126
+ connectGithubInstallation: vi.fn(() => {
127
+ throw new Error('must not connect');
128
+ }),
129
+ });
130
+
131
+ await expect(result).rejects.toBeInstanceOf(GithubInstallationNotAuthorizedError);
132
+ });
133
+
134
+ it('rejects callbacks completed by a different session user than the one in state', async () => {
135
+ const stateUserId = crypto.randomUUID();
136
+ const state = signGithubInstallState({
137
+ workspaceId: crypto.randomUUID(),
138
+ userId: stateUserId,
139
+ });
140
+ const github = githubClient();
141
+
142
+ const result = handleGithubCallback({
143
+ github,
144
+ code: 'code',
145
+ installationId: 123,
146
+ state,
147
+ sessionUserId: crypto.randomUUID(),
148
+ sessionMemberships: [],
149
+ requireWorkspaceMembership: vi.fn(() => Promise.resolve()),
150
+ getExistingGithubConnection: vi.fn(() => Promise.resolve(undefined)),
151
+ connectGithubInstallation: vi.fn(() => {
152
+ throw new Error('must not connect');
153
+ }),
154
+ });
155
+
156
+ await expect(result).rejects.toBeInstanceOf(GithubInstallStateActorMismatchError);
157
+ expect(github.exchangeOAuthCode).not.toHaveBeenCalled();
158
+ });
159
+
160
+ it('returns the existing connection without re-running OAuth on reload', async () => {
161
+ const workspaceId = crypto.randomUUID();
162
+ const userId = crypto.randomUUID();
163
+ const state = signGithubInstallState({workspaceId, userId});
164
+ const github = githubClient();
165
+ const existing = githubConnection({workspaceId, lifecycleStatus: 'active'});
166
+ const connectGithubInstallation = vi.fn(() => {
167
+ throw new Error('must not reconnect');
168
+ });
169
+
170
+ const result = await handleGithubCallback({
171
+ github,
172
+ code: 'code',
173
+ installationId: 123,
174
+ state,
175
+ sessionUserId: userId,
176
+ sessionMemberships: [],
177
+ requireWorkspaceMembership: vi.fn(() => Promise.resolve()),
178
+ getExistingGithubConnection: vi.fn(() => Promise.resolve(existing)),
179
+ connectGithubInstallation,
180
+ });
181
+
182
+ expect(result).toBe(existing);
183
+ expect(github.exchangeOAuthCode).not.toHaveBeenCalled();
184
+ expect(connectGithubInstallation).not.toHaveBeenCalled();
185
+ });
186
+
187
+ it('refuses to claim an installation already linked to another workspace', async () => {
188
+ const workspaceId = crypto.randomUUID();
189
+ const userId = crypto.randomUUID();
190
+ const state = signGithubInstallState({workspaceId, userId});
191
+ const github = githubClient();
192
+ const existing = githubConnection({workspaceId: crypto.randomUUID()});
193
+
194
+ const result = handleGithubCallback({
195
+ github,
196
+ code: 'code',
197
+ installationId: 123,
198
+ state,
199
+ sessionUserId: userId,
200
+ sessionMemberships: [],
201
+ requireWorkspaceMembership: vi.fn(() => Promise.resolve()),
202
+ getExistingGithubConnection: vi.fn(() => Promise.resolve(existing)),
203
+ connectGithubInstallation: vi.fn(() => {
204
+ throw new Error('must not connect');
205
+ }),
206
+ });
207
+
208
+ await expect(result).rejects.toBeInstanceOf(GithubInstallationAlreadyLinkedError);
209
+ expect(github.exchangeOAuthCode).not.toHaveBeenCalled();
210
+ });
211
+
212
+ it('re-runs the install flow when the existing connection is not active', async () => {
213
+ const workspaceId = crypto.randomUUID();
214
+ const userId = crypto.randomUUID();
215
+ const state = signGithubInstallState({workspaceId, userId});
216
+ const github = githubClient();
217
+ const existing = githubConnection({workspaceId, lifecycleStatus: 'disabled'});
218
+ const connectGithubInstallation = vi.fn(() =>
219
+ Promise.resolve(githubConnection({workspaceId, lifecycleStatus: 'active'})),
220
+ );
221
+
222
+ const result = await handleGithubCallback({
223
+ github,
224
+ code: 'code',
225
+ installationId: 123,
226
+ state,
227
+ sessionUserId: userId,
228
+ sessionMemberships: [],
229
+ requireWorkspaceMembership: vi.fn(() => Promise.resolve()),
230
+ getExistingGithubConnection: vi.fn(() => Promise.resolve(existing)),
231
+ connectGithubInstallation,
232
+ });
233
+
234
+ expect(result.lifecycleStatus).toBe('active');
235
+ expect(github.exchangeOAuthCode).toHaveBeenCalled();
236
+ expect(connectGithubInstallation).toHaveBeenCalled();
237
+ });
238
+ });
@@ -0,0 +1,119 @@
1
+ import type {UserContextMembership} from '@shipfox/api-auth-context';
2
+ import type {IntegrationConnection} from '@shipfox/api-integration-core-dto';
3
+ import type {GithubApiClient, GithubInstallationDetails} from '#api/client.js';
4
+ import {
5
+ GithubInstallationAlreadyLinkedError,
6
+ GithubInstallationNotAuthorizedError,
7
+ GithubInstallStateActorMismatchError,
8
+ } from './errors.js';
9
+ import {verifyGithubInstallState} from './state.js';
10
+
11
+ export interface ConnectGithubInstallationInput {
12
+ workspaceId: string;
13
+ installationId: string;
14
+ displayName: string;
15
+ installerUserId: string;
16
+ installation: {
17
+ installationId: string;
18
+ accountLogin: string;
19
+ accountType: string;
20
+ repositorySelection: string;
21
+ suspendedAt: Date | null;
22
+ deletedAt: Date | null;
23
+ latestEvent: Record<string, unknown>;
24
+ installerUserId: string;
25
+ };
26
+ }
27
+
28
+ export interface HandleGithubCallbackParams {
29
+ github: GithubApiClient;
30
+ code: string;
31
+ installationId: number;
32
+ state: string;
33
+ sessionUserId: string;
34
+ sessionMemberships: ReadonlyArray<UserContextMembership>;
35
+ requireWorkspaceMembership: (params: {
36
+ workspaceId: string;
37
+ userId: string;
38
+ memberships: ReadonlyArray<UserContextMembership>;
39
+ }) => Promise<unknown>;
40
+ getExistingGithubConnection: (input: {
41
+ installationId: string;
42
+ }) => Promise<IntegrationConnection<'github'> | undefined>;
43
+ connectGithubInstallation: (
44
+ input: ConnectGithubInstallationInput,
45
+ ) => Promise<IntegrationConnection<'github'>>;
46
+ }
47
+
48
+ export async function handleGithubCallback(
49
+ params: HandleGithubCallbackParams,
50
+ ): Promise<IntegrationConnection<'github'>> {
51
+ const claims = verifyGithubInstallState(params.state);
52
+ if (claims.userId !== params.sessionUserId) {
53
+ throw new GithubInstallStateActorMismatchError();
54
+ }
55
+ await params.requireWorkspaceMembership({
56
+ workspaceId: claims.workspaceId,
57
+ userId: claims.userId,
58
+ memberships: params.sessionMemberships,
59
+ });
60
+
61
+ const installationIdStr = String(params.installationId);
62
+ const existing = await params.getExistingGithubConnection({installationId: installationIdStr});
63
+ if (existing && existing.workspaceId !== claims.workspaceId) {
64
+ throw new GithubInstallationAlreadyLinkedError(params.installationId);
65
+ }
66
+ if (existing && existing.lifecycleStatus === 'active') {
67
+ return existing;
68
+ }
69
+
70
+ const userAccessToken = await params.github.exchangeOAuthCode(params.code);
71
+ const accessible = await userCanAccessInstallation({
72
+ github: params.github,
73
+ userAccessToken,
74
+ installationId: params.installationId,
75
+ });
76
+ if (!accessible) throw new GithubInstallationNotAuthorizedError(params.installationId);
77
+
78
+ const installation = await params.github.getInstallation(params.installationId);
79
+ return await params.connectGithubInstallation({
80
+ workspaceId: claims.workspaceId,
81
+ installationId: installationIdStr,
82
+ displayName: `GitHub ${installation.account.login}`,
83
+ installerUserId: claims.userId,
84
+ installation: toConnectionInstallationInput(installation, claims.userId),
85
+ });
86
+ }
87
+
88
+ async function userCanAccessInstallation(params: {
89
+ github: GithubApiClient;
90
+ userAccessToken: string;
91
+ installationId: number;
92
+ }): Promise<boolean> {
93
+ let cursor: string | undefined;
94
+ do {
95
+ const page = await params.github.listUserInstallations({
96
+ userAccessToken: params.userAccessToken,
97
+ cursor,
98
+ });
99
+ if (page.installationIds.includes(params.installationId)) return true;
100
+ cursor = page.nextCursor ?? undefined;
101
+ } while (cursor);
102
+ return false;
103
+ }
104
+
105
+ function toConnectionInstallationInput(
106
+ installation: GithubInstallationDetails,
107
+ installerUserId: string,
108
+ ) {
109
+ return {
110
+ installationId: String(installation.id),
111
+ accountLogin: installation.account.login,
112
+ accountType: installation.account.type,
113
+ repositorySelection: installation.repositorySelection,
114
+ suspendedAt: installation.suspendedAt,
115
+ deletedAt: null,
116
+ latestEvent: installation.raw,
117
+ installerUserId,
118
+ };
119
+ }
@@ -0,0 +1,327 @@
1
+ import type {GithubApiClient} from '#api/client.js';
2
+ import {githubInstallationFactory} from '#test/index.js';
3
+ import {GithubIntegrationProviderError} from './errors.js';
4
+ import {GithubSourceControlProvider} from './source-control.js';
5
+
6
+ function githubClient(overrides: Partial<GithubApiClient> = {}): GithubApiClient {
7
+ return {
8
+ exchangeOAuthCode: vi.fn(() => Promise.resolve('token')),
9
+ listUserInstallations: vi.fn(() => Promise.resolve({installationIds: [], nextCursor: null})),
10
+ getInstallation: vi.fn(() => {
11
+ throw new Error('not used');
12
+ }),
13
+ listInstallationRepositories: vi.fn(() =>
14
+ Promise.resolve({
15
+ repositories: [
16
+ {
17
+ id: 42,
18
+ ownerLogin: 'shipfox',
19
+ name: 'platform',
20
+ fullName: 'shipfox/platform',
21
+ defaultBranch: 'main',
22
+ private: true,
23
+ visibility: 'private',
24
+ cloneUrl: 'https://github.com/shipfox/platform.git',
25
+ htmlUrl: 'https://github.com/shipfox/platform',
26
+ },
27
+ ],
28
+ nextCursor: '2',
29
+ }),
30
+ ),
31
+ getRepository: vi.fn(() =>
32
+ Promise.resolve({
33
+ id: 42,
34
+ ownerLogin: 'shipfox',
35
+ name: 'platform',
36
+ fullName: 'shipfox/platform',
37
+ defaultBranch: 'main',
38
+ private: true,
39
+ visibility: 'private',
40
+ cloneUrl: 'https://github.com/shipfox/platform.git',
41
+ htmlUrl: 'https://github.com/shipfox/platform',
42
+ }),
43
+ ),
44
+ listRepositoryFiles: vi.fn(() =>
45
+ Promise.resolve({
46
+ files: [{path: '.shipfox/workflows/ci.yml', size: 64}],
47
+ nextCursor: null,
48
+ }),
49
+ ),
50
+ fetchRepositoryFile: vi.fn(() =>
51
+ Promise.resolve({
52
+ path: '.shipfox/workflows/ci.yml',
53
+ content: 'name: CI\njobs:\n build:\n steps:\n - run: pnpm test\n',
54
+ size: 58,
55
+ }),
56
+ ),
57
+ createInstallationAccessToken: vi.fn(() =>
58
+ Promise.resolve({
59
+ token: 'ghs_installationtoken',
60
+ expiresAt: new Date('2026-06-10T12:00:00.000Z'),
61
+ }),
62
+ ),
63
+ ...overrides,
64
+ };
65
+ }
66
+
67
+ describe('GithubSourceControlProvider', () => {
68
+ let connectionId: string;
69
+ let installationId: number;
70
+
71
+ beforeEach(() => {
72
+ connectionId = crypto.randomUUID();
73
+ installationId = Math.floor(Math.random() * 1_000_000) + 1;
74
+ });
75
+
76
+ async function createInstallation(): Promise<void> {
77
+ await githubInstallationFactory.create({
78
+ connectionId,
79
+ installationId: String(installationId),
80
+ latestEvent: {id: 123},
81
+ });
82
+ }
83
+
84
+ function connection() {
85
+ return {
86
+ id: connectionId,
87
+ workspaceId: crypto.randomUUID(),
88
+ provider: 'github' as const,
89
+ externalAccountId: '123',
90
+ slug: 'github_shipfox',
91
+ displayName: 'GitHub shipfox',
92
+ lifecycleStatus: 'active' as const,
93
+ createdAt: new Date(),
94
+ updatedAt: new Date(),
95
+ };
96
+ }
97
+
98
+ it('lists repositories using installation auth metadata', async () => {
99
+ await createInstallation();
100
+ const github = githubClient();
101
+ const provider = new GithubSourceControlProvider(github);
102
+
103
+ const result = await provider.listRepositories({
104
+ connection: connection(),
105
+ limit: 50,
106
+ });
107
+
108
+ expect(result.repositories[0]?.externalRepositoryId).toBe('github:42');
109
+ expect(result.repositories[0]?.visibility).toBe('private');
110
+ expect(result.nextCursor).toBe('2');
111
+ expect(github.listInstallationRepositories).toHaveBeenCalledWith({
112
+ installationId,
113
+ limit: 50,
114
+ cursor: undefined,
115
+ });
116
+ });
117
+
118
+ it('resolves repositories directly from the provider-owned repository id', async () => {
119
+ await createInstallation();
120
+ const github = githubClient();
121
+ const provider = new GithubSourceControlProvider(github);
122
+
123
+ const result = await provider.resolveRepository({
124
+ connection: connection(),
125
+ externalRepositoryId: 'github:42',
126
+ });
127
+
128
+ expect(result.fullName).toBe('shipfox/platform');
129
+ expect(github.getRepository).toHaveBeenCalledWith({
130
+ installationId,
131
+ repositoryId: 42,
132
+ });
133
+ expect(github.listInstallationRepositories).not.toHaveBeenCalled();
134
+ });
135
+
136
+ it('rejects missing installation metadata', async () => {
137
+ const provider = new GithubSourceControlProvider(githubClient());
138
+
139
+ const result = provider.listRepositories({
140
+ connection: {
141
+ id: crypto.randomUUID(),
142
+ workspaceId: crypto.randomUUID(),
143
+ provider: 'github',
144
+ externalAccountId: '123',
145
+ slug: 'github_shipfox',
146
+ displayName: 'GitHub shipfox',
147
+ lifecycleStatus: 'active',
148
+ createdAt: new Date(),
149
+ updatedAt: new Date(),
150
+ },
151
+ limit: 50,
152
+ });
153
+
154
+ await expect(result).rejects.toBeInstanceOf(GithubIntegrationProviderError);
155
+ });
156
+
157
+ it('lists repository files using the provider-owned repository id', async () => {
158
+ await createInstallation();
159
+ const github = githubClient();
160
+ const provider = new GithubSourceControlProvider(github);
161
+
162
+ const result = await provider.listFiles({
163
+ connection: connection(),
164
+ externalRepositoryId: 'github:42',
165
+ ref: 'main',
166
+ prefix: '.shipfox/workflows/',
167
+ limit: 100,
168
+ });
169
+
170
+ expect(result.files[0]?.path).toBe('.shipfox/workflows/ci.yml');
171
+ expect(github.listRepositoryFiles).toHaveBeenCalledWith({
172
+ installationId,
173
+ repositoryId: 42,
174
+ ref: 'main',
175
+ prefix: '.shipfox/workflows/',
176
+ limit: 100,
177
+ cursor: undefined,
178
+ });
179
+ });
180
+
181
+ it('fetches repository file contents using the provider-owned repository id', async () => {
182
+ await createInstallation();
183
+ const github = githubClient();
184
+ const provider = new GithubSourceControlProvider(github);
185
+
186
+ const result = await provider.fetchFile({
187
+ connection: connection(),
188
+ externalRepositoryId: 'github:42',
189
+ ref: 'main',
190
+ path: '.shipfox/workflows/ci.yml',
191
+ });
192
+
193
+ expect(result.content).toContain('name: CI');
194
+ expect(github.fetchRepositoryFile).toHaveBeenCalledWith({
195
+ installationId,
196
+ repositoryId: 42,
197
+ ref: 'main',
198
+ path: '.shipfox/workflows/ci.yml',
199
+ });
200
+ });
201
+
202
+ it.each([
203
+ 'shipfox/platform',
204
+ 'github:',
205
+ 'github:foo',
206
+ 'github:foo/bar',
207
+ 'github:42abc',
208
+ 'github:-1',
209
+ 'github:0',
210
+ 'github:42.5',
211
+ 'gitlab:42',
212
+ '',
213
+ ])('rejects malformed external repository id %s', async (externalRepositoryId) => {
214
+ await createInstallation();
215
+ const github = githubClient();
216
+ const provider = new GithubSourceControlProvider(github);
217
+
218
+ const result = provider.resolveRepository({
219
+ connection: connection(),
220
+ externalRepositoryId,
221
+ });
222
+
223
+ await expect(result).rejects.toMatchObject({reason: 'repository-not-found'});
224
+ expect(github.getRepository).not.toHaveBeenCalled();
225
+ });
226
+
227
+ it('rejects oversized repository file contents', async () => {
228
+ await createInstallation();
229
+ const github = githubClient({
230
+ fetchRepositoryFile: vi.fn(() =>
231
+ Promise.resolve({
232
+ path: '.shipfox/workflows/huge.yml',
233
+ content: 'x'.repeat(1_000_001),
234
+ size: 1_000_001,
235
+ }),
236
+ ),
237
+ });
238
+ const provider = new GithubSourceControlProvider(github);
239
+
240
+ const result = provider.fetchFile({
241
+ connection: connection(),
242
+ externalRepositoryId: 'github:42',
243
+ ref: 'main',
244
+ path: '.shipfox/workflows/huge.yml',
245
+ });
246
+
247
+ await expect(result).rejects.toMatchObject({reason: 'content-too-large'});
248
+ });
249
+
250
+ it('creates a checkout spec with a clean url and short-lived credentials', async () => {
251
+ await createInstallation();
252
+ const github = githubClient();
253
+ const provider = new GithubSourceControlProvider(github);
254
+
255
+ const result = await provider.createCheckoutSpec({
256
+ connection: connection(),
257
+ externalRepositoryId: 'github:42',
258
+ ref: 'feature/x',
259
+ permissions: {contents: 'write'},
260
+ });
261
+
262
+ expect(result).toEqual({
263
+ repositoryUrl: 'https://github.com/shipfox/platform.git',
264
+ ref: 'feature/x',
265
+ credentials: {
266
+ username: 'x-access-token',
267
+ token: 'ghs_installationtoken',
268
+ expiresAt: new Date('2026-06-10T12:00:00.000Z'),
269
+ },
270
+ gitAuthor: {
271
+ name: 'shipfox-test[bot]',
272
+ email: '1+shipfox-test[bot]@users.noreply.github.com',
273
+ },
274
+ });
275
+ expect(result.repositoryUrl).not.toContain('ghs_installationtoken');
276
+ expect(github.createInstallationAccessToken).toHaveBeenCalledWith({
277
+ installationId,
278
+ repositoryId: 42,
279
+ permissions: {contents: 'write'},
280
+ });
281
+ });
282
+
283
+ it('defaults the checkout ref to the repository default branch', async () => {
284
+ await createInstallation();
285
+ const github = githubClient();
286
+ const provider = new GithubSourceControlProvider(github);
287
+
288
+ const result = await provider.createCheckoutSpec({
289
+ connection: connection(),
290
+ externalRepositoryId: 'github:42',
291
+ });
292
+
293
+ expect(result.ref).toBe('main');
294
+ });
295
+
296
+ it('propagates provider errors raised while minting the checkout token', async () => {
297
+ await createInstallation();
298
+ const github = githubClient({
299
+ createInstallationAccessToken: vi.fn(() =>
300
+ Promise.reject(new GithubIntegrationProviderError('access-denied', 'token denied')),
301
+ ),
302
+ });
303
+ const provider = new GithubSourceControlProvider(github);
304
+
305
+ const result = provider.createCheckoutSpec({
306
+ connection: connection(),
307
+ externalRepositoryId: 'github:42',
308
+ });
309
+
310
+ await expect(result).rejects.toMatchObject({reason: 'access-denied'});
311
+ });
312
+
313
+ it('rejects a malformed external repository id before any api call', async () => {
314
+ await createInstallation();
315
+ const github = githubClient();
316
+ const provider = new GithubSourceControlProvider(github);
317
+
318
+ const result = provider.createCheckoutSpec({
319
+ connection: connection(),
320
+ externalRepositoryId: 'github:not-a-number',
321
+ });
322
+
323
+ await expect(result).rejects.toMatchObject({reason: 'repository-not-found'});
324
+ expect(github.getRepository).not.toHaveBeenCalled();
325
+ expect(github.createInstallationAccessToken).not.toHaveBeenCalled();
326
+ });
327
+ });