@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
@@ -12,6 +12,8 @@ import {
12
12
  } from '#core/agent-tools.js';
13
13
  import {createGithubIntegrationProvider} from '#index.js';
14
14
 
15
+ const githubAppReviewUser = {login: 'shipfox-test[bot]'};
16
+
15
17
  const expectedCatalogRows = [
16
18
  {
17
19
  id: 'issue_read',
@@ -81,6 +83,7 @@ const expectedCatalogRows = [
81
83
  'get_files',
82
84
  'get_commits',
83
85
  'get_review_comments',
86
+ 'get_review_threads',
84
87
  'get_reviews',
85
88
  'get_comments',
86
89
  'get_check_runs',
@@ -146,6 +149,14 @@ const expectedCatalogRows = [
146
149
  requiredScope: [{permission: 'pull_requests', access: 'write'}],
147
150
  methods: ['create', 'submit_pending', 'delete_pending'],
148
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
+ },
149
160
  {
150
161
  id: 'add_comment_to_pending_review',
151
162
  category: 'pull_requests',
@@ -349,6 +360,12 @@ const githubOperationRouteCases = [
349
360
  args: {pull_number: 1},
350
361
  expectedRoute: 'GET /repos/{owner}/{repo}/pulls/{pull_number}/comments',
351
362
  },
363
+ {
364
+ toolId: 'pull_request_read',
365
+ method: 'get_review_threads',
366
+ args: {pull_number: 1},
367
+ expectedRoute: 'POST /graphql',
368
+ },
352
369
  {
353
370
  toolId: 'pull_request_read',
354
371
  method: 'get_reviews',
@@ -427,6 +444,12 @@ const githubOperationRouteCases = [
427
444
  runtimeInjectedProperties: ['review_id'],
428
445
  expectedRoute: 'DELETE /repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}',
429
446
  },
447
+ {
448
+ toolId: 'pull_request_review_thread_write',
449
+ method: 'resolve',
450
+ args: {thread_id: 'PRRT_kwDOExample'},
451
+ expectedRoute: 'POST /graphql',
452
+ },
430
453
  {
431
454
  toolId: 'add_comment_to_pending_review',
432
455
  args: {pull_number: 1, path: 'src/index.ts', body: 'Comment'},
@@ -661,6 +684,7 @@ describe('github agent tool catalog', () => {
661
684
  {properties: {method: {const: 'get_files'}}, required: []},
662
685
  {properties: {method: {const: 'get_commits'}}, required: []},
663
686
  {properties: {method: {const: 'get_review_comments'}}, required: []},
687
+ {properties: {method: {const: 'get_review_threads'}}, required: []},
664
688
  {properties: {method: {const: 'get_reviews'}}, required: []},
665
689
  {properties: {method: {const: 'get_comments'}}, required: []},
666
690
  {properties: {method: {const: 'get_check_runs'}}, required: ['ref']},
@@ -810,6 +834,7 @@ describe('github agent tool catalog', () => {
810
834
  expect(result).toEqual({
811
835
  isError: true,
812
836
  content: [{type: 'text', text: 'GitHub artifact download did not return a download URL'}],
837
+ structuredContent: {code: 'malformed-provider-response'},
813
838
  });
814
839
  });
815
840
 
@@ -838,6 +863,104 @@ describe('github agent tool catalog', () => {
838
863
  });
839
864
  });
840
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
+
841
964
  it('projects issue comment reactions through the provider session', async () => {
842
965
  const request = vi.fn(() => Promise.resolve({data: {id: 7}}));
843
966
  const result = await callGithubToolWithRequest(
@@ -856,34 +979,22 @@ describe('github agent tool catalog', () => {
856
979
  });
857
980
  });
858
981
 
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
- },
982
+ it('adds a comment to the latest caller pending review through GraphQL', async () => {
983
+ const request = vi.fn().mockResolvedValueOnce({
984
+ data: [
985
+ {id: 40, node_id: 'review-older', state: 'PENDING', user: githubAppReviewUser},
986
+ {id: 41, node_id: 'review-latest', state: 'PENDING', user: githubAppReviewUser},
987
+ {
988
+ id: 42,
989
+ node_id: 'another-review',
990
+ state: 'PENDING',
991
+ user: {login: 'another-user'},
882
992
  },
883
- })
884
- .mockResolvedValueOnce({
885
- addPullRequestReviewThread: {thread: {id: 'thread-1'}},
886
- });
993
+ ],
994
+ });
995
+ const graphql = vi.fn().mockResolvedValueOnce({
996
+ addPullRequestReviewThread: {thread: {id: 'thread-1'}},
997
+ });
887
998
  const provider = createAgentToolsProvider({request, graphql});
888
999
  const session = await provider.openSession({
889
1000
  connection: connection(),
@@ -907,28 +1018,29 @@ describe('github agent tool catalog', () => {
907
1018
  },
908
1019
  });
909
1020
 
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},
1021
+ expect(request).toHaveBeenCalledWith(
1022
+ 'GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews',
1023
+ expect.objectContaining({
1024
+ owner: 'shipfox',
1025
+ repo: 'platform',
1026
+ pull_number: 2,
1027
+ per_page: 100,
1028
+ page: 1,
1029
+ }),
915
1030
  );
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
- },
1031
+ expect(request.mock.calls[0]?.[1]).toHaveProperty('request.signal');
1032
+ expect(graphql).toHaveBeenCalledWith(expect.stringContaining('addPullRequestReviewThread'), {
1033
+ input: {
1034
+ pullRequestReviewId: 'review-latest',
1035
+ path: 'src/agent-tools.ts',
1036
+ body: 'Please handle this error.',
1037
+ subjectType: 'LINE',
1038
+ line: 42,
1039
+ side: 'RIGHT',
1040
+ startLine: 40,
1041
+ startSide: 'RIGHT',
930
1042
  },
931
- );
1043
+ });
932
1044
  expect(result).toEqual({
933
1045
  content: [
934
1046
  {
@@ -941,23 +1053,17 @@ describe('github agent tool catalog', () => {
941
1053
  });
942
1054
 
943
1055
  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
- },
1056
+ const request = vi.fn().mockResolvedValueOnce({
1057
+ data: [
1058
+ {
1059
+ id: 41,
1060
+ node_id: 'another-review',
1061
+ state: 'PENDING',
1062
+ user: {login: 'another-user'},
958
1063
  },
959
- },
1064
+ ],
960
1065
  });
1066
+ const graphql = vi.fn();
961
1067
  const provider = createAgentToolsProvider({request, graphql});
962
1068
  const session = await provider.openSession({
963
1069
  connection: connection(),
@@ -976,8 +1082,8 @@ describe('github agent tool catalog', () => {
976
1082
  },
977
1083
  });
978
1084
 
979
- expect(graphql).toHaveBeenCalledTimes(1);
980
- expect(request).not.toHaveBeenCalled();
1085
+ expect(request).toHaveBeenCalledOnce();
1086
+ expect(graphql).not.toHaveBeenCalled();
981
1087
  expect(result).toEqual({
982
1088
  isError: true,
983
1089
  content: [
@@ -986,7 +1092,142 @@ describe('github agent tool catalog', () => {
986
1092
  text: 'No pending pull request review found for the authenticated GitHub user.',
987
1093
  },
988
1094
  ],
1095
+ structuredContent: {code: 'provider-rejected'},
1096
+ });
1097
+ });
1098
+
1099
+ it('returns an access-denied code when the installation lacks a required permission', async () => {
1100
+ const provider = new GithubAgentToolsProvider({
1101
+ getInstallationByConnectionId: vi.fn(() => Promise.resolve(installation())),
1102
+ tokenProvider: {
1103
+ getInstallationAccessToken: vi.fn(() =>
1104
+ Promise.resolve({
1105
+ token: 'installation-token',
1106
+ expiresAt: new Date(),
1107
+ permissions: {},
1108
+ }),
1109
+ ),
1110
+ },
1111
+ });
1112
+ const tool = githubAgentToolCatalog.find((entry) => entry.id === 'list_issues');
1113
+ if (!tool) throw new Error('Missing list_issues tool');
1114
+ const session = await provider.openSession({
1115
+ connection: connection(),
1116
+ tools: [tool],
1117
+ scope: undefined,
1118
+ });
1119
+
1120
+ const result = await session.call({
1121
+ toolId: 'list_issues',
1122
+ arguments: {owner: 'shipfox', repo: 'platform'},
1123
+ });
1124
+
1125
+ expect(result).toEqual({
1126
+ isError: true,
1127
+ content: [
1128
+ {
1129
+ type: 'text',
1130
+ text: 'GitHub installation token is missing permission for this operation',
1131
+ },
1132
+ ],
1133
+ structuredContent: {code: 'access-denied'},
1134
+ });
1135
+ });
1136
+
1137
+ it('rejects a pending review without a GraphQL node ID', async () => {
1138
+ const request = vi.fn().mockResolvedValueOnce({
1139
+ data: [{id: 41, state: 'PENDING', user: githubAppReviewUser}],
1140
+ });
1141
+ const graphql = vi.fn();
1142
+ const provider = createAgentToolsProvider({request, graphql});
1143
+ const session = await provider.openSession({
1144
+ connection: connection(),
1145
+ tools: [pendingReviewTool()],
1146
+ scope: undefined,
1147
+ });
1148
+
1149
+ await expect(
1150
+ session.call({
1151
+ toolId: 'add_comment_to_pending_review',
1152
+ arguments: {
1153
+ owner: 'shipfox',
1154
+ repo: 'platform',
1155
+ pull_number: 2,
1156
+ path: 'src/agent-tools.ts',
1157
+ body: 'Please handle this error.',
1158
+ },
1159
+ }),
1160
+ ).rejects.toMatchObject({
1161
+ reason: 'malformed-provider-response',
1162
+ message: 'GitHub pending pull request review did not include a node ID',
989
1163
  });
1164
+ expect(graphql).not.toHaveBeenCalled();
1165
+ });
1166
+
1167
+ it('skips a malformed newer review for an older valid caller review', async () => {
1168
+ const request = vi.fn().mockResolvedValueOnce({
1169
+ data: [
1170
+ {
1171
+ id: 40,
1172
+ node_id: 'review-valid',
1173
+ state: 'PENDING',
1174
+ user: githubAppReviewUser,
1175
+ },
1176
+ {id: 41, node_id: ' ', state: 'PENDING', user: githubAppReviewUser},
1177
+ ],
1178
+ });
1179
+ const graphql = vi.fn().mockResolvedValueOnce({
1180
+ addPullRequestReviewThread: {thread: {id: 'thread-1'}},
1181
+ });
1182
+ const provider = createAgentToolsProvider({request, graphql});
1183
+ const session = await provider.openSession({
1184
+ connection: connection(),
1185
+ tools: [pendingReviewTool()],
1186
+ scope: undefined,
1187
+ });
1188
+
1189
+ await session.call({
1190
+ toolId: 'add_comment_to_pending_review',
1191
+ arguments: {
1192
+ owner: 'shipfox',
1193
+ repo: 'platform',
1194
+ pull_number: 2,
1195
+ path: 'src/agent-tools.ts',
1196
+ body: 'Please handle this error.',
1197
+ },
1198
+ });
1199
+
1200
+ expect(graphql).toHaveBeenCalledWith(expect.stringContaining('addPullRequestReviewThread'), {
1201
+ input: expect.objectContaining({pullRequestReviewId: 'review-valid'}),
1202
+ });
1203
+ });
1204
+
1205
+ it('rejects a malformed pending review list response', async () => {
1206
+ const request = vi.fn().mockResolvedValueOnce({data: {reviews: []}});
1207
+ const graphql = vi.fn();
1208
+ const provider = createAgentToolsProvider({request, graphql});
1209
+ const session = await provider.openSession({
1210
+ connection: connection(),
1211
+ tools: [pendingReviewTool()],
1212
+ scope: undefined,
1213
+ });
1214
+
1215
+ await expect(
1216
+ session.call({
1217
+ toolId: 'add_comment_to_pending_review',
1218
+ arguments: {
1219
+ owner: 'shipfox',
1220
+ repo: 'platform',
1221
+ pull_number: 2,
1222
+ path: 'src/agent-tools.ts',
1223
+ body: 'Please handle this error.',
1224
+ },
1225
+ }),
1226
+ ).rejects.toMatchObject({
1227
+ reason: 'malformed-provider-response',
1228
+ message: 'GitHub pull request review list response was malformed',
1229
+ });
1230
+ expect(graphql).not.toHaveBeenCalled();
990
1231
  });
991
1232
 
992
1233
  it('maps Octokit 4xx failures to terminal provider errors', async () => {
@@ -1040,6 +1281,7 @@ describe('github agent tool catalog', () => {
1040
1281
  expect(result).toEqual({
1041
1282
  isError: true,
1042
1283
  content: [{type: 'text', text: 'Missing required parameter: ref'}],
1284
+ structuredContent: {code: 'invalid-request'},
1043
1285
  });
1044
1286
  });
1045
1287
 
@@ -1094,8 +1336,9 @@ describe('github agent tool catalog', () => {
1094
1336
  .mockResolvedValueOnce({
1095
1337
  data: [
1096
1338
  {id: 40, state: 'APPROVED'},
1097
- {id: 41, state: 'PENDING'},
1098
- {id: 42, state: 'PENDING'},
1339
+ {id: 41, state: 'PENDING', user: githubAppReviewUser},
1340
+ {id: 42, state: 'PENDING', user: githubAppReviewUser},
1341
+ {id: 43, state: 'PENDING', user: {login: 'another-user'}},
1099
1342
  ],
1100
1343
  })
1101
1344
  .mockResolvedValueOnce({data: testCase.data});
@@ -1113,13 +1356,13 @@ describe('github agent tool catalog', () => {
1113
1356
  expect(request).toHaveBeenNthCalledWith(
1114
1357
  1,
1115
1358
  'GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews',
1116
- {
1359
+ expect.objectContaining({
1117
1360
  owner: 'shipfox',
1118
1361
  repo: 'platform',
1119
1362
  pull_number: 2,
1120
1363
  per_page: 100,
1121
1364
  page: 1,
1122
- },
1365
+ }),
1123
1366
  );
1124
1367
  expect(request).toHaveBeenNthCalledWith(2, testCase.route, testCase.parameters);
1125
1368
  });
@@ -1128,9 +1371,17 @@ describe('github agent tool catalog', () => {
1128
1371
  const request = vi
1129
1372
  .fn()
1130
1373
  .mockResolvedValueOnce({
1131
- data: Array.from({length: 100}, (_, index) => ({id: index + 1, state: 'APPROVED'})),
1374
+ data: [
1375
+ ...Array.from({length: 99}, (_, index) => ({id: index + 1, state: 'APPROVED'})),
1376
+ {id: 100, state: 'PENDING', user: githubAppReviewUser},
1377
+ ],
1378
+ headers: {
1379
+ link: '<https://api.github.com/repositories/1/pulls/2/reviews?per_page=100&page=2>; rel="last"',
1380
+ },
1381
+ })
1382
+ .mockResolvedValueOnce({
1383
+ data: [{id: 101, state: 'PENDING', user: githubAppReviewUser}],
1132
1384
  })
1133
- .mockResolvedValueOnce({data: [{id: 101, state: 'PENDING'}]})
1134
1385
  .mockResolvedValueOnce({data: {id: 101, state: 'COMMENTED'}});
1135
1386
 
1136
1387
  const result = await callGithubToolWithRequest(
@@ -1153,24 +1404,24 @@ describe('github agent tool catalog', () => {
1153
1404
  expect(request).toHaveBeenNthCalledWith(
1154
1405
  1,
1155
1406
  'GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews',
1156
- {
1407
+ expect.objectContaining({
1157
1408
  owner: 'shipfox',
1158
1409
  repo: 'platform',
1159
1410
  pull_number: 2,
1160
1411
  per_page: 100,
1161
1412
  page: 1,
1162
- },
1413
+ }),
1163
1414
  );
1164
1415
  expect(request).toHaveBeenNthCalledWith(
1165
1416
  2,
1166
1417
  'GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews',
1167
- {
1418
+ expect.objectContaining({
1168
1419
  owner: 'shipfox',
1169
1420
  repo: 'platform',
1170
1421
  pull_number: 2,
1171
1422
  per_page: 100,
1172
1423
  page: 2,
1173
- },
1424
+ }),
1174
1425
  );
1175
1426
  expect(request).toHaveBeenNthCalledWith(
1176
1427
  3,
@@ -1186,6 +1437,61 @@ describe('github agent tool catalog', () => {
1186
1437
  );
1187
1438
  });
1188
1439
 
1440
+ it('bounds pending review lookup to the newest review pages', async () => {
1441
+ const request = vi.fn().mockResolvedValue({data: []});
1442
+ request.mockResolvedValueOnce({
1443
+ data: Array.from({length: 100}, (_, index) => ({id: index + 1, state: 'APPROVED'})),
1444
+ headers: {
1445
+ link: '<https://api.github.com/repositories/1/pulls/2/reviews?per_page=100&page=6>; rel="last"',
1446
+ },
1447
+ });
1448
+
1449
+ await expect(
1450
+ callGithubToolWithRequest(
1451
+ 'pull_request_review_write',
1452
+ {
1453
+ method: 'submit_pending',
1454
+ owner: 'shipfox',
1455
+ repo: 'platform',
1456
+ pull_number: 2,
1457
+ body: 'Looks good.',
1458
+ event: 'COMMENT',
1459
+ },
1460
+ request,
1461
+ ),
1462
+ ).rejects.toMatchObject({
1463
+ reason: 'content-too-large',
1464
+ message: 'GitHub pull request review history exceeded the pending review lookup limit',
1465
+ });
1466
+ expect(request).toHaveBeenCalledTimes(5);
1467
+ expect(request.mock.calls.map((call) => call[1]?.page)).toEqual([1, 6, 5, 4, 3]);
1468
+ });
1469
+
1470
+ it.each([0, -1])('rejects non-positive pending review ID %s', async (id) => {
1471
+ const request = vi.fn().mockResolvedValueOnce({
1472
+ data: [{id, state: 'PENDING', user: githubAppReviewUser}],
1473
+ });
1474
+
1475
+ await expect(
1476
+ callGithubToolWithRequest(
1477
+ 'pull_request_review_write',
1478
+ {
1479
+ method: 'submit_pending',
1480
+ owner: 'shipfox',
1481
+ repo: 'platform',
1482
+ pull_number: 2,
1483
+ body: 'Looks good.',
1484
+ event: 'COMMENT',
1485
+ },
1486
+ request,
1487
+ ),
1488
+ ).rejects.toMatchObject({
1489
+ reason: 'malformed-provider-response',
1490
+ message: 'GitHub pending pull request review did not include a numeric ID',
1491
+ });
1492
+ expect(request).toHaveBeenCalledOnce();
1493
+ });
1494
+
1189
1495
  it.each([
1190
1496
  'submit_pending',
1191
1497
  'delete_pending',
@@ -1206,6 +1512,7 @@ describe('github agent tool catalog', () => {
1206
1512
  text: 'No pending pull request review found for the authenticated GitHub user.',
1207
1513
  },
1208
1514
  ],
1515
+ structuredContent: {code: 'provider-rejected'},
1209
1516
  });
1210
1517
  expect(request).toHaveBeenCalledOnce();
1211
1518
  });
@@ -1359,6 +1666,20 @@ function pendingReviewTool() {
1359
1666
  return tool;
1360
1667
  }
1361
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
+
1362
1683
  function connection() {
1363
1684
  return {
1364
1685
  id: 'connection-1',