@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +8 -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 +124 -78
- package/dist/core/agent-tools.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 +261 -74
- package/src/core/agent-tools.ts +191 -80
- 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,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)
|
|
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
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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:
|
|
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
|
|
483
|
-
|
|
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
|
-
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
|
|
502
|
-
|
|
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:
|
|
567
|
+
per_page: PENDING_REVIEW_PAGE_SIZE,
|
|
507
568
|
page,
|
|
569
|
+
request: {signal: pageController.signal},
|
|
508
570
|
});
|
|
509
|
-
|
|
571
|
+
} finally {
|
|
572
|
+
clearTimeout(pageTimeout);
|
|
573
|
+
lookupSignal.removeEventListener('abort', abortPage);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
510
576
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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 {
|
|
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> {
|
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';
|