@shipfox/api-integration-github 12.2.0 → 12.5.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.
@@ -1,10 +1,14 @@
1
+ import {RequestError} from 'octokit';
1
2
  import type {GithubApiClient} from '#api/client.js';
2
3
  import {DEFAULT_JOB_LOG_TAIL_LINES} from '#core/actions-logs.js';
3
4
  import {
4
5
  type GithubAgentToolId,
5
6
  GithubAgentToolsProvider,
7
+ type GithubToolClient,
6
8
  githubAgentToolCatalog,
7
9
  githubAgentToolSelectionCatalog,
10
+ githubOperationRoute,
11
+ projectGithubOperationParameters,
8
12
  } from '#core/agent-tools.js';
9
13
  import {createGithubIntegrationProvider} from '#index.js';
10
14
 
@@ -200,6 +204,331 @@ const expectedCatalogRows = [
200
204
  },
201
205
  ];
202
206
 
207
+ type GithubOperationRouteCase = {
208
+ toolId: GithubAgentToolId;
209
+ method?: string;
210
+ args: Record<string, unknown>;
211
+ expectedRoute: string;
212
+ runtimeInjectedProperties?: readonly string[];
213
+ };
214
+
215
+ const githubOperationRouteCases = [
216
+ {
217
+ toolId: 'issue_read',
218
+ method: 'get',
219
+ args: {issue_number: 1},
220
+ expectedRoute: 'GET /repos/{owner}/{repo}/issues/{issue_number}',
221
+ },
222
+ {
223
+ toolId: 'issue_read',
224
+ method: 'get_comments',
225
+ args: {issue_number: 1},
226
+ expectedRoute: 'GET /repos/{owner}/{repo}/issues/{issue_number}/comments',
227
+ },
228
+ {
229
+ toolId: 'issue_read',
230
+ method: 'get_sub_issues',
231
+ args: {issue_number: 1},
232
+ expectedRoute: 'GET /repos/{owner}/{repo}/issues/{issue_number}/sub_issues',
233
+ },
234
+ {
235
+ toolId: 'issue_read',
236
+ method: 'get_parent',
237
+ args: {issue_number: 1},
238
+ expectedRoute: 'GET /repos/{owner}/{repo}/issues/{issue_number}/parent',
239
+ },
240
+ {
241
+ toolId: 'issue_read',
242
+ method: 'get_labels',
243
+ args: {issue_number: 1},
244
+ expectedRoute: 'GET /repos/{owner}/{repo}/issues/{issue_number}/labels',
245
+ },
246
+ {
247
+ toolId: 'list_issue_types',
248
+ args: {owner: 'shipfox'},
249
+ expectedRoute: 'GET /orgs/{owner}/issue-types',
250
+ },
251
+ {
252
+ toolId: 'list_issue_types',
253
+ args: {owner: 'shipfox', repo: 'platform'},
254
+ expectedRoute: 'GET /repos/{owner}/{repo}/issue-types',
255
+ },
256
+ {
257
+ toolId: 'list_issues',
258
+ args: {},
259
+ expectedRoute: 'GET /repos/{owner}/{repo}/issues',
260
+ },
261
+ {
262
+ toolId: 'search_issues',
263
+ args: {},
264
+ expectedRoute: 'GET /search/issues',
265
+ },
266
+ {
267
+ toolId: 'add_issue_comment',
268
+ args: {issue_number: 1, body: 'Comment'},
269
+ expectedRoute: 'POST /repos/{owner}/{repo}/issues/{issue_number}/comments',
270
+ },
271
+ {
272
+ toolId: 'add_issue_comment',
273
+ args: {issue_number: 1, reaction: '+1'},
274
+ expectedRoute: 'POST /repos/{owner}/{repo}/issues/{issue_number}/reactions',
275
+ },
276
+ {
277
+ toolId: 'add_issue_comment',
278
+ args: {issue_number: 1, reaction: '+1', body: 'Comment'},
279
+ expectedRoute: 'POST /repos/{owner}/{repo}/issues/{issue_number}/comments',
280
+ },
281
+ {
282
+ toolId: 'add_issue_comment',
283
+ args: {comment_id: 1, reaction: '+1'},
284
+ expectedRoute: 'POST /repos/{owner}/{repo}/issues/comments/{comment_id}/reactions',
285
+ },
286
+ {
287
+ toolId: 'issue_write',
288
+ method: 'create',
289
+ args: {},
290
+ expectedRoute: 'POST /repos/{owner}/{repo}/issues',
291
+ },
292
+ {
293
+ toolId: 'issue_write',
294
+ method: 'update',
295
+ args: {issue_number: 1},
296
+ expectedRoute: 'PATCH /repos/{owner}/{repo}/issues/{issue_number}',
297
+ },
298
+ {
299
+ toolId: 'sub_issue_write',
300
+ method: 'add',
301
+ args: {issue_number: 1},
302
+ expectedRoute: 'POST /repos/{owner}/{repo}/issues/{issue_number}/sub_issues',
303
+ },
304
+ {
305
+ toolId: 'sub_issue_write',
306
+ method: 'remove',
307
+ args: {issue_number: 1, sub_issue_id: 2},
308
+ expectedRoute: 'DELETE /repos/{owner}/{repo}/issues/{issue_number}/sub_issues/{sub_issue_id}',
309
+ },
310
+ {
311
+ toolId: 'sub_issue_write',
312
+ method: 'reprioritize',
313
+ args: {issue_number: 1, sub_issue_id: 2, after_id: 3},
314
+ expectedRoute: 'PATCH /repos/{owner}/{repo}/issues/{issue_number}/sub_issues/priority',
315
+ },
316
+ {
317
+ toolId: 'pull_request_read',
318
+ method: 'get',
319
+ args: {pull_number: 1},
320
+ expectedRoute: 'GET /repos/{owner}/{repo}/pulls/{pull_number}',
321
+ },
322
+ {
323
+ toolId: 'pull_request_read',
324
+ method: 'get_diff',
325
+ args: {pull_number: 1},
326
+ expectedRoute: 'GET /repos/{owner}/{repo}/pulls/{pull_number}',
327
+ },
328
+ {
329
+ toolId: 'pull_request_read',
330
+ method: 'get_status',
331
+ args: {pull_number: 1, ref: 'main'},
332
+ expectedRoute: 'GET /repos/{owner}/{repo}/commits/{ref}/status',
333
+ },
334
+ {
335
+ toolId: 'pull_request_read',
336
+ method: 'get_files',
337
+ args: {pull_number: 1},
338
+ expectedRoute: 'GET /repos/{owner}/{repo}/pulls/{pull_number}/files',
339
+ },
340
+ {
341
+ toolId: 'pull_request_read',
342
+ method: 'get_commits',
343
+ args: {pull_number: 1},
344
+ expectedRoute: 'GET /repos/{owner}/{repo}/pulls/{pull_number}/commits',
345
+ },
346
+ {
347
+ toolId: 'pull_request_read',
348
+ method: 'get_review_comments',
349
+ args: {pull_number: 1},
350
+ expectedRoute: 'GET /repos/{owner}/{repo}/pulls/{pull_number}/comments',
351
+ },
352
+ {
353
+ toolId: 'pull_request_read',
354
+ method: 'get_reviews',
355
+ args: {pull_number: 1},
356
+ expectedRoute: 'GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews',
357
+ },
358
+ {
359
+ toolId: 'pull_request_read',
360
+ method: 'get_comments',
361
+ args: {pull_number: 1},
362
+ expectedRoute: 'GET /repos/{owner}/{repo}/issues/{pull_number}/comments',
363
+ },
364
+ {
365
+ toolId: 'pull_request_read',
366
+ method: 'get_check_runs',
367
+ args: {pull_number: 1, ref: 'main'},
368
+ expectedRoute: 'GET /repos/{owner}/{repo}/commits/{ref}/check-runs',
369
+ },
370
+ {
371
+ toolId: 'list_pull_requests',
372
+ args: {},
373
+ expectedRoute: 'GET /repos/{owner}/{repo}/pulls',
374
+ },
375
+ {
376
+ toolId: 'search_pull_requests',
377
+ args: {},
378
+ expectedRoute: 'GET /search/issues',
379
+ },
380
+ {
381
+ toolId: 'create_pull_request',
382
+ args: {},
383
+ expectedRoute: 'POST /repos/{owner}/{repo}/pulls',
384
+ },
385
+ {
386
+ toolId: 'update_pull_request',
387
+ args: {pull_number: 1},
388
+ expectedRoute: 'PATCH /repos/{owner}/{repo}/pulls/{pull_number}',
389
+ },
390
+ {
391
+ toolId: 'add_reply_to_pull_request_comment',
392
+ args: {pull_number: 1, comment_id: 2, body: 'Reply'},
393
+ expectedRoute: 'POST /repos/{owner}/{repo}/pulls/{pull_number}/comments/{comment_id}/replies',
394
+ },
395
+ {
396
+ toolId: 'add_reply_to_pull_request_comment',
397
+ args: {comment_id: 2, reaction: '+1'},
398
+ expectedRoute: 'POST /repos/{owner}/{repo}/pulls/comments/{comment_id}/reactions',
399
+ },
400
+ {
401
+ toolId: 'merge_pull_request',
402
+ args: {pull_number: 1},
403
+ expectedRoute: 'PUT /repos/{owner}/{repo}/pulls/{pull_number}/merge',
404
+ },
405
+ {
406
+ toolId: 'update_pull_request_branch',
407
+ args: {pull_number: 1},
408
+ expectedRoute: 'PUT /repos/{owner}/{repo}/pulls/{pull_number}/update-branch',
409
+ },
410
+ {
411
+ toolId: 'pull_request_review_write',
412
+ method: 'create',
413
+ args: {pull_number: 1},
414
+ expectedRoute: 'POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews',
415
+ },
416
+ {
417
+ toolId: 'pull_request_review_write',
418
+ method: 'submit_pending',
419
+ args: {pull_number: 1},
420
+ runtimeInjectedProperties: ['review_id'],
421
+ expectedRoute: 'POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}/events',
422
+ },
423
+ {
424
+ toolId: 'pull_request_review_write',
425
+ method: 'delete_pending',
426
+ args: {pull_number: 1},
427
+ runtimeInjectedProperties: ['review_id'],
428
+ expectedRoute: 'DELETE /repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}',
429
+ },
430
+ {
431
+ toolId: 'add_comment_to_pending_review',
432
+ args: {pull_number: 1, path: 'src/index.ts', body: 'Comment'},
433
+ expectedRoute: 'POST /graphql',
434
+ },
435
+ {
436
+ toolId: 'actions_list',
437
+ method: 'list_workflows',
438
+ args: {},
439
+ expectedRoute: 'GET /repos/{owner}/{repo}/actions/workflows',
440
+ },
441
+ {
442
+ toolId: 'actions_list',
443
+ method: 'list_workflow_runs',
444
+ args: {resource_id: 'workflow'},
445
+ expectedRoute: 'GET /repos/{owner}/{repo}/actions/workflows/{resource_id}/runs',
446
+ },
447
+ {
448
+ toolId: 'actions_list',
449
+ method: 'list_workflow_jobs',
450
+ args: {resource_id: 'run'},
451
+ expectedRoute: 'GET /repos/{owner}/{repo}/actions/runs/{resource_id}/jobs',
452
+ },
453
+ {
454
+ toolId: 'actions_list',
455
+ method: 'list_workflow_run_artifacts',
456
+ args: {resource_id: 'run'},
457
+ expectedRoute: 'GET /repos/{owner}/{repo}/actions/runs/{resource_id}/artifacts',
458
+ },
459
+ {
460
+ toolId: 'actions_get',
461
+ method: 'get_workflow',
462
+ args: {resource_id: 'workflow'},
463
+ expectedRoute: 'GET /repos/{owner}/{repo}/actions/workflows/{resource_id}',
464
+ },
465
+ {
466
+ toolId: 'actions_get',
467
+ method: 'get_workflow_run',
468
+ args: {resource_id: 'run'},
469
+ expectedRoute: 'GET /repos/{owner}/{repo}/actions/runs/{resource_id}',
470
+ },
471
+ {
472
+ toolId: 'actions_get',
473
+ method: 'get_workflow_job',
474
+ args: {resource_id: 'job'},
475
+ expectedRoute: 'GET /repos/{owner}/{repo}/actions/jobs/{resource_id}',
476
+ },
477
+ {
478
+ toolId: 'actions_get',
479
+ method: 'download_workflow_run_artifact',
480
+ args: {resource_id: 'artifact'},
481
+ expectedRoute: 'GET /repos/{owner}/{repo}/actions/artifacts/{resource_id}/zip',
482
+ },
483
+ {
484
+ toolId: 'actions_get',
485
+ method: 'get_workflow_run_usage',
486
+ args: {resource_id: 'run'},
487
+ expectedRoute: 'GET /repos/{owner}/{repo}/actions/runs/{resource_id}/timing',
488
+ },
489
+ {
490
+ toolId: 'actions_get',
491
+ method: 'get_workflow_run_logs_url',
492
+ args: {resource_id: 'run'},
493
+ expectedRoute: 'GET /repos/{owner}/{repo}/actions/runs/{resource_id}/logs',
494
+ },
495
+ {
496
+ toolId: 'actions_run_trigger',
497
+ method: 'run_workflow',
498
+ args: {workflow_id: 'ci.yml', ref: 'main'},
499
+ expectedRoute: 'POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches',
500
+ },
501
+ {
502
+ toolId: 'actions_run_trigger',
503
+ method: 'rerun_workflow_run',
504
+ args: {run_id: 1},
505
+ expectedRoute: 'POST /repos/{owner}/{repo}/actions/runs/{run_id}/rerun',
506
+ },
507
+ {
508
+ toolId: 'actions_run_trigger',
509
+ method: 'rerun_failed_jobs',
510
+ args: {run_id: 1},
511
+ expectedRoute: 'POST /repos/{owner}/{repo}/actions/runs/{run_id}/rerun-failed-jobs',
512
+ },
513
+ {
514
+ toolId: 'actions_run_trigger',
515
+ method: 'cancel_workflow_run',
516
+ args: {run_id: 1},
517
+ expectedRoute: 'POST /repos/{owner}/{repo}/actions/runs/{run_id}/cancel',
518
+ },
519
+ {
520
+ toolId: 'actions_run_trigger',
521
+ method: 'delete_workflow_run_logs',
522
+ args: {run_id: 1},
523
+ expectedRoute: 'DELETE /repos/{owner}/{repo}/actions/runs/{run_id}/logs',
524
+ },
525
+ {
526
+ toolId: 'get_job_logs',
527
+ args: {job_id: 1},
528
+ expectedRoute: 'GET /repos/{owner}/{repo}/actions/jobs/{job_id}/logs',
529
+ },
530
+ ] satisfies readonly GithubOperationRouteCase[];
531
+
203
532
  describe('github agent tool catalog', () => {
204
533
  it('matches the GitHub MCP-style tool rows', () => {
205
534
  const rows = githubAgentToolCatalog.map(
@@ -216,6 +545,57 @@ describe('github agent tool catalog', () => {
216
545
  expect(rows).toEqual(expectedCatalogRows);
217
546
  });
218
547
 
548
+ it('covers every catalog operation with an exact route case', () => {
549
+ const catalogOperationKeys = githubAgentToolCatalog.flatMap(
550
+ (entry) =>
551
+ entry.methods?.map((method) => operationKey(entry.id, method.id)) ?? [
552
+ operationKey(entry.id),
553
+ ],
554
+ );
555
+ const routeCaseOperationKeys = githubOperationRouteCases.map(({toolId, method}) =>
556
+ operationKey(toolId, method),
557
+ );
558
+
559
+ expect([...new Set(routeCaseOperationKeys)].sort()).toEqual(
560
+ [...new Set(catalogOperationKeys)].sort(),
561
+ );
562
+ });
563
+
564
+ it.each(
565
+ githubOperationRouteCases,
566
+ )('asserts the $toolId.$method route and its input placeholders', ({
567
+ toolId,
568
+ method,
569
+ args,
570
+ expectedRoute,
571
+ runtimeInjectedProperties = [],
572
+ }) => {
573
+ const route = githubOperationRoute(toolId, method, args);
574
+
575
+ expect(route).toBe(expectedRoute);
576
+ if (route === undefined) return;
577
+
578
+ const inputProperties = new Set(Object.keys(inputSchemaFor(toolId).properties ?? {}));
579
+ const undeclaredArguments = Object.keys(args).filter((name) => !inputProperties.has(name));
580
+ expect(undeclaredArguments).toEqual([]);
581
+
582
+ const projectedParameters = projectGithubOperationParameters(toolId, method, args);
583
+ const injectedProperties = new Set([
584
+ ...runtimeInjectedProperties,
585
+ ...Object.keys(projectedParameters).filter(
586
+ (name) => !inputProperties.has(name) && !Object.hasOwn(args, name),
587
+ ),
588
+ ]);
589
+ const placeholders = Array.from(route.matchAll(/\{([^{}]+)\}/g), (match) => match[1]).filter(
590
+ (name): name is string => name !== undefined,
591
+ );
592
+ const undeclaredPlaceholders = placeholders.filter(
593
+ (name) => !inputProperties.has(name) && !injectedProperties.has(name),
594
+ );
595
+
596
+ expect(undeclaredPlaceholders).toEqual([]);
597
+ });
598
+
219
599
  it('defines descriptions and schemas for every tool and method', () => {
220
600
  const entriesMissingCatalogData = githubAgentToolCatalog.filter(
221
601
  (entry) =>
@@ -369,6 +749,287 @@ describe('github agent tool catalog', () => {
369
749
  });
370
750
  });
371
751
 
752
+ it('returns artifact download metadata without buffering archive bytes', async () => {
753
+ const request = vi.fn(() =>
754
+ Promise.resolve({
755
+ status: 302,
756
+ headers: {
757
+ location: 'https://objects.example/artifact.zip?token=temporary',
758
+ 'content-type': 'application/zip',
759
+ 'content-length': '1234',
760
+ },
761
+ data: new ArrayBuffer(1024),
762
+ }),
763
+ );
764
+ const result = await callGithubToolWithRequest(
765
+ 'actions_get',
766
+ {
767
+ method: 'download_workflow_run_artifact',
768
+ owner: 'shipfox',
769
+ repo: 'platform',
770
+ resource_id: '42',
771
+ },
772
+ request,
773
+ );
774
+ const expected = {
775
+ archive_format: 'zip',
776
+ download_url: 'https://objects.example/artifact.zip?token=temporary',
777
+ artifact_id: '42',
778
+ content_type: 'application/zip',
779
+ size_bytes: 1234,
780
+ };
781
+
782
+ expect(request).toHaveBeenCalledWith(
783
+ 'GET /repos/{owner}/{repo}/actions/artifacts/{resource_id}/zip',
784
+ {owner: 'shipfox', repo: 'platform', resource_id: '42'},
785
+ );
786
+ expect(result).toEqual({
787
+ content: [{type: 'text', text: JSON.stringify(expected)}],
788
+ structuredContent: expected,
789
+ });
790
+ });
791
+
792
+ it('fails artifact downloads without a redirect URL instead of returning an empty success', async () => {
793
+ const result = await callGithubToolWithRequest(
794
+ 'actions_get',
795
+ {
796
+ method: 'download_workflow_run_artifact',
797
+ owner: 'shipfox',
798
+ repo: 'platform',
799
+ resource_id: '42',
800
+ },
801
+ vi.fn(() =>
802
+ Promise.resolve({
803
+ status: 302,
804
+ headers: {},
805
+ data: new ArrayBuffer(0),
806
+ }),
807
+ ),
808
+ );
809
+
810
+ expect(result).toEqual({
811
+ isError: true,
812
+ content: [{type: 'text', text: 'GitHub artifact download did not return a download URL'}],
813
+ });
814
+ });
815
+
816
+ it('projects get_diff request headers through the provider session', async () => {
817
+ const request = vi.fn(() => Promise.resolve({data: 'diff --git a/file b/file'}));
818
+ const result = await callGithubToolWithRequest(
819
+ 'pull_request_read',
820
+ {
821
+ method: 'get_diff',
822
+ owner: 'shipfox',
823
+ repo: 'platform',
824
+ pull_number: 2,
825
+ },
826
+ request,
827
+ );
828
+
829
+ expect(request).toHaveBeenCalledWith('GET /repos/{owner}/{repo}/pulls/{pull_number}', {
830
+ owner: 'shipfox',
831
+ repo: 'platform',
832
+ pull_number: 2,
833
+ headers: {accept: 'application/vnd.github.diff'},
834
+ });
835
+ expect(result).toEqual({
836
+ content: [{type: 'text', text: '{"result":"diff --git a/file b/file"}'}],
837
+ structuredContent: {result: 'diff --git a/file b/file'},
838
+ });
839
+ });
840
+
841
+ it('projects issue comment reactions through the provider session', async () => {
842
+ const request = vi.fn(() => Promise.resolve({data: {id: 7}}));
843
+ const result = await callGithubToolWithRequest(
844
+ 'add_issue_comment',
845
+ {owner: 'shipfox', repo: 'platform', issue_number: 1, reaction: '+1'},
846
+ request,
847
+ );
848
+
849
+ expect(request).toHaveBeenCalledWith(
850
+ 'POST /repos/{owner}/{repo}/issues/{issue_number}/reactions',
851
+ {owner: 'shipfox', repo: 'platform', issue_number: 1, content: '+1'},
852
+ );
853
+ expect(result).toEqual({
854
+ content: [{type: 'text', text: '{"id":7}'}],
855
+ structuredContent: {id: 7},
856
+ });
857
+ });
858
+
859
+ it('adds a comment to the latest pending review through GraphQL', async () => {
860
+ const request = vi.fn();
861
+ const graphql = vi
862
+ .fn()
863
+ .mockResolvedValueOnce({
864
+ viewer: {login: 'shipfox-ai[bot]'},
865
+ repository: {
866
+ pullRequest: {
867
+ reviews: {
868
+ nodes: [
869
+ {
870
+ id: 'review-older',
871
+ author: {login: 'shipfox-ai[bot]'},
872
+ createdAt: '2026-08-09T10:00:00Z',
873
+ },
874
+ {
875
+ id: 'review-latest',
876
+ author: {login: 'shipfox-ai[bot]'},
877
+ createdAt: '2026-08-09T10:01:00Z',
878
+ },
879
+ ],
880
+ },
881
+ },
882
+ },
883
+ })
884
+ .mockResolvedValueOnce({
885
+ addPullRequestReviewThread: {thread: {id: 'thread-1'}},
886
+ });
887
+ const provider = createAgentToolsProvider({request, graphql});
888
+ const session = await provider.openSession({
889
+ connection: connection(),
890
+ tools: [pendingReviewTool()],
891
+ scope: undefined,
892
+ });
893
+
894
+ const result = await session.call({
895
+ toolId: 'add_comment_to_pending_review',
896
+ arguments: {
897
+ owner: 'shipfox',
898
+ repo: 'platform',
899
+ pull_number: 2,
900
+ path: 'src/agent-tools.ts',
901
+ body: 'Please handle this error.',
902
+ subject_type: 'LINE',
903
+ line: 42,
904
+ side: 'RIGHT',
905
+ start_line: 40,
906
+ start_side: 'RIGHT',
907
+ },
908
+ });
909
+
910
+ expect(request).not.toHaveBeenCalled();
911
+ expect(graphql).toHaveBeenNthCalledWith(
912
+ 1,
913
+ expect.stringContaining('reviews(last: 100, states: [PENDING])'),
914
+ {owner: 'shipfox', repo: 'platform', pullNumber: 2},
915
+ );
916
+ expect(graphql).toHaveBeenNthCalledWith(
917
+ 2,
918
+ expect.stringContaining('addPullRequestReviewThread'),
919
+ {
920
+ input: {
921
+ pullRequestReviewId: 'review-latest',
922
+ path: 'src/agent-tools.ts',
923
+ body: 'Please handle this error.',
924
+ subjectType: 'LINE',
925
+ line: 42,
926
+ side: 'RIGHT',
927
+ startLine: 40,
928
+ startSide: 'RIGHT',
929
+ },
930
+ },
931
+ );
932
+ expect(result).toEqual({
933
+ content: [
934
+ {
935
+ type: 'text',
936
+ text: '{"addPullRequestReviewThread":{"thread":{"id":"thread-1"}}}',
937
+ },
938
+ ],
939
+ structuredContent: {addPullRequestReviewThread: {thread: {id: 'thread-1'}}},
940
+ });
941
+ });
942
+
943
+ it('returns an explicit error when there is no pending review for the caller', async () => {
944
+ const request = vi.fn();
945
+ const graphql = vi.fn().mockResolvedValueOnce({
946
+ viewer: {login: 'shipfox-ai[bot]'},
947
+ repository: {
948
+ pullRequest: {
949
+ reviews: {
950
+ nodes: [
951
+ {
952
+ id: 'review-other-user',
953
+ author: {login: 'another-user'},
954
+ createdAt: '2026-08-09T10:00:00Z',
955
+ },
956
+ ],
957
+ },
958
+ },
959
+ },
960
+ });
961
+ const provider = createAgentToolsProvider({request, graphql});
962
+ const session = await provider.openSession({
963
+ connection: connection(),
964
+ tools: [pendingReviewTool()],
965
+ scope: undefined,
966
+ });
967
+
968
+ const result = await session.call({
969
+ toolId: 'add_comment_to_pending_review',
970
+ arguments: {
971
+ owner: 'shipfox',
972
+ repo: 'platform',
973
+ pull_number: 2,
974
+ path: 'src/agent-tools.ts',
975
+ body: 'Please handle this error.',
976
+ },
977
+ });
978
+
979
+ expect(graphql).toHaveBeenCalledTimes(1);
980
+ expect(request).not.toHaveBeenCalled();
981
+ expect(result).toEqual({
982
+ isError: true,
983
+ content: [
984
+ {
985
+ type: 'text',
986
+ text: 'No pending pull request review found for the authenticated GitHub user.',
987
+ },
988
+ ],
989
+ });
990
+ });
991
+
992
+ it('maps Octokit 4xx failures to terminal provider errors', async () => {
993
+ const providerError = new RequestError('commit_id is missing', 422, {
994
+ request: {
995
+ method: 'POST',
996
+ url: 'https://api.github.com/repos/shipfox/platform/issues/1',
997
+ headers: {},
998
+ },
999
+ });
1000
+ const provider = new GithubAgentToolsProvider({
1001
+ getInstallationByConnectionId: vi.fn(() => Promise.resolve(installation())),
1002
+ tokenProvider: {
1003
+ getInstallationAccessToken: vi.fn(() =>
1004
+ Promise.resolve({
1005
+ token: 'installation-token',
1006
+ expiresAt: new Date(),
1007
+ permissions: {issues: 'read' as const},
1008
+ }),
1009
+ ),
1010
+ },
1011
+ createClient: vi.fn(() => ({
1012
+ request: vi.fn(() => Promise.reject(providerError)),
1013
+ })),
1014
+ });
1015
+ const session = await provider.openSession({
1016
+ connection: connection(),
1017
+ tools: [githubAgentToolCatalog[0]],
1018
+ scope: undefined,
1019
+ });
1020
+
1021
+ await expect(
1022
+ session.call({
1023
+ toolId: 'issue_read',
1024
+ arguments: {method: 'get', owner: 'shipfox', repo: 'platform', issue_number: 1},
1025
+ }),
1026
+ ).rejects.toMatchObject({
1027
+ reason: 'provider-rejected',
1028
+ message: 'commit_id is missing',
1029
+ status: 422,
1030
+ });
1031
+ });
1032
+
372
1033
  it('requires a ref for pull request status and check-run reads', async () => {
373
1034
  const result = await callGithubTool(
374
1035
  'pull_request_read',
@@ -382,6 +1043,173 @@ describe('github agent tool catalog', () => {
382
1043
  });
383
1044
  });
384
1045
 
1046
+ it.each([
1047
+ {
1048
+ method: 'submit_pending',
1049
+ arguments: {
1050
+ method: 'submit_pending',
1051
+ owner: 'shipfox',
1052
+ repo: 'platform',
1053
+ pull_number: 2,
1054
+ body: 'Please address this before merging.',
1055
+ event: 'REQUEST_CHANGES',
1056
+ },
1057
+ route: 'POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}/events',
1058
+ parameters: {
1059
+ owner: 'shipfox',
1060
+ repo: 'platform',
1061
+ pull_number: 2,
1062
+ body: 'Please address this before merging.',
1063
+ event: 'REQUEST_CHANGES',
1064
+ review_id: 42,
1065
+ },
1066
+ data: {id: 42, state: 'CHANGES_REQUESTED'},
1067
+ },
1068
+ {
1069
+ method: 'delete_pending',
1070
+ arguments: {
1071
+ method: 'delete_pending',
1072
+ owner: 'shipfox',
1073
+ repo: 'platform',
1074
+ pull_number: 2,
1075
+ },
1076
+ route: 'DELETE /repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}',
1077
+ parameters: {
1078
+ owner: 'shipfox',
1079
+ repo: 'platform',
1080
+ pull_number: 2,
1081
+ review_id: 42,
1082
+ },
1083
+ data: {id: 42, state: 'PENDING'},
1084
+ },
1085
+ ] satisfies Array<{
1086
+ method: 'submit_pending' | 'delete_pending';
1087
+ arguments: Record<string, unknown>;
1088
+ route: string;
1089
+ parameters: Record<string, unknown>;
1090
+ data: unknown;
1091
+ }>)('$method resolves the latest pending review before writing', async (testCase) => {
1092
+ const request = vi
1093
+ .fn()
1094
+ .mockResolvedValueOnce({
1095
+ data: [
1096
+ {id: 40, state: 'APPROVED'},
1097
+ {id: 41, state: 'PENDING'},
1098
+ {id: 42, state: 'PENDING'},
1099
+ ],
1100
+ })
1101
+ .mockResolvedValueOnce({data: testCase.data});
1102
+
1103
+ const result = await callGithubToolWithRequest(
1104
+ 'pull_request_review_write',
1105
+ testCase.arguments,
1106
+ request,
1107
+ );
1108
+
1109
+ expect(result).toEqual({
1110
+ content: [{type: 'text', text: JSON.stringify(testCase.data)}],
1111
+ structuredContent: testCase.data,
1112
+ });
1113
+ expect(request).toHaveBeenNthCalledWith(
1114
+ 1,
1115
+ 'GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews',
1116
+ {
1117
+ owner: 'shipfox',
1118
+ repo: 'platform',
1119
+ pull_number: 2,
1120
+ per_page: 100,
1121
+ page: 1,
1122
+ },
1123
+ );
1124
+ expect(request).toHaveBeenNthCalledWith(2, testCase.route, testCase.parameters);
1125
+ });
1126
+
1127
+ it('resolves a pending review from a later review page', async () => {
1128
+ const request = vi
1129
+ .fn()
1130
+ .mockResolvedValueOnce({
1131
+ data: Array.from({length: 100}, (_, index) => ({id: index + 1, state: 'APPROVED'})),
1132
+ })
1133
+ .mockResolvedValueOnce({data: [{id: 101, state: 'PENDING'}]})
1134
+ .mockResolvedValueOnce({data: {id: 101, state: 'COMMENTED'}});
1135
+
1136
+ const result = await callGithubToolWithRequest(
1137
+ 'pull_request_review_write',
1138
+ {
1139
+ method: 'submit_pending',
1140
+ owner: 'shipfox',
1141
+ repo: 'platform',
1142
+ pull_number: 2,
1143
+ body: 'Looks good.',
1144
+ event: 'COMMENT',
1145
+ },
1146
+ request,
1147
+ );
1148
+
1149
+ expect(result).toEqual({
1150
+ content: [{type: 'text', text: JSON.stringify({id: 101, state: 'COMMENTED'})}],
1151
+ structuredContent: {id: 101, state: 'COMMENTED'},
1152
+ });
1153
+ expect(request).toHaveBeenNthCalledWith(
1154
+ 1,
1155
+ 'GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews',
1156
+ {
1157
+ owner: 'shipfox',
1158
+ repo: 'platform',
1159
+ pull_number: 2,
1160
+ per_page: 100,
1161
+ page: 1,
1162
+ },
1163
+ );
1164
+ expect(request).toHaveBeenNthCalledWith(
1165
+ 2,
1166
+ 'GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews',
1167
+ {
1168
+ owner: 'shipfox',
1169
+ repo: 'platform',
1170
+ pull_number: 2,
1171
+ per_page: 100,
1172
+ page: 2,
1173
+ },
1174
+ );
1175
+ expect(request).toHaveBeenNthCalledWith(
1176
+ 3,
1177
+ 'POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}/events',
1178
+ {
1179
+ owner: 'shipfox',
1180
+ repo: 'platform',
1181
+ pull_number: 2,
1182
+ body: 'Looks good.',
1183
+ event: 'COMMENT',
1184
+ review_id: 101,
1185
+ },
1186
+ );
1187
+ });
1188
+
1189
+ it.each([
1190
+ 'submit_pending',
1191
+ 'delete_pending',
1192
+ ] as const)('%s reports when no pending review exists', async (method) => {
1193
+ const request = vi.fn(() => Promise.resolve({data: []}));
1194
+
1195
+ const result = await callGithubToolWithRequest(
1196
+ 'pull_request_review_write',
1197
+ {method, owner: 'shipfox', repo: 'platform', pull_number: 2},
1198
+ request,
1199
+ );
1200
+
1201
+ expect(result).toEqual({
1202
+ isError: true,
1203
+ content: [
1204
+ {
1205
+ type: 'text',
1206
+ text: 'No pending pull request review found for the authenticated GitHub user.',
1207
+ },
1208
+ ],
1209
+ });
1210
+ expect(request).toHaveBeenCalledOnce();
1211
+ });
1212
+
385
1213
  it.each([
386
1214
  {
387
1215
  toolId: 'list_issue_types',
@@ -479,10 +1307,33 @@ async function callGithubTool(
479
1307
  toolId: GithubAgentToolId,
480
1308
  arguments_: Record<string, unknown>,
481
1309
  data: unknown,
1310
+ ) {
1311
+ return await callGithubToolWithRequest(
1312
+ toolId,
1313
+ arguments_,
1314
+ vi.fn(() => Promise.resolve({data})),
1315
+ );
1316
+ }
1317
+
1318
+ async function callGithubToolWithRequest(
1319
+ toolId: GithubAgentToolId,
1320
+ arguments_: Record<string, unknown>,
1321
+ request: GithubToolClient['request'],
482
1322
  ) {
483
1323
  const tool = githubAgentToolCatalog.find((entry) => entry.id === toolId);
484
1324
  if (!tool) throw new Error(`Missing GitHub tool: ${toolId}`);
485
- const provider = new GithubAgentToolsProvider({
1325
+ const provider = createAgentToolsProvider({request});
1326
+ const session = await provider.openSession({
1327
+ connection: connection(),
1328
+ tools: [tool],
1329
+ scope: undefined,
1330
+ });
1331
+
1332
+ return await session.call({toolId, arguments: arguments_});
1333
+ }
1334
+
1335
+ function createAgentToolsProvider(client: GithubToolClient) {
1336
+ return new GithubAgentToolsProvider({
486
1337
  getInstallationByConnectionId: vi.fn(() => Promise.resolve(installation())),
487
1338
  tokenProvider: {
488
1339
  getInstallationAccessToken: vi.fn(() =>
@@ -498,15 +1349,14 @@ async function callGithubTool(
498
1349
  }),
499
1350
  ),
500
1351
  },
501
- createClient: vi.fn(() => ({request: vi.fn(() => Promise.resolve({data}))})),
502
- });
503
- const session = await provider.openSession({
504
- connection: connection(),
505
- tools: [tool],
506
- scope: undefined,
1352
+ createClient: vi.fn(() => client),
507
1353
  });
1354
+ }
508
1355
 
509
- return await session.call({toolId, arguments: arguments_});
1356
+ function pendingReviewTool() {
1357
+ const tool = githubAgentToolCatalog.find((entry) => entry.id === 'add_comment_to_pending_review');
1358
+ if (!tool) throw new Error('Missing add_comment_to_pending_review tool');
1359
+ return tool;
510
1360
  }
511
1361
 
512
1362
  function connection() {
@@ -575,3 +1425,7 @@ function inputSchemaFor(id: (typeof githubAgentToolCatalog)[number]['id']) {
575
1425
  oneOf?: unknown[] | undefined;
576
1426
  };
577
1427
  }
1428
+
1429
+ function operationKey(toolId: GithubAgentToolId, method?: string) {
1430
+ return `${toolId}.${method ?? ''}`;
1431
+ }