@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +19 -0
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +15 -9
- 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-envelope.d.ts +5 -1
- package/dist/api/installation-token-envelope.d.ts.map +1 -1
- package/dist/api/installation-token-envelope.js +16 -5
- package/dist/api/installation-token-envelope.js.map +1 -1
- 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/api/shared-installation-token-cache.d.ts.map +1 -1
- package/dist/api/shared-installation-token-cache.js +10 -4
- package/dist/api/shared-installation-token-cache.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 +11 -4
- package/dist/core/agent-tools.d.ts.map +1 -1
- package/dist/core/agent-tools.js +235 -24
- package/dist/core/agent-tools.js.map +1 -1
- package/dist/core/github-agent-tool-catalog.js +1 -1
- 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 +2 -2
- package/src/api/client.test.ts +66 -10
- package/src/api/client.ts +44 -7
- package/src/api/github-octokit.test.ts +115 -0
- package/src/api/github-octokit.ts +49 -0
- package/src/api/installation-token-envelope.ts +24 -2
- package/src/api/installation-token-provider.test.ts +44 -5
- package/src/api/installation-token-provider.ts +5 -2
- package/src/api/shared-installation-token-cache.test.ts +28 -0
- package/src/api/shared-installation-token-cache.ts +7 -0
- package/src/config.ts +5 -0
- package/src/core/agent-tools.test.ts +1049 -8
- package/src/core/agent-tools.ts +388 -25
- package/src/core/github-agent-tool-catalog.ts +1 -1
- 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
|
@@ -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)
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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<
|
|
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) =>
|
|
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
|
-
: {
|
|
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/
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
316
|
-
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
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,
|
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';
|