@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +14 -0
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +8 -5
- package/dist/api/client.js.map +1 -1
- package/dist/api/github-octokit.d.ts +7 -0
- package/dist/api/github-octokit.d.ts.map +1 -0
- package/dist/api/github-octokit.js +32 -0
- package/dist/api/github-octokit.js.map +1 -0
- package/dist/api/installation-token-provider.d.ts.map +1 -1
- package/dist/api/installation-token-provider.js +4 -2
- package/dist/api/installation-token-provider.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +8 -0
- package/dist/config.js.map +1 -1
- package/dist/core/agent-tools.d.ts.map +1 -1
- package/dist/core/agent-tools.js +203 -68
- package/dist/core/agent-tools.js.map +1 -1
- package/dist/core/github-agent-tool-catalog.d.ts +1 -1
- package/dist/core/github-agent-tool-catalog.d.ts.map +1 -1
- package/dist/core/github-agent-tool-catalog.js +19 -0
- package/dist/core/github-agent-tool-catalog.js.map +1 -1
- package/dist/metrics/instance.d.ts +1 -0
- package/dist/metrics/instance.d.ts.map +1 -1
- package/dist/metrics/instance.js +19 -0
- package/dist/metrics/instance.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/api/client.test.ts +25 -5
- package/src/api/client.ts +22 -5
- package/src/api/github-octokit.test.ts +115 -0
- package/src/api/github-octokit.ts +49 -0
- package/src/api/installation-token-provider.test.ts +44 -5
- package/src/api/installation-token-provider.ts +5 -2
- package/src/config.ts +5 -0
- package/src/core/agent-tools.test.ts +395 -74
- package/src/core/agent-tools.ts +284 -69
- package/src/core/github-agent-tool-catalog.ts +35 -0
- package/src/metrics/instance.ts +25 -0
- package/test/env.ts +1 -0
- package/test/fixtures/github-installation-token.ts +8 -0
- package/test/index.ts +4 -0
- package/tsconfig.build.tsbuildinfo +1 -1
package/src/core/agent-tools.ts
CHANGED
|
@@ -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,27 +49,74 @@ 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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
71
|
+
const ADD_PENDING_REVIEW_COMMENT_MUTATION = `
|
|
72
|
+
mutation AddCommentToPendingReview($input: AddPullRequestReviewThreadInput!) {
|
|
73
|
+
addPullRequestReviewThread(input: $input) {
|
|
74
|
+
thread {
|
|
75
|
+
id
|
|
76
|
+
}
|
|
63
77
|
}
|
|
78
|
+
}
|
|
79
|
+
`;
|
|
80
|
+
|
|
81
|
+
const GET_PULL_REQUEST_REVIEW_THREADS_QUERY = `
|
|
82
|
+
query GetPullRequestReviewThreads(
|
|
83
|
+
$owner: String!
|
|
84
|
+
$repo: String!
|
|
85
|
+
$pullNumber: Int!
|
|
86
|
+
$after: String
|
|
87
|
+
) {
|
|
64
88
|
repository(owner: $owner, name: $repo) {
|
|
65
89
|
pullRequest(number: $pullNumber) {
|
|
66
|
-
|
|
90
|
+
reviewThreads(first: 100, after: $after) {
|
|
67
91
|
nodes {
|
|
68
92
|
id
|
|
69
|
-
|
|
70
|
-
|
|
93
|
+
isResolved
|
|
94
|
+
comments(first: 100) {
|
|
95
|
+
nodes {
|
|
96
|
+
id
|
|
97
|
+
databaseId
|
|
98
|
+
body
|
|
99
|
+
author {
|
|
100
|
+
login
|
|
101
|
+
}
|
|
102
|
+
path
|
|
103
|
+
line
|
|
104
|
+
side
|
|
105
|
+
startLine
|
|
106
|
+
startSide
|
|
107
|
+
createdAt
|
|
108
|
+
updatedAt
|
|
109
|
+
url
|
|
110
|
+
}
|
|
111
|
+
pageInfo {
|
|
112
|
+
hasNextPage
|
|
113
|
+
endCursor
|
|
114
|
+
}
|
|
71
115
|
}
|
|
72
|
-
|
|
116
|
+
}
|
|
117
|
+
pageInfo {
|
|
118
|
+
hasNextPage
|
|
119
|
+
endCursor
|
|
73
120
|
}
|
|
74
121
|
}
|
|
75
122
|
}
|
|
@@ -77,11 +124,12 @@ const LATEST_PENDING_REVIEW_QUERY = `
|
|
|
77
124
|
}
|
|
78
125
|
`;
|
|
79
126
|
|
|
80
|
-
const
|
|
81
|
-
mutation
|
|
82
|
-
|
|
127
|
+
const RESOLVE_PULL_REQUEST_REVIEW_THREAD_MUTATION = `
|
|
128
|
+
mutation ResolvePullRequestReviewThread($input: ResolveReviewThreadInput!) {
|
|
129
|
+
resolveReviewThread(input: $input) {
|
|
83
130
|
thread {
|
|
84
131
|
id
|
|
132
|
+
isResolved
|
|
85
133
|
}
|
|
86
134
|
}
|
|
87
135
|
}
|
|
@@ -134,16 +182,18 @@ export class GithubAgentToolsProvider
|
|
|
134
182
|
return {
|
|
135
183
|
call: async (call) => {
|
|
136
184
|
const tool = input.tools.find((candidate) => candidate.id === call.toolId);
|
|
137
|
-
if (!tool) return githubToolError(`Unknown GitHub tool: ${call.toolId}
|
|
185
|
+
if (!tool) return githubToolError(`Unknown GitHub tool: ${call.toolId}`, 'invalid-request');
|
|
138
186
|
const operation = resolveGithubOperation(tool, call);
|
|
139
|
-
if (operation === undefined)
|
|
187
|
+
if (operation === undefined)
|
|
188
|
+
return githubToolError('Unknown GitHub tool operation', 'invalid-request');
|
|
140
189
|
const validationError = validateGithubToolArguments(tool, call.arguments);
|
|
141
|
-
if (validationError) return githubToolError(validationError);
|
|
190
|
+
if (validationError) return githubToolError(validationError, 'invalid-request');
|
|
142
191
|
tokenPromise ??= this.tokenProvider.getInstallationAccessToken(installationId);
|
|
143
192
|
const token = await tokenPromise;
|
|
144
193
|
if (!hasGrantedPermissions(token.permissions ?? {}, tool, call)) {
|
|
145
194
|
return githubToolError(
|
|
146
195
|
'GitHub installation token is missing permission for this operation',
|
|
196
|
+
'access-denied',
|
|
147
197
|
);
|
|
148
198
|
}
|
|
149
199
|
const client = (this.options.createClient ?? createOctokitClient)(token.token);
|
|
@@ -152,11 +202,17 @@ export class GithubAgentToolsProvider
|
|
|
152
202
|
|
|
153
203
|
if (operation.kind === 'graphql') {
|
|
154
204
|
const data = await mapGithubError(() =>
|
|
155
|
-
|
|
205
|
+
executeGithubGraphqlOperation(
|
|
206
|
+
client,
|
|
207
|
+
tool.id as GithubAgentToolId,
|
|
208
|
+
method,
|
|
209
|
+
operation.parameters,
|
|
210
|
+
),
|
|
156
211
|
);
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
212
|
+
if (data === undefined && tool.id === 'add_comment_to_pending_review') {
|
|
213
|
+
return githubToolError(NO_PENDING_REVIEW_MESSAGE, 'provider-rejected');
|
|
214
|
+
}
|
|
215
|
+
return githubToolResult(tool.id as GithubAgentToolId, data);
|
|
160
216
|
}
|
|
161
217
|
|
|
162
218
|
const operationParameters = await mapGithubError(() =>
|
|
@@ -168,7 +224,7 @@ export class GithubAgentToolsProvider
|
|
|
168
224
|
),
|
|
169
225
|
);
|
|
170
226
|
if (operationParameters === undefined) {
|
|
171
|
-
return githubToolError(NO_PENDING_REVIEW_MESSAGE);
|
|
227
|
+
return githubToolError(NO_PENDING_REVIEW_MESSAGE, 'provider-rejected');
|
|
172
228
|
}
|
|
173
229
|
const response = await mapGithubError(() =>
|
|
174
230
|
client.request(operation.route, operationParameters),
|
|
@@ -329,6 +385,8 @@ export function githubOperationRoute(
|
|
|
329
385
|
return `GET ${repoPath}/pulls/${pull}/commits`;
|
|
330
386
|
case 'pull_request_read.get_review_comments':
|
|
331
387
|
return `GET ${repoPath}/pulls/${pull}/comments`;
|
|
388
|
+
case 'pull_request_read.get_review_threads':
|
|
389
|
+
return GITHUB_GRAPHQL_ROUTE;
|
|
332
390
|
case 'pull_request_read.get_reviews':
|
|
333
391
|
return `GET ${repoPath}/pulls/${pull}/reviews`;
|
|
334
392
|
case 'pull_request_read.get_comments':
|
|
@@ -357,6 +415,8 @@ export function githubOperationRoute(
|
|
|
357
415
|
return `POST ${repoPath}/pulls/${pull}/reviews/{review_id}/events`;
|
|
358
416
|
case 'pull_request_review_write.delete_pending':
|
|
359
417
|
return `DELETE ${repoPath}/pulls/${pull}/reviews/{review_id}`;
|
|
418
|
+
case 'pull_request_review_thread_write.resolve':
|
|
419
|
+
return GITHUB_GRAPHQL_ROUTE;
|
|
360
420
|
case 'add_comment_to_pending_review.':
|
|
361
421
|
return GITHUB_GRAPHQL_ROUTE;
|
|
362
422
|
case 'actions_list.list_workflows':
|
|
@@ -407,16 +467,17 @@ async function addCommentToPendingReview(
|
|
|
407
467
|
);
|
|
408
468
|
}
|
|
409
469
|
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
470
|
+
const review = await latestPendingReview(client, args, 'nodeId');
|
|
471
|
+
if (review === undefined) return undefined;
|
|
472
|
+
if (review.nodeId === undefined) {
|
|
473
|
+
throw new GithubIntegrationProviderError(
|
|
474
|
+
'malformed-provider-response',
|
|
475
|
+
'GitHub pending pull request review did not include a node ID',
|
|
476
|
+
);
|
|
477
|
+
}
|
|
417
478
|
|
|
418
479
|
const input: Record<string, unknown> = {
|
|
419
|
-
pullRequestReviewId:
|
|
480
|
+
pullRequestReviewId: review.nodeId,
|
|
420
481
|
path: args.path,
|
|
421
482
|
body: args.body,
|
|
422
483
|
subjectType: args.subject_type,
|
|
@@ -429,29 +490,41 @@ async function addCommentToPendingReview(
|
|
|
429
490
|
return await client.graphql(ADD_PENDING_REVIEW_COMMENT_MUTATION, {input});
|
|
430
491
|
}
|
|
431
492
|
|
|
432
|
-
function
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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};
|
|
493
|
+
async function executeGithubGraphqlOperation(
|
|
494
|
+
client: GithubToolClient,
|
|
495
|
+
toolId: GithubAgentToolId,
|
|
496
|
+
method: string | undefined,
|
|
497
|
+
parameters: Record<string, unknown>,
|
|
498
|
+
): Promise<unknown | undefined> {
|
|
499
|
+
if (client.graphql === undefined) {
|
|
500
|
+
throw new GithubIntegrationProviderError(
|
|
501
|
+
'malformed-provider-response',
|
|
502
|
+
'GitHub client does not support GraphQL operations',
|
|
503
|
+
);
|
|
452
504
|
}
|
|
453
505
|
|
|
454
|
-
|
|
506
|
+
switch (`${toolId}.${method ?? ''}`) {
|
|
507
|
+
case 'pull_request_read.get_review_threads': {
|
|
508
|
+
const variables: Record<string, unknown> = {
|
|
509
|
+
owner: parameters.owner,
|
|
510
|
+
repo: parameters.repo,
|
|
511
|
+
pullNumber: parameters.pull_number,
|
|
512
|
+
};
|
|
513
|
+
if (typeof parameters.cursor === 'string') variables.after = parameters.cursor;
|
|
514
|
+
return await client.graphql(GET_PULL_REQUEST_REVIEW_THREADS_QUERY, variables);
|
|
515
|
+
}
|
|
516
|
+
case 'pull_request_review_thread_write.resolve':
|
|
517
|
+
return await client.graphql(RESOLVE_PULL_REQUEST_REVIEW_THREAD_MUTATION, {
|
|
518
|
+
input: {threadId: parameters.thread_id},
|
|
519
|
+
});
|
|
520
|
+
case 'add_comment_to_pending_review.':
|
|
521
|
+
return await addCommentToPendingReview(client, parameters);
|
|
522
|
+
default:
|
|
523
|
+
throw new GithubIntegrationProviderError(
|
|
524
|
+
'malformed-provider-response',
|
|
525
|
+
'GitHub operation does not support GraphQL operations',
|
|
526
|
+
);
|
|
527
|
+
}
|
|
455
528
|
}
|
|
456
529
|
|
|
457
530
|
export function projectGithubOperationParameters(
|
|
@@ -479,8 +552,15 @@ async function resolvePendingReviewParameters(
|
|
|
479
552
|
): Promise<Record<string, unknown> | undefined> {
|
|
480
553
|
if (!isPendingReviewOperation(toolId, method)) return parameters;
|
|
481
554
|
|
|
482
|
-
const
|
|
483
|
-
|
|
555
|
+
const review = await latestPendingReview(client, parameters, 'id');
|
|
556
|
+
if (review === undefined) return undefined;
|
|
557
|
+
if (review.id === undefined) {
|
|
558
|
+
throw new GithubIntegrationProviderError(
|
|
559
|
+
'malformed-provider-response',
|
|
560
|
+
'GitHub pending pull request review did not include a numeric ID',
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
return {...parameters, review_id: review.id};
|
|
484
564
|
}
|
|
485
565
|
|
|
486
566
|
function isPendingReviewOperation(toolId: GithubAgentToolId, method: string | undefined): boolean {
|
|
@@ -490,38 +570,166 @@ function isPendingReviewOperation(toolId: GithubAgentToolId, method: string | un
|
|
|
490
570
|
);
|
|
491
571
|
}
|
|
492
572
|
|
|
493
|
-
|
|
573
|
+
interface PendingReviewReference {
|
|
574
|
+
id?: number | undefined;
|
|
575
|
+
nodeId?: string | undefined;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
type PendingReviewIdentifier = keyof PendingReviewReference;
|
|
579
|
+
|
|
580
|
+
interface PendingReviewPageResult {
|
|
581
|
+
malformed: boolean;
|
|
582
|
+
review?: PendingReviewReference | undefined;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
async function latestPendingReview(
|
|
586
|
+
client: GithubToolClient,
|
|
587
|
+
parameters: Record<string, unknown>,
|
|
588
|
+
requiredIdentifier: PendingReviewIdentifier,
|
|
589
|
+
): Promise<PendingReviewReference | undefined> {
|
|
590
|
+
const lookupController = new AbortController();
|
|
591
|
+
const lookupTimeout = setTimeout(
|
|
592
|
+
() => lookupController.abort(),
|
|
593
|
+
PENDING_REVIEW_LOOKUP_TIMEOUT_MS,
|
|
594
|
+
);
|
|
595
|
+
|
|
596
|
+
try {
|
|
597
|
+
return await latestPendingReviewBeforeDeadline(
|
|
598
|
+
client,
|
|
599
|
+
parameters,
|
|
600
|
+
requiredIdentifier,
|
|
601
|
+
lookupController.signal,
|
|
602
|
+
);
|
|
603
|
+
} finally {
|
|
604
|
+
clearTimeout(lookupTimeout);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async function latestPendingReviewBeforeDeadline(
|
|
494
609
|
client: GithubToolClient,
|
|
495
610
|
parameters: Record<string, unknown>,
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
611
|
+
requiredIdentifier: PendingReviewIdentifier,
|
|
612
|
+
lookupSignal: AbortSignal,
|
|
613
|
+
): Promise<PendingReviewReference | undefined> {
|
|
614
|
+
const firstPage = await requestPendingReviewPage(client, parameters, 1, lookupSignal);
|
|
615
|
+
const lastPage = pendingReviewLastPage(firstPage.headers);
|
|
616
|
+
let requests = 1;
|
|
617
|
+
let malformed = false;
|
|
618
|
+
|
|
619
|
+
for (let page = lastPage; page >= 1; page -= 1) {
|
|
620
|
+
let response = firstPage;
|
|
621
|
+
if (page !== 1) {
|
|
622
|
+
if (requests >= PENDING_REVIEW_MAX_PAGE_REQUESTS) {
|
|
623
|
+
throw new GithubIntegrationProviderError(
|
|
624
|
+
'content-too-large',
|
|
625
|
+
'GitHub pull request review history exceeded the pending review lookup limit',
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
response = await requestPendingReviewPage(client, parameters, page, lookupSignal);
|
|
629
|
+
requests += 1;
|
|
630
|
+
}
|
|
631
|
+
if (!Array.isArray(response.data)) {
|
|
632
|
+
throw new GithubIntegrationProviderError(
|
|
633
|
+
'malformed-provider-response',
|
|
634
|
+
'GitHub pull request review list response was malformed',
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const result = latestPendingReviewOnPage(response.data, requiredIdentifier);
|
|
639
|
+
if (result.review !== undefined) return result.review;
|
|
640
|
+
malformed ||= result.malformed;
|
|
641
|
+
}
|
|
500
642
|
|
|
501
|
-
|
|
502
|
-
|
|
643
|
+
if (malformed) {
|
|
644
|
+
throw new GithubIntegrationProviderError(
|
|
645
|
+
'malformed-provider-response',
|
|
646
|
+
requiredIdentifier === 'nodeId'
|
|
647
|
+
? 'GitHub pending pull request review did not include a node ID'
|
|
648
|
+
: 'GitHub pending pull request review did not include a numeric ID',
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
return undefined;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
async function requestPendingReviewPage(
|
|
655
|
+
client: GithubToolClient,
|
|
656
|
+
parameters: Record<string, unknown>,
|
|
657
|
+
page: number,
|
|
658
|
+
lookupSignal: AbortSignal,
|
|
659
|
+
): Promise<GithubToolResponse> {
|
|
660
|
+
const pageController = new AbortController();
|
|
661
|
+
const abortPage = () => pageController.abort();
|
|
662
|
+
if (lookupSignal.aborted) abortPage();
|
|
663
|
+
else lookupSignal.addEventListener('abort', abortPage, {once: true});
|
|
664
|
+
const pageTimeout = setTimeout(abortPage, PENDING_REVIEW_PAGE_TIMEOUT_MS);
|
|
665
|
+
|
|
666
|
+
try {
|
|
667
|
+
return await client.request('GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews', {
|
|
503
668
|
owner: parameters.owner,
|
|
504
669
|
repo: parameters.repo,
|
|
505
670
|
pull_number: parameters.pull_number,
|
|
506
|
-
per_page:
|
|
671
|
+
per_page: PENDING_REVIEW_PAGE_SIZE,
|
|
507
672
|
page,
|
|
673
|
+
request: {signal: pageController.signal},
|
|
508
674
|
});
|
|
509
|
-
|
|
675
|
+
} finally {
|
|
676
|
+
clearTimeout(pageTimeout);
|
|
677
|
+
lookupSignal.removeEventListener('abort', abortPage);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
510
680
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
681
|
+
function pendingReviewLastPage(headers: GithubToolResponse['headers']): number {
|
|
682
|
+
const link = headers?.link;
|
|
683
|
+
if (typeof link !== 'string') return 1;
|
|
684
|
+
const lastLink = link.split(',').find((part) => part.includes('rel="last"'));
|
|
685
|
+
if (lastLink === undefined) return 1;
|
|
686
|
+
const match = PENDING_REVIEW_PAGE_PATTERN.exec(lastLink);
|
|
687
|
+
const page = match?.[1] === undefined ? Number.NaN : Number.parseInt(match[1], 10);
|
|
688
|
+
if (!Number.isSafeInteger(page) || page < 1) {
|
|
689
|
+
throw new GithubIntegrationProviderError(
|
|
690
|
+
'malformed-provider-response',
|
|
691
|
+
'GitHub pull request review pagination response was malformed',
|
|
692
|
+
);
|
|
514
693
|
}
|
|
694
|
+
return page;
|
|
515
695
|
}
|
|
516
696
|
|
|
517
|
-
function
|
|
697
|
+
function latestPendingReviewOnPage(
|
|
698
|
+
data: readonly unknown[],
|
|
699
|
+
requiredIdentifier: PendingReviewIdentifier,
|
|
700
|
+
): PendingReviewPageResult {
|
|
701
|
+
let malformed = false;
|
|
702
|
+
const appLogin = githubAppBotLogin().toLowerCase();
|
|
518
703
|
for (let index = data.length - 1; index >= 0; index -= 1) {
|
|
519
704
|
const review = data[index];
|
|
520
705
|
if (!isRecord(review) || review.state !== 'PENDING') continue;
|
|
521
|
-
|
|
706
|
+
const userLogin = isRecord(review.user) ? review.user.login : undefined;
|
|
707
|
+
if (typeof userLogin !== 'string' || userLogin.trim().length === 0) {
|
|
708
|
+
malformed = true;
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
if (userLogin.trim().toLowerCase() !== appLogin) continue;
|
|
712
|
+
const id =
|
|
713
|
+
typeof review.id === 'number' && Number.isSafeInteger(review.id) && review.id > 0
|
|
714
|
+
? review.id
|
|
715
|
+
: undefined;
|
|
716
|
+
const nodeId =
|
|
717
|
+
typeof review.node_id === 'string' && review.node_id.trim().length > 0
|
|
718
|
+
? review.node_id.trim()
|
|
719
|
+
: undefined;
|
|
720
|
+
const reference = {id, nodeId};
|
|
721
|
+
if (reference[requiredIdentifier] !== undefined) return {malformed, review: reference};
|
|
722
|
+
malformed = true;
|
|
522
723
|
}
|
|
523
724
|
|
|
524
|
-
return
|
|
725
|
+
return {malformed};
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function githubAppBotLogin(): string {
|
|
729
|
+
const configuredUsername = config.GITHUB_APP_USERNAME?.trim() || config.GITHUB_APP_SLUG.trim();
|
|
730
|
+
return configuredUsername.toLowerCase().endsWith(GITHUB_APP_BOT_SUFFIX)
|
|
731
|
+
? configuredUsername
|
|
732
|
+
: `${configuredUsername}${GITHUB_APP_BOT_SUFFIX}`;
|
|
525
733
|
}
|
|
526
734
|
|
|
527
735
|
function githubToolResult(
|
|
@@ -533,7 +741,10 @@ function githubToolResult(
|
|
|
533
741
|
): GithubToolCallResult {
|
|
534
742
|
const structuredContent = projectGithubToolOutput(toolId, data, response, parameters, route);
|
|
535
743
|
if (structuredContent === undefined) {
|
|
536
|
-
return githubToolError(
|
|
744
|
+
return githubToolError(
|
|
745
|
+
'GitHub artifact download did not return a download URL',
|
|
746
|
+
'malformed-provider-response',
|
|
747
|
+
);
|
|
537
748
|
}
|
|
538
749
|
return {
|
|
539
750
|
content: [{type: 'text', text: JSON.stringify(structuredContent)}],
|
|
@@ -601,8 +812,12 @@ function githubSearchItems(data: unknown): unknown {
|
|
|
601
812
|
return isRecord(data) ? data.items : data;
|
|
602
813
|
}
|
|
603
814
|
|
|
604
|
-
function githubToolError(message: string): GithubToolCallResult {
|
|
605
|
-
return {
|
|
815
|
+
function githubToolError(message: string, code: GithubToolErrorCode): GithubToolCallResult {
|
|
816
|
+
return {
|
|
817
|
+
isError: true,
|
|
818
|
+
content: [{type: 'text', text: message}],
|
|
819
|
+
structuredContent: {code},
|
|
820
|
+
};
|
|
606
821
|
}
|
|
607
822
|
|
|
608
823
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -174,6 +174,13 @@ const pullRequestReadMethods = [
|
|
|
174
174
|
false,
|
|
175
175
|
scopes.pullRequestsRead,
|
|
176
176
|
),
|
|
177
|
+
method(
|
|
178
|
+
'get_review_threads',
|
|
179
|
+
'Get review threads, their resolution state, and comments for a specific pull request.',
|
|
180
|
+
'read',
|
|
181
|
+
false,
|
|
182
|
+
scopes.pullRequestsRead,
|
|
183
|
+
),
|
|
177
184
|
method(
|
|
178
185
|
'get_reviews',
|
|
179
186
|
'Get reviews for a specific pull request.',
|
|
@@ -221,6 +228,16 @@ const pullRequestReviewWriteMethods = [
|
|
|
221
228
|
),
|
|
222
229
|
] as const satisfies readonly GithubAgentToolCatalogMethod[];
|
|
223
230
|
|
|
231
|
+
const pullRequestReviewThreadWriteMethods = [
|
|
232
|
+
method(
|
|
233
|
+
'resolve',
|
|
234
|
+
'Resolve a pull request review thread.',
|
|
235
|
+
'write',
|
|
236
|
+
false,
|
|
237
|
+
scopes.pullRequestsWrite,
|
|
238
|
+
),
|
|
239
|
+
] as const satisfies readonly GithubAgentToolCatalogMethod[];
|
|
240
|
+
|
|
224
241
|
const actionsListMethods = [
|
|
225
242
|
method('list_workflows', 'List workflows in a repository.', 'read', false, scopes.actionsRead),
|
|
226
243
|
method(
|
|
@@ -472,6 +489,7 @@ export const githubAgentToolCatalog = [
|
|
|
472
489
|
methodRequiredSchema('get_files', []),
|
|
473
490
|
methodRequiredSchema('get_commits', []),
|
|
474
491
|
methodRequiredSchema('get_review_comments', []),
|
|
492
|
+
methodRequiredSchema('get_review_threads', []),
|
|
475
493
|
methodRequiredSchema('get_reviews', []),
|
|
476
494
|
methodRequiredSchema('get_comments', []),
|
|
477
495
|
methodRequiredSchema('get_check_runs', ['ref']),
|
|
@@ -666,6 +684,23 @@ export const githubAgentToolCatalog = [
|
|
|
666
684
|
),
|
|
667
685
|
outputSchema: openObjectSchema('Pull request review write result'),
|
|
668
686
|
}),
|
|
687
|
+
tool({
|
|
688
|
+
id: 'pull_request_review_thread_write',
|
|
689
|
+
category: 'pull_requests',
|
|
690
|
+
description: 'Resolve review threads on a pull request in a GitHub repository.',
|
|
691
|
+
methods: pullRequestReviewThreadWriteMethods,
|
|
692
|
+
inputSchema: repositoryInputSchema(
|
|
693
|
+
{
|
|
694
|
+
method: methodSchema(
|
|
695
|
+
pullRequestReviewThreadWriteMethods,
|
|
696
|
+
'The write operation to perform on a pull request review thread',
|
|
697
|
+
),
|
|
698
|
+
thread_id: stringSchema('The node ID of the review thread'),
|
|
699
|
+
},
|
|
700
|
+
['method', 'thread_id'],
|
|
701
|
+
),
|
|
702
|
+
outputSchema: openObjectSchema('Pull request review thread write result'),
|
|
703
|
+
}),
|
|
669
704
|
tool({
|
|
670
705
|
id: 'add_comment_to_pending_review',
|
|
671
706
|
category: 'pull_requests',
|
package/src/metrics/instance.ts
CHANGED
|
@@ -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';
|