@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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-integration-github",
3
3
  "license": "MIT",
4
- "version": "12.6.0",
4
+ "version": "13.1.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -7,37 +7,211 @@ 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, RequestErrorMock} = vi.hoisted(() => {
11
- class RequestErrorMock extends Error {
12
- constructor(
13
- message: string,
14
- public readonly status: number,
15
- ) {
16
- super(message);
17
- this.name = 'HttpError';
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
+ }
18
20
  }
19
- }
20
21
 
21
- return {createInstallationAccessTokenMock: vi.fn(), RequestErrorMock};
22
- });
22
+ return {
23
+ createInstallationAccessTokenMock: vi.fn(),
24
+ getByUsernameMock: vi.fn(),
25
+ octokitOptionsMock: vi.fn(),
26
+ RequestErrorMock,
27
+ };
28
+ });
23
29
 
24
30
  vi.mock('octokit', () => ({
25
31
  App: class App {
26
32
  octokit = {
27
- rest: {apps: {createInstallationAccessToken: createInstallationAccessTokenMock}},
33
+ rest: {
34
+ apps: {createInstallationAccessToken: createInstallationAccessTokenMock},
35
+ users: {getByUsername: getByUsernameMock},
36
+ },
28
37
  };
29
38
  },
30
- Octokit: {
31
- plugin() {
32
- return this;
33
- },
34
- defaults(options: unknown) {
39
+ Octokit: class Octokit {
40
+ rest = {users: {getByUsername: getByUsernameMock}};
41
+
42
+ constructor(options: unknown) {
43
+ octokitOptionsMock(options);
44
+ }
45
+
46
+ static plugin() {
47
+ return Octokit;
48
+ }
49
+
50
+ static defaults(options: unknown) {
35
51
  return {defaults: options};
36
- },
52
+ }
37
53
  },
38
54
  RequestError: RequestErrorMock,
39
55
  }));
40
56
 
57
+ describe('OctokitGithubApiClient.getBotUser', () => {
58
+ beforeEach(() => {
59
+ getByUsernameMock.mockReset();
60
+ octokitOptionsMock.mockReset();
61
+ });
62
+
63
+ it('shares one lookup for concurrent requests with the same token and caches the bot', async () => {
64
+ getByUsernameMock.mockResolvedValue({
65
+ data: {id: 307_629_549, login: 'shipfox-ai[bot]', type: 'Bot'},
66
+ });
67
+ const client = createGithubApiClient();
68
+
69
+ const firstLookup = client.getBotUser({
70
+ username: 'shipfox-ai[bot]',
71
+ installationAccessToken: 'ghs_first',
72
+ });
73
+ const secondLookup = client.getBotUser({
74
+ username: 'SHIPFOX-AI[BOT]',
75
+ installationAccessToken: 'ghs_first',
76
+ });
77
+ const [first, second] = await Promise.all([firstLookup, secondLookup]);
78
+ const cached = await client.getBotUser({
79
+ username: 'shipfox-ai[bot]',
80
+ installationAccessToken: 'ghs_third',
81
+ });
82
+
83
+ expect(first).toEqual({id: 307_629_549, login: 'shipfox-ai[bot]'});
84
+ expect(second).toEqual(first);
85
+ expect(cached).toEqual(first);
86
+ expect(getByUsernameMock).toHaveBeenCalledTimes(1);
87
+ expect(getByUsernameMock).toHaveBeenCalledWith({
88
+ username: 'shipfox-ai[bot]',
89
+ request: {signal: expect.any(AbortSignal)},
90
+ });
91
+ expect(octokitOptionsMock).toHaveBeenCalledWith({
92
+ auth: 'ghs_first',
93
+ baseUrl: 'https://api.github.com',
94
+ });
95
+ });
96
+
97
+ it('does not share an in-flight lookup across installation tokens', async () => {
98
+ getByUsernameMock.mockResolvedValue({
99
+ data: {id: 307_629_549, login: 'shipfox-ai[bot]', type: 'Bot'},
100
+ });
101
+ const client = createGithubApiClient();
102
+
103
+ const firstLookup = client.getBotUser({
104
+ username: 'shipfox-ai[bot]',
105
+ installationAccessToken: 'ghs_first',
106
+ });
107
+ const secondLookup = client.getBotUser({
108
+ username: 'shipfox-ai[bot]',
109
+ installationAccessToken: 'ghs_second',
110
+ });
111
+
112
+ await expect(Promise.all([firstLookup, secondLookup])).resolves.toEqual([
113
+ {id: 307_629_549, login: 'shipfox-ai[bot]'},
114
+ {id: 307_629_549, login: 'shipfox-ai[bot]'},
115
+ ]);
116
+ expect(getByUsernameMock).toHaveBeenCalledTimes(2);
117
+ });
118
+
119
+ it('evicts a failed lookup so a later request can retry', async () => {
120
+ getByUsernameMock
121
+ .mockRejectedValueOnce(new RequestErrorMock('GitHub unavailable', 503))
122
+ .mockResolvedValueOnce({
123
+ data: {id: 307_629_549, login: 'shipfox-ai[bot]', type: 'Bot'},
124
+ });
125
+ const client = createGithubApiClient();
126
+
127
+ const failed = client.getBotUser({
128
+ username: 'shipfox-ai[bot]',
129
+ installationAccessToken: 'ghs_first',
130
+ });
131
+ await expect(failed).rejects.toMatchObject({reason: 'provider-unavailable'});
132
+ const retried = await client.getBotUser({
133
+ username: 'shipfox-ai[bot]',
134
+ installationAccessToken: 'ghs_second',
135
+ });
136
+
137
+ expect(retried).toEqual({id: 307_629_549, login: 'shipfox-ai[bot]'});
138
+ expect(getByUsernameMock).toHaveBeenCalledTimes(2);
139
+ });
140
+
141
+ it('maps a missing configured bot and retries after the username becomes available', async () => {
142
+ getByUsernameMock
143
+ .mockRejectedValueOnce(new RequestErrorMock('Not Found', 404))
144
+ .mockResolvedValueOnce({
145
+ data: {id: 307_629_549, login: 'shipfox-ai[bot]', type: 'Bot'},
146
+ });
147
+ const client = createGithubApiClient();
148
+
149
+ const missing = client.getBotUser({
150
+ username: 'shipfox-ai[bot]',
151
+ installationAccessToken: 'ghs_installationtoken',
152
+ });
153
+ await expect(missing).rejects.toMatchObject({
154
+ reason: 'provider-rejected',
155
+ message: 'Configured GitHub bot user shipfox-ai[bot] was not found',
156
+ status: 404,
157
+ });
158
+ const corrected = client.getBotUser({
159
+ username: 'shipfox-ai[bot]',
160
+ installationAccessToken: 'ghs_installationtoken',
161
+ });
162
+
163
+ await expect(corrected).resolves.toEqual({
164
+ id: 307_629_549,
165
+ login: 'shipfox-ai[bot]',
166
+ });
167
+ expect(getByUsernameMock).toHaveBeenCalledTimes(2);
168
+ });
169
+
170
+ it.each([
171
+ ['a null body', null, 'GitHub bot user response is missing required fields'],
172
+ [
173
+ 'an empty login',
174
+ {id: 307_629_549, login: '', type: 'Bot'},
175
+ 'GitHub bot user response is missing required fields',
176
+ ],
177
+ [
178
+ 'a zero id',
179
+ {id: 0, login: 'shipfox-ai[bot]', type: 'Bot'},
180
+ 'GitHub bot user response is missing required fields',
181
+ ],
182
+ [
183
+ 'a negative id',
184
+ {id: -1, login: 'shipfox-ai[bot]', type: 'Bot'},
185
+ 'GitHub bot user response is missing required fields',
186
+ ],
187
+ [
188
+ 'a fractional id',
189
+ {id: 1.5, login: 'shipfox-ai[bot]', type: 'Bot'},
190
+ 'GitHub bot user response is missing required fields',
191
+ ],
192
+ [
193
+ 'a non-bot account',
194
+ {id: 307_629_549, login: 'shipfox-ai[bot]', type: 'User'},
195
+ 'Configured GitHub username is not a bot account',
196
+ ],
197
+ [
198
+ 'a different bot account',
199
+ {id: 307_629_549, login: 'another-app[bot]', type: 'Bot'},
200
+ 'GitHub bot user response did not match the configured username',
201
+ ],
202
+ ])('rejects %s', async (_label, data, message) => {
203
+ getByUsernameMock.mockResolvedValue({data});
204
+ const client = createGithubApiClient();
205
+
206
+ const result = client.getBotUser({
207
+ username: 'shipfox-ai[bot]',
208
+ installationAccessToken: 'ghs_installationtoken',
209
+ });
210
+
211
+ await expect(result).rejects.toMatchObject({reason: 'malformed-provider-response', message});
212
+ });
213
+ });
214
+
41
215
  describe('mapGithubError', () => {
42
216
  it.each([400, 409, 422])('maps HTTP %i to a terminal provider rejection', async (status) => {
43
217
  const error = new RequestErrorMock(`GitHub rejected request with HTTP ${status}`, status);
@@ -62,6 +236,20 @@ describe('mapGithubError', () => {
62
236
  status: 503,
63
237
  });
64
238
  });
239
+
240
+ it('maps a request timeout cause to timeout', async () => {
241
+ const timeout = new Error('The operation was aborted due to timeout');
242
+ timeout.name = 'TimeoutError';
243
+ const error = new RequestErrorMock('fetch failed', 500);
244
+ error.cause = timeout;
245
+
246
+ const result = mapGithubError(() => Promise.reject(error));
247
+
248
+ await expect(result).rejects.toMatchObject({
249
+ reason: 'timeout',
250
+ message: 'GitHub request timed out',
251
+ });
252
+ });
65
253
  });
66
254
 
67
255
  describe('OctokitGithubApiClient.createInstallationAccessToken', () => {
package/src/api/client.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import {Buffer} from 'node:buffer';
2
- import {MAX_REPOSITORY_FILE_BYTES} from '@shipfox/api-integration-spi';
2
+ import {isRecord, MAX_REPOSITORY_FILE_BYTES} from '@shipfox/api-integration-spi';
3
+ import {logger} from '@shipfox/node-opentelemetry';
3
4
  import ky, {HTTPError, TimeoutError} from 'ky';
4
5
  import {App, Octokit, RequestError} from 'octokit';
5
6
  import {config, normalizedGithubApiBaseUrl, normalizedGithubPrivateKey} from '#config.js';
@@ -13,6 +14,7 @@ import {
13
14
  const NEXT_PAGE_RE = /[?&]page=(\d+)/;
14
15
  const TRAILING_SLASHES_RE = /\/+$/;
15
16
  const MAX_TREE_WALK_DEPTH = 10;
17
+ const GITHUB_API_TIMEOUT_MS = 10_000;
16
18
 
17
19
  export interface GithubAccount {
18
20
  login: string;
@@ -66,7 +68,16 @@ export interface GithubUserInstallationPage {
66
68
  nextCursor: string | null;
67
69
  }
68
70
 
69
- export interface GithubApiClient {
71
+ export interface GithubBotUser {
72
+ id: number;
73
+ login: string;
74
+ }
75
+
76
+ export interface GithubBotUserClient {
77
+ getBotUser(input: {username: string; installationAccessToken: string}): Promise<GithubBotUser>;
78
+ }
79
+
80
+ export interface GithubApiClient extends Partial<GithubBotUserClient> {
70
81
  exchangeOAuthCode(code: string): Promise<string>;
71
82
  listUserInstallations(input: {
72
83
  userAccessToken: string;
@@ -106,12 +117,17 @@ export interface GithubInstallationAccessToken {
106
117
  permissions?: Record<string, 'read' | 'write' | 'admin'> | undefined;
107
118
  }
108
119
 
109
- export function createGithubApiClient(): GithubApiClient {
120
+ export function createGithubApiClient(): GithubApiClient & GithubBotUserClient {
110
121
  return new OctokitGithubApiClient();
111
122
  }
112
123
 
113
- class OctokitGithubApiClient implements GithubApiClient {
124
+ class OctokitGithubApiClient implements GithubApiClient, GithubBotUserClient {
114
125
  private app: App | undefined;
126
+ private readonly botUsers = new Map<string, GithubBotUser>();
127
+ private readonly botUserLookups = new Map<
128
+ string,
129
+ {installationAccessToken: string; promise: Promise<GithubBotUser>}
130
+ >();
115
131
 
116
132
  async exchangeOAuthCode(code: string): Promise<string> {
117
133
  const body = await mapGithubOAuthError(() =>
@@ -136,6 +152,39 @@ class OctokitGithubApiClient implements GithubApiClient {
136
152
  return body.access_token;
137
153
  }
138
154
 
155
+ getBotUser(input: {username: string; installationAccessToken: string}): Promise<GithubBotUser> {
156
+ const cacheKey = input.username.trim().toLowerCase();
157
+ const cached = this.botUsers.get(cacheKey);
158
+ if (cached) return Promise.resolve(cached);
159
+
160
+ const pending = this.botUserLookups.get(cacheKey);
161
+ if (pending?.installationAccessToken === input.installationAccessToken) {
162
+ return pending.promise;
163
+ }
164
+
165
+ const lookup = this.fetchBotUser(input).then((botUser) => {
166
+ const resolved = this.botUsers.get(cacheKey);
167
+ if (resolved) return resolved;
168
+
169
+ this.botUsers.set(cacheKey, botUser);
170
+ logger().info(
171
+ {githubAppBotLogin: botUser.login, githubAppBotUserId: botUser.id},
172
+ 'Resolved GitHub App bot identity',
173
+ );
174
+ return botUser;
175
+ });
176
+ const trackedLookup = lookup.finally(() => {
177
+ if (this.botUserLookups.get(cacheKey)?.promise === trackedLookup) {
178
+ this.botUserLookups.delete(cacheKey);
179
+ }
180
+ });
181
+ this.botUserLookups.set(cacheKey, {
182
+ installationAccessToken: input.installationAccessToken,
183
+ promise: trackedLookup,
184
+ });
185
+ return trackedLookup;
186
+ }
187
+
139
188
  async listUserInstallations(input: {
140
189
  userAccessToken: string;
141
190
  cursor?: string | undefined;
@@ -405,6 +454,72 @@ class OctokitGithubApiClient implements GithubApiClient {
405
454
  }
406
455
  return this.app;
407
456
  }
457
+
458
+ private async fetchBotUser(input: {
459
+ username: string;
460
+ installationAccessToken: string;
461
+ }): Promise<GithubBotUser> {
462
+ const octokit = new Octokit({
463
+ auth: input.installationAccessToken,
464
+ baseUrl: normalizedGithubApiBaseUrl(),
465
+ });
466
+ let response: Awaited<ReturnType<typeof octokit.rest.users.getByUsername>>;
467
+ try {
468
+ response = await mapGithubError(
469
+ () =>
470
+ octokit.rest.users.getByUsername({
471
+ username: input.username,
472
+ request: {signal: AbortSignal.timeout(GITHUB_API_TIMEOUT_MS)},
473
+ }),
474
+ 'provider-rejected',
475
+ );
476
+ } catch (error) {
477
+ if (error instanceof GithubIntegrationProviderError && error.status === 404) {
478
+ throw new GithubIntegrationProviderError(
479
+ 'provider-rejected',
480
+ `Configured GitHub bot user ${input.username} was not found`,
481
+ undefined,
482
+ error.status,
483
+ );
484
+ }
485
+ throw error;
486
+ }
487
+
488
+ const data: unknown = response.data;
489
+ if (!isRecord(data)) {
490
+ throw new GithubIntegrationProviderError(
491
+ 'malformed-provider-response',
492
+ 'GitHub bot user response is missing required fields',
493
+ );
494
+ }
495
+ const {id, login, type} = data;
496
+ if (
497
+ typeof id !== 'number' ||
498
+ !Number.isSafeInteger(id) ||
499
+ id <= 0 ||
500
+ typeof login !== 'string' ||
501
+ login.trim().length === 0
502
+ ) {
503
+ throw new GithubIntegrationProviderError(
504
+ 'malformed-provider-response',
505
+ 'GitHub bot user response is missing required fields',
506
+ );
507
+ }
508
+ if (type !== 'Bot') {
509
+ throw new GithubIntegrationProviderError(
510
+ 'malformed-provider-response',
511
+ 'Configured GitHub username is not a bot account',
512
+ );
513
+ }
514
+ const canonicalLogin = login.trim();
515
+ if (canonicalLogin.toLowerCase() !== input.username.trim().toLowerCase()) {
516
+ throw new GithubIntegrationProviderError(
517
+ 'malformed-provider-response',
518
+ 'GitHub bot user response did not match the configured username',
519
+ );
520
+ }
521
+ return {id, login: canonicalLogin};
522
+ }
408
523
  }
409
524
 
410
525
  async function mapGithubOAuthError<T>(operation: () => Promise<T>): Promise<T> {
@@ -442,12 +557,16 @@ export async function mapGithubError<T>(
442
557
  notFoundReason:
443
558
  | 'repository-not-found'
444
559
  | 'installation-not-found'
445
- | 'file-not-found' = 'repository-not-found',
560
+ | 'file-not-found'
561
+ | 'provider-rejected' = 'repository-not-found',
446
562
  ): Promise<T> {
447
563
  try {
448
564
  return await operation();
449
565
  } catch (error) {
450
566
  if (error instanceof GithubIntegrationProviderError) throw error;
567
+ if (isGithubTimeoutError(error)) {
568
+ throw new GithubIntegrationProviderError('timeout', 'GitHub request timed out');
569
+ }
451
570
  if (error instanceof RequestError) {
452
571
  if (error.status === 404) {
453
572
  throw new GithubIntegrationProviderError(
@@ -490,13 +609,16 @@ export async function mapGithubError<T>(
490
609
  );
491
610
  }
492
611
  }
493
- if (error instanceof Error && error.name === 'AbortError') {
494
- throw new GithubIntegrationProviderError('timeout', 'GitHub request timed out');
495
- }
496
612
  throw error;
497
613
  }
498
614
  }
499
615
 
616
+ function isGithubTimeoutError(error: unknown): boolean {
617
+ if (!(error instanceof Error)) return false;
618
+ if (error.name === 'AbortError' || error.name === 'TimeoutError') return true;
619
+ return error.cause instanceof Error && isGithubTimeoutError(error.cause);
620
+ }
621
+
500
622
  function isGithubRateLimitError(error: RequestError): boolean {
501
623
  return error.status === 403 && error.response?.headers['x-ratelimit-remaining'] === '0';
502
624
  }
@@ -83,6 +83,7 @@ const expectedCatalogRows = [
83
83
  'get_files',
84
84
  'get_commits',
85
85
  'get_review_comments',
86
+ 'get_review_threads',
86
87
  'get_reviews',
87
88
  'get_comments',
88
89
  'get_check_runs',
@@ -148,6 +149,14 @@ const expectedCatalogRows = [
148
149
  requiredScope: [{permission: 'pull_requests', access: 'write'}],
149
150
  methods: ['create', 'submit_pending', 'delete_pending'],
150
151
  },
152
+ {
153
+ id: 'pull_request_review_thread_write',
154
+ category: 'pull_requests',
155
+ sensitivity: 'write',
156
+ sensitive: false,
157
+ requiredScope: [{permission: 'pull_requests', access: 'write'}],
158
+ methods: ['resolve'],
159
+ },
151
160
  {
152
161
  id: 'add_comment_to_pending_review',
153
162
  category: 'pull_requests',
@@ -351,6 +360,12 @@ const githubOperationRouteCases = [
351
360
  args: {pull_number: 1},
352
361
  expectedRoute: 'GET /repos/{owner}/{repo}/pulls/{pull_number}/comments',
353
362
  },
363
+ {
364
+ toolId: 'pull_request_read',
365
+ method: 'get_review_threads',
366
+ args: {pull_number: 1},
367
+ expectedRoute: 'POST /graphql',
368
+ },
354
369
  {
355
370
  toolId: 'pull_request_read',
356
371
  method: 'get_reviews',
@@ -429,6 +444,12 @@ const githubOperationRouteCases = [
429
444
  runtimeInjectedProperties: ['review_id'],
430
445
  expectedRoute: 'DELETE /repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}',
431
446
  },
447
+ {
448
+ toolId: 'pull_request_review_thread_write',
449
+ method: 'resolve',
450
+ args: {thread_id: 'PRRT_kwDOExample'},
451
+ expectedRoute: 'POST /graphql',
452
+ },
432
453
  {
433
454
  toolId: 'add_comment_to_pending_review',
434
455
  args: {pull_number: 1, path: 'src/index.ts', body: 'Comment'},
@@ -663,6 +684,7 @@ describe('github agent tool catalog', () => {
663
684
  {properties: {method: {const: 'get_files'}}, required: []},
664
685
  {properties: {method: {const: 'get_commits'}}, required: []},
665
686
  {properties: {method: {const: 'get_review_comments'}}, required: []},
687
+ {properties: {method: {const: 'get_review_threads'}}, required: []},
666
688
  {properties: {method: {const: 'get_reviews'}}, required: []},
667
689
  {properties: {method: {const: 'get_comments'}}, required: []},
668
690
  {properties: {method: {const: 'get_check_runs'}}, required: ['ref']},
@@ -841,6 +863,104 @@ describe('github agent tool catalog', () => {
841
863
  });
842
864
  });
843
865
 
866
+ it('reads pull request review threads through GraphQL', async () => {
867
+ const request = vi.fn();
868
+ const data = {
869
+ repository: {
870
+ pullRequest: {
871
+ reviewThreads: {
872
+ nodes: [
873
+ {
874
+ id: 'PRRT_kwDOExample',
875
+ isResolved: false,
876
+ comments: {
877
+ nodes: [
878
+ {
879
+ id: 'PRRC_kwDOExample',
880
+ databaseId: 7,
881
+ body: 'Please handle this.',
882
+ author: {login: 'reviewer'},
883
+ path: 'src/index.ts',
884
+ line: 42,
885
+ },
886
+ ],
887
+ },
888
+ },
889
+ ],
890
+ },
891
+ },
892
+ },
893
+ };
894
+ const graphql = vi.fn().mockResolvedValueOnce(data);
895
+ const provider = createAgentToolsProvider({request, graphql});
896
+ const session = await provider.openSession({
897
+ connection: connection(),
898
+ tools: [pullRequestReadTool()],
899
+ scope: undefined,
900
+ });
901
+
902
+ const result = await session.call({
903
+ toolId: 'pull_request_read',
904
+ arguments: {
905
+ method: 'get_review_threads',
906
+ owner: 'shipfox',
907
+ repo: 'platform',
908
+ pull_number: 2,
909
+ cursor: 'cursor-1',
910
+ },
911
+ });
912
+
913
+ expect(request).not.toHaveBeenCalled();
914
+ expect(graphql).toHaveBeenCalledWith(
915
+ expect.stringContaining('reviewThreads(first: 100, after: $after)'),
916
+ {owner: 'shipfox', repo: 'platform', pullNumber: 2, after: 'cursor-1'},
917
+ );
918
+ const query = graphql.mock.calls[0]?.[0];
919
+ expect(query).toContain('isResolved');
920
+ expect(query).toContain('author');
921
+ expect(query).toContain('path');
922
+ expect(query).toContain('line');
923
+ expect(result).toEqual({
924
+ content: [{type: 'text', text: JSON.stringify(data)}],
925
+ structuredContent: data,
926
+ });
927
+ });
928
+
929
+ it('resolves a pull request review thread through GraphQL', async () => {
930
+ const request = vi.fn();
931
+ const data = {
932
+ resolveReviewThread: {
933
+ thread: {id: 'PRRT_kwDOExample', isResolved: true},
934
+ },
935
+ };
936
+ const graphql = vi.fn().mockResolvedValueOnce(data);
937
+ const provider = createAgentToolsProvider({request, graphql});
938
+ const session = await provider.openSession({
939
+ connection: connection(),
940
+ tools: [pullRequestReviewThreadWriteTool()],
941
+ scope: undefined,
942
+ });
943
+
944
+ const result = await session.call({
945
+ toolId: 'pull_request_review_thread_write',
946
+ arguments: {
947
+ method: 'resolve',
948
+ owner: 'shipfox',
949
+ repo: 'platform',
950
+ thread_id: 'PRRT_kwDOExample',
951
+ },
952
+ });
953
+
954
+ expect(request).not.toHaveBeenCalled();
955
+ expect(graphql).toHaveBeenCalledWith(expect.stringContaining('resolveReviewThread'), {
956
+ input: {threadId: 'PRRT_kwDOExample'},
957
+ });
958
+ expect(result).toEqual({
959
+ content: [{type: 'text', text: JSON.stringify(data)}],
960
+ structuredContent: data,
961
+ });
962
+ });
963
+
844
964
  it('projects issue comment reactions through the provider session', async () => {
845
965
  const request = vi.fn(() => Promise.resolve({data: {id: 7}}));
846
966
  const result = await callGithubToolWithRequest(
@@ -1546,6 +1666,20 @@ function pendingReviewTool() {
1546
1666
  return tool;
1547
1667
  }
1548
1668
 
1669
+ function pullRequestReadTool() {
1670
+ const tool = githubAgentToolCatalog.find((entry) => entry.id === 'pull_request_read');
1671
+ if (!tool) throw new Error('Missing pull_request_read tool');
1672
+ return tool;
1673
+ }
1674
+
1675
+ function pullRequestReviewThreadWriteTool() {
1676
+ const tool = githubAgentToolCatalog.find(
1677
+ (entry) => entry.id === 'pull_request_review_thread_write',
1678
+ );
1679
+ if (!tool) throw new Error('Missing pull_request_review_thread_write tool');
1680
+ return tool;
1681
+ }
1682
+
1549
1683
  function connection() {
1550
1684
  return {
1551
1685
  id: 'connection-1',