@shipfox/api-integration-github 12.3.0 → 12.6.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 (53) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +19 -0
  3. package/dist/api/client.d.ts.map +1 -1
  4. package/dist/api/client.js +15 -9
  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-envelope.d.ts +5 -1
  11. package/dist/api/installation-token-envelope.d.ts.map +1 -1
  12. package/dist/api/installation-token-envelope.js +16 -5
  13. package/dist/api/installation-token-envelope.js.map +1 -1
  14. package/dist/api/installation-token-provider.d.ts.map +1 -1
  15. package/dist/api/installation-token-provider.js +4 -2
  16. package/dist/api/installation-token-provider.js.map +1 -1
  17. package/dist/api/shared-installation-token-cache.d.ts.map +1 -1
  18. package/dist/api/shared-installation-token-cache.js +10 -4
  19. package/dist/api/shared-installation-token-cache.js.map +1 -1
  20. package/dist/config.d.ts +1 -0
  21. package/dist/config.d.ts.map +1 -1
  22. package/dist/config.js +8 -0
  23. package/dist/config.js.map +1 -1
  24. package/dist/core/agent-tools.d.ts +11 -4
  25. package/dist/core/agent-tools.d.ts.map +1 -1
  26. package/dist/core/agent-tools.js +235 -24
  27. package/dist/core/agent-tools.js.map +1 -1
  28. package/dist/core/github-agent-tool-catalog.js +1 -1
  29. package/dist/core/github-agent-tool-catalog.js.map +1 -1
  30. package/dist/metrics/instance.d.ts +1 -0
  31. package/dist/metrics/instance.d.ts.map +1 -1
  32. package/dist/metrics/instance.js +19 -0
  33. package/dist/metrics/instance.js.map +1 -1
  34. package/dist/tsconfig.test.tsbuildinfo +1 -1
  35. package/package.json +2 -2
  36. package/src/api/client.test.ts +66 -10
  37. package/src/api/client.ts +44 -7
  38. package/src/api/github-octokit.test.ts +115 -0
  39. package/src/api/github-octokit.ts +49 -0
  40. package/src/api/installation-token-envelope.ts +24 -2
  41. package/src/api/installation-token-provider.test.ts +44 -5
  42. package/src/api/installation-token-provider.ts +5 -2
  43. package/src/api/shared-installation-token-cache.test.ts +28 -0
  44. package/src/api/shared-installation-token-cache.ts +7 -0
  45. package/src/config.ts +5 -0
  46. package/src/core/agent-tools.test.ts +1049 -8
  47. package/src/core/agent-tools.ts +388 -25
  48. package/src/core/github-agent-tool-catalog.ts +1 -1
  49. package/src/metrics/instance.ts +25 -0
  50. package/test/env.ts +1 -0
  51. package/test/fixtures/github-installation-token.ts +8 -0
  52. package/test/index.ts +4 -0
  53. package/tsconfig.build.tsbuildinfo +1 -1
@@ -1,13 +1,19 @@
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
 
15
+ const githubAppReviewUser = {login: 'shipfox-test[bot]'};
16
+
11
17
  const expectedCatalogRows = [
12
18
  {
13
19
  id: 'issue_read',
@@ -200,6 +206,331 @@ const expectedCatalogRows = [
200
206
  },
201
207
  ];
202
208
 
209
+ type GithubOperationRouteCase = {
210
+ toolId: GithubAgentToolId;
211
+ method?: string;
212
+ args: Record<string, unknown>;
213
+ expectedRoute: string;
214
+ runtimeInjectedProperties?: readonly string[];
215
+ };
216
+
217
+ const githubOperationRouteCases = [
218
+ {
219
+ toolId: 'issue_read',
220
+ method: 'get',
221
+ args: {issue_number: 1},
222
+ expectedRoute: 'GET /repos/{owner}/{repo}/issues/{issue_number}',
223
+ },
224
+ {
225
+ toolId: 'issue_read',
226
+ method: 'get_comments',
227
+ args: {issue_number: 1},
228
+ expectedRoute: 'GET /repos/{owner}/{repo}/issues/{issue_number}/comments',
229
+ },
230
+ {
231
+ toolId: 'issue_read',
232
+ method: 'get_sub_issues',
233
+ args: {issue_number: 1},
234
+ expectedRoute: 'GET /repos/{owner}/{repo}/issues/{issue_number}/sub_issues',
235
+ },
236
+ {
237
+ toolId: 'issue_read',
238
+ method: 'get_parent',
239
+ args: {issue_number: 1},
240
+ expectedRoute: 'GET /repos/{owner}/{repo}/issues/{issue_number}/parent',
241
+ },
242
+ {
243
+ toolId: 'issue_read',
244
+ method: 'get_labels',
245
+ args: {issue_number: 1},
246
+ expectedRoute: 'GET /repos/{owner}/{repo}/issues/{issue_number}/labels',
247
+ },
248
+ {
249
+ toolId: 'list_issue_types',
250
+ args: {owner: 'shipfox'},
251
+ expectedRoute: 'GET /orgs/{owner}/issue-types',
252
+ },
253
+ {
254
+ toolId: 'list_issue_types',
255
+ args: {owner: 'shipfox', repo: 'platform'},
256
+ expectedRoute: 'GET /repos/{owner}/{repo}/issue-types',
257
+ },
258
+ {
259
+ toolId: 'list_issues',
260
+ args: {},
261
+ expectedRoute: 'GET /repos/{owner}/{repo}/issues',
262
+ },
263
+ {
264
+ toolId: 'search_issues',
265
+ args: {},
266
+ expectedRoute: 'GET /search/issues',
267
+ },
268
+ {
269
+ toolId: 'add_issue_comment',
270
+ args: {issue_number: 1, body: 'Comment'},
271
+ expectedRoute: 'POST /repos/{owner}/{repo}/issues/{issue_number}/comments',
272
+ },
273
+ {
274
+ toolId: 'add_issue_comment',
275
+ args: {issue_number: 1, reaction: '+1'},
276
+ expectedRoute: 'POST /repos/{owner}/{repo}/issues/{issue_number}/reactions',
277
+ },
278
+ {
279
+ toolId: 'add_issue_comment',
280
+ args: {issue_number: 1, reaction: '+1', body: 'Comment'},
281
+ expectedRoute: 'POST /repos/{owner}/{repo}/issues/{issue_number}/comments',
282
+ },
283
+ {
284
+ toolId: 'add_issue_comment',
285
+ args: {comment_id: 1, reaction: '+1'},
286
+ expectedRoute: 'POST /repos/{owner}/{repo}/issues/comments/{comment_id}/reactions',
287
+ },
288
+ {
289
+ toolId: 'issue_write',
290
+ method: 'create',
291
+ args: {},
292
+ expectedRoute: 'POST /repos/{owner}/{repo}/issues',
293
+ },
294
+ {
295
+ toolId: 'issue_write',
296
+ method: 'update',
297
+ args: {issue_number: 1},
298
+ expectedRoute: 'PATCH /repos/{owner}/{repo}/issues/{issue_number}',
299
+ },
300
+ {
301
+ toolId: 'sub_issue_write',
302
+ method: 'add',
303
+ args: {issue_number: 1},
304
+ expectedRoute: 'POST /repos/{owner}/{repo}/issues/{issue_number}/sub_issues',
305
+ },
306
+ {
307
+ toolId: 'sub_issue_write',
308
+ method: 'remove',
309
+ args: {issue_number: 1, sub_issue_id: 2},
310
+ expectedRoute: 'DELETE /repos/{owner}/{repo}/issues/{issue_number}/sub_issues/{sub_issue_id}',
311
+ },
312
+ {
313
+ toolId: 'sub_issue_write',
314
+ method: 'reprioritize',
315
+ args: {issue_number: 1, sub_issue_id: 2, after_id: 3},
316
+ expectedRoute: 'PATCH /repos/{owner}/{repo}/issues/{issue_number}/sub_issues/priority',
317
+ },
318
+ {
319
+ toolId: 'pull_request_read',
320
+ method: 'get',
321
+ args: {pull_number: 1},
322
+ expectedRoute: 'GET /repos/{owner}/{repo}/pulls/{pull_number}',
323
+ },
324
+ {
325
+ toolId: 'pull_request_read',
326
+ method: 'get_diff',
327
+ args: {pull_number: 1},
328
+ expectedRoute: 'GET /repos/{owner}/{repo}/pulls/{pull_number}',
329
+ },
330
+ {
331
+ toolId: 'pull_request_read',
332
+ method: 'get_status',
333
+ args: {pull_number: 1, ref: 'main'},
334
+ expectedRoute: 'GET /repos/{owner}/{repo}/commits/{ref}/status',
335
+ },
336
+ {
337
+ toolId: 'pull_request_read',
338
+ method: 'get_files',
339
+ args: {pull_number: 1},
340
+ expectedRoute: 'GET /repos/{owner}/{repo}/pulls/{pull_number}/files',
341
+ },
342
+ {
343
+ toolId: 'pull_request_read',
344
+ method: 'get_commits',
345
+ args: {pull_number: 1},
346
+ expectedRoute: 'GET /repos/{owner}/{repo}/pulls/{pull_number}/commits',
347
+ },
348
+ {
349
+ toolId: 'pull_request_read',
350
+ method: 'get_review_comments',
351
+ args: {pull_number: 1},
352
+ expectedRoute: 'GET /repos/{owner}/{repo}/pulls/{pull_number}/comments',
353
+ },
354
+ {
355
+ toolId: 'pull_request_read',
356
+ method: 'get_reviews',
357
+ args: {pull_number: 1},
358
+ expectedRoute: 'GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews',
359
+ },
360
+ {
361
+ toolId: 'pull_request_read',
362
+ method: 'get_comments',
363
+ args: {pull_number: 1},
364
+ expectedRoute: 'GET /repos/{owner}/{repo}/issues/{pull_number}/comments',
365
+ },
366
+ {
367
+ toolId: 'pull_request_read',
368
+ method: 'get_check_runs',
369
+ args: {pull_number: 1, ref: 'main'},
370
+ expectedRoute: 'GET /repos/{owner}/{repo}/commits/{ref}/check-runs',
371
+ },
372
+ {
373
+ toolId: 'list_pull_requests',
374
+ args: {},
375
+ expectedRoute: 'GET /repos/{owner}/{repo}/pulls',
376
+ },
377
+ {
378
+ toolId: 'search_pull_requests',
379
+ args: {},
380
+ expectedRoute: 'GET /search/issues',
381
+ },
382
+ {
383
+ toolId: 'create_pull_request',
384
+ args: {},
385
+ expectedRoute: 'POST /repos/{owner}/{repo}/pulls',
386
+ },
387
+ {
388
+ toolId: 'update_pull_request',
389
+ args: {pull_number: 1},
390
+ expectedRoute: 'PATCH /repos/{owner}/{repo}/pulls/{pull_number}',
391
+ },
392
+ {
393
+ toolId: 'add_reply_to_pull_request_comment',
394
+ args: {pull_number: 1, comment_id: 2, body: 'Reply'},
395
+ expectedRoute: 'POST /repos/{owner}/{repo}/pulls/{pull_number}/comments/{comment_id}/replies',
396
+ },
397
+ {
398
+ toolId: 'add_reply_to_pull_request_comment',
399
+ args: {comment_id: 2, reaction: '+1'},
400
+ expectedRoute: 'POST /repos/{owner}/{repo}/pulls/comments/{comment_id}/reactions',
401
+ },
402
+ {
403
+ toolId: 'merge_pull_request',
404
+ args: {pull_number: 1},
405
+ expectedRoute: 'PUT /repos/{owner}/{repo}/pulls/{pull_number}/merge',
406
+ },
407
+ {
408
+ toolId: 'update_pull_request_branch',
409
+ args: {pull_number: 1},
410
+ expectedRoute: 'PUT /repos/{owner}/{repo}/pulls/{pull_number}/update-branch',
411
+ },
412
+ {
413
+ toolId: 'pull_request_review_write',
414
+ method: 'create',
415
+ args: {pull_number: 1},
416
+ expectedRoute: 'POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews',
417
+ },
418
+ {
419
+ toolId: 'pull_request_review_write',
420
+ method: 'submit_pending',
421
+ args: {pull_number: 1},
422
+ runtimeInjectedProperties: ['review_id'],
423
+ expectedRoute: 'POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}/events',
424
+ },
425
+ {
426
+ toolId: 'pull_request_review_write',
427
+ method: 'delete_pending',
428
+ args: {pull_number: 1},
429
+ runtimeInjectedProperties: ['review_id'],
430
+ expectedRoute: 'DELETE /repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}',
431
+ },
432
+ {
433
+ toolId: 'add_comment_to_pending_review',
434
+ args: {pull_number: 1, path: 'src/index.ts', body: 'Comment'},
435
+ expectedRoute: 'POST /graphql',
436
+ },
437
+ {
438
+ toolId: 'actions_list',
439
+ method: 'list_workflows',
440
+ args: {},
441
+ expectedRoute: 'GET /repos/{owner}/{repo}/actions/workflows',
442
+ },
443
+ {
444
+ toolId: 'actions_list',
445
+ method: 'list_workflow_runs',
446
+ args: {resource_id: 'workflow'},
447
+ expectedRoute: 'GET /repos/{owner}/{repo}/actions/workflows/{resource_id}/runs',
448
+ },
449
+ {
450
+ toolId: 'actions_list',
451
+ method: 'list_workflow_jobs',
452
+ args: {resource_id: 'run'},
453
+ expectedRoute: 'GET /repos/{owner}/{repo}/actions/runs/{resource_id}/jobs',
454
+ },
455
+ {
456
+ toolId: 'actions_list',
457
+ method: 'list_workflow_run_artifacts',
458
+ args: {resource_id: 'run'},
459
+ expectedRoute: 'GET /repos/{owner}/{repo}/actions/runs/{resource_id}/artifacts',
460
+ },
461
+ {
462
+ toolId: 'actions_get',
463
+ method: 'get_workflow',
464
+ args: {resource_id: 'workflow'},
465
+ expectedRoute: 'GET /repos/{owner}/{repo}/actions/workflows/{resource_id}',
466
+ },
467
+ {
468
+ toolId: 'actions_get',
469
+ method: 'get_workflow_run',
470
+ args: {resource_id: 'run'},
471
+ expectedRoute: 'GET /repos/{owner}/{repo}/actions/runs/{resource_id}',
472
+ },
473
+ {
474
+ toolId: 'actions_get',
475
+ method: 'get_workflow_job',
476
+ args: {resource_id: 'job'},
477
+ expectedRoute: 'GET /repos/{owner}/{repo}/actions/jobs/{resource_id}',
478
+ },
479
+ {
480
+ toolId: 'actions_get',
481
+ method: 'download_workflow_run_artifact',
482
+ args: {resource_id: 'artifact'},
483
+ expectedRoute: 'GET /repos/{owner}/{repo}/actions/artifacts/{resource_id}/zip',
484
+ },
485
+ {
486
+ toolId: 'actions_get',
487
+ method: 'get_workflow_run_usage',
488
+ args: {resource_id: 'run'},
489
+ expectedRoute: 'GET /repos/{owner}/{repo}/actions/runs/{resource_id}/timing',
490
+ },
491
+ {
492
+ toolId: 'actions_get',
493
+ method: 'get_workflow_run_logs_url',
494
+ args: {resource_id: 'run'},
495
+ expectedRoute: 'GET /repos/{owner}/{repo}/actions/runs/{resource_id}/logs',
496
+ },
497
+ {
498
+ toolId: 'actions_run_trigger',
499
+ method: 'run_workflow',
500
+ args: {workflow_id: 'ci.yml', ref: 'main'},
501
+ expectedRoute: 'POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches',
502
+ },
503
+ {
504
+ toolId: 'actions_run_trigger',
505
+ method: 'rerun_workflow_run',
506
+ args: {run_id: 1},
507
+ expectedRoute: 'POST /repos/{owner}/{repo}/actions/runs/{run_id}/rerun',
508
+ },
509
+ {
510
+ toolId: 'actions_run_trigger',
511
+ method: 'rerun_failed_jobs',
512
+ args: {run_id: 1},
513
+ expectedRoute: 'POST /repos/{owner}/{repo}/actions/runs/{run_id}/rerun-failed-jobs',
514
+ },
515
+ {
516
+ toolId: 'actions_run_trigger',
517
+ method: 'cancel_workflow_run',
518
+ args: {run_id: 1},
519
+ expectedRoute: 'POST /repos/{owner}/{repo}/actions/runs/{run_id}/cancel',
520
+ },
521
+ {
522
+ toolId: 'actions_run_trigger',
523
+ method: 'delete_workflow_run_logs',
524
+ args: {run_id: 1},
525
+ expectedRoute: 'DELETE /repos/{owner}/{repo}/actions/runs/{run_id}/logs',
526
+ },
527
+ {
528
+ toolId: 'get_job_logs',
529
+ args: {job_id: 1},
530
+ expectedRoute: 'GET /repos/{owner}/{repo}/actions/jobs/{job_id}/logs',
531
+ },
532
+ ] satisfies readonly GithubOperationRouteCase[];
533
+
203
534
  describe('github agent tool catalog', () => {
204
535
  it('matches the GitHub MCP-style tool rows', () => {
205
536
  const rows = githubAgentToolCatalog.map(
@@ -216,6 +547,57 @@ describe('github agent tool catalog', () => {
216
547
  expect(rows).toEqual(expectedCatalogRows);
217
548
  });
218
549
 
550
+ it('covers every catalog operation with an exact route case', () => {
551
+ const catalogOperationKeys = githubAgentToolCatalog.flatMap(
552
+ (entry) =>
553
+ entry.methods?.map((method) => operationKey(entry.id, method.id)) ?? [
554
+ operationKey(entry.id),
555
+ ],
556
+ );
557
+ const routeCaseOperationKeys = githubOperationRouteCases.map(({toolId, method}) =>
558
+ operationKey(toolId, method),
559
+ );
560
+
561
+ expect([...new Set(routeCaseOperationKeys)].sort()).toEqual(
562
+ [...new Set(catalogOperationKeys)].sort(),
563
+ );
564
+ });
565
+
566
+ it.each(
567
+ githubOperationRouteCases,
568
+ )('asserts the $toolId.$method route and its input placeholders', ({
569
+ toolId,
570
+ method,
571
+ args,
572
+ expectedRoute,
573
+ runtimeInjectedProperties = [],
574
+ }) => {
575
+ const route = githubOperationRoute(toolId, method, args);
576
+
577
+ expect(route).toBe(expectedRoute);
578
+ if (route === undefined) return;
579
+
580
+ const inputProperties = new Set(Object.keys(inputSchemaFor(toolId).properties ?? {}));
581
+ const undeclaredArguments = Object.keys(args).filter((name) => !inputProperties.has(name));
582
+ expect(undeclaredArguments).toEqual([]);
583
+
584
+ const projectedParameters = projectGithubOperationParameters(toolId, method, args);
585
+ const injectedProperties = new Set([
586
+ ...runtimeInjectedProperties,
587
+ ...Object.keys(projectedParameters).filter(
588
+ (name) => !inputProperties.has(name) && !Object.hasOwn(args, name),
589
+ ),
590
+ ]);
591
+ const placeholders = Array.from(route.matchAll(/\{([^{}]+)\}/g), (match) => match[1]).filter(
592
+ (name): name is string => name !== undefined,
593
+ );
594
+ const undeclaredPlaceholders = placeholders.filter(
595
+ (name) => !inputProperties.has(name) && !injectedProperties.has(name),
596
+ );
597
+
598
+ expect(undeclaredPlaceholders).toEqual([]);
599
+ });
600
+
219
601
  it('defines descriptions and schemas for every tool and method', () => {
220
602
  const entriesMissingCatalogData = githubAgentToolCatalog.filter(
221
603
  (entry) =>
@@ -369,6 +751,406 @@ describe('github agent tool catalog', () => {
369
751
  });
370
752
  });
371
753
 
754
+ it('returns artifact download metadata without buffering archive bytes', async () => {
755
+ const request = vi.fn(() =>
756
+ Promise.resolve({
757
+ status: 302,
758
+ headers: {
759
+ location: 'https://objects.example/artifact.zip?token=temporary',
760
+ 'content-type': 'application/zip',
761
+ 'content-length': '1234',
762
+ },
763
+ data: new ArrayBuffer(1024),
764
+ }),
765
+ );
766
+ const result = await callGithubToolWithRequest(
767
+ 'actions_get',
768
+ {
769
+ method: 'download_workflow_run_artifact',
770
+ owner: 'shipfox',
771
+ repo: 'platform',
772
+ resource_id: '42',
773
+ },
774
+ request,
775
+ );
776
+ const expected = {
777
+ archive_format: 'zip',
778
+ download_url: 'https://objects.example/artifact.zip?token=temporary',
779
+ artifact_id: '42',
780
+ content_type: 'application/zip',
781
+ size_bytes: 1234,
782
+ };
783
+
784
+ expect(request).toHaveBeenCalledWith(
785
+ 'GET /repos/{owner}/{repo}/actions/artifacts/{resource_id}/zip',
786
+ {owner: 'shipfox', repo: 'platform', resource_id: '42'},
787
+ );
788
+ expect(result).toEqual({
789
+ content: [{type: 'text', text: JSON.stringify(expected)}],
790
+ structuredContent: expected,
791
+ });
792
+ });
793
+
794
+ it('fails artifact downloads without a redirect URL instead of returning an empty success', async () => {
795
+ const result = await callGithubToolWithRequest(
796
+ 'actions_get',
797
+ {
798
+ method: 'download_workflow_run_artifact',
799
+ owner: 'shipfox',
800
+ repo: 'platform',
801
+ resource_id: '42',
802
+ },
803
+ vi.fn(() =>
804
+ Promise.resolve({
805
+ status: 302,
806
+ headers: {},
807
+ data: new ArrayBuffer(0),
808
+ }),
809
+ ),
810
+ );
811
+
812
+ expect(result).toEqual({
813
+ isError: true,
814
+ content: [{type: 'text', text: 'GitHub artifact download did not return a download URL'}],
815
+ structuredContent: {code: 'malformed-provider-response'},
816
+ });
817
+ });
818
+
819
+ it('projects get_diff request headers through the provider session', async () => {
820
+ const request = vi.fn(() => Promise.resolve({data: 'diff --git a/file b/file'}));
821
+ const result = await callGithubToolWithRequest(
822
+ 'pull_request_read',
823
+ {
824
+ method: 'get_diff',
825
+ owner: 'shipfox',
826
+ repo: 'platform',
827
+ pull_number: 2,
828
+ },
829
+ request,
830
+ );
831
+
832
+ expect(request).toHaveBeenCalledWith('GET /repos/{owner}/{repo}/pulls/{pull_number}', {
833
+ owner: 'shipfox',
834
+ repo: 'platform',
835
+ pull_number: 2,
836
+ headers: {accept: 'application/vnd.github.diff'},
837
+ });
838
+ expect(result).toEqual({
839
+ content: [{type: 'text', text: '{"result":"diff --git a/file b/file"}'}],
840
+ structuredContent: {result: 'diff --git a/file b/file'},
841
+ });
842
+ });
843
+
844
+ it('projects issue comment reactions through the provider session', async () => {
845
+ const request = vi.fn(() => Promise.resolve({data: {id: 7}}));
846
+ const result = await callGithubToolWithRequest(
847
+ 'add_issue_comment',
848
+ {owner: 'shipfox', repo: 'platform', issue_number: 1, reaction: '+1'},
849
+ request,
850
+ );
851
+
852
+ expect(request).toHaveBeenCalledWith(
853
+ 'POST /repos/{owner}/{repo}/issues/{issue_number}/reactions',
854
+ {owner: 'shipfox', repo: 'platform', issue_number: 1, content: '+1'},
855
+ );
856
+ expect(result).toEqual({
857
+ content: [{type: 'text', text: '{"id":7}'}],
858
+ structuredContent: {id: 7},
859
+ });
860
+ });
861
+
862
+ it('adds a comment to the latest caller pending review through GraphQL', async () => {
863
+ const request = vi.fn().mockResolvedValueOnce({
864
+ data: [
865
+ {id: 40, node_id: 'review-older', state: 'PENDING', user: githubAppReviewUser},
866
+ {id: 41, node_id: 'review-latest', state: 'PENDING', user: githubAppReviewUser},
867
+ {
868
+ id: 42,
869
+ node_id: 'another-review',
870
+ state: 'PENDING',
871
+ user: {login: 'another-user'},
872
+ },
873
+ ],
874
+ });
875
+ const graphql = vi.fn().mockResolvedValueOnce({
876
+ addPullRequestReviewThread: {thread: {id: 'thread-1'}},
877
+ });
878
+ const provider = createAgentToolsProvider({request, graphql});
879
+ const session = await provider.openSession({
880
+ connection: connection(),
881
+ tools: [pendingReviewTool()],
882
+ scope: undefined,
883
+ });
884
+
885
+ const result = await session.call({
886
+ toolId: 'add_comment_to_pending_review',
887
+ arguments: {
888
+ owner: 'shipfox',
889
+ repo: 'platform',
890
+ pull_number: 2,
891
+ path: 'src/agent-tools.ts',
892
+ body: 'Please handle this error.',
893
+ subject_type: 'LINE',
894
+ line: 42,
895
+ side: 'RIGHT',
896
+ start_line: 40,
897
+ start_side: 'RIGHT',
898
+ },
899
+ });
900
+
901
+ expect(request).toHaveBeenCalledWith(
902
+ 'GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews',
903
+ expect.objectContaining({
904
+ owner: 'shipfox',
905
+ repo: 'platform',
906
+ pull_number: 2,
907
+ per_page: 100,
908
+ page: 1,
909
+ }),
910
+ );
911
+ expect(request.mock.calls[0]?.[1]).toHaveProperty('request.signal');
912
+ expect(graphql).toHaveBeenCalledWith(expect.stringContaining('addPullRequestReviewThread'), {
913
+ input: {
914
+ pullRequestReviewId: 'review-latest',
915
+ path: 'src/agent-tools.ts',
916
+ body: 'Please handle this error.',
917
+ subjectType: 'LINE',
918
+ line: 42,
919
+ side: 'RIGHT',
920
+ startLine: 40,
921
+ startSide: 'RIGHT',
922
+ },
923
+ });
924
+ expect(result).toEqual({
925
+ content: [
926
+ {
927
+ type: 'text',
928
+ text: '{"addPullRequestReviewThread":{"thread":{"id":"thread-1"}}}',
929
+ },
930
+ ],
931
+ structuredContent: {addPullRequestReviewThread: {thread: {id: 'thread-1'}}},
932
+ });
933
+ });
934
+
935
+ it('returns an explicit error when there is no pending review for the caller', async () => {
936
+ const request = vi.fn().mockResolvedValueOnce({
937
+ data: [
938
+ {
939
+ id: 41,
940
+ node_id: 'another-review',
941
+ state: 'PENDING',
942
+ user: {login: 'another-user'},
943
+ },
944
+ ],
945
+ });
946
+ const graphql = vi.fn();
947
+ const provider = createAgentToolsProvider({request, graphql});
948
+ const session = await provider.openSession({
949
+ connection: connection(),
950
+ tools: [pendingReviewTool()],
951
+ scope: undefined,
952
+ });
953
+
954
+ const result = await session.call({
955
+ toolId: 'add_comment_to_pending_review',
956
+ arguments: {
957
+ owner: 'shipfox',
958
+ repo: 'platform',
959
+ pull_number: 2,
960
+ path: 'src/agent-tools.ts',
961
+ body: 'Please handle this error.',
962
+ },
963
+ });
964
+
965
+ expect(request).toHaveBeenCalledOnce();
966
+ expect(graphql).not.toHaveBeenCalled();
967
+ expect(result).toEqual({
968
+ isError: true,
969
+ content: [
970
+ {
971
+ type: 'text',
972
+ text: 'No pending pull request review found for the authenticated GitHub user.',
973
+ },
974
+ ],
975
+ structuredContent: {code: 'provider-rejected'},
976
+ });
977
+ });
978
+
979
+ it('returns an access-denied code when the installation lacks a required permission', async () => {
980
+ const provider = new GithubAgentToolsProvider({
981
+ getInstallationByConnectionId: vi.fn(() => Promise.resolve(installation())),
982
+ tokenProvider: {
983
+ getInstallationAccessToken: vi.fn(() =>
984
+ Promise.resolve({
985
+ token: 'installation-token',
986
+ expiresAt: new Date(),
987
+ permissions: {},
988
+ }),
989
+ ),
990
+ },
991
+ });
992
+ const tool = githubAgentToolCatalog.find((entry) => entry.id === 'list_issues');
993
+ if (!tool) throw new Error('Missing list_issues tool');
994
+ const session = await provider.openSession({
995
+ connection: connection(),
996
+ tools: [tool],
997
+ scope: undefined,
998
+ });
999
+
1000
+ const result = await session.call({
1001
+ toolId: 'list_issues',
1002
+ arguments: {owner: 'shipfox', repo: 'platform'},
1003
+ });
1004
+
1005
+ expect(result).toEqual({
1006
+ isError: true,
1007
+ content: [
1008
+ {
1009
+ type: 'text',
1010
+ text: 'GitHub installation token is missing permission for this operation',
1011
+ },
1012
+ ],
1013
+ structuredContent: {code: 'access-denied'},
1014
+ });
1015
+ });
1016
+
1017
+ it('rejects a pending review without a GraphQL node ID', async () => {
1018
+ const request = vi.fn().mockResolvedValueOnce({
1019
+ data: [{id: 41, state: 'PENDING', user: githubAppReviewUser}],
1020
+ });
1021
+ const graphql = vi.fn();
1022
+ const provider = createAgentToolsProvider({request, graphql});
1023
+ const session = await provider.openSession({
1024
+ connection: connection(),
1025
+ tools: [pendingReviewTool()],
1026
+ scope: undefined,
1027
+ });
1028
+
1029
+ await expect(
1030
+ session.call({
1031
+ toolId: 'add_comment_to_pending_review',
1032
+ arguments: {
1033
+ owner: 'shipfox',
1034
+ repo: 'platform',
1035
+ pull_number: 2,
1036
+ path: 'src/agent-tools.ts',
1037
+ body: 'Please handle this error.',
1038
+ },
1039
+ }),
1040
+ ).rejects.toMatchObject({
1041
+ reason: 'malformed-provider-response',
1042
+ message: 'GitHub pending pull request review did not include a node ID',
1043
+ });
1044
+ expect(graphql).not.toHaveBeenCalled();
1045
+ });
1046
+
1047
+ it('skips a malformed newer review for an older valid caller review', async () => {
1048
+ const request = vi.fn().mockResolvedValueOnce({
1049
+ data: [
1050
+ {
1051
+ id: 40,
1052
+ node_id: 'review-valid',
1053
+ state: 'PENDING',
1054
+ user: githubAppReviewUser,
1055
+ },
1056
+ {id: 41, node_id: ' ', state: 'PENDING', user: githubAppReviewUser},
1057
+ ],
1058
+ });
1059
+ const graphql = vi.fn().mockResolvedValueOnce({
1060
+ addPullRequestReviewThread: {thread: {id: 'thread-1'}},
1061
+ });
1062
+ const provider = createAgentToolsProvider({request, graphql});
1063
+ const session = await provider.openSession({
1064
+ connection: connection(),
1065
+ tools: [pendingReviewTool()],
1066
+ scope: undefined,
1067
+ });
1068
+
1069
+ await session.call({
1070
+ toolId: 'add_comment_to_pending_review',
1071
+ arguments: {
1072
+ owner: 'shipfox',
1073
+ repo: 'platform',
1074
+ pull_number: 2,
1075
+ path: 'src/agent-tools.ts',
1076
+ body: 'Please handle this error.',
1077
+ },
1078
+ });
1079
+
1080
+ expect(graphql).toHaveBeenCalledWith(expect.stringContaining('addPullRequestReviewThread'), {
1081
+ input: expect.objectContaining({pullRequestReviewId: 'review-valid'}),
1082
+ });
1083
+ });
1084
+
1085
+ it('rejects a malformed pending review list response', async () => {
1086
+ const request = vi.fn().mockResolvedValueOnce({data: {reviews: []}});
1087
+ const graphql = vi.fn();
1088
+ const provider = createAgentToolsProvider({request, graphql});
1089
+ const session = await provider.openSession({
1090
+ connection: connection(),
1091
+ tools: [pendingReviewTool()],
1092
+ scope: undefined,
1093
+ });
1094
+
1095
+ await expect(
1096
+ session.call({
1097
+ toolId: 'add_comment_to_pending_review',
1098
+ arguments: {
1099
+ owner: 'shipfox',
1100
+ repo: 'platform',
1101
+ pull_number: 2,
1102
+ path: 'src/agent-tools.ts',
1103
+ body: 'Please handle this error.',
1104
+ },
1105
+ }),
1106
+ ).rejects.toMatchObject({
1107
+ reason: 'malformed-provider-response',
1108
+ message: 'GitHub pull request review list response was malformed',
1109
+ });
1110
+ expect(graphql).not.toHaveBeenCalled();
1111
+ });
1112
+
1113
+ it('maps Octokit 4xx failures to terminal provider errors', async () => {
1114
+ const providerError = new RequestError('commit_id is missing', 422, {
1115
+ request: {
1116
+ method: 'POST',
1117
+ url: 'https://api.github.com/repos/shipfox/platform/issues/1',
1118
+ headers: {},
1119
+ },
1120
+ });
1121
+ const provider = new GithubAgentToolsProvider({
1122
+ getInstallationByConnectionId: vi.fn(() => Promise.resolve(installation())),
1123
+ tokenProvider: {
1124
+ getInstallationAccessToken: vi.fn(() =>
1125
+ Promise.resolve({
1126
+ token: 'installation-token',
1127
+ expiresAt: new Date(),
1128
+ permissions: {issues: 'read' as const},
1129
+ }),
1130
+ ),
1131
+ },
1132
+ createClient: vi.fn(() => ({
1133
+ request: vi.fn(() => Promise.reject(providerError)),
1134
+ })),
1135
+ });
1136
+ const session = await provider.openSession({
1137
+ connection: connection(),
1138
+ tools: [githubAgentToolCatalog[0]],
1139
+ scope: undefined,
1140
+ });
1141
+
1142
+ await expect(
1143
+ session.call({
1144
+ toolId: 'issue_read',
1145
+ arguments: {method: 'get', owner: 'shipfox', repo: 'platform', issue_number: 1},
1146
+ }),
1147
+ ).rejects.toMatchObject({
1148
+ reason: 'provider-rejected',
1149
+ message: 'commit_id is missing',
1150
+ status: 422,
1151
+ });
1152
+ });
1153
+
372
1154
  it('requires a ref for pull request status and check-run reads', async () => {
373
1155
  const result = await callGithubTool(
374
1156
  'pull_request_read',
@@ -379,7 +1161,240 @@ describe('github agent tool catalog', () => {
379
1161
  expect(result).toEqual({
380
1162
  isError: true,
381
1163
  content: [{type: 'text', text: 'Missing required parameter: ref'}],
1164
+ structuredContent: {code: 'invalid-request'},
1165
+ });
1166
+ });
1167
+
1168
+ it.each([
1169
+ {
1170
+ method: 'submit_pending',
1171
+ arguments: {
1172
+ method: 'submit_pending',
1173
+ owner: 'shipfox',
1174
+ repo: 'platform',
1175
+ pull_number: 2,
1176
+ body: 'Please address this before merging.',
1177
+ event: 'REQUEST_CHANGES',
1178
+ },
1179
+ route: 'POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}/events',
1180
+ parameters: {
1181
+ owner: 'shipfox',
1182
+ repo: 'platform',
1183
+ pull_number: 2,
1184
+ body: 'Please address this before merging.',
1185
+ event: 'REQUEST_CHANGES',
1186
+ review_id: 42,
1187
+ },
1188
+ data: {id: 42, state: 'CHANGES_REQUESTED'},
1189
+ },
1190
+ {
1191
+ method: 'delete_pending',
1192
+ arguments: {
1193
+ method: 'delete_pending',
1194
+ owner: 'shipfox',
1195
+ repo: 'platform',
1196
+ pull_number: 2,
1197
+ },
1198
+ route: 'DELETE /repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}',
1199
+ parameters: {
1200
+ owner: 'shipfox',
1201
+ repo: 'platform',
1202
+ pull_number: 2,
1203
+ review_id: 42,
1204
+ },
1205
+ data: {id: 42, state: 'PENDING'},
1206
+ },
1207
+ ] satisfies Array<{
1208
+ method: 'submit_pending' | 'delete_pending';
1209
+ arguments: Record<string, unknown>;
1210
+ route: string;
1211
+ parameters: Record<string, unknown>;
1212
+ data: unknown;
1213
+ }>)('$method resolves the latest pending review before writing', async (testCase) => {
1214
+ const request = vi
1215
+ .fn()
1216
+ .mockResolvedValueOnce({
1217
+ data: [
1218
+ {id: 40, state: 'APPROVED'},
1219
+ {id: 41, state: 'PENDING', user: githubAppReviewUser},
1220
+ {id: 42, state: 'PENDING', user: githubAppReviewUser},
1221
+ {id: 43, state: 'PENDING', user: {login: 'another-user'}},
1222
+ ],
1223
+ })
1224
+ .mockResolvedValueOnce({data: testCase.data});
1225
+
1226
+ const result = await callGithubToolWithRequest(
1227
+ 'pull_request_review_write',
1228
+ testCase.arguments,
1229
+ request,
1230
+ );
1231
+
1232
+ expect(result).toEqual({
1233
+ content: [{type: 'text', text: JSON.stringify(testCase.data)}],
1234
+ structuredContent: testCase.data,
1235
+ });
1236
+ expect(request).toHaveBeenNthCalledWith(
1237
+ 1,
1238
+ 'GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews',
1239
+ expect.objectContaining({
1240
+ owner: 'shipfox',
1241
+ repo: 'platform',
1242
+ pull_number: 2,
1243
+ per_page: 100,
1244
+ page: 1,
1245
+ }),
1246
+ );
1247
+ expect(request).toHaveBeenNthCalledWith(2, testCase.route, testCase.parameters);
1248
+ });
1249
+
1250
+ it('resolves a pending review from a later review page', async () => {
1251
+ const request = vi
1252
+ .fn()
1253
+ .mockResolvedValueOnce({
1254
+ data: [
1255
+ ...Array.from({length: 99}, (_, index) => ({id: index + 1, state: 'APPROVED'})),
1256
+ {id: 100, state: 'PENDING', user: githubAppReviewUser},
1257
+ ],
1258
+ headers: {
1259
+ link: '<https://api.github.com/repositories/1/pulls/2/reviews?per_page=100&page=2>; rel="last"',
1260
+ },
1261
+ })
1262
+ .mockResolvedValueOnce({
1263
+ data: [{id: 101, state: 'PENDING', user: githubAppReviewUser}],
1264
+ })
1265
+ .mockResolvedValueOnce({data: {id: 101, state: 'COMMENTED'}});
1266
+
1267
+ const result = await callGithubToolWithRequest(
1268
+ 'pull_request_review_write',
1269
+ {
1270
+ method: 'submit_pending',
1271
+ owner: 'shipfox',
1272
+ repo: 'platform',
1273
+ pull_number: 2,
1274
+ body: 'Looks good.',
1275
+ event: 'COMMENT',
1276
+ },
1277
+ request,
1278
+ );
1279
+
1280
+ expect(result).toEqual({
1281
+ content: [{type: 'text', text: JSON.stringify({id: 101, state: 'COMMENTED'})}],
1282
+ structuredContent: {id: 101, state: 'COMMENTED'},
1283
+ });
1284
+ expect(request).toHaveBeenNthCalledWith(
1285
+ 1,
1286
+ 'GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews',
1287
+ expect.objectContaining({
1288
+ owner: 'shipfox',
1289
+ repo: 'platform',
1290
+ pull_number: 2,
1291
+ per_page: 100,
1292
+ page: 1,
1293
+ }),
1294
+ );
1295
+ expect(request).toHaveBeenNthCalledWith(
1296
+ 2,
1297
+ 'GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews',
1298
+ expect.objectContaining({
1299
+ owner: 'shipfox',
1300
+ repo: 'platform',
1301
+ pull_number: 2,
1302
+ per_page: 100,
1303
+ page: 2,
1304
+ }),
1305
+ );
1306
+ expect(request).toHaveBeenNthCalledWith(
1307
+ 3,
1308
+ 'POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}/events',
1309
+ {
1310
+ owner: 'shipfox',
1311
+ repo: 'platform',
1312
+ pull_number: 2,
1313
+ body: 'Looks good.',
1314
+ event: 'COMMENT',
1315
+ review_id: 101,
1316
+ },
1317
+ );
1318
+ });
1319
+
1320
+ it('bounds pending review lookup to the newest review pages', async () => {
1321
+ const request = vi.fn().mockResolvedValue({data: []});
1322
+ request.mockResolvedValueOnce({
1323
+ data: Array.from({length: 100}, (_, index) => ({id: index + 1, state: 'APPROVED'})),
1324
+ headers: {
1325
+ link: '<https://api.github.com/repositories/1/pulls/2/reviews?per_page=100&page=6>; rel="last"',
1326
+ },
1327
+ });
1328
+
1329
+ await expect(
1330
+ callGithubToolWithRequest(
1331
+ 'pull_request_review_write',
1332
+ {
1333
+ method: 'submit_pending',
1334
+ owner: 'shipfox',
1335
+ repo: 'platform',
1336
+ pull_number: 2,
1337
+ body: 'Looks good.',
1338
+ event: 'COMMENT',
1339
+ },
1340
+ request,
1341
+ ),
1342
+ ).rejects.toMatchObject({
1343
+ reason: 'content-too-large',
1344
+ message: 'GitHub pull request review history exceeded the pending review lookup limit',
1345
+ });
1346
+ expect(request).toHaveBeenCalledTimes(5);
1347
+ expect(request.mock.calls.map((call) => call[1]?.page)).toEqual([1, 6, 5, 4, 3]);
1348
+ });
1349
+
1350
+ it.each([0, -1])('rejects non-positive pending review ID %s', async (id) => {
1351
+ const request = vi.fn().mockResolvedValueOnce({
1352
+ data: [{id, state: 'PENDING', user: githubAppReviewUser}],
1353
+ });
1354
+
1355
+ await expect(
1356
+ callGithubToolWithRequest(
1357
+ 'pull_request_review_write',
1358
+ {
1359
+ method: 'submit_pending',
1360
+ owner: 'shipfox',
1361
+ repo: 'platform',
1362
+ pull_number: 2,
1363
+ body: 'Looks good.',
1364
+ event: 'COMMENT',
1365
+ },
1366
+ request,
1367
+ ),
1368
+ ).rejects.toMatchObject({
1369
+ reason: 'malformed-provider-response',
1370
+ message: 'GitHub pending pull request review did not include a numeric ID',
1371
+ });
1372
+ expect(request).toHaveBeenCalledOnce();
1373
+ });
1374
+
1375
+ it.each([
1376
+ 'submit_pending',
1377
+ 'delete_pending',
1378
+ ] as const)('%s reports when no pending review exists', async (method) => {
1379
+ const request = vi.fn(() => Promise.resolve({data: []}));
1380
+
1381
+ const result = await callGithubToolWithRequest(
1382
+ 'pull_request_review_write',
1383
+ {method, owner: 'shipfox', repo: 'platform', pull_number: 2},
1384
+ request,
1385
+ );
1386
+
1387
+ expect(result).toEqual({
1388
+ isError: true,
1389
+ content: [
1390
+ {
1391
+ type: 'text',
1392
+ text: 'No pending pull request review found for the authenticated GitHub user.',
1393
+ },
1394
+ ],
1395
+ structuredContent: {code: 'provider-rejected'},
382
1396
  });
1397
+ expect(request).toHaveBeenCalledOnce();
383
1398
  });
384
1399
 
385
1400
  it.each([
@@ -479,10 +1494,33 @@ async function callGithubTool(
479
1494
  toolId: GithubAgentToolId,
480
1495
  arguments_: Record<string, unknown>,
481
1496
  data: unknown,
1497
+ ) {
1498
+ return await callGithubToolWithRequest(
1499
+ toolId,
1500
+ arguments_,
1501
+ vi.fn(() => Promise.resolve({data})),
1502
+ );
1503
+ }
1504
+
1505
+ async function callGithubToolWithRequest(
1506
+ toolId: GithubAgentToolId,
1507
+ arguments_: Record<string, unknown>,
1508
+ request: GithubToolClient['request'],
482
1509
  ) {
483
1510
  const tool = githubAgentToolCatalog.find((entry) => entry.id === toolId);
484
1511
  if (!tool) throw new Error(`Missing GitHub tool: ${toolId}`);
485
- const provider = new GithubAgentToolsProvider({
1512
+ const provider = createAgentToolsProvider({request});
1513
+ const session = await provider.openSession({
1514
+ connection: connection(),
1515
+ tools: [tool],
1516
+ scope: undefined,
1517
+ });
1518
+
1519
+ return await session.call({toolId, arguments: arguments_});
1520
+ }
1521
+
1522
+ function createAgentToolsProvider(client: GithubToolClient) {
1523
+ return new GithubAgentToolsProvider({
486
1524
  getInstallationByConnectionId: vi.fn(() => Promise.resolve(installation())),
487
1525
  tokenProvider: {
488
1526
  getInstallationAccessToken: vi.fn(() =>
@@ -498,15 +1536,14 @@ async function callGithubTool(
498
1536
  }),
499
1537
  ),
500
1538
  },
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,
1539
+ createClient: vi.fn(() => client),
507
1540
  });
1541
+ }
508
1542
 
509
- return await session.call({toolId, arguments: arguments_});
1543
+ function pendingReviewTool() {
1544
+ const tool = githubAgentToolCatalog.find((entry) => entry.id === 'add_comment_to_pending_review');
1545
+ if (!tool) throw new Error('Missing add_comment_to_pending_review tool');
1546
+ return tool;
510
1547
  }
511
1548
 
512
1549
  function connection() {
@@ -575,3 +1612,7 @@ function inputSchemaFor(id: (typeof githubAgentToolCatalog)[number]['id']) {
575
1612
  oneOf?: unknown[] | undefined;
576
1613
  };
577
1614
  }
1615
+
1616
+ function operationKey(toolId: GithubAgentToolId, method?: string) {
1617
+ return `${toolId}.${method ?? ''}`;
1618
+ }