@shipfox/api-integration-github 12.5.0 → 12.7.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 (44) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +14 -0
  3. package/dist/api/client.d.ts.map +1 -1
  4. package/dist/api/client.js +8 -5
  5. package/dist/api/client.js.map +1 -1
  6. package/dist/api/github-octokit.d.ts +7 -0
  7. package/dist/api/github-octokit.d.ts.map +1 -0
  8. package/dist/api/github-octokit.js +32 -0
  9. package/dist/api/github-octokit.js.map +1 -0
  10. package/dist/api/installation-token-provider.d.ts.map +1 -1
  11. package/dist/api/installation-token-provider.js +4 -2
  12. package/dist/api/installation-token-provider.js.map +1 -1
  13. package/dist/config.d.ts +1 -0
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js +8 -0
  16. package/dist/config.js.map +1 -1
  17. package/dist/core/agent-tools.d.ts.map +1 -1
  18. package/dist/core/agent-tools.js +203 -68
  19. package/dist/core/agent-tools.js.map +1 -1
  20. package/dist/core/github-agent-tool-catalog.d.ts +1 -1
  21. package/dist/core/github-agent-tool-catalog.d.ts.map +1 -1
  22. package/dist/core/github-agent-tool-catalog.js +19 -0
  23. package/dist/core/github-agent-tool-catalog.js.map +1 -1
  24. package/dist/metrics/instance.d.ts +1 -0
  25. package/dist/metrics/instance.d.ts.map +1 -1
  26. package/dist/metrics/instance.js +19 -0
  27. package/dist/metrics/instance.js.map +1 -1
  28. package/dist/tsconfig.test.tsbuildinfo +1 -1
  29. package/package.json +1 -1
  30. package/src/api/client.test.ts +25 -5
  31. package/src/api/client.ts +22 -5
  32. package/src/api/github-octokit.test.ts +115 -0
  33. package/src/api/github-octokit.ts +49 -0
  34. package/src/api/installation-token-provider.test.ts +44 -5
  35. package/src/api/installation-token-provider.ts +5 -2
  36. package/src/config.ts +5 -0
  37. package/src/core/agent-tools.test.ts +395 -74
  38. package/src/core/agent-tools.ts +284 -69
  39. package/src/core/github-agent-tool-catalog.ts +35 -0
  40. package/src/metrics/instance.ts +25 -0
  41. package/test/env.ts +1 -0
  42. package/test/fixtures/github-installation-token.ts +8 -0
  43. package/test/index.ts +4 -0
  44. package/tsconfig.build.tsbuildinfo +1 -1
@@ -13,7 +13,7 @@ import {
13
13
  createGithubInstallationTokenProvider,
14
14
  type GithubInstallationTokenProvider,
15
15
  } from '#api/installation-token-provider.js';
16
- import {normalizedGithubApiBaseUrl} from '#config.js';
16
+ import {config, normalizedGithubApiBaseUrl} from '#config.js';
17
17
  import type {GithubInstallation} from '#db/installations.js';
18
18
  import {GithubIntegrationProviderError} from './errors.js';
19
19
  import {
@@ -49,27 +49,74 @@ type GithubToolCallResult = {
49
49
  structuredContent?: Record<string, unknown> | undefined;
50
50
  };
51
51
 
52
+ type GithubToolErrorCode =
53
+ | 'invalid-request'
54
+ | 'access-denied'
55
+ | 'provider-rejected'
56
+ | 'malformed-provider-response';
57
+
52
58
  const GITHUB_GRAPHQL_ROUTE = 'POST /graphql';
53
59
  const GITHUB_ARTIFACT_ARCHIVE_FORMAT = 'zip';
54
60
  const GITHUB_ARTIFACT_DOWNLOAD_ROUTE = `GET /repos/{owner}/{repo}/actions/artifacts/{resource_id}/${GITHUB_ARTIFACT_ARCHIVE_FORMAT}`;
55
61
  const GITHUB_ARTIFACT_DOWNLOAD_TIMEOUT_MS = 30_000;
62
+ const GITHUB_APP_BOT_SUFFIX = '[bot]';
63
+ const PENDING_REVIEW_PAGE_SIZE = 100;
64
+ const PENDING_REVIEW_MAX_PAGE_REQUESTS = 5;
65
+ const PENDING_REVIEW_LOOKUP_TIMEOUT_MS = 15_000;
66
+ const PENDING_REVIEW_PAGE_TIMEOUT_MS = 5_000;
67
+ const PENDING_REVIEW_PAGE_PATTERN = /[?&]page=(\d+)/u;
56
68
  const NO_PENDING_REVIEW_MESSAGE =
57
69
  'No pending pull request review found for the authenticated GitHub user.';
58
70
 
59
- const LATEST_PENDING_REVIEW_QUERY = `
60
- query LatestPendingPullRequestReview($owner: String!, $repo: String!, $pullNumber: Int!) {
61
- viewer {
62
- login
71
+ const ADD_PENDING_REVIEW_COMMENT_MUTATION = `
72
+ mutation AddCommentToPendingReview($input: AddPullRequestReviewThreadInput!) {
73
+ addPullRequestReviewThread(input: $input) {
74
+ thread {
75
+ id
76
+ }
63
77
  }
78
+ }
79
+ `;
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
+ ) {
64
88
  repository(owner: $owner, name: $repo) {
65
89
  pullRequest(number: $pullNumber) {
66
- reviews(last: 100, states: [PENDING]) {
90
+ reviewThreads(first: 100, after: $after) {
67
91
  nodes {
68
92
  id
69
- author {
70
- login
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
+ }
71
115
  }
72
- createdAt
116
+ }
117
+ pageInfo {
118
+ hasNextPage
119
+ endCursor
73
120
  }
74
121
  }
75
122
  }
@@ -77,11 +124,12 @@ const LATEST_PENDING_REVIEW_QUERY = `
77
124
  }
78
125
  `;
79
126
 
80
- const ADD_PENDING_REVIEW_COMMENT_MUTATION = `
81
- mutation AddCommentToPendingReview($input: AddPullRequestReviewThreadInput!) {
82
- addPullRequestReviewThread(input: $input) {
127
+ const RESOLVE_PULL_REQUEST_REVIEW_THREAD_MUTATION = `
128
+ mutation ResolvePullRequestReviewThread($input: ResolveReviewThreadInput!) {
129
+ resolveReviewThread(input: $input) {
83
130
  thread {
84
131
  id
132
+ isResolved
85
133
  }
86
134
  }
87
135
  }
@@ -134,16 +182,18 @@ export class GithubAgentToolsProvider
134
182
  return {
135
183
  call: async (call) => {
136
184
  const tool = input.tools.find((candidate) => candidate.id === call.toolId);
137
- if (!tool) return githubToolError(`Unknown GitHub tool: ${call.toolId}`);
185
+ if (!tool) return githubToolError(`Unknown GitHub tool: ${call.toolId}`, 'invalid-request');
138
186
  const operation = resolveGithubOperation(tool, call);
139
- if (operation === undefined) return githubToolError('Unknown GitHub tool operation');
187
+ if (operation === undefined)
188
+ return githubToolError('Unknown GitHub tool operation', 'invalid-request');
140
189
  const validationError = validateGithubToolArguments(tool, call.arguments);
141
- if (validationError) return githubToolError(validationError);
190
+ if (validationError) return githubToolError(validationError, 'invalid-request');
142
191
  tokenPromise ??= this.tokenProvider.getInstallationAccessToken(installationId);
143
192
  const token = await tokenPromise;
144
193
  if (!hasGrantedPermissions(token.permissions ?? {}, tool, call)) {
145
194
  return githubToolError(
146
195
  'GitHub installation token is missing permission for this operation',
196
+ 'access-denied',
147
197
  );
148
198
  }
149
199
  const client = (this.options.createClient ?? createOctokitClient)(token.token);
@@ -152,11 +202,17 @@ export class GithubAgentToolsProvider
152
202
 
153
203
  if (operation.kind === 'graphql') {
154
204
  const data = await mapGithubError(() =>
155
- addCommentToPendingReview(client, operation.parameters),
205
+ executeGithubGraphqlOperation(
206
+ client,
207
+ tool.id as GithubAgentToolId,
208
+ method,
209
+ operation.parameters,
210
+ ),
156
211
  );
157
- return data === undefined
158
- ? githubToolError(NO_PENDING_REVIEW_MESSAGE)
159
- : 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);
160
216
  }
161
217
 
162
218
  const operationParameters = await mapGithubError(() =>
@@ -168,7 +224,7 @@ export class GithubAgentToolsProvider
168
224
  ),
169
225
  );
170
226
  if (operationParameters === undefined) {
171
- return githubToolError(NO_PENDING_REVIEW_MESSAGE);
227
+ return githubToolError(NO_PENDING_REVIEW_MESSAGE, 'provider-rejected');
172
228
  }
173
229
  const response = await mapGithubError(() =>
174
230
  client.request(operation.route, operationParameters),
@@ -329,6 +385,8 @@ export function githubOperationRoute(
329
385
  return `GET ${repoPath}/pulls/${pull}/commits`;
330
386
  case 'pull_request_read.get_review_comments':
331
387
  return `GET ${repoPath}/pulls/${pull}/comments`;
388
+ case 'pull_request_read.get_review_threads':
389
+ return GITHUB_GRAPHQL_ROUTE;
332
390
  case 'pull_request_read.get_reviews':
333
391
  return `GET ${repoPath}/pulls/${pull}/reviews`;
334
392
  case 'pull_request_read.get_comments':
@@ -357,6 +415,8 @@ export function githubOperationRoute(
357
415
  return `POST ${repoPath}/pulls/${pull}/reviews/{review_id}/events`;
358
416
  case 'pull_request_review_write.delete_pending':
359
417
  return `DELETE ${repoPath}/pulls/${pull}/reviews/{review_id}`;
418
+ case 'pull_request_review_thread_write.resolve':
419
+ return GITHUB_GRAPHQL_ROUTE;
360
420
  case 'add_comment_to_pending_review.':
361
421
  return GITHUB_GRAPHQL_ROUTE;
362
422
  case 'actions_list.list_workflows':
@@ -407,16 +467,17 @@ async function addCommentToPendingReview(
407
467
  );
408
468
  }
409
469
 
410
- const reviewLookup = await client.graphql(LATEST_PENDING_REVIEW_QUERY, {
411
- owner: args.owner,
412
- repo: args.repo,
413
- pullNumber: args.pull_number,
414
- });
415
- const reviewId = latestPendingReviewNodeId(reviewLookup);
416
- if (reviewId === undefined) return undefined;
470
+ const review = await latestPendingReview(client, args, 'nodeId');
471
+ if (review === undefined) return undefined;
472
+ if (review.nodeId === undefined) {
473
+ throw new GithubIntegrationProviderError(
474
+ 'malformed-provider-response',
475
+ 'GitHub pending pull request review did not include a node ID',
476
+ );
477
+ }
417
478
 
418
479
  const input: Record<string, unknown> = {
419
- pullRequestReviewId: reviewId,
480
+ pullRequestReviewId: review.nodeId,
420
481
  path: args.path,
421
482
  body: args.body,
422
483
  subjectType: args.subject_type,
@@ -429,29 +490,41 @@ async function addCommentToPendingReview(
429
490
  return await client.graphql(ADD_PENDING_REVIEW_COMMENT_MUTATION, {input});
430
491
  }
431
492
 
432
- function latestPendingReviewNodeId(data: unknown): string | undefined {
433
- if (!isRecord(data)) return undefined;
434
-
435
- const viewer = isRecord(data.viewer) ? data.viewer.login : undefined;
436
- if (typeof viewer !== 'string') return undefined;
437
-
438
- const repository = isRecord(data.repository) ? data.repository : undefined;
439
- const pullRequest =
440
- repository && isRecord(repository.pullRequest) ? repository.pullRequest : undefined;
441
- const reviews = pullRequest && isRecord(pullRequest.reviews) ? pullRequest.reviews : undefined;
442
- const nodes = reviews && Array.isArray(reviews.nodes) ? reviews.nodes : [];
443
-
444
- let latest: {createdAt: string; id: string} | undefined;
445
- for (const node of nodes) {
446
- if (!isRecord(node)) continue;
447
- const author = isRecord(node.author) ? node.author.login : undefined;
448
- const id = node.id;
449
- const createdAt = node.createdAt;
450
- if (author !== viewer || typeof id !== 'string' || typeof createdAt !== 'string') continue;
451
- if (latest === undefined || createdAt > latest.createdAt) latest = {createdAt, id};
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
+ );
452
504
  }
453
505
 
454
- return latest?.id;
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
+ }
455
528
  }
456
529
 
457
530
  export function projectGithubOperationParameters(
@@ -479,8 +552,15 @@ async function resolvePendingReviewParameters(
479
552
  ): Promise<Record<string, unknown> | undefined> {
480
553
  if (!isPendingReviewOperation(toolId, method)) return parameters;
481
554
 
482
- const reviewId = await latestPendingReviewId(client, parameters);
483
- return reviewId === undefined ? undefined : {...parameters, review_id: reviewId};
555
+ const review = await latestPendingReview(client, parameters, 'id');
556
+ if (review === undefined) return undefined;
557
+ if (review.id === undefined) {
558
+ throw new GithubIntegrationProviderError(
559
+ 'malformed-provider-response',
560
+ 'GitHub pending pull request review did not include a numeric ID',
561
+ );
562
+ }
563
+ return {...parameters, review_id: review.id};
484
564
  }
485
565
 
486
566
  function isPendingReviewOperation(toolId: GithubAgentToolId, method: string | undefined): boolean {
@@ -490,38 +570,166 @@ function isPendingReviewOperation(toolId: GithubAgentToolId, method: string | un
490
570
  );
491
571
  }
492
572
 
493
- async function latestPendingReviewId(
573
+ interface PendingReviewReference {
574
+ id?: number | undefined;
575
+ nodeId?: string | undefined;
576
+ }
577
+
578
+ type PendingReviewIdentifier = keyof PendingReviewReference;
579
+
580
+ interface PendingReviewPageResult {
581
+ malformed: boolean;
582
+ review?: PendingReviewReference | undefined;
583
+ }
584
+
585
+ async function latestPendingReview(
586
+ client: GithubToolClient,
587
+ parameters: Record<string, unknown>,
588
+ requiredIdentifier: PendingReviewIdentifier,
589
+ ): Promise<PendingReviewReference | undefined> {
590
+ const lookupController = new AbortController();
591
+ const lookupTimeout = setTimeout(
592
+ () => lookupController.abort(),
593
+ PENDING_REVIEW_LOOKUP_TIMEOUT_MS,
594
+ );
595
+
596
+ try {
597
+ return await latestPendingReviewBeforeDeadline(
598
+ client,
599
+ parameters,
600
+ requiredIdentifier,
601
+ lookupController.signal,
602
+ );
603
+ } finally {
604
+ clearTimeout(lookupTimeout);
605
+ }
606
+ }
607
+
608
+ async function latestPendingReviewBeforeDeadline(
494
609
  client: GithubToolClient,
495
610
  parameters: Record<string, unknown>,
496
- ): Promise<number | undefined> {
497
- const perPage = 100;
498
- let page = 1;
499
- let reviewId: number | undefined;
611
+ requiredIdentifier: PendingReviewIdentifier,
612
+ lookupSignal: AbortSignal,
613
+ ): Promise<PendingReviewReference | undefined> {
614
+ const firstPage = await requestPendingReviewPage(client, parameters, 1, lookupSignal);
615
+ const lastPage = pendingReviewLastPage(firstPage.headers);
616
+ let requests = 1;
617
+ let malformed = false;
618
+
619
+ for (let page = lastPage; page >= 1; page -= 1) {
620
+ let response = firstPage;
621
+ if (page !== 1) {
622
+ if (requests >= PENDING_REVIEW_MAX_PAGE_REQUESTS) {
623
+ throw new GithubIntegrationProviderError(
624
+ 'content-too-large',
625
+ 'GitHub pull request review history exceeded the pending review lookup limit',
626
+ );
627
+ }
628
+ response = await requestPendingReviewPage(client, parameters, page, lookupSignal);
629
+ requests += 1;
630
+ }
631
+ if (!Array.isArray(response.data)) {
632
+ throw new GithubIntegrationProviderError(
633
+ 'malformed-provider-response',
634
+ 'GitHub pull request review list response was malformed',
635
+ );
636
+ }
637
+
638
+ const result = latestPendingReviewOnPage(response.data, requiredIdentifier);
639
+ if (result.review !== undefined) return result.review;
640
+ malformed ||= result.malformed;
641
+ }
500
642
 
501
- while (true) {
502
- const response = await client.request('GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews', {
643
+ if (malformed) {
644
+ throw new GithubIntegrationProviderError(
645
+ 'malformed-provider-response',
646
+ requiredIdentifier === 'nodeId'
647
+ ? 'GitHub pending pull request review did not include a node ID'
648
+ : 'GitHub pending pull request review did not include a numeric ID',
649
+ );
650
+ }
651
+ return undefined;
652
+ }
653
+
654
+ async function requestPendingReviewPage(
655
+ client: GithubToolClient,
656
+ parameters: Record<string, unknown>,
657
+ page: number,
658
+ lookupSignal: AbortSignal,
659
+ ): Promise<GithubToolResponse> {
660
+ const pageController = new AbortController();
661
+ const abortPage = () => pageController.abort();
662
+ if (lookupSignal.aborted) abortPage();
663
+ else lookupSignal.addEventListener('abort', abortPage, {once: true});
664
+ const pageTimeout = setTimeout(abortPage, PENDING_REVIEW_PAGE_TIMEOUT_MS);
665
+
666
+ try {
667
+ return await client.request('GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews', {
503
668
  owner: parameters.owner,
504
669
  repo: parameters.repo,
505
670
  pull_number: parameters.pull_number,
506
- per_page: perPage,
671
+ per_page: PENDING_REVIEW_PAGE_SIZE,
507
672
  page,
673
+ request: {signal: pageController.signal},
508
674
  });
509
- if (!Array.isArray(response.data)) return undefined;
675
+ } finally {
676
+ clearTimeout(pageTimeout);
677
+ lookupSignal.removeEventListener('abort', abortPage);
678
+ }
679
+ }
510
680
 
511
- reviewId = latestPendingReviewIdOnPage(response.data) ?? reviewId;
512
- if (response.data.length < perPage) return reviewId;
513
- page += 1;
681
+ function pendingReviewLastPage(headers: GithubToolResponse['headers']): number {
682
+ const link = headers?.link;
683
+ if (typeof link !== 'string') return 1;
684
+ const lastLink = link.split(',').find((part) => part.includes('rel="last"'));
685
+ if (lastLink === undefined) return 1;
686
+ const match = PENDING_REVIEW_PAGE_PATTERN.exec(lastLink);
687
+ const page = match?.[1] === undefined ? Number.NaN : Number.parseInt(match[1], 10);
688
+ if (!Number.isSafeInteger(page) || page < 1) {
689
+ throw new GithubIntegrationProviderError(
690
+ 'malformed-provider-response',
691
+ 'GitHub pull request review pagination response was malformed',
692
+ );
514
693
  }
694
+ return page;
515
695
  }
516
696
 
517
- function latestPendingReviewIdOnPage(data: readonly unknown[]): number | undefined {
697
+ function latestPendingReviewOnPage(
698
+ data: readonly unknown[],
699
+ requiredIdentifier: PendingReviewIdentifier,
700
+ ): PendingReviewPageResult {
701
+ let malformed = false;
702
+ const appLogin = githubAppBotLogin().toLowerCase();
518
703
  for (let index = data.length - 1; index >= 0; index -= 1) {
519
704
  const review = data[index];
520
705
  if (!isRecord(review) || review.state !== 'PENDING') continue;
521
- if (typeof review.id === 'number' && Number.isSafeInteger(review.id)) return review.id;
706
+ const userLogin = isRecord(review.user) ? review.user.login : undefined;
707
+ if (typeof userLogin !== 'string' || userLogin.trim().length === 0) {
708
+ malformed = true;
709
+ continue;
710
+ }
711
+ if (userLogin.trim().toLowerCase() !== appLogin) continue;
712
+ const id =
713
+ typeof review.id === 'number' && Number.isSafeInteger(review.id) && review.id > 0
714
+ ? review.id
715
+ : undefined;
716
+ const nodeId =
717
+ typeof review.node_id === 'string' && review.node_id.trim().length > 0
718
+ ? review.node_id.trim()
719
+ : undefined;
720
+ const reference = {id, nodeId};
721
+ if (reference[requiredIdentifier] !== undefined) return {malformed, review: reference};
722
+ malformed = true;
522
723
  }
523
724
 
524
- return undefined;
725
+ return {malformed};
726
+ }
727
+
728
+ function githubAppBotLogin(): string {
729
+ const configuredUsername = config.GITHUB_APP_USERNAME?.trim() || config.GITHUB_APP_SLUG.trim();
730
+ return configuredUsername.toLowerCase().endsWith(GITHUB_APP_BOT_SUFFIX)
731
+ ? configuredUsername
732
+ : `${configuredUsername}${GITHUB_APP_BOT_SUFFIX}`;
525
733
  }
526
734
 
527
735
  function githubToolResult(
@@ -533,7 +741,10 @@ function githubToolResult(
533
741
  ): GithubToolCallResult {
534
742
  const structuredContent = projectGithubToolOutput(toolId, data, response, parameters, route);
535
743
  if (structuredContent === undefined) {
536
- return githubToolError('GitHub artifact download did not return a download URL');
744
+ return githubToolError(
745
+ 'GitHub artifact download did not return a download URL',
746
+ 'malformed-provider-response',
747
+ );
537
748
  }
538
749
  return {
539
750
  content: [{type: 'text', text: JSON.stringify(structuredContent)}],
@@ -601,8 +812,12 @@ function githubSearchItems(data: unknown): unknown {
601
812
  return isRecord(data) ? data.items : data;
602
813
  }
603
814
 
604
- function githubToolError(message: string): GithubToolCallResult {
605
- return {isError: true, content: [{type: 'text', text: message}]};
815
+ function githubToolError(message: string, code: GithubToolErrorCode): GithubToolCallResult {
816
+ return {
817
+ isError: true,
818
+ content: [{type: 'text', text: message}],
819
+ structuredContent: {code},
820
+ };
606
821
  }
607
822
 
608
823
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -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',
@@ -1,6 +1,7 @@
1
1
  import type {IntegrationProviderErrorReason} from '@shipfox/api-integration-spi';
2
2
  import {instanceMetrics} from '@shipfox/node-opentelemetry';
3
3
  import type {MintErrorClass} from '#api/installation-token-envelope.js';
4
+ import {config} from '#config.js';
4
5
 
5
6
  const meter = instanceMetrics.getMeter('github');
6
7
 
@@ -32,6 +33,13 @@ const installationTokenMintDuration = meter.createHistogram<Record<string, never
32
33
  },
33
34
  );
34
35
 
36
+ const installationTokenFormatCount = meter.createCounter<{
37
+ format: 'stateless' | 'stateful' | 'unknown';
38
+ override: 'enabled' | 'disabled' | 'absent';
39
+ }>('github_installation_token_format', {
40
+ description: 'GitHub installation tokens observed by format and requested override',
41
+ });
42
+
35
43
  const installationTokenLockWaitDuration = meter.createHistogram<Record<string, never>>(
36
44
  'github_installation_token_lock_wait_duration',
37
45
  {
@@ -70,6 +78,15 @@ export function recordInstallationTokenMint(params: {
70
78
  });
71
79
  }
72
80
 
81
+ export function recordInstallationTokenFormat(token: string): void {
82
+ recordMetric(() => {
83
+ installationTokenFormatCount.add(1, {
84
+ format: installationTokenFormat(token),
85
+ override: config.GITHUB_INSTALLATION_TOKEN_FORMAT_OVERRIDE ?? 'absent',
86
+ });
87
+ });
88
+ }
89
+
73
90
  export function recordInstallationTokenLockWait(durationMs: number): void {
74
91
  recordMetric(() => installationTokenLockWaitDuration.record(durationMs));
75
92
  }
@@ -80,3 +97,11 @@ export function recordInstallationTokenBackoff(params: {
80
97
  }): void {
81
98
  recordMetric(() => installationTokenBackoffCount.add(1, params));
82
99
  }
100
+
101
+ function installationTokenFormat(token: string): 'stateless' | 'stateful' | 'unknown' {
102
+ if (!token.startsWith('ghs_')) return 'unknown';
103
+ const dotCount = token.slice(4).split('.').length - 1;
104
+ if (dotCount === 2) return 'stateless';
105
+ if (dotCount === 0) return 'stateful';
106
+ return 'unknown';
107
+ }
package/test/env.ts CHANGED
@@ -14,3 +14,4 @@ process.env.GITHUB_APP_SLUG = 'shipfox-test';
14
14
  process.env.GITHUB_APP_USERNAME = 'shipfox-test';
15
15
  process.env.GITHUB_INSTALL_STATE_SECRET = 'test-install-state-secret';
16
16
  process.env.GITHUB_API_BASE_URL = 'https://api.github.com';
17
+ process.env.GITHUB_INSTALLATION_TOKEN_FORMAT_OVERRIDE = 'enabled';
@@ -0,0 +1,8 @@
1
+ const JWT_SEGMENT_LENGTH = 169;
2
+
3
+ export const GITHUB_STATELESS_INSTALLATION_TOKEN =
4
+ `ghs_123456_${'a'.repeat(JWT_SEGMENT_LENGTH)}` +
5
+ `.${'b'.repeat(JWT_SEGMENT_LENGTH)}` +
6
+ `.${'c'.repeat(JWT_SEGMENT_LENGTH)}`;
7
+
8
+ export const GITHUB_STATEFUL_INSTALLATION_TOKEN = `ghs_${'d'.repeat(36)}`;
package/test/index.ts CHANGED
@@ -1,2 +1,6 @@
1
1
  export {githubInstallationFactory} from './factories/github-installation.js';
2
+ export {
3
+ GITHUB_STATEFUL_INSTALLATION_TOKEN,
4
+ GITHUB_STATELESS_INSTALLATION_TOKEN,
5
+ } from './fixtures/github-installation-token.js';
2
6
  export {type GithubPushPayloadOptions, githubPushPayload} from './fixtures/github-webhook.js';