@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
@@ -8,11 +8,12 @@ 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,
14
15
  } from '#api/installation-token-provider.js';
15
- import {normalizedGithubApiBaseUrl} from '#config.js';
16
+ import {config, normalizedGithubApiBaseUrl} from '#config.js';
16
17
  import type {GithubInstallation} from '#db/installations.js';
17
18
  import {GithubIntegrationProviderError} from './errors.js';
18
19
  import {
@@ -48,6 +49,35 @@ type GithubToolCallResult = {
48
49
  structuredContent?: Record<string, unknown> | undefined;
49
50
  };
50
51
 
52
+ type GithubToolErrorCode =
53
+ | 'invalid-request'
54
+ | 'access-denied'
55
+ | 'provider-rejected'
56
+ | 'malformed-provider-response';
57
+
58
+ const GITHUB_GRAPHQL_ROUTE = 'POST /graphql';
59
+ const GITHUB_ARTIFACT_ARCHIVE_FORMAT = 'zip';
60
+ const GITHUB_ARTIFACT_DOWNLOAD_ROUTE = `GET /repos/{owner}/{repo}/actions/artifacts/{resource_id}/${GITHUB_ARTIFACT_ARCHIVE_FORMAT}`;
61
+ const GITHUB_ARTIFACT_DOWNLOAD_TIMEOUT_MS = 30_000;
62
+ const GITHUB_APP_BOT_SUFFIX = '[bot]';
63
+ const PENDING_REVIEW_PAGE_SIZE = 100;
64
+ const PENDING_REVIEW_MAX_PAGE_REQUESTS = 5;
65
+ const PENDING_REVIEW_LOOKUP_TIMEOUT_MS = 15_000;
66
+ const PENDING_REVIEW_PAGE_TIMEOUT_MS = 5_000;
67
+ const PENDING_REVIEW_PAGE_PATTERN = /[?&]page=(\d+)/u;
68
+ const NO_PENDING_REVIEW_MESSAGE =
69
+ 'No pending pull request review found for the authenticated GitHub user.';
70
+
71
+ const ADD_PENDING_REVIEW_COMMENT_MUTATION = `
72
+ mutation AddCommentToPendingReview($input: AddPullRequestReviewThreadInput!) {
73
+ addPullRequestReviewThread(input: $input) {
74
+ thread {
75
+ id
76
+ }
77
+ }
78
+ }
79
+ `;
80
+
51
81
  export class GithubAgentToolsProvider
52
82
  implements
53
83
  AgentToolsProvider<
@@ -95,28 +125,54 @@ export class GithubAgentToolsProvider
95
125
  return {
96
126
  call: async (call) => {
97
127
  const tool = input.tools.find((candidate) => candidate.id === call.toolId);
98
- if (!tool) return githubToolError(`Unknown GitHub tool: ${call.toolId}`);
128
+ if (!tool) return githubToolError(`Unknown GitHub tool: ${call.toolId}`, 'invalid-request');
99
129
  const operation = resolveGithubOperation(tool, call);
100
- if (operation === undefined) return githubToolError('Unknown GitHub tool operation');
130
+ if (operation === undefined)
131
+ return githubToolError('Unknown GitHub tool operation', 'invalid-request');
101
132
  const validationError = validateGithubToolArguments(tool, call.arguments);
102
- if (validationError) return githubToolError(validationError);
133
+ if (validationError) return githubToolError(validationError, 'invalid-request');
103
134
  tokenPromise ??= this.tokenProvider.getInstallationAccessToken(installationId);
104
135
  const token = await tokenPromise;
105
136
  if (!hasGrantedPermissions(token.permissions ?? {}, tool, call)) {
106
137
  return githubToolError(
107
138
  'GitHub installation token is missing permission for this operation',
139
+ 'access-denied',
108
140
  );
109
141
  }
110
142
  const client = (this.options.createClient ?? createOctokitClient)(token.token);
143
+ const method =
144
+ typeof call.arguments.method === 'string' ? call.arguments.method : undefined;
145
+
146
+ if (operation.kind === 'graphql') {
147
+ const data = await mapGithubError(() =>
148
+ addCommentToPendingReview(client, operation.parameters),
149
+ );
150
+ return data === undefined
151
+ ? githubToolError(NO_PENDING_REVIEW_MESSAGE, 'provider-rejected')
152
+ : githubToolResult(tool.id as GithubAgentToolId, data);
153
+ }
111
154
 
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;
155
+ const operationParameters = await mapGithubError(() =>
156
+ resolvePendingReviewParameters(
157
+ client,
158
+ operation.parameters,
159
+ tool.id as GithubAgentToolId,
160
+ method,
161
+ ),
162
+ );
163
+ if (operationParameters === undefined) {
164
+ return githubToolError(NO_PENDING_REVIEW_MESSAGE, 'provider-rejected');
119
165
  }
166
+ const response = await mapGithubError(() =>
167
+ client.request(operation.route, operationParameters),
168
+ );
169
+ return githubToolResult(
170
+ tool.id as GithubAgentToolId,
171
+ response.data,
172
+ response,
173
+ operationParameters,
174
+ operation.route,
175
+ );
120
176
  },
121
177
  };
122
178
  }
@@ -130,8 +186,16 @@ export interface GithubAgentToolsProviderOptions {
130
186
  createClient?: GithubToolClientFactory | undefined;
131
187
  }
132
188
 
189
+ export interface GithubToolResponse {
190
+ data: unknown;
191
+ headers?: Record<string, string | number | undefined> | undefined;
192
+ status?: number | undefined;
193
+ url?: string | undefined;
194
+ }
195
+
133
196
  export interface GithubToolClient {
134
- request(route: string, parameters: Record<string, unknown>): Promise<{data: unknown}>;
197
+ request(route: string, parameters: Record<string, unknown>): Promise<GithubToolResponse>;
198
+ graphql?: ((query: string, variables: Record<string, unknown>) => Promise<unknown>) | undefined;
135
199
  }
136
200
 
137
201
  export type GithubToolClientFactory = (token: string) => GithubToolClient;
@@ -139,6 +203,7 @@ export type GithubToolClientFactory = (token: string) => GithubToolClient;
139
203
  interface GithubToolOperation {
140
204
  route: string;
141
205
  parameters: Record<string, unknown>;
206
+ kind: 'rest' | 'graphql';
142
207
  }
143
208
 
144
209
  function createOctokitClient(token: string): GithubToolClient {
@@ -148,7 +213,30 @@ function createOctokitClient(token: string): GithubToolClient {
148
213
  retry: {enabled: false},
149
214
  });
150
215
  return {
151
- request: async (route, parameters) => await octokit.request(route, parameters),
216
+ request: async (route, parameters) => {
217
+ if (route !== GITHUB_ARTIFACT_DOWNLOAD_ROUTE) {
218
+ return await octokit.request(route, parameters);
219
+ }
220
+
221
+ const abortController = new AbortController();
222
+ const timeout = setTimeout(
223
+ () => abortController.abort(),
224
+ GITHUB_ARTIFACT_DOWNLOAD_TIMEOUT_MS,
225
+ );
226
+ try {
227
+ return await octokit.request(route, {
228
+ ...parameters,
229
+ request: {
230
+ redirect: 'manual',
231
+ parseSuccessResponseBody: false,
232
+ signal: abortController.signal,
233
+ },
234
+ });
235
+ } finally {
236
+ clearTimeout(timeout);
237
+ }
238
+ },
239
+ graphql: async (query, variables) => await octokit.graphql(query, variables),
152
240
  };
153
241
  }
154
242
 
@@ -167,10 +255,14 @@ function resolveGithubOperation(
167
255
  const route = githubOperationRoute(toolId, method, params);
168
256
  return route === undefined
169
257
  ? undefined
170
- : {route, parameters: projectGithubOperationParameters(toolId, method, params)};
258
+ : {
259
+ route,
260
+ parameters: projectGithubOperationParameters(toolId, method, params),
261
+ kind: route === GITHUB_GRAPHQL_ROUTE ? 'graphql' : 'rest',
262
+ };
171
263
  }
172
264
 
173
- function githubOperationRoute(
265
+ export function githubOperationRoute(
174
266
  toolId: GithubAgentToolId,
175
267
  method: string | undefined,
176
268
  args: Record<string, unknown>,
@@ -217,7 +309,7 @@ function githubOperationRoute(
217
309
  case 'sub_issue_write.remove':
218
310
  return `DELETE ${repoPath}/issues/${issue}/sub_issues/{sub_issue_id}`;
219
311
  case 'sub_issue_write.reprioritize':
220
- return `PATCH ${repoPath}/issues/${issue}/sub_issues/{sub_issue_id}`;
312
+ return `PATCH ${repoPath}/issues/${issue}/sub_issues/priority`;
221
313
  case 'pull_request_read.get':
222
314
  return `GET ${repoPath}/pulls/${pull}`;
223
315
  case 'pull_request_read.get_diff':
@@ -245,7 +337,9 @@ function githubOperationRoute(
245
337
  case 'update_pull_request.':
246
338
  return `PATCH ${repoPath}/pulls/${pull}`;
247
339
  case 'add_reply_to_pull_request_comment.':
248
- return `POST ${repoPath}/pulls/{comment_id}/replies`;
340
+ return args.reaction !== undefined && args.body === undefined
341
+ ? `POST ${repoPath}/pulls/comments/{comment_id}/reactions`
342
+ : `POST ${repoPath}/pulls/${pull}/comments/{comment_id}/replies`;
249
343
  case 'merge_pull_request.':
250
344
  return `PUT ${repoPath}/pulls/${pull}/merge`;
251
345
  case 'update_pull_request_branch.':
@@ -257,7 +351,7 @@ function githubOperationRoute(
257
351
  case 'pull_request_review_write.delete_pending':
258
352
  return `DELETE ${repoPath}/pulls/${pull}/reviews/{review_id}`;
259
353
  case 'add_comment_to_pending_review.':
260
- return `POST ${repoPath}/pulls/${pull}/comments`;
354
+ return GITHUB_GRAPHQL_ROUTE;
261
355
  case 'actions_list.list_workflows':
262
356
  return `GET ${repoPath}/actions/workflows`;
263
357
  case 'actions_list.list_workflow_runs':
@@ -273,7 +367,7 @@ function githubOperationRoute(
273
367
  case 'actions_get.get_workflow_job':
274
368
  return `GET ${repoPath}/actions/jobs/${resource}`;
275
369
  case 'actions_get.download_workflow_run_artifact':
276
- return `GET ${repoPath}/actions/artifacts/${resource}/{archive_format}`;
370
+ return GITHUB_ARTIFACT_DOWNLOAD_ROUTE;
277
371
  case 'actions_get.get_workflow_run_usage':
278
372
  return `GET ${repoPath}/actions/runs/${resource}/timing`;
279
373
  case 'actions_get.get_workflow_run_logs_url':
@@ -295,7 +389,41 @@ function githubOperationRoute(
295
389
  }
296
390
  }
297
391
 
298
- function projectGithubOperationParameters(
392
+ async function addCommentToPendingReview(
393
+ client: GithubToolClient,
394
+ args: Record<string, unknown>,
395
+ ): Promise<unknown | undefined> {
396
+ if (client.graphql === undefined) {
397
+ throw new GithubIntegrationProviderError(
398
+ 'malformed-provider-response',
399
+ 'GitHub client does not support GraphQL operations',
400
+ );
401
+ }
402
+
403
+ const review = await latestPendingReview(client, args, 'nodeId');
404
+ if (review === undefined) return undefined;
405
+ if (review.nodeId === undefined) {
406
+ throw new GithubIntegrationProviderError(
407
+ 'malformed-provider-response',
408
+ 'GitHub pending pull request review did not include a node ID',
409
+ );
410
+ }
411
+
412
+ const input: Record<string, unknown> = {
413
+ pullRequestReviewId: review.nodeId,
414
+ path: args.path,
415
+ body: args.body,
416
+ subjectType: args.subject_type,
417
+ };
418
+ if (args.line !== undefined) input.line = args.line;
419
+ if (args.side !== undefined) input.side = args.side;
420
+ if (args.start_line !== undefined) input.startLine = args.start_line;
421
+ if (args.start_side !== undefined) input.startSide = args.start_side;
422
+
423
+ return await client.graphql(ADD_PENDING_REVIEW_COMMENT_MUTATION, {input});
424
+ }
425
+
426
+ export function projectGithubOperationParameters(
299
427
  toolId: GithubAgentToolId,
300
428
  method: string | undefined,
301
429
  args: Record<string, unknown>,
@@ -312,8 +440,208 @@ function projectGithubOperationParameters(
312
440
  return parameters;
313
441
  }
314
442
 
315
- function githubToolResult(toolId: GithubAgentToolId, data: unknown): GithubToolCallResult {
316
- const structuredContent = projectGithubToolOutput(toolId, data);
443
+ async function resolvePendingReviewParameters(
444
+ client: GithubToolClient,
445
+ parameters: Record<string, unknown>,
446
+ toolId: GithubAgentToolId,
447
+ method: string | undefined,
448
+ ): Promise<Record<string, unknown> | undefined> {
449
+ if (!isPendingReviewOperation(toolId, method)) return parameters;
450
+
451
+ const review = await latestPendingReview(client, parameters, 'id');
452
+ if (review === undefined) return undefined;
453
+ if (review.id === undefined) {
454
+ throw new GithubIntegrationProviderError(
455
+ 'malformed-provider-response',
456
+ 'GitHub pending pull request review did not include a numeric ID',
457
+ );
458
+ }
459
+ return {...parameters, review_id: review.id};
460
+ }
461
+
462
+ function isPendingReviewOperation(toolId: GithubAgentToolId, method: string | undefined): boolean {
463
+ return (
464
+ toolId === 'pull_request_review_write' &&
465
+ (method === 'submit_pending' || method === 'delete_pending')
466
+ );
467
+ }
468
+
469
+ interface PendingReviewReference {
470
+ id?: number | undefined;
471
+ nodeId?: string | undefined;
472
+ }
473
+
474
+ type PendingReviewIdentifier = keyof PendingReviewReference;
475
+
476
+ interface PendingReviewPageResult {
477
+ malformed: boolean;
478
+ review?: PendingReviewReference | undefined;
479
+ }
480
+
481
+ async function latestPendingReview(
482
+ client: GithubToolClient,
483
+ parameters: Record<string, unknown>,
484
+ requiredIdentifier: PendingReviewIdentifier,
485
+ ): Promise<PendingReviewReference | undefined> {
486
+ const lookupController = new AbortController();
487
+ const lookupTimeout = setTimeout(
488
+ () => lookupController.abort(),
489
+ PENDING_REVIEW_LOOKUP_TIMEOUT_MS,
490
+ );
491
+
492
+ try {
493
+ return await latestPendingReviewBeforeDeadline(
494
+ client,
495
+ parameters,
496
+ requiredIdentifier,
497
+ lookupController.signal,
498
+ );
499
+ } finally {
500
+ clearTimeout(lookupTimeout);
501
+ }
502
+ }
503
+
504
+ async function latestPendingReviewBeforeDeadline(
505
+ client: GithubToolClient,
506
+ parameters: Record<string, unknown>,
507
+ requiredIdentifier: PendingReviewIdentifier,
508
+ lookupSignal: AbortSignal,
509
+ ): Promise<PendingReviewReference | undefined> {
510
+ const firstPage = await requestPendingReviewPage(client, parameters, 1, lookupSignal);
511
+ const lastPage = pendingReviewLastPage(firstPage.headers);
512
+ let requests = 1;
513
+ let malformed = false;
514
+
515
+ for (let page = lastPage; page >= 1; page -= 1) {
516
+ let response = firstPage;
517
+ if (page !== 1) {
518
+ if (requests >= PENDING_REVIEW_MAX_PAGE_REQUESTS) {
519
+ throw new GithubIntegrationProviderError(
520
+ 'content-too-large',
521
+ 'GitHub pull request review history exceeded the pending review lookup limit',
522
+ );
523
+ }
524
+ response = await requestPendingReviewPage(client, parameters, page, lookupSignal);
525
+ requests += 1;
526
+ }
527
+ if (!Array.isArray(response.data)) {
528
+ throw new GithubIntegrationProviderError(
529
+ 'malformed-provider-response',
530
+ 'GitHub pull request review list response was malformed',
531
+ );
532
+ }
533
+
534
+ const result = latestPendingReviewOnPage(response.data, requiredIdentifier);
535
+ if (result.review !== undefined) return result.review;
536
+ malformed ||= result.malformed;
537
+ }
538
+
539
+ if (malformed) {
540
+ throw new GithubIntegrationProviderError(
541
+ 'malformed-provider-response',
542
+ requiredIdentifier === 'nodeId'
543
+ ? 'GitHub pending pull request review did not include a node ID'
544
+ : 'GitHub pending pull request review did not include a numeric ID',
545
+ );
546
+ }
547
+ return undefined;
548
+ }
549
+
550
+ async function requestPendingReviewPage(
551
+ client: GithubToolClient,
552
+ parameters: Record<string, unknown>,
553
+ page: number,
554
+ lookupSignal: AbortSignal,
555
+ ): Promise<GithubToolResponse> {
556
+ const pageController = new AbortController();
557
+ const abortPage = () => pageController.abort();
558
+ if (lookupSignal.aborted) abortPage();
559
+ else lookupSignal.addEventListener('abort', abortPage, {once: true});
560
+ const pageTimeout = setTimeout(abortPage, PENDING_REVIEW_PAGE_TIMEOUT_MS);
561
+
562
+ try {
563
+ return await client.request('GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews', {
564
+ owner: parameters.owner,
565
+ repo: parameters.repo,
566
+ pull_number: parameters.pull_number,
567
+ per_page: PENDING_REVIEW_PAGE_SIZE,
568
+ page,
569
+ request: {signal: pageController.signal},
570
+ });
571
+ } finally {
572
+ clearTimeout(pageTimeout);
573
+ lookupSignal.removeEventListener('abort', abortPage);
574
+ }
575
+ }
576
+
577
+ function pendingReviewLastPage(headers: GithubToolResponse['headers']): number {
578
+ const link = headers?.link;
579
+ if (typeof link !== 'string') return 1;
580
+ const lastLink = link.split(',').find((part) => part.includes('rel="last"'));
581
+ if (lastLink === undefined) return 1;
582
+ const match = PENDING_REVIEW_PAGE_PATTERN.exec(lastLink);
583
+ const page = match?.[1] === undefined ? Number.NaN : Number.parseInt(match[1], 10);
584
+ if (!Number.isSafeInteger(page) || page < 1) {
585
+ throw new GithubIntegrationProviderError(
586
+ 'malformed-provider-response',
587
+ 'GitHub pull request review pagination response was malformed',
588
+ );
589
+ }
590
+ return page;
591
+ }
592
+
593
+ function latestPendingReviewOnPage(
594
+ data: readonly unknown[],
595
+ requiredIdentifier: PendingReviewIdentifier,
596
+ ): PendingReviewPageResult {
597
+ let malformed = false;
598
+ const appLogin = githubAppBotLogin().toLowerCase();
599
+ for (let index = data.length - 1; index >= 0; index -= 1) {
600
+ const review = data[index];
601
+ if (!isRecord(review) || review.state !== 'PENDING') continue;
602
+ const userLogin = isRecord(review.user) ? review.user.login : undefined;
603
+ if (typeof userLogin !== 'string' || userLogin.trim().length === 0) {
604
+ malformed = true;
605
+ continue;
606
+ }
607
+ if (userLogin.trim().toLowerCase() !== appLogin) continue;
608
+ const id =
609
+ typeof review.id === 'number' && Number.isSafeInteger(review.id) && review.id > 0
610
+ ? review.id
611
+ : undefined;
612
+ const nodeId =
613
+ typeof review.node_id === 'string' && review.node_id.trim().length > 0
614
+ ? review.node_id.trim()
615
+ : undefined;
616
+ const reference = {id, nodeId};
617
+ if (reference[requiredIdentifier] !== undefined) return {malformed, review: reference};
618
+ malformed = true;
619
+ }
620
+
621
+ return {malformed};
622
+ }
623
+
624
+ function githubAppBotLogin(): string {
625
+ const configuredUsername = config.GITHUB_APP_USERNAME?.trim() || config.GITHUB_APP_SLUG.trim();
626
+ return configuredUsername.toLowerCase().endsWith(GITHUB_APP_BOT_SUFFIX)
627
+ ? configuredUsername
628
+ : `${configuredUsername}${GITHUB_APP_BOT_SUFFIX}`;
629
+ }
630
+
631
+ function githubToolResult(
632
+ toolId: GithubAgentToolId,
633
+ data: unknown,
634
+ response?: GithubToolResponse,
635
+ parameters?: Record<string, unknown>,
636
+ route?: string,
637
+ ): GithubToolCallResult {
638
+ const structuredContent = projectGithubToolOutput(toolId, data, response, parameters, route);
639
+ if (structuredContent === undefined) {
640
+ return githubToolError(
641
+ 'GitHub artifact download did not return a download URL',
642
+ 'malformed-provider-response',
643
+ );
644
+ }
317
645
  return {
318
646
  content: [{type: 'text', text: JSON.stringify(structuredContent)}],
319
647
  structuredContent,
@@ -323,7 +651,14 @@ function githubToolResult(toolId: GithubAgentToolId, data: unknown): GithubToolC
323
651
  function projectGithubToolOutput(
324
652
  toolId: GithubAgentToolId,
325
653
  data: unknown,
326
- ): Record<string, unknown> {
654
+ response?: GithubToolResponse,
655
+ parameters?: Record<string, unknown>,
656
+ route?: string,
657
+ ): Record<string, unknown> | undefined {
658
+ if (route === GITHUB_ARTIFACT_DOWNLOAD_ROUTE) {
659
+ return projectGithubArtifactDownloadOutput(response, parameters);
660
+ }
661
+
327
662
  switch (toolId) {
328
663
  case 'list_issue_types':
329
664
  return {issue_types: data};
@@ -345,12 +680,40 @@ function projectGithubToolOutput(
345
680
  }
346
681
  }
347
682
 
683
+ function projectGithubArtifactDownloadOutput(
684
+ response: GithubToolResponse | undefined,
685
+ parameters: Record<string, unknown> | undefined,
686
+ ): Record<string, unknown> | undefined {
687
+ if (response === undefined) return undefined;
688
+ const downloadUrl = response.headers?.location;
689
+ if (typeof downloadUrl !== 'string' || downloadUrl.length === 0) return undefined;
690
+
691
+ const output: Record<string, unknown> = {
692
+ archive_format: GITHUB_ARTIFACT_ARCHIVE_FORMAT,
693
+ download_url: downloadUrl,
694
+ };
695
+ if (typeof parameters?.resource_id === 'string') output.artifact_id = parameters.resource_id;
696
+
697
+ const contentType = response.headers?.['content-type'];
698
+ if (typeof contentType === 'string') output.content_type = contentType;
699
+
700
+ const contentLength = response.headers?.['content-length'];
701
+ const sizeBytes = typeof contentLength === 'number' ? contentLength : Number(contentLength);
702
+ if (Number.isSafeInteger(sizeBytes) && sizeBytes >= 0) output.size_bytes = sizeBytes;
703
+
704
+ return output;
705
+ }
706
+
348
707
  function githubSearchItems(data: unknown): unknown {
349
708
  return isRecord(data) ? data.items : data;
350
709
  }
351
710
 
352
- function githubToolError(message: string): GithubToolCallResult {
353
- return {isError: true, content: [{type: 'text', text: message}]};
711
+ function githubToolError(message: string, code: GithubToolErrorCode): GithubToolCallResult {
712
+ return {
713
+ isError: true,
714
+ content: [{type: 'text', text: message}],
715
+ structuredContent: {code},
716
+ };
354
717
  }
355
718
 
356
719
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -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,
@@ -1,6 +1,7 @@
1
1
  import type {IntegrationProviderErrorReason} from '@shipfox/api-integration-spi';
2
2
  import {instanceMetrics} from '@shipfox/node-opentelemetry';
3
3
  import type {MintErrorClass} from '#api/installation-token-envelope.js';
4
+ import {config} from '#config.js';
4
5
 
5
6
  const meter = instanceMetrics.getMeter('github');
6
7
 
@@ -32,6 +33,13 @@ const installationTokenMintDuration = meter.createHistogram<Record<string, never
32
33
  },
33
34
  );
34
35
 
36
+ const installationTokenFormatCount = meter.createCounter<{
37
+ format: 'stateless' | 'stateful' | 'unknown';
38
+ override: 'enabled' | 'disabled' | 'absent';
39
+ }>('github_installation_token_format', {
40
+ description: 'GitHub installation tokens observed by format and requested override',
41
+ });
42
+
35
43
  const installationTokenLockWaitDuration = meter.createHistogram<Record<string, never>>(
36
44
  'github_installation_token_lock_wait_duration',
37
45
  {
@@ -70,6 +78,15 @@ export function recordInstallationTokenMint(params: {
70
78
  });
71
79
  }
72
80
 
81
+ export function recordInstallationTokenFormat(token: string): void {
82
+ recordMetric(() => {
83
+ installationTokenFormatCount.add(1, {
84
+ format: installationTokenFormat(token),
85
+ override: config.GITHUB_INSTALLATION_TOKEN_FORMAT_OVERRIDE ?? 'absent',
86
+ });
87
+ });
88
+ }
89
+
73
90
  export function recordInstallationTokenLockWait(durationMs: number): void {
74
91
  recordMetric(() => installationTokenLockWaitDuration.record(durationMs));
75
92
  }
@@ -80,3 +97,11 @@ export function recordInstallationTokenBackoff(params: {
80
97
  }): void {
81
98
  recordMetric(() => installationTokenBackoffCount.add(1, params));
82
99
  }
100
+
101
+ function installationTokenFormat(token: string): 'stateless' | 'stateful' | 'unknown' {
102
+ if (!token.startsWith('ghs_')) return 'unknown';
103
+ const dotCount = token.slice(4).split('.').length - 1;
104
+ if (dotCount === 2) return 'stateless';
105
+ if (dotCount === 0) return 'stateful';
106
+ return 'unknown';
107
+ }
package/test/env.ts CHANGED
@@ -14,3 +14,4 @@ process.env.GITHUB_APP_SLUG = 'shipfox-test';
14
14
  process.env.GITHUB_APP_USERNAME = 'shipfox-test';
15
15
  process.env.GITHUB_INSTALL_STATE_SECRET = 'test-install-state-secret';
16
16
  process.env.GITHUB_API_BASE_URL = 'https://api.github.com';
17
+ process.env.GITHUB_INSTALLATION_TOKEN_FORMAT_OVERRIDE = 'enabled';
@@ -0,0 +1,8 @@
1
+ const JWT_SEGMENT_LENGTH = 169;
2
+
3
+ export const GITHUB_STATELESS_INSTALLATION_TOKEN =
4
+ `ghs_123456_${'a'.repeat(JWT_SEGMENT_LENGTH)}` +
5
+ `.${'b'.repeat(JWT_SEGMENT_LENGTH)}` +
6
+ `.${'c'.repeat(JWT_SEGMENT_LENGTH)}`;
7
+
8
+ export const GITHUB_STATEFUL_INSTALLATION_TOKEN = `ghs_${'d'.repeat(36)}`;
package/test/index.ts CHANGED
@@ -1,2 +1,6 @@
1
1
  export {githubInstallationFactory} from './factories/github-installation.js';
2
+ export {
3
+ GITHUB_STATEFUL_INSTALLATION_TOKEN,
4
+ GITHUB_STATELESS_INSTALLATION_TOKEN,
5
+ } from './fixtures/github-installation-token.js';
2
6
  export {type GithubPushPayloadOptions, githubPushPayload} from './fixtures/github-webhook.js';