@shipfox/api-integration-github 12.5.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 (39) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +8 -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 +124 -78
  19. package/dist/core/agent-tools.js.map +1 -1
  20. package/dist/metrics/instance.d.ts +1 -0
  21. package/dist/metrics/instance.d.ts.map +1 -1
  22. package/dist/metrics/instance.js +19 -0
  23. package/dist/metrics/instance.js.map +1 -1
  24. package/dist/tsconfig.test.tsbuildinfo +1 -1
  25. package/package.json +1 -1
  26. package/src/api/client.test.ts +25 -5
  27. package/src/api/client.ts +22 -5
  28. package/src/api/github-octokit.test.ts +115 -0
  29. package/src/api/github-octokit.ts +49 -0
  30. package/src/api/installation-token-provider.test.ts +44 -5
  31. package/src/api/installation-token-provider.ts +5 -2
  32. package/src/config.ts +5 -0
  33. package/src/core/agent-tools.test.ts +261 -74
  34. package/src/core/agent-tools.ts +191 -80
  35. package/src/metrics/instance.ts +25 -0
  36. package/test/env.ts +1 -0
  37. package/test/fixtures/github-installation-token.ts +8 -0
  38. package/test/index.ts +4 -0
  39. package/tsconfig.build.tsbuildinfo +1 -1
@@ -13,7 +13,7 @@ import {
13
13
  createGithubInstallationTokenProvider,
14
14
  type GithubInstallationTokenProvider,
15
15
  } from '#api/installation-token-provider.js';
16
- import {normalizedGithubApiBaseUrl} from '#config.js';
16
+ import {config, normalizedGithubApiBaseUrl} from '#config.js';
17
17
  import type {GithubInstallation} from '#db/installations.js';
18
18
  import {GithubIntegrationProviderError} from './errors.js';
19
19
  import {
@@ -49,34 +49,25 @@ type GithubToolCallResult = {
49
49
  structuredContent?: Record<string, unknown> | undefined;
50
50
  };
51
51
 
52
+ type GithubToolErrorCode =
53
+ | 'invalid-request'
54
+ | 'access-denied'
55
+ | 'provider-rejected'
56
+ | 'malformed-provider-response';
57
+
52
58
  const GITHUB_GRAPHQL_ROUTE = 'POST /graphql';
53
59
  const GITHUB_ARTIFACT_ARCHIVE_FORMAT = 'zip';
54
60
  const GITHUB_ARTIFACT_DOWNLOAD_ROUTE = `GET /repos/{owner}/{repo}/actions/artifacts/{resource_id}/${GITHUB_ARTIFACT_ARCHIVE_FORMAT}`;
55
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;
56
68
  const NO_PENDING_REVIEW_MESSAGE =
57
69
  'No pending pull request review found for the authenticated GitHub user.';
58
70
 
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
71
  const ADD_PENDING_REVIEW_COMMENT_MUTATION = `
81
72
  mutation AddCommentToPendingReview($input: AddPullRequestReviewThreadInput!) {
82
73
  addPullRequestReviewThread(input: $input) {
@@ -134,16 +125,18 @@ export class GithubAgentToolsProvider
134
125
  return {
135
126
  call: async (call) => {
136
127
  const tool = input.tools.find((candidate) => candidate.id === call.toolId);
137
- if (!tool) return githubToolError(`Unknown GitHub tool: ${call.toolId}`);
128
+ if (!tool) return githubToolError(`Unknown GitHub tool: ${call.toolId}`, 'invalid-request');
138
129
  const operation = resolveGithubOperation(tool, call);
139
- if (operation === undefined) return githubToolError('Unknown GitHub tool operation');
130
+ if (operation === undefined)
131
+ return githubToolError('Unknown GitHub tool operation', 'invalid-request');
140
132
  const validationError = validateGithubToolArguments(tool, call.arguments);
141
- if (validationError) return githubToolError(validationError);
133
+ if (validationError) return githubToolError(validationError, 'invalid-request');
142
134
  tokenPromise ??= this.tokenProvider.getInstallationAccessToken(installationId);
143
135
  const token = await tokenPromise;
144
136
  if (!hasGrantedPermissions(token.permissions ?? {}, tool, call)) {
145
137
  return githubToolError(
146
138
  'GitHub installation token is missing permission for this operation',
139
+ 'access-denied',
147
140
  );
148
141
  }
149
142
  const client = (this.options.createClient ?? createOctokitClient)(token.token);
@@ -155,7 +148,7 @@ export class GithubAgentToolsProvider
155
148
  addCommentToPendingReview(client, operation.parameters),
156
149
  );
157
150
  return data === undefined
158
- ? githubToolError(NO_PENDING_REVIEW_MESSAGE)
151
+ ? githubToolError(NO_PENDING_REVIEW_MESSAGE, 'provider-rejected')
159
152
  : githubToolResult(tool.id as GithubAgentToolId, data);
160
153
  }
161
154
 
@@ -168,7 +161,7 @@ export class GithubAgentToolsProvider
168
161
  ),
169
162
  );
170
163
  if (operationParameters === undefined) {
171
- return githubToolError(NO_PENDING_REVIEW_MESSAGE);
164
+ return githubToolError(NO_PENDING_REVIEW_MESSAGE, 'provider-rejected');
172
165
  }
173
166
  const response = await mapGithubError(() =>
174
167
  client.request(operation.route, operationParameters),
@@ -407,16 +400,17 @@ async function addCommentToPendingReview(
407
400
  );
408
401
  }
409
402
 
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;
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
+ }
417
411
 
418
412
  const input: Record<string, unknown> = {
419
- pullRequestReviewId: reviewId,
413
+ pullRequestReviewId: review.nodeId,
420
414
  path: args.path,
421
415
  body: args.body,
422
416
  subjectType: args.subject_type,
@@ -429,31 +423,6 @@ async function addCommentToPendingReview(
429
423
  return await client.graphql(ADD_PENDING_REVIEW_COMMENT_MUTATION, {input});
430
424
  }
431
425
 
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
426
  export function projectGithubOperationParameters(
458
427
  toolId: GithubAgentToolId,
459
428
  method: string | undefined,
@@ -479,8 +448,15 @@ async function resolvePendingReviewParameters(
479
448
  ): Promise<Record<string, unknown> | undefined> {
480
449
  if (!isPendingReviewOperation(toolId, method)) return parameters;
481
450
 
482
- const reviewId = await latestPendingReviewId(client, parameters);
483
- return reviewId === undefined ? undefined : {...parameters, review_id: reviewId};
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};
484
460
  }
485
461
 
486
462
  function isPendingReviewOperation(toolId: GithubAgentToolId, method: string | undefined): boolean {
@@ -490,38 +466,166 @@ function isPendingReviewOperation(toolId: GithubAgentToolId, method: string | un
490
466
  );
491
467
  }
492
468
 
493
- async function latestPendingReviewId(
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(
494
482
  client: GithubToolClient,
495
483
  parameters: Record<string, unknown>,
496
- ): Promise<number | undefined> {
497
- const perPage = 100;
498
- let page = 1;
499
- let reviewId: number | undefined;
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
+ }
500
503
 
501
- while (true) {
502
- const response = await client.request('GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews', {
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', {
503
564
  owner: parameters.owner,
504
565
  repo: parameters.repo,
505
566
  pull_number: parameters.pull_number,
506
- per_page: perPage,
567
+ per_page: PENDING_REVIEW_PAGE_SIZE,
507
568
  page,
569
+ request: {signal: pageController.signal},
508
570
  });
509
- if (!Array.isArray(response.data)) return undefined;
571
+ } finally {
572
+ clearTimeout(pageTimeout);
573
+ lookupSignal.removeEventListener('abort', abortPage);
574
+ }
575
+ }
510
576
 
511
- reviewId = latestPendingReviewIdOnPage(response.data) ?? reviewId;
512
- if (response.data.length < perPage) return reviewId;
513
- page += 1;
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
+ );
514
589
  }
590
+ return page;
515
591
  }
516
592
 
517
- function latestPendingReviewIdOnPage(data: readonly unknown[]): number | undefined {
593
+ function latestPendingReviewOnPage(
594
+ data: readonly unknown[],
595
+ requiredIdentifier: PendingReviewIdentifier,
596
+ ): PendingReviewPageResult {
597
+ let malformed = false;
598
+ const appLogin = githubAppBotLogin().toLowerCase();
518
599
  for (let index = data.length - 1; index >= 0; index -= 1) {
519
600
  const review = data[index];
520
601
  if (!isRecord(review) || review.state !== 'PENDING') continue;
521
- if (typeof review.id === 'number' && Number.isSafeInteger(review.id)) return review.id;
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;
522
619
  }
523
620
 
524
- return undefined;
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}`;
525
629
  }
526
630
 
527
631
  function githubToolResult(
@@ -533,7 +637,10 @@ function githubToolResult(
533
637
  ): GithubToolCallResult {
534
638
  const structuredContent = projectGithubToolOutput(toolId, data, response, parameters, route);
535
639
  if (structuredContent === undefined) {
536
- return githubToolError('GitHub artifact download did not return a download URL');
640
+ return githubToolError(
641
+ 'GitHub artifact download did not return a download URL',
642
+ 'malformed-provider-response',
643
+ );
537
644
  }
538
645
  return {
539
646
  content: [{type: 'text', text: JSON.stringify(structuredContent)}],
@@ -601,8 +708,12 @@ function githubSearchItems(data: unknown): unknown {
601
708
  return isRecord(data) ? data.items : data;
602
709
  }
603
710
 
604
- function githubToolError(message: string): GithubToolCallResult {
605
- 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
+ };
606
717
  }
607
718
 
608
719
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -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';