@shipfox/api-integration-github 13.1.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-integration-github",
3
3
  "license": "MIT",
4
- "version": "13.1.0",
4
+ "version": "14.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -28,8 +28,8 @@
28
28
  "octokit": "^5.0.3",
29
29
  "zod": "^4.4.3",
30
30
  "@shipfox/api-auth-context": "12.2.0",
31
- "@shipfox/api-integration-spi": "1.1.1",
32
- "@shipfox/api-integration-github-dto": "12.2.0",
31
+ "@shipfox/api-integration-spi": "2.0.0",
32
+ "@shipfox/api-integration-github-dto": "14.0.0",
33
33
  "@shipfox/config": "1.2.4",
34
34
  "@shipfox/node-drizzle": "0.3.5",
35
35
  "@shipfox/node-fastify": "0.4.2",
@@ -7,29 +7,40 @@ import {createGithubApiClient, mapGithubError} from './client.js';
7
7
 
8
8
  const GITHUB_INSTALLATION_TOKEN_PATTERN = /^ghs_[A-Za-z0-9._-]{36,}$/u;
9
9
 
10
- const {createInstallationAccessTokenMock, getByUsernameMock, octokitOptionsMock, RequestErrorMock} =
11
- vi.hoisted(() => {
12
- class RequestErrorMock extends Error {
13
- constructor(
14
- message: string,
15
- public readonly status: number,
16
- ) {
17
- super(message);
18
- this.name = 'HttpError';
19
- }
10
+ const {
11
+ authMock,
12
+ createInstallationAccessTokenMock,
13
+ getByUsernameMock,
14
+ listRepositoryCommitsMock,
15
+ octokitOptionsMock,
16
+ requestMock,
17
+ RequestErrorMock,
18
+ } = vi.hoisted(() => {
19
+ class RequestErrorMock extends Error {
20
+ constructor(
21
+ message: string,
22
+ public readonly status: number,
23
+ ) {
24
+ super(message);
25
+ this.name = 'HttpError';
20
26
  }
21
-
22
- return {
23
- createInstallationAccessTokenMock: vi.fn(),
24
- getByUsernameMock: vi.fn(),
25
- octokitOptionsMock: vi.fn(),
26
- RequestErrorMock,
27
- };
28
- });
27
+ }
28
+
29
+ return {
30
+ authMock: vi.fn(),
31
+ createInstallationAccessTokenMock: vi.fn(),
32
+ getByUsernameMock: vi.fn(),
33
+ listRepositoryCommitsMock: vi.fn(),
34
+ octokitOptionsMock: vi.fn(),
35
+ requestMock: vi.fn(),
36
+ RequestErrorMock,
37
+ };
38
+ });
29
39
 
30
40
  vi.mock('octokit', () => ({
31
41
  App: class App {
32
42
  octokit = {
43
+ auth: authMock,
33
44
  rest: {
34
45
  apps: {createInstallationAccessToken: createInstallationAccessTokenMock},
35
46
  users: {getByUsername: getByUsernameMock},
@@ -37,7 +48,11 @@ vi.mock('octokit', () => ({
37
48
  };
38
49
  },
39
50
  Octokit: class Octokit {
40
- rest = {users: {getByUsername: getByUsernameMock}};
51
+ rest = {
52
+ repos: {listCommits: listRepositoryCommitsMock},
53
+ users: {getByUsername: getByUsernameMock},
54
+ };
55
+ request = requestMock;
41
56
 
42
57
  constructor(options: unknown) {
43
58
  octokitOptionsMock(options);
@@ -252,6 +267,61 @@ describe('mapGithubError', () => {
252
267
  });
253
268
  });
254
269
 
270
+ describe('OctokitGithubApiClient.listRepositoryCommits', () => {
271
+ beforeEach(() => {
272
+ authMock.mockReset();
273
+ listRepositoryCommitsMock.mockReset();
274
+ requestMock.mockReset();
275
+ });
276
+
277
+ it.each([
278
+ [404, 'repository-not-found'],
279
+ [409, 'ref-not-found'],
280
+ [422, 'ref-not-found'],
281
+ ] as const)('maps HTTP %i commit lookup failures to %s', async (status, reason) => {
282
+ authMock.mockResolvedValue({token: 'ghs_installationtoken'});
283
+ requestMock.mockResolvedValue({
284
+ data: {
285
+ id: 42,
286
+ owner: {login: 'shipfox'},
287
+ name: 'platform',
288
+ full_name: 'shipfox/platform',
289
+ default_branch: 'main',
290
+ private: true,
291
+ clone_url: 'https://github.com/shipfox/platform.git',
292
+ html_url: 'https://github.com/shipfox/platform',
293
+ },
294
+ });
295
+ listRepositoryCommitsMock.mockRejectedValue(
296
+ new RequestErrorMock('No commit found for SHA', status),
297
+ );
298
+ const client = createGithubApiClient();
299
+
300
+ const result = client.listRepositoryCommits({
301
+ installationId: 1,
302
+ repositoryId: 42,
303
+ ref: 'refs/heads/missing',
304
+ });
305
+
306
+ await expect(result).rejects.toMatchObject({
307
+ reason,
308
+ status,
309
+ message: 'No commit found for SHA',
310
+ });
311
+ expect(requestMock).toHaveBeenCalledWith('GET /repositories/{repository_id}', {
312
+ repository_id: 42,
313
+ request: {signal: expect.any(AbortSignal)},
314
+ });
315
+ expect(listRepositoryCommitsMock).toHaveBeenCalledWith({
316
+ owner: 'shipfox',
317
+ repo: 'platform',
318
+ sha: 'refs/heads/missing',
319
+ per_page: 1,
320
+ request: {signal: expect.any(AbortSignal)},
321
+ });
322
+ });
323
+ });
324
+
255
325
  describe('OctokitGithubApiClient.createInstallationAccessToken', () => {
256
326
  beforeEach(() => {
257
327
  createInstallationAccessTokenMock.mockReset();
package/src/api/client.ts CHANGED
@@ -63,6 +63,10 @@ export interface GithubFileContent {
63
63
  size: number;
64
64
  }
65
65
 
66
+ export interface GithubCommit {
67
+ sha: string;
68
+ }
69
+
66
70
  export interface GithubUserInstallationPage {
67
71
  installationIds: number[];
68
72
  nextCursor: string | null;
@@ -104,6 +108,11 @@ export interface GithubApiClient extends Partial<GithubBotUserClient> {
104
108
  ref: string;
105
109
  path: string;
106
110
  }): Promise<GithubFileContent>;
111
+ listRepositoryCommits(input: {
112
+ installationId: number;
113
+ repositoryId: number;
114
+ ref: string;
115
+ }): Promise<GithubCommit[]>;
107
116
  createInstallationAccessToken(input: {
108
117
  installationId: number;
109
118
  repositoryId: number;
@@ -267,6 +276,7 @@ class OctokitGithubApiClient implements GithubApiClient, GithubBotUserClient {
267
276
  const response = await mapGithubError(() =>
268
277
  octokit.request('GET /repositories/{repository_id}', {
269
278
  repository_id: input.repositoryId,
279
+ request: {signal: AbortSignal.timeout(GITHUB_API_TIMEOUT_MS)},
270
280
  }),
271
281
  );
272
282
 
@@ -405,6 +415,40 @@ class OctokitGithubApiClient implements GithubApiClient, GithubBotUserClient {
405
415
  };
406
416
  }
407
417
 
418
+ async listRepositoryCommits(input: {
419
+ installationId: number;
420
+ repositoryId: number;
421
+ ref: string;
422
+ }): Promise<GithubCommit[]> {
423
+ const octokit = await mapGithubError(() =>
424
+ getGithubInstallationOctokit(this.getApp(), input.installationId),
425
+ );
426
+ const repository = await this.getRepository({
427
+ installationId: input.installationId,
428
+ repositoryId: input.repositoryId,
429
+ });
430
+ const response = await mapGithubError(
431
+ () =>
432
+ octokit.rest.repos.listCommits({
433
+ owner: repository.ownerLogin,
434
+ repo: repository.name,
435
+ sha: input.ref,
436
+ per_page: 1,
437
+ request: {signal: AbortSignal.timeout(GITHUB_API_TIMEOUT_MS)},
438
+ }),
439
+ 'ref-not-found',
440
+ );
441
+ return response.data.map((commit) => {
442
+ if (typeof commit.sha !== 'string') {
443
+ throw new GithubIntegrationProviderError(
444
+ 'malformed-provider-response',
445
+ 'GitHub commit response is missing the commit sha',
446
+ );
447
+ }
448
+ return {sha: commit.sha};
449
+ });
450
+ }
451
+
408
452
  async createInstallationAccessToken(input: {
409
453
  installationId: number;
410
454
  repositoryId: number;
@@ -558,6 +602,7 @@ export async function mapGithubError<T>(
558
602
  | 'repository-not-found'
559
603
  | 'installation-not-found'
560
604
  | 'file-not-found'
605
+ | 'ref-not-found'
561
606
  | 'provider-rejected' = 'repository-not-found',
562
607
  ): Promise<T> {
563
608
  try {
@@ -570,7 +615,15 @@ export async function mapGithubError<T>(
570
615
  if (error instanceof RequestError) {
571
616
  if (error.status === 404) {
572
617
  throw new GithubIntegrationProviderError(
573
- notFoundReason,
618
+ notFoundReason === 'ref-not-found' ? 'repository-not-found' : notFoundReason,
619
+ error.message,
620
+ undefined,
621
+ error.status,
622
+ );
623
+ }
624
+ if (notFoundReason === 'ref-not-found' && (error.status === 409 || error.status === 422)) {
625
+ throw new GithubIntegrationProviderError(
626
+ 'ref-not-found',
574
627
  error.message,
575
628
  undefined,
576
629
  error.status,
@@ -37,9 +37,11 @@ const terminalMintErrorReasons = new Set<IntegrationProviderErrorReason>([
37
37
  'malformed-provider-response',
38
38
  ]);
39
39
 
40
+ // Ref reasons describe ref-resolution failures, which a token mint never
41
+ // observes; keep them out of the envelope schema and acknowledge them here.
40
42
  type MissingProviderErrorReason = Exclude<
41
43
  IntegrationProviderErrorReason,
42
- (typeof providerErrorReasons)[number]
44
+ (typeof providerErrorReasons)[number] | 'ref-not-found' | 'ref-invalid'
43
45
  >;
44
46
  const providerErrorReasonSchemaCoversUnion: Record<MissingProviderErrorReason, never> = {};
45
47
  void providerErrorReasonSchemaCoversUnion;
@@ -1734,6 +1734,7 @@ function githubClient(): GithubApiClient {
1734
1734
  getRepository: vi.fn(() => Promise.reject(new Error('not used'))),
1735
1735
  listRepositoryFiles: vi.fn(() => Promise.reject(new Error('not used'))),
1736
1736
  fetchRepositoryFile: vi.fn(() => Promise.reject(new Error('not used'))),
1737
+ listRepositoryCommits: vi.fn(() => Promise.reject(new Error('not used'))),
1737
1738
  createInstallationAccessToken: vi.fn(() => Promise.reject(new Error('not used'))),
1738
1739
  };
1739
1740
  }
@@ -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',
@@ -57,6 +57,9 @@ function githubClient(overrides: Partial<GithubApiClient> = {}): GithubApiClient
57
57
  size: 58,
58
58
  }),
59
59
  ),
60
+ listRepositoryCommits: vi.fn(() =>
61
+ Promise.resolve([{sha: 'a'.repeat(40)}, {sha: 'b'.repeat(40)}]),
62
+ ),
60
63
  createInstallationAccessToken: vi.fn(() =>
61
64
  Promise.resolve({
62
65
  token: 'ghs_installationtoken',
@@ -259,6 +262,96 @@ describe('GithubSourceControlProvider', () => {
259
262
  });
260
263
  });
261
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
+
262
355
  it('fetches repository file contents using the provider-owned repository id', async () => {
263
356
  await createInstallation();
264
357
  const github = githubClient();
@@ -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,6 +21,8 @@ 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,
@@ -194,6 +197,37 @@ export class GithubSourceControlProvider
194
197
  };
195
198
  }
196
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
+
197
231
  async createCheckoutSpec(
198
232
  input: CreateCheckoutSpecInput<GithubIntegrationConnection>,
199
233
  ): Promise<CheckoutSpec> {
@@ -256,6 +290,10 @@ function sameGithubRepository(
256
290
  return Boolean(firstName && secondName && firstName.toLowerCase() === secondName.toLowerCase());
257
291
  }
258
292
 
293
+ function formatRefForMessage(ref: string): string {
294
+ return JSON.stringify(ref);
295
+ }
296
+
259
297
  async function githubAppGitAuthor(
260
298
  github: GithubApiClient,
261
299
  installationAccessToken: string,
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',