@shipfox/api-integration-github 12.7.0 → 14.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 (39) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +23 -0
  3. package/dist/api/client.d.ts +21 -3
  4. package/dist/api/client.d.ts.map +1 -1
  5. package/dist/api/client.js +115 -6
  6. package/dist/api/client.js.map +1 -1
  7. package/dist/api/installation-token-envelope.d.ts.map +1 -1
  8. package/dist/api/installation-token-envelope.js.map +1 -1
  9. package/dist/core/agent-tools.d.ts.map +1 -1
  10. package/dist/core/agent-tools.js +2 -6
  11. package/dist/core/agent-tools.js.map +1 -1
  12. package/dist/core/bot-identity.d.ts +4 -0
  13. package/dist/core/bot-identity.d.ts.map +1 -0
  14. package/dist/core/bot-identity.js +15 -0
  15. package/dist/core/bot-identity.js.map +1 -0
  16. package/dist/core/source-control.d.ts +4 -2
  17. package/dist/core/source-control.d.ts.map +1 -1
  18. package/dist/core/source-control.js +42 -11
  19. package/dist/core/source-control.js.map +1 -1
  20. package/dist/index.d.ts +78 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/tsconfig.test.tsbuildinfo +1 -1
  25. package/package.json +3 -3
  26. package/src/api/client.test.ts +267 -9
  27. package/src/api/client.ts +184 -9
  28. package/src/api/installation-token-envelope.ts +3 -1
  29. package/src/core/agent-tools.test.ts +1 -0
  30. package/src/core/agent-tools.ts +2 -9
  31. package/src/core/bot-identity.test.ts +19 -0
  32. package/src/core/bot-identity.ts +19 -0
  33. package/src/core/install.test.ts +3 -0
  34. package/src/core/source-control.test.ts +180 -1
  35. package/src/core/source-control.ts +65 -11
  36. package/src/index.test.ts +3 -1
  37. package/src/index.ts +2 -0
  38. package/src/presentation/routes/install.test.ts +3 -0
  39. package/tsconfig.build.tsbuildinfo +1 -1
@@ -0,0 +1,19 @@
1
+ import {config} from '#config.js';
2
+
3
+ const GITHUB_APP_BOT_SUFFIX = '[bot]';
4
+
5
+ export function githubBotLogin(username: string): string {
6
+ const normalized = username.trim();
7
+ return normalized.toLowerCase().endsWith(GITHUB_APP_BOT_SUFFIX)
8
+ ? normalized
9
+ : `${normalized}${GITHUB_APP_BOT_SUFFIX}`;
10
+ }
11
+
12
+ export function configuredGithubAppBotLogin(): string | undefined {
13
+ const configuredUsername = config.GITHUB_APP_USERNAME?.trim();
14
+ return configuredUsername ? githubBotLogin(configuredUsername) : undefined;
15
+ }
16
+
17
+ export function githubAppBotLogin(): string {
18
+ return configuredGithubAppBotLogin() ?? githubBotLogin(config.GITHUB_APP_SLUG);
19
+ }
@@ -37,6 +37,9 @@ function githubClient(overrides: Partial<GithubApiClient> = {}): GithubApiClient
37
37
  fetchRepositoryFile: vi.fn(() => {
38
38
  throw new Error('not used');
39
39
  }),
40
+ listRepositoryCommits: vi.fn(() => {
41
+ throw new Error('not used');
42
+ }),
40
43
  createInstallationAccessToken: vi.fn(() =>
41
44
  Promise.resolve({
42
45
  token: 'ghs_installationtoken',
@@ -8,6 +8,7 @@ const VALID_COMMIT = 'a'.repeat(40);
8
8
  function githubClient(overrides: Partial<GithubApiClient> = {}): GithubApiClient {
9
9
  return {
10
10
  exchangeOAuthCode: vi.fn(() => Promise.resolve('token')),
11
+ getBotUser: vi.fn(() => Promise.resolve({id: 12_345, login: 'shipfox-test[bot]'})),
11
12
  listUserInstallations: vi.fn(() => Promise.resolve({installationIds: [], nextCursor: null})),
12
13
  getInstallation: vi.fn(() => {
13
14
  throw new Error('not used');
@@ -56,6 +57,9 @@ function githubClient(overrides: Partial<GithubApiClient> = {}): GithubApiClient
56
57
  size: 58,
57
58
  }),
58
59
  ),
60
+ listRepositoryCommits: vi.fn(() =>
61
+ Promise.resolve([{sha: 'a'.repeat(40)}, {sha: 'b'.repeat(40)}]),
62
+ ),
59
63
  createInstallationAccessToken: vi.fn(() =>
60
64
  Promise.resolve({
61
65
  token: 'ghs_installationtoken',
@@ -258,6 +262,96 @@ describe('GithubSourceControlProvider', () => {
258
262
  });
259
263
  });
260
264
 
265
+ it('resolves a branch ref to the commit it points at', async () => {
266
+ await createInstallation();
267
+ const github = githubClient();
268
+ const provider = new GithubSourceControlProvider(github);
269
+
270
+ const result = await provider.resolveRef({
271
+ connection: connection(),
272
+ externalRepositoryId: 'github:42',
273
+ ref: 'refs/heads/main',
274
+ });
275
+
276
+ expect(result).toEqual({ref: 'refs/heads/main', commit: VALID_COMMIT});
277
+ expect(github.listRepositoryCommits).toHaveBeenCalledWith({
278
+ installationId,
279
+ repositoryId: 42,
280
+ ref: 'refs/heads/main',
281
+ });
282
+ });
283
+
284
+ it('resolves a tag ref to the commit it points at', async () => {
285
+ await createInstallation();
286
+ const github = githubClient({
287
+ listRepositoryCommits: vi.fn(() => Promise.resolve([{sha: 'c'.repeat(40)}])),
288
+ });
289
+ const provider = new GithubSourceControlProvider(github);
290
+
291
+ const result = await provider.resolveRef({
292
+ connection: connection(),
293
+ externalRepositoryId: 'github:42',
294
+ ref: 'refs/tags/v1.0.0',
295
+ });
296
+
297
+ expect(result).toEqual({ref: 'refs/tags/v1.0.0', commit: 'c'.repeat(40)});
298
+ });
299
+
300
+ it('maps a ref with no commits to ref-not-found', async () => {
301
+ await createInstallation();
302
+ const github = githubClient({
303
+ listRepositoryCommits: vi.fn(() => Promise.resolve([])),
304
+ });
305
+ const provider = new GithubSourceControlProvider(github);
306
+
307
+ const result = provider.resolveRef({
308
+ connection: connection(),
309
+ externalRepositoryId: 'github:42',
310
+ ref: 'refs/heads/missing',
311
+ });
312
+
313
+ await expect(result).rejects.toMatchObject({reason: 'ref-not-found'});
314
+ });
315
+
316
+ it('rejects a commit response with an invalid object id', async () => {
317
+ await createInstallation();
318
+ const github = githubClient({
319
+ listRepositoryCommits: vi.fn(() => Promise.resolve([{sha: 'not-a-commit'}])),
320
+ });
321
+ const provider = new GithubSourceControlProvider(github);
322
+
323
+ const result = provider.resolveRef({
324
+ connection: connection(),
325
+ externalRepositoryId: 'github:42',
326
+ ref: 'refs/heads/main',
327
+ });
328
+
329
+ await expect(result).rejects.toMatchObject({
330
+ reason: 'malformed-provider-response',
331
+ message: 'GitHub ref "refs/heads/main" resolved to an invalid commit',
332
+ });
333
+ });
334
+
335
+ it.each([
336
+ 'a'.repeat(40),
337
+ 'refs/pull/17/head',
338
+ 'main',
339
+ '-evil',
340
+ ])('rejects ref %s as ref-invalid', async (ref) => {
341
+ await createInstallation();
342
+ const github = githubClient();
343
+ const provider = new GithubSourceControlProvider(github);
344
+
345
+ const result = provider.resolveRef({
346
+ connection: connection(),
347
+ externalRepositoryId: 'github:42',
348
+ ref,
349
+ });
350
+
351
+ await expect(result).rejects.toMatchObject({reason: 'ref-invalid'});
352
+ expect(github.listRepositoryCommits).not.toHaveBeenCalled();
353
+ });
354
+
261
355
  it('fetches repository file contents using the provider-owned repository id', async () => {
262
356
  await createInstallation();
263
357
  const github = githubClient();
@@ -349,7 +443,7 @@ describe('GithubSourceControlProvider', () => {
349
443
  },
350
444
  gitAuthor: {
351
445
  name: 'shipfox-test[bot]',
352
- email: '1+shipfox-test[bot]@users.noreply.github.com',
446
+ email: '12345+shipfox-test[bot]@users.noreply.github.com',
353
447
  },
354
448
  });
355
449
  expect(result.repositoryUrl).not.toContain('ghs_installationtoken');
@@ -358,6 +452,91 @@ describe('GithubSourceControlProvider', () => {
358
452
  repositoryId: 42,
359
453
  permissions: {contents: 'write'},
360
454
  });
455
+ expect(github.getBotUser).toHaveBeenCalledWith({
456
+ username: 'shipfox-test[bot]',
457
+ installationAccessToken: 'ghs_installationtoken',
458
+ });
459
+ });
460
+
461
+ it('propagates bot identity resolution failures for write checkouts', async () => {
462
+ await createInstallation();
463
+ const github = githubClient({
464
+ getBotUser: vi.fn(() =>
465
+ Promise.reject(
466
+ new GithubIntegrationProviderError('provider-rejected', 'bot user not found'),
467
+ ),
468
+ ),
469
+ });
470
+ const provider = new GithubSourceControlProvider(github);
471
+
472
+ const result = provider.createCheckoutSpec({
473
+ connection: connection(),
474
+ externalRepositoryId: 'github:42',
475
+ permissions: {contents: 'write'},
476
+ });
477
+
478
+ await expect(result).rejects.toMatchObject({reason: 'provider-rejected'});
479
+ });
480
+
481
+ it('propagates unexpected bot lookup errors', async () => {
482
+ await createInstallation();
483
+ const github = githubClient({
484
+ getBotUser: vi.fn(() => Promise.reject(new Error('unexpected lookup failure'))),
485
+ });
486
+ const provider = new GithubSourceControlProvider(github);
487
+
488
+ const result = provider.createCheckoutSpec({
489
+ connection: connection(),
490
+ externalRepositoryId: 'github:42',
491
+ permissions: {contents: 'write'},
492
+ });
493
+
494
+ await expect(result).rejects.toThrow('unexpected lookup failure');
495
+ });
496
+
497
+ it('rejects write checkout when the bot identity resolver is unavailable', async () => {
498
+ await createInstallation();
499
+ const github = githubClient();
500
+ delete github.getBotUser;
501
+ const provider = new GithubSourceControlProvider(github);
502
+
503
+ const result = provider.createCheckoutSpec({
504
+ connection: connection(),
505
+ externalRepositoryId: 'github:42',
506
+ permissions: {contents: 'write'},
507
+ });
508
+
509
+ await expect(result).rejects.toMatchObject({reason: 'provider-unavailable'});
510
+ });
511
+
512
+ it('omits the author and bot lookup for read-only checkouts', async () => {
513
+ await createInstallation();
514
+ const github = githubClient();
515
+ const provider = new GithubSourceControlProvider(github);
516
+
517
+ const result = await provider.createCheckoutSpec({
518
+ connection: connection(),
519
+ externalRepositoryId: 'github:42',
520
+ permissions: {contents: 'read'},
521
+ });
522
+
523
+ expect(result.gitAuthor).toBeUndefined();
524
+ expect(github.getBotUser).not.toHaveBeenCalled();
525
+ });
526
+
527
+ it('omits the author and bot lookup when the App username is unset', async () => {
528
+ await createInstallation();
529
+ const github = githubClient();
530
+ const provider = new GithubSourceControlProvider(github, () => undefined);
531
+
532
+ const result = await provider.createCheckoutSpec({
533
+ connection: connection(),
534
+ externalRepositoryId: 'github:42',
535
+ permissions: {contents: 'write'},
536
+ });
537
+
538
+ expect(result.gitAuthor).toBeUndefined();
539
+ expect(github.getBotUser).not.toHaveBeenCalled();
361
540
  });
362
541
 
363
542
  it('defaults the checkout ref to the repository default branch', async () => {
@@ -10,6 +10,7 @@ import {
10
10
  type IntegrationConnection,
11
11
  isRecord,
12
12
  isValidGitObjectId,
13
+ isValidResolvableRef,
13
14
  isValidTriggerRef,
14
15
  type ListFilesInput,
15
16
  type ListRepositoriesInput,
@@ -20,26 +21,30 @@ import {
20
21
  type RepositoryPage,
21
22
  type RepositorySnapshot,
22
23
  type RepositoryVisibility,
24
+ type ResolvedRef,
25
+ type ResolveRefInput,
23
26
  type ResolveRepositoryInput,
24
27
  type SourceControlProvider,
25
28
  type TriggerReference,
26
29
  } from '@shipfox/api-integration-spi';
27
30
  import type {GithubApiClient, GithubRepository} from '#api/client.js';
28
- import {config} from '#config.js';
29
31
  import {getGithubInstallationByConnectionId} from '#db/installations.js';
32
+ import {configuredGithubAppBotLogin} from './bot-identity.js';
30
33
  import {GithubIntegrationProviderError} from './errors.js';
31
34
 
32
35
  type GithubIntegrationConnection = IntegrationConnection<'github'>;
33
36
 
34
37
  const GITHUB_PROVIDER = 'github';
35
- const GITHUB_APP_BOT_SUFFIX = '[bot]';
36
38
  const SEARCH_PAGE_SIZE = 100;
37
39
  const SEARCH_MAX_PAGES_PER_REQUEST = 5;
38
40
 
39
41
  export class GithubSourceControlProvider
40
42
  implements SourceControlProvider<GithubIntegrationConnection>
41
43
  {
42
- constructor(private readonly github: GithubApiClient) {}
44
+ constructor(
45
+ private readonly github: GithubApiClient,
46
+ private readonly appBotLogin: () => string | undefined = configuredGithubAppBotLogin,
47
+ ) {}
43
48
 
44
49
  async listRepositories(
45
50
  input: ListRepositoriesInput<GithubIntegrationConnection>,
@@ -192,6 +197,37 @@ export class GithubSourceControlProvider
192
197
  };
193
198
  }
194
199
 
200
+ async resolveRef(input: ResolveRefInput<GithubIntegrationConnection>): Promise<ResolvedRef> {
201
+ if (!isValidResolvableRef(input.ref)) {
202
+ throw new GithubIntegrationProviderError(
203
+ 'ref-invalid',
204
+ `GitHub ref ${formatRefForMessage(input.ref)} is not a resolvable branch or tag name`,
205
+ );
206
+ }
207
+ const installationId = await this.installationId(input.connection.id);
208
+ const {repositoryId} = parseGithubRepositoryLocator(input.externalRepositoryId);
209
+ const commits = await this.github.listRepositoryCommits({
210
+ installationId,
211
+ repositoryId,
212
+ ref: input.ref,
213
+ });
214
+ const commit = commits[0]?.sha;
215
+ if (!commit) {
216
+ throw new GithubIntegrationProviderError(
217
+ 'ref-not-found',
218
+ `GitHub ref ${formatRefForMessage(input.ref)} does not resolve to a commit`,
219
+ );
220
+ }
221
+ if (!isValidGitObjectId(commit)) {
222
+ throw new GithubIntegrationProviderError(
223
+ 'malformed-provider-response',
224
+ `GitHub ref ${formatRefForMessage(input.ref)} resolved to an invalid commit`,
225
+ );
226
+ }
227
+
228
+ return {ref: input.ref, commit};
229
+ }
230
+
195
231
  async createCheckoutSpec(
196
232
  input: CreateCheckoutSpecInput<GithubIntegrationConnection>,
197
233
  ): Promise<CheckoutSpec> {
@@ -204,7 +240,11 @@ export class GithubSourceControlProvider
204
240
  repositoryId,
205
241
  permissions: input.permissions,
206
242
  });
207
- const gitAuthor = githubAppGitAuthor();
243
+ const botLogin = this.appBotLogin();
244
+ const gitAuthor =
245
+ botLogin && input.permissions?.contents === 'write'
246
+ ? await githubAppGitAuthor(this.github, token, botLogin)
247
+ : undefined;
208
248
 
209
249
  return {
210
250
  repositoryUrl: repository.cloneUrl,
@@ -250,13 +290,27 @@ function sameGithubRepository(
250
290
  return Boolean(firstName && secondName && firstName.toLowerCase() === secondName.toLowerCase());
251
291
  }
252
292
 
253
- function githubAppGitAuthor(): CheckoutSpec['gitAuthor'] {
254
- const appUsername = config.GITHUB_APP_USERNAME?.trim();
255
- if (!appUsername) return undefined;
256
- const name = appUsername.endsWith(GITHUB_APP_BOT_SUFFIX)
257
- ? appUsername
258
- : `${appUsername}${GITHUB_APP_BOT_SUFFIX}`;
259
- return {name, email: `${config.GITHUB_APP_ID}+${name}@users.noreply.github.com`};
293
+ function formatRefForMessage(ref: string): string {
294
+ return JSON.stringify(ref);
295
+ }
296
+
297
+ async function githubAppGitAuthor(
298
+ github: GithubApiClient,
299
+ installationAccessToken: string,
300
+ name: string,
301
+ ): Promise<CheckoutSpec['gitAuthor']> {
302
+ if (!github.getBotUser) {
303
+ throw new GithubIntegrationProviderError(
304
+ 'provider-unavailable',
305
+ 'GitHub bot identity resolution is unavailable',
306
+ );
307
+ }
308
+
309
+ const botUser = await github.getBotUser({username: name, installationAccessToken});
310
+ return {
311
+ name: botUser.login,
312
+ email: `${botUser.id}+${botUser.login}@users.noreply.github.com`,
313
+ };
260
314
  }
261
315
 
262
316
  function toRepositorySnapshot(repository: GithubRepository): RepositorySnapshot {
package/src/index.test.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import {githubEventCatalog} from '@shipfox/api-integration-github-dto';
1
2
  import {githubInstallationTokenNamespace} from '#api/installation-token-envelope.js';
2
3
  import {createGithubIntegrationProvider} from '#index.js';
3
4
 
@@ -19,7 +20,7 @@ vi.mock('#core/webhook-processor.js', () => ({
19
20
  describe('createGithubIntegrationProvider', () => {
20
21
  it('shares installation-token cleanup with the direct and composed processors', async () => {
21
22
  const deleteSecrets = vi.fn(() => Promise.resolve(1));
22
- createGithubIntegrationProvider({
23
+ const provider = createGithubIntegrationProvider({
23
24
  github: {} as never,
24
25
  getExistingGithubConnection: vi.fn(() => Promise.resolve(undefined)),
25
26
  connectGithubInstallation: vi.fn() as never,
@@ -31,6 +32,7 @@ describe('createGithubIntegrationProvider', () => {
31
32
  getIntegrationConnectionById: vi.fn(() => Promise.resolve(undefined)),
32
33
  deleteSecrets,
33
34
  });
35
+ expect(provider.eventCatalog).toBe(githubEventCatalog);
34
36
  const processorOptions = state.processorOptions as {
35
37
  deleteInstallationTokenSecret: (params: {
36
38
  workspaceId: string;
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import {githubEventCatalog} from '@shipfox/api-integration-github-dto';
1
2
  import type {
2
3
  GetIntegrationConnectionByIdFn,
3
4
  PublishIntegrationEventReceivedFn,
@@ -102,6 +103,7 @@ export function createGithubIntegrationProvider(options: CreateGithubIntegration
102
103
  return {
103
104
  provider: 'github' as const,
104
105
  displayName: 'GitHub',
106
+ eventCatalog: githubEventCatalog,
105
107
  adapters: {
106
108
  source_control: new GithubSourceControlProvider(github),
107
109
  agent_tools: new GithubAgentToolsProvider({
@@ -61,6 +61,9 @@ function githubClient(overrides: Partial<GithubApiClient> = {}): GithubApiClient
61
61
  fetchRepositoryFile: vi.fn(() => {
62
62
  throw new Error('not used');
63
63
  }),
64
+ listRepositoryCommits: vi.fn(() => {
65
+ throw new Error('not used');
66
+ }),
64
67
  createInstallationAccessToken: vi.fn(() =>
65
68
  Promise.resolve({
66
69
  token: 'ghs_installationtoken',