@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.
@@ -8,6 +8,7 @@ import type {
8
8
  OpenAgentToolsSessionInput,
9
9
  } from '@shipfox/api-integration-spi';
10
10
  import {Octokit} from 'octokit';
11
+ import {mapGithubError} from '#api/client.js';
11
12
  import {
12
13
  createGithubInstallationTokenProvider,
13
14
  type GithubInstallationTokenProvider,
@@ -48,6 +49,44 @@ type GithubToolCallResult = {
48
49
  structuredContent?: Record<string, unknown> | undefined;
49
50
  };
50
51
 
52
+ const GITHUB_GRAPHQL_ROUTE = 'POST /graphql';
53
+ const GITHUB_ARTIFACT_ARCHIVE_FORMAT = 'zip';
54
+ const GITHUB_ARTIFACT_DOWNLOAD_ROUTE = `GET /repos/{owner}/{repo}/actions/artifacts/{resource_id}/${GITHUB_ARTIFACT_ARCHIVE_FORMAT}`;
55
+ const GITHUB_ARTIFACT_DOWNLOAD_TIMEOUT_MS = 30_000;
56
+ const NO_PENDING_REVIEW_MESSAGE =
57
+ 'No pending pull request review found for the authenticated GitHub user.';
58
+
59
+ const LATEST_PENDING_REVIEW_QUERY = `
60
+ query LatestPendingPullRequestReview($owner: String!, $repo: String!, $pullNumber: Int!) {
61
+ viewer {
62
+ login
63
+ }
64
+ repository(owner: $owner, name: $repo) {
65
+ pullRequest(number: $pullNumber) {
66
+ reviews(last: 100, states: [PENDING]) {
67
+ nodes {
68
+ id
69
+ author {
70
+ login
71
+ }
72
+ createdAt
73
+ }
74
+ }
75
+ }
76
+ }
77
+ }
78
+ `;
79
+
80
+ const ADD_PENDING_REVIEW_COMMENT_MUTATION = `
81
+ mutation AddCommentToPendingReview($input: AddPullRequestReviewThreadInput!) {
82
+ addPullRequestReviewThread(input: $input) {
83
+ thread {
84
+ id
85
+ }
86
+ }
87
+ }
88
+ `;
89
+
51
90
  export class GithubAgentToolsProvider
52
91
  implements
53
92
  AgentToolsProvider<
@@ -108,15 +147,39 @@ export class GithubAgentToolsProvider
108
147
  );
109
148
  }
110
149
  const client = (this.options.createClient ?? createOctokitClient)(token.token);
150
+ const method =
151
+ typeof call.arguments.method === 'string' ? call.arguments.method : undefined;
111
152
 
112
- try {
113
- const response = await client.request(operation.route, operation.parameters);
114
- return githubToolResult(tool.id as GithubAgentToolId, response.data);
115
- } catch (error) {
116
- if (error instanceof GithubIntegrationProviderError)
117
- return githubToolError(error.message);
118
- throw error;
153
+ if (operation.kind === 'graphql') {
154
+ const data = await mapGithubError(() =>
155
+ addCommentToPendingReview(client, operation.parameters),
156
+ );
157
+ return data === undefined
158
+ ? githubToolError(NO_PENDING_REVIEW_MESSAGE)
159
+ : githubToolResult(tool.id as GithubAgentToolId, data);
160
+ }
161
+
162
+ const operationParameters = await mapGithubError(() =>
163
+ resolvePendingReviewParameters(
164
+ client,
165
+ operation.parameters,
166
+ tool.id as GithubAgentToolId,
167
+ method,
168
+ ),
169
+ );
170
+ if (operationParameters === undefined) {
171
+ return githubToolError(NO_PENDING_REVIEW_MESSAGE);
119
172
  }
173
+ const response = await mapGithubError(() =>
174
+ client.request(operation.route, operationParameters),
175
+ );
176
+ return githubToolResult(
177
+ tool.id as GithubAgentToolId,
178
+ response.data,
179
+ response,
180
+ operationParameters,
181
+ operation.route,
182
+ );
120
183
  },
121
184
  };
122
185
  }
@@ -130,8 +193,16 @@ export interface GithubAgentToolsProviderOptions {
130
193
  createClient?: GithubToolClientFactory | undefined;
131
194
  }
132
195
 
196
+ export interface GithubToolResponse {
197
+ data: unknown;
198
+ headers?: Record<string, string | number | undefined> | undefined;
199
+ status?: number | undefined;
200
+ url?: string | undefined;
201
+ }
202
+
133
203
  export interface GithubToolClient {
134
- request(route: string, parameters: Record<string, unknown>): Promise<{data: unknown}>;
204
+ request(route: string, parameters: Record<string, unknown>): Promise<GithubToolResponse>;
205
+ graphql?: ((query: string, variables: Record<string, unknown>) => Promise<unknown>) | undefined;
135
206
  }
136
207
 
137
208
  export type GithubToolClientFactory = (token: string) => GithubToolClient;
@@ -139,6 +210,7 @@ export type GithubToolClientFactory = (token: string) => GithubToolClient;
139
210
  interface GithubToolOperation {
140
211
  route: string;
141
212
  parameters: Record<string, unknown>;
213
+ kind: 'rest' | 'graphql';
142
214
  }
143
215
 
144
216
  function createOctokitClient(token: string): GithubToolClient {
@@ -148,7 +220,30 @@ function createOctokitClient(token: string): GithubToolClient {
148
220
  retry: {enabled: false},
149
221
  });
150
222
  return {
151
- request: async (route, parameters) => await octokit.request(route, parameters),
223
+ request: async (route, parameters) => {
224
+ if (route !== GITHUB_ARTIFACT_DOWNLOAD_ROUTE) {
225
+ return await octokit.request(route, parameters);
226
+ }
227
+
228
+ const abortController = new AbortController();
229
+ const timeout = setTimeout(
230
+ () => abortController.abort(),
231
+ GITHUB_ARTIFACT_DOWNLOAD_TIMEOUT_MS,
232
+ );
233
+ try {
234
+ return await octokit.request(route, {
235
+ ...parameters,
236
+ request: {
237
+ redirect: 'manual',
238
+ parseSuccessResponseBody: false,
239
+ signal: abortController.signal,
240
+ },
241
+ });
242
+ } finally {
243
+ clearTimeout(timeout);
244
+ }
245
+ },
246
+ graphql: async (query, variables) => await octokit.graphql(query, variables),
152
247
  };
153
248
  }
154
249
 
@@ -167,10 +262,14 @@ function resolveGithubOperation(
167
262
  const route = githubOperationRoute(toolId, method, params);
168
263
  return route === undefined
169
264
  ? undefined
170
- : {route, parameters: projectGithubOperationParameters(toolId, method, params)};
265
+ : {
266
+ route,
267
+ parameters: projectGithubOperationParameters(toolId, method, params),
268
+ kind: route === GITHUB_GRAPHQL_ROUTE ? 'graphql' : 'rest',
269
+ };
171
270
  }
172
271
 
173
- function githubOperationRoute(
272
+ export function githubOperationRoute(
174
273
  toolId: GithubAgentToolId,
175
274
  method: string | undefined,
176
275
  args: Record<string, unknown>,
@@ -217,7 +316,7 @@ function githubOperationRoute(
217
316
  case 'sub_issue_write.remove':
218
317
  return `DELETE ${repoPath}/issues/${issue}/sub_issues/{sub_issue_id}`;
219
318
  case 'sub_issue_write.reprioritize':
220
- return `PATCH ${repoPath}/issues/${issue}/sub_issues/{sub_issue_id}`;
319
+ return `PATCH ${repoPath}/issues/${issue}/sub_issues/priority`;
221
320
  case 'pull_request_read.get':
222
321
  return `GET ${repoPath}/pulls/${pull}`;
223
322
  case 'pull_request_read.get_diff':
@@ -245,7 +344,9 @@ function githubOperationRoute(
245
344
  case 'update_pull_request.':
246
345
  return `PATCH ${repoPath}/pulls/${pull}`;
247
346
  case 'add_reply_to_pull_request_comment.':
248
- return `POST ${repoPath}/pulls/{comment_id}/replies`;
347
+ return args.reaction !== undefined && args.body === undefined
348
+ ? `POST ${repoPath}/pulls/comments/{comment_id}/reactions`
349
+ : `POST ${repoPath}/pulls/${pull}/comments/{comment_id}/replies`;
249
350
  case 'merge_pull_request.':
250
351
  return `PUT ${repoPath}/pulls/${pull}/merge`;
251
352
  case 'update_pull_request_branch.':
@@ -257,7 +358,7 @@ function githubOperationRoute(
257
358
  case 'pull_request_review_write.delete_pending':
258
359
  return `DELETE ${repoPath}/pulls/${pull}/reviews/{review_id}`;
259
360
  case 'add_comment_to_pending_review.':
260
- return `POST ${repoPath}/pulls/${pull}/comments`;
361
+ return GITHUB_GRAPHQL_ROUTE;
261
362
  case 'actions_list.list_workflows':
262
363
  return `GET ${repoPath}/actions/workflows`;
263
364
  case 'actions_list.list_workflow_runs':
@@ -273,7 +374,7 @@ function githubOperationRoute(
273
374
  case 'actions_get.get_workflow_job':
274
375
  return `GET ${repoPath}/actions/jobs/${resource}`;
275
376
  case 'actions_get.download_workflow_run_artifact':
276
- return `GET ${repoPath}/actions/artifacts/${resource}/{archive_format}`;
377
+ return GITHUB_ARTIFACT_DOWNLOAD_ROUTE;
277
378
  case 'actions_get.get_workflow_run_usage':
278
379
  return `GET ${repoPath}/actions/runs/${resource}/timing`;
279
380
  case 'actions_get.get_workflow_run_logs_url':
@@ -295,7 +396,65 @@ function githubOperationRoute(
295
396
  }
296
397
  }
297
398
 
298
- function projectGithubOperationParameters(
399
+ async function addCommentToPendingReview(
400
+ client: GithubToolClient,
401
+ args: Record<string, unknown>,
402
+ ): Promise<unknown | undefined> {
403
+ if (client.graphql === undefined) {
404
+ throw new GithubIntegrationProviderError(
405
+ 'malformed-provider-response',
406
+ 'GitHub client does not support GraphQL operations',
407
+ );
408
+ }
409
+
410
+ const reviewLookup = await client.graphql(LATEST_PENDING_REVIEW_QUERY, {
411
+ owner: args.owner,
412
+ repo: args.repo,
413
+ pullNumber: args.pull_number,
414
+ });
415
+ const reviewId = latestPendingReviewNodeId(reviewLookup);
416
+ if (reviewId === undefined) return undefined;
417
+
418
+ const input: Record<string, unknown> = {
419
+ pullRequestReviewId: reviewId,
420
+ path: args.path,
421
+ body: args.body,
422
+ subjectType: args.subject_type,
423
+ };
424
+ if (args.line !== undefined) input.line = args.line;
425
+ if (args.side !== undefined) input.side = args.side;
426
+ if (args.start_line !== undefined) input.startLine = args.start_line;
427
+ if (args.start_side !== undefined) input.startSide = args.start_side;
428
+
429
+ return await client.graphql(ADD_PENDING_REVIEW_COMMENT_MUTATION, {input});
430
+ }
431
+
432
+ function latestPendingReviewNodeId(data: unknown): string | undefined {
433
+ if (!isRecord(data)) return undefined;
434
+
435
+ const viewer = isRecord(data.viewer) ? data.viewer.login : undefined;
436
+ if (typeof viewer !== 'string') return undefined;
437
+
438
+ const repository = isRecord(data.repository) ? data.repository : undefined;
439
+ const pullRequest =
440
+ repository && isRecord(repository.pullRequest) ? repository.pullRequest : undefined;
441
+ const reviews = pullRequest && isRecord(pullRequest.reviews) ? pullRequest.reviews : undefined;
442
+ const nodes = reviews && Array.isArray(reviews.nodes) ? reviews.nodes : [];
443
+
444
+ let latest: {createdAt: string; id: string} | undefined;
445
+ for (const node of nodes) {
446
+ if (!isRecord(node)) continue;
447
+ const author = isRecord(node.author) ? node.author.login : undefined;
448
+ const id = node.id;
449
+ const createdAt = node.createdAt;
450
+ if (author !== viewer || typeof id !== 'string' || typeof createdAt !== 'string') continue;
451
+ if (latest === undefined || createdAt > latest.createdAt) latest = {createdAt, id};
452
+ }
453
+
454
+ return latest?.id;
455
+ }
456
+
457
+ export function projectGithubOperationParameters(
299
458
  toolId: GithubAgentToolId,
300
459
  method: string | undefined,
301
460
  args: Record<string, unknown>,
@@ -312,8 +471,70 @@ function projectGithubOperationParameters(
312
471
  return parameters;
313
472
  }
314
473
 
315
- function githubToolResult(toolId: GithubAgentToolId, data: unknown): GithubToolCallResult {
316
- const structuredContent = projectGithubToolOutput(toolId, data);
474
+ async function resolvePendingReviewParameters(
475
+ client: GithubToolClient,
476
+ parameters: Record<string, unknown>,
477
+ toolId: GithubAgentToolId,
478
+ method: string | undefined,
479
+ ): Promise<Record<string, unknown> | undefined> {
480
+ if (!isPendingReviewOperation(toolId, method)) return parameters;
481
+
482
+ const reviewId = await latestPendingReviewId(client, parameters);
483
+ return reviewId === undefined ? undefined : {...parameters, review_id: reviewId};
484
+ }
485
+
486
+ function isPendingReviewOperation(toolId: GithubAgentToolId, method: string | undefined): boolean {
487
+ return (
488
+ toolId === 'pull_request_review_write' &&
489
+ (method === 'submit_pending' || method === 'delete_pending')
490
+ );
491
+ }
492
+
493
+ async function latestPendingReviewId(
494
+ client: GithubToolClient,
495
+ parameters: Record<string, unknown>,
496
+ ): Promise<number | undefined> {
497
+ const perPage = 100;
498
+ let page = 1;
499
+ let reviewId: number | undefined;
500
+
501
+ while (true) {
502
+ const response = await client.request('GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews', {
503
+ owner: parameters.owner,
504
+ repo: parameters.repo,
505
+ pull_number: parameters.pull_number,
506
+ per_page: perPage,
507
+ page,
508
+ });
509
+ if (!Array.isArray(response.data)) return undefined;
510
+
511
+ reviewId = latestPendingReviewIdOnPage(response.data) ?? reviewId;
512
+ if (response.data.length < perPage) return reviewId;
513
+ page += 1;
514
+ }
515
+ }
516
+
517
+ function latestPendingReviewIdOnPage(data: readonly unknown[]): number | undefined {
518
+ for (let index = data.length - 1; index >= 0; index -= 1) {
519
+ const review = data[index];
520
+ if (!isRecord(review) || review.state !== 'PENDING') continue;
521
+ if (typeof review.id === 'number' && Number.isSafeInteger(review.id)) return review.id;
522
+ }
523
+
524
+ return undefined;
525
+ }
526
+
527
+ function githubToolResult(
528
+ toolId: GithubAgentToolId,
529
+ data: unknown,
530
+ response?: GithubToolResponse,
531
+ parameters?: Record<string, unknown>,
532
+ route?: string,
533
+ ): GithubToolCallResult {
534
+ const structuredContent = projectGithubToolOutput(toolId, data, response, parameters, route);
535
+ if (structuredContent === undefined) {
536
+ return githubToolError('GitHub artifact download did not return a download URL');
537
+ }
317
538
  return {
318
539
  content: [{type: 'text', text: JSON.stringify(structuredContent)}],
319
540
  structuredContent,
@@ -323,7 +544,14 @@ function githubToolResult(toolId: GithubAgentToolId, data: unknown): GithubToolC
323
544
  function projectGithubToolOutput(
324
545
  toolId: GithubAgentToolId,
325
546
  data: unknown,
326
- ): Record<string, unknown> {
547
+ response?: GithubToolResponse,
548
+ parameters?: Record<string, unknown>,
549
+ route?: string,
550
+ ): Record<string, unknown> | undefined {
551
+ if (route === GITHUB_ARTIFACT_DOWNLOAD_ROUTE) {
552
+ return projectGithubArtifactDownloadOutput(response, parameters);
553
+ }
554
+
327
555
  switch (toolId) {
328
556
  case 'list_issue_types':
329
557
  return {issue_types: data};
@@ -345,6 +573,30 @@ function projectGithubToolOutput(
345
573
  }
346
574
  }
347
575
 
576
+ function projectGithubArtifactDownloadOutput(
577
+ response: GithubToolResponse | undefined,
578
+ parameters: Record<string, unknown> | undefined,
579
+ ): Record<string, unknown> | undefined {
580
+ if (response === undefined) return undefined;
581
+ const downloadUrl = response.headers?.location;
582
+ if (typeof downloadUrl !== 'string' || downloadUrl.length === 0) return undefined;
583
+
584
+ const output: Record<string, unknown> = {
585
+ archive_format: GITHUB_ARTIFACT_ARCHIVE_FORMAT,
586
+ download_url: downloadUrl,
587
+ };
588
+ if (typeof parameters?.resource_id === 'string') output.artifact_id = parameters.resource_id;
589
+
590
+ const contentType = response.headers?.['content-type'];
591
+ if (typeof contentType === 'string') output.content_type = contentType;
592
+
593
+ const contentLength = response.headers?.['content-length'];
594
+ const sizeBytes = typeof contentLength === 'number' ? contentLength : Number(contentLength);
595
+ if (Number.isSafeInteger(sizeBytes) && sizeBytes >= 0) output.size_bytes = sizeBytes;
596
+
597
+ return output;
598
+ }
599
+
348
600
  function githubSearchItems(data: unknown): unknown {
349
601
  return isRecord(data) ? data.items : data;
350
602
  }
@@ -670,7 +670,7 @@ export const githubAgentToolCatalog = [
670
670
  id: 'add_comment_to_pending_review',
671
671
  category: 'pull_requests',
672
672
  description:
673
- "Add review comment to the requester's latest pending pull request review. A pending review needs to already exist to call this.",
673
+ "Add a review comment to the requester's latest pending pull request review. The comment remains part of that pending review until it is submitted; a pending review needs to already exist to call this.",
674
674
  sensitivity: 'write',
675
675
  sensitive: false,
676
676
  requiredScope: scopes.pullRequestsWrite,