@shipfox/api-integration-github 12.6.0 → 13.1.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.
@@ -13,8 +13,9 @@ import {
13
13
  createGithubInstallationTokenProvider,
14
14
  type GithubInstallationTokenProvider,
15
15
  } from '#api/installation-token-provider.js';
16
- import {config, normalizedGithubApiBaseUrl} from '#config.js';
16
+ import {normalizedGithubApiBaseUrl} from '#config.js';
17
17
  import type {GithubInstallation} from '#db/installations.js';
18
+ import {githubAppBotLogin} from './bot-identity.js';
18
19
  import {GithubIntegrationProviderError} from './errors.js';
19
20
  import {
20
21
  type GithubAgentToolCatalogEntry,
@@ -59,7 +60,6 @@ const GITHUB_GRAPHQL_ROUTE = 'POST /graphql';
59
60
  const GITHUB_ARTIFACT_ARCHIVE_FORMAT = 'zip';
60
61
  const GITHUB_ARTIFACT_DOWNLOAD_ROUTE = `GET /repos/{owner}/{repo}/actions/artifacts/{resource_id}/${GITHUB_ARTIFACT_ARCHIVE_FORMAT}`;
61
62
  const GITHUB_ARTIFACT_DOWNLOAD_TIMEOUT_MS = 30_000;
62
- const GITHUB_APP_BOT_SUFFIX = '[bot]';
63
63
  const PENDING_REVIEW_PAGE_SIZE = 100;
64
64
  const PENDING_REVIEW_MAX_PAGE_REQUESTS = 5;
65
65
  const PENDING_REVIEW_LOOKUP_TIMEOUT_MS = 15_000;
@@ -78,6 +78,63 @@ const ADD_PENDING_REVIEW_COMMENT_MUTATION = `
78
78
  }
79
79
  `;
80
80
 
81
+ const GET_PULL_REQUEST_REVIEW_THREADS_QUERY = `
82
+ query GetPullRequestReviewThreads(
83
+ $owner: String!
84
+ $repo: String!
85
+ $pullNumber: Int!
86
+ $after: String
87
+ ) {
88
+ repository(owner: $owner, name: $repo) {
89
+ pullRequest(number: $pullNumber) {
90
+ reviewThreads(first: 100, after: $after) {
91
+ nodes {
92
+ id
93
+ isResolved
94
+ comments(first: 100) {
95
+ nodes {
96
+ id
97
+ databaseId
98
+ body
99
+ author {
100
+ login
101
+ }
102
+ path
103
+ line
104
+ side
105
+ startLine
106
+ startSide
107
+ createdAt
108
+ updatedAt
109
+ url
110
+ }
111
+ pageInfo {
112
+ hasNextPage
113
+ endCursor
114
+ }
115
+ }
116
+ }
117
+ pageInfo {
118
+ hasNextPage
119
+ endCursor
120
+ }
121
+ }
122
+ }
123
+ }
124
+ }
125
+ `;
126
+
127
+ const RESOLVE_PULL_REQUEST_REVIEW_THREAD_MUTATION = `
128
+ mutation ResolvePullRequestReviewThread($input: ResolveReviewThreadInput!) {
129
+ resolveReviewThread(input: $input) {
130
+ thread {
131
+ id
132
+ isResolved
133
+ }
134
+ }
135
+ }
136
+ `;
137
+
81
138
  export class GithubAgentToolsProvider
82
139
  implements
83
140
  AgentToolsProvider<
@@ -145,11 +202,17 @@ export class GithubAgentToolsProvider
145
202
 
146
203
  if (operation.kind === 'graphql') {
147
204
  const data = await mapGithubError(() =>
148
- addCommentToPendingReview(client, operation.parameters),
205
+ executeGithubGraphqlOperation(
206
+ client,
207
+ tool.id as GithubAgentToolId,
208
+ method,
209
+ operation.parameters,
210
+ ),
149
211
  );
150
- return data === undefined
151
- ? githubToolError(NO_PENDING_REVIEW_MESSAGE, 'provider-rejected')
152
- : githubToolResult(tool.id as GithubAgentToolId, data);
212
+ if (data === undefined && tool.id === 'add_comment_to_pending_review') {
213
+ return githubToolError(NO_PENDING_REVIEW_MESSAGE, 'provider-rejected');
214
+ }
215
+ return githubToolResult(tool.id as GithubAgentToolId, data);
153
216
  }
154
217
 
155
218
  const operationParameters = await mapGithubError(() =>
@@ -322,6 +385,8 @@ export function githubOperationRoute(
322
385
  return `GET ${repoPath}/pulls/${pull}/commits`;
323
386
  case 'pull_request_read.get_review_comments':
324
387
  return `GET ${repoPath}/pulls/${pull}/comments`;
388
+ case 'pull_request_read.get_review_threads':
389
+ return GITHUB_GRAPHQL_ROUTE;
325
390
  case 'pull_request_read.get_reviews':
326
391
  return `GET ${repoPath}/pulls/${pull}/reviews`;
327
392
  case 'pull_request_read.get_comments':
@@ -350,6 +415,8 @@ export function githubOperationRoute(
350
415
  return `POST ${repoPath}/pulls/${pull}/reviews/{review_id}/events`;
351
416
  case 'pull_request_review_write.delete_pending':
352
417
  return `DELETE ${repoPath}/pulls/${pull}/reviews/{review_id}`;
418
+ case 'pull_request_review_thread_write.resolve':
419
+ return GITHUB_GRAPHQL_ROUTE;
353
420
  case 'add_comment_to_pending_review.':
354
421
  return GITHUB_GRAPHQL_ROUTE;
355
422
  case 'actions_list.list_workflows':
@@ -423,6 +490,43 @@ async function addCommentToPendingReview(
423
490
  return await client.graphql(ADD_PENDING_REVIEW_COMMENT_MUTATION, {input});
424
491
  }
425
492
 
493
+ async function executeGithubGraphqlOperation(
494
+ client: GithubToolClient,
495
+ toolId: GithubAgentToolId,
496
+ method: string | undefined,
497
+ parameters: Record<string, unknown>,
498
+ ): Promise<unknown | undefined> {
499
+ if (client.graphql === undefined) {
500
+ throw new GithubIntegrationProviderError(
501
+ 'malformed-provider-response',
502
+ 'GitHub client does not support GraphQL operations',
503
+ );
504
+ }
505
+
506
+ switch (`${toolId}.${method ?? ''}`) {
507
+ case 'pull_request_read.get_review_threads': {
508
+ const variables: Record<string, unknown> = {
509
+ owner: parameters.owner,
510
+ repo: parameters.repo,
511
+ pullNumber: parameters.pull_number,
512
+ };
513
+ if (typeof parameters.cursor === 'string') variables.after = parameters.cursor;
514
+ return await client.graphql(GET_PULL_REQUEST_REVIEW_THREADS_QUERY, variables);
515
+ }
516
+ case 'pull_request_review_thread_write.resolve':
517
+ return await client.graphql(RESOLVE_PULL_REQUEST_REVIEW_THREAD_MUTATION, {
518
+ input: {threadId: parameters.thread_id},
519
+ });
520
+ case 'add_comment_to_pending_review.':
521
+ return await addCommentToPendingReview(client, parameters);
522
+ default:
523
+ throw new GithubIntegrationProviderError(
524
+ 'malformed-provider-response',
525
+ 'GitHub operation does not support GraphQL operations',
526
+ );
527
+ }
528
+ }
529
+
426
530
  export function projectGithubOperationParameters(
427
531
  toolId: GithubAgentToolId,
428
532
  method: string | undefined,
@@ -621,13 +725,6 @@ function latestPendingReviewOnPage(
621
725
  return {malformed};
622
726
  }
623
727
 
624
- function githubAppBotLogin(): string {
625
- const configuredUsername = config.GITHUB_APP_USERNAME?.trim() || config.GITHUB_APP_SLUG.trim();
626
- return configuredUsername.toLowerCase().endsWith(GITHUB_APP_BOT_SUFFIX)
627
- ? configuredUsername
628
- : `${configuredUsername}${GITHUB_APP_BOT_SUFFIX}`;
629
- }
630
-
631
728
  function githubToolResult(
632
729
  toolId: GithubAgentToolId,
633
730
  data: unknown,
@@ -0,0 +1,19 @@
1
+ import {configuredGithubAppBotLogin, githubAppBotLogin, githubBotLogin} from './bot-identity.js';
2
+
3
+ describe('GitHub App bot identity', () => {
4
+ it.each([
5
+ ['shipfox-ai', 'shipfox-ai[bot]'],
6
+ ['shipfox-ai[bot]', 'shipfox-ai[bot]'],
7
+ ['Shipfox-AI[Bot]', 'Shipfox-AI[Bot]'],
8
+ ])('normalizes %s to %s', (username, expected) => {
9
+ expect(githubBotLogin(username)).toBe(expected);
10
+ });
11
+
12
+ it('uses the configured username for commit attribution', () => {
13
+ expect(configuredGithubAppBotLogin()).toBe('shipfox-test[bot]');
14
+ });
15
+
16
+ it('uses the configured username for App review identity', () => {
17
+ expect(githubAppBotLogin()).toBe('shipfox-test[bot]');
18
+ });
19
+ });
@@ -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
+ }
@@ -174,6 +174,13 @@ const pullRequestReadMethods = [
174
174
  false,
175
175
  scopes.pullRequestsRead,
176
176
  ),
177
+ method(
178
+ 'get_review_threads',
179
+ 'Get review threads, their resolution state, and comments for a specific pull request.',
180
+ 'read',
181
+ false,
182
+ scopes.pullRequestsRead,
183
+ ),
177
184
  method(
178
185
  'get_reviews',
179
186
  'Get reviews for a specific pull request.',
@@ -221,6 +228,16 @@ const pullRequestReviewWriteMethods = [
221
228
  ),
222
229
  ] as const satisfies readonly GithubAgentToolCatalogMethod[];
223
230
 
231
+ const pullRequestReviewThreadWriteMethods = [
232
+ method(
233
+ 'resolve',
234
+ 'Resolve a pull request review thread.',
235
+ 'write',
236
+ false,
237
+ scopes.pullRequestsWrite,
238
+ ),
239
+ ] as const satisfies readonly GithubAgentToolCatalogMethod[];
240
+
224
241
  const actionsListMethods = [
225
242
  method('list_workflows', 'List workflows in a repository.', 'read', false, scopes.actionsRead),
226
243
  method(
@@ -472,6 +489,7 @@ export const githubAgentToolCatalog = [
472
489
  methodRequiredSchema('get_files', []),
473
490
  methodRequiredSchema('get_commits', []),
474
491
  methodRequiredSchema('get_review_comments', []),
492
+ methodRequiredSchema('get_review_threads', []),
475
493
  methodRequiredSchema('get_reviews', []),
476
494
  methodRequiredSchema('get_comments', []),
477
495
  methodRequiredSchema('get_check_runs', ['ref']),
@@ -666,6 +684,23 @@ export const githubAgentToolCatalog = [
666
684
  ),
667
685
  outputSchema: openObjectSchema('Pull request review write result'),
668
686
  }),
687
+ tool({
688
+ id: 'pull_request_review_thread_write',
689
+ category: 'pull_requests',
690
+ description: 'Resolve review threads on a pull request in a GitHub repository.',
691
+ methods: pullRequestReviewThreadWriteMethods,
692
+ inputSchema: repositoryInputSchema(
693
+ {
694
+ method: methodSchema(
695
+ pullRequestReviewThreadWriteMethods,
696
+ 'The write operation to perform on a pull request review thread',
697
+ ),
698
+ thread_id: stringSchema('The node ID of the review thread'),
699
+ },
700
+ ['method', 'thread_id'],
701
+ ),
702
+ outputSchema: openObjectSchema('Pull request review thread write result'),
703
+ }),
669
704
  tool({
670
705
  id: 'add_comment_to_pending_review',
671
706
  category: 'pull_requests',
@@ -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');
@@ -349,7 +350,7 @@ describe('GithubSourceControlProvider', () => {
349
350
  },
350
351
  gitAuthor: {
351
352
  name: 'shipfox-test[bot]',
352
- email: '1+shipfox-test[bot]@users.noreply.github.com',
353
+ email: '12345+shipfox-test[bot]@users.noreply.github.com',
353
354
  },
354
355
  });
355
356
  expect(result.repositoryUrl).not.toContain('ghs_installationtoken');
@@ -358,6 +359,91 @@ describe('GithubSourceControlProvider', () => {
358
359
  repositoryId: 42,
359
360
  permissions: {contents: 'write'},
360
361
  });
362
+ expect(github.getBotUser).toHaveBeenCalledWith({
363
+ username: 'shipfox-test[bot]',
364
+ installationAccessToken: 'ghs_installationtoken',
365
+ });
366
+ });
367
+
368
+ it('propagates bot identity resolution failures for write checkouts', async () => {
369
+ await createInstallation();
370
+ const github = githubClient({
371
+ getBotUser: vi.fn(() =>
372
+ Promise.reject(
373
+ new GithubIntegrationProviderError('provider-rejected', 'bot user not found'),
374
+ ),
375
+ ),
376
+ });
377
+ const provider = new GithubSourceControlProvider(github);
378
+
379
+ const result = provider.createCheckoutSpec({
380
+ connection: connection(),
381
+ externalRepositoryId: 'github:42',
382
+ permissions: {contents: 'write'},
383
+ });
384
+
385
+ await expect(result).rejects.toMatchObject({reason: 'provider-rejected'});
386
+ });
387
+
388
+ it('propagates unexpected bot lookup errors', async () => {
389
+ await createInstallation();
390
+ const github = githubClient({
391
+ getBotUser: vi.fn(() => Promise.reject(new Error('unexpected lookup failure'))),
392
+ });
393
+ const provider = new GithubSourceControlProvider(github);
394
+
395
+ const result = provider.createCheckoutSpec({
396
+ connection: connection(),
397
+ externalRepositoryId: 'github:42',
398
+ permissions: {contents: 'write'},
399
+ });
400
+
401
+ await expect(result).rejects.toThrow('unexpected lookup failure');
402
+ });
403
+
404
+ it('rejects write checkout when the bot identity resolver is unavailable', async () => {
405
+ await createInstallation();
406
+ const github = githubClient();
407
+ delete github.getBotUser;
408
+ const provider = new GithubSourceControlProvider(github);
409
+
410
+ const result = provider.createCheckoutSpec({
411
+ connection: connection(),
412
+ externalRepositoryId: 'github:42',
413
+ permissions: {contents: 'write'},
414
+ });
415
+
416
+ await expect(result).rejects.toMatchObject({reason: 'provider-unavailable'});
417
+ });
418
+
419
+ it('omits the author and bot lookup for read-only checkouts', async () => {
420
+ await createInstallation();
421
+ const github = githubClient();
422
+ const provider = new GithubSourceControlProvider(github);
423
+
424
+ const result = await provider.createCheckoutSpec({
425
+ connection: connection(),
426
+ externalRepositoryId: 'github:42',
427
+ permissions: {contents: 'read'},
428
+ });
429
+
430
+ expect(result.gitAuthor).toBeUndefined();
431
+ expect(github.getBotUser).not.toHaveBeenCalled();
432
+ });
433
+
434
+ it('omits the author and bot lookup when the App username is unset', async () => {
435
+ await createInstallation();
436
+ const github = githubClient();
437
+ const provider = new GithubSourceControlProvider(github, () => undefined);
438
+
439
+ const result = await provider.createCheckoutSpec({
440
+ connection: connection(),
441
+ externalRepositoryId: 'github:42',
442
+ permissions: {contents: 'write'},
443
+ });
444
+
445
+ expect(result.gitAuthor).toBeUndefined();
446
+ expect(github.getBotUser).not.toHaveBeenCalled();
361
447
  });
362
448
 
363
449
  it('defaults the checkout ref to the repository default branch', async () => {
@@ -25,21 +25,23 @@ import {
25
25
  type TriggerReference,
26
26
  } from '@shipfox/api-integration-spi';
27
27
  import type {GithubApiClient, GithubRepository} from '#api/client.js';
28
- import {config} from '#config.js';
29
28
  import {getGithubInstallationByConnectionId} from '#db/installations.js';
29
+ import {configuredGithubAppBotLogin} from './bot-identity.js';
30
30
  import {GithubIntegrationProviderError} from './errors.js';
31
31
 
32
32
  type GithubIntegrationConnection = IntegrationConnection<'github'>;
33
33
 
34
34
  const GITHUB_PROVIDER = 'github';
35
- const GITHUB_APP_BOT_SUFFIX = '[bot]';
36
35
  const SEARCH_PAGE_SIZE = 100;
37
36
  const SEARCH_MAX_PAGES_PER_REQUEST = 5;
38
37
 
39
38
  export class GithubSourceControlProvider
40
39
  implements SourceControlProvider<GithubIntegrationConnection>
41
40
  {
42
- constructor(private readonly github: GithubApiClient) {}
41
+ constructor(
42
+ private readonly github: GithubApiClient,
43
+ private readonly appBotLogin: () => string | undefined = configuredGithubAppBotLogin,
44
+ ) {}
43
45
 
44
46
  async listRepositories(
45
47
  input: ListRepositoriesInput<GithubIntegrationConnection>,
@@ -204,7 +206,11 @@ export class GithubSourceControlProvider
204
206
  repositoryId,
205
207
  permissions: input.permissions,
206
208
  });
207
- const gitAuthor = githubAppGitAuthor();
209
+ const botLogin = this.appBotLogin();
210
+ const gitAuthor =
211
+ botLogin && input.permissions?.contents === 'write'
212
+ ? await githubAppGitAuthor(this.github, token, botLogin)
213
+ : undefined;
208
214
 
209
215
  return {
210
216
  repositoryUrl: repository.cloneUrl,
@@ -250,13 +256,23 @@ function sameGithubRepository(
250
256
  return Boolean(firstName && secondName && firstName.toLowerCase() === secondName.toLowerCase());
251
257
  }
252
258
 
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`};
259
+ async function githubAppGitAuthor(
260
+ github: GithubApiClient,
261
+ installationAccessToken: string,
262
+ name: string,
263
+ ): Promise<CheckoutSpec['gitAuthor']> {
264
+ if (!github.getBotUser) {
265
+ throw new GithubIntegrationProviderError(
266
+ 'provider-unavailable',
267
+ 'GitHub bot identity resolution is unavailable',
268
+ );
269
+ }
270
+
271
+ const botUser = await github.getBotUser({username: name, installationAccessToken});
272
+ return {
273
+ name: botUser.login,
274
+ email: `${botUser.id}+${botUser.login}@users.noreply.github.com`,
275
+ };
260
276
  }
261
277
 
262
278
  function toRepositorySnapshot(repository: GithubRepository): RepositorySnapshot {