@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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/api-integration-github",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "12.
|
|
4
|
+
"version": "12.6.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"octokit": "^5.0.3",
|
|
29
29
|
"zod": "^4.4.3",
|
|
30
30
|
"@shipfox/api-auth-context": "12.2.0",
|
|
31
|
-
"@shipfox/api-integration-spi": "1.1.
|
|
31
|
+
"@shipfox/api-integration-spi": "1.1.1",
|
|
32
32
|
"@shipfox/api-integration-github-dto": "12.2.0",
|
|
33
33
|
"@shipfox/config": "1.2.4",
|
|
34
34
|
"@shipfox/node-drizzle": "0.3.5",
|
package/src/api/client.test.ts
CHANGED
|
@@ -1,9 +1,25 @@
|
|
|
1
1
|
import {GithubIntegrationProviderError} from '#core/errors.js';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
GITHUB_STATEFUL_INSTALLATION_TOKEN,
|
|
4
|
+
GITHUB_STATELESS_INSTALLATION_TOKEN,
|
|
5
|
+
} from '#test/index.js';
|
|
6
|
+
import {createGithubApiClient, mapGithubError} from './client.js';
|
|
3
7
|
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
})
|
|
8
|
+
const GITHUB_INSTALLATION_TOKEN_PATTERN = /^ghs_[A-Za-z0-9._-]{36,}$/u;
|
|
9
|
+
|
|
10
|
+
const {createInstallationAccessTokenMock, RequestErrorMock} = vi.hoisted(() => {
|
|
11
|
+
class RequestErrorMock extends Error {
|
|
12
|
+
constructor(
|
|
13
|
+
message: string,
|
|
14
|
+
public readonly status: number,
|
|
15
|
+
) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = 'HttpError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return {createInstallationAccessTokenMock: vi.fn(), RequestErrorMock};
|
|
22
|
+
});
|
|
7
23
|
|
|
8
24
|
vi.mock('octokit', () => ({
|
|
9
25
|
App: class App {
|
|
@@ -12,13 +28,42 @@ vi.mock('octokit', () => ({
|
|
|
12
28
|
};
|
|
13
29
|
},
|
|
14
30
|
Octokit: {
|
|
31
|
+
plugin() {
|
|
32
|
+
return this;
|
|
33
|
+
},
|
|
15
34
|
defaults(options: unknown) {
|
|
16
35
|
return {defaults: options};
|
|
17
36
|
},
|
|
18
37
|
},
|
|
19
|
-
RequestError:
|
|
38
|
+
RequestError: RequestErrorMock,
|
|
20
39
|
}));
|
|
21
40
|
|
|
41
|
+
describe('mapGithubError', () => {
|
|
42
|
+
it.each([400, 409, 422])('maps HTTP %i to a terminal provider rejection', async (status) => {
|
|
43
|
+
const error = new RequestErrorMock(`GitHub rejected request with HTTP ${status}`, status);
|
|
44
|
+
|
|
45
|
+
const result = mapGithubError(() => Promise.reject(error));
|
|
46
|
+
|
|
47
|
+
await expect(result).rejects.toMatchObject({
|
|
48
|
+
reason: 'provider-rejected',
|
|
49
|
+
message: `GitHub rejected request with HTTP ${status}`,
|
|
50
|
+
status,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('preserves the status when mapping provider unavailability', async () => {
|
|
55
|
+
const error = new RequestErrorMock('GitHub is unavailable', 503);
|
|
56
|
+
|
|
57
|
+
const result = mapGithubError(() => Promise.reject(error));
|
|
58
|
+
|
|
59
|
+
await expect(result).rejects.toMatchObject({
|
|
60
|
+
reason: 'provider-unavailable',
|
|
61
|
+
message: 'GitHub is unavailable',
|
|
62
|
+
status: 503,
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
22
67
|
describe('OctokitGithubApiClient.createInstallationAccessToken', () => {
|
|
23
68
|
beforeEach(() => {
|
|
24
69
|
createInstallationAccessTokenMock.mockReset();
|
|
@@ -26,7 +71,10 @@ describe('OctokitGithubApiClient.createInstallationAccessToken', () => {
|
|
|
26
71
|
|
|
27
72
|
it('mints a repository-scoped, read-only installation token', async () => {
|
|
28
73
|
createInstallationAccessTokenMock.mockResolvedValue({
|
|
29
|
-
data: {
|
|
74
|
+
data: {
|
|
75
|
+
token: GITHUB_STATELESS_INSTALLATION_TOKEN,
|
|
76
|
+
expires_at: '2026-06-10T12:00:00.000Z',
|
|
77
|
+
},
|
|
30
78
|
});
|
|
31
79
|
const client = createGithubApiClient();
|
|
32
80
|
|
|
@@ -36,9 +84,11 @@ describe('OctokitGithubApiClient.createInstallationAccessToken', () => {
|
|
|
36
84
|
});
|
|
37
85
|
|
|
38
86
|
expect(result).toEqual({
|
|
39
|
-
token:
|
|
87
|
+
token: GITHUB_STATELESS_INSTALLATION_TOKEN,
|
|
40
88
|
expiresAt: new Date('2026-06-10T12:00:00.000Z'),
|
|
41
89
|
});
|
|
90
|
+
expect(GITHUB_STATELESS_INSTALLATION_TOKEN).toMatch(GITHUB_INSTALLATION_TOKEN_PATTERN);
|
|
91
|
+
expect(GITHUB_STATELESS_INSTALLATION_TOKEN.slice(4).split('.')).toHaveLength(3);
|
|
42
92
|
expect(createInstallationAccessTokenMock).toHaveBeenCalledWith({
|
|
43
93
|
installation_id: 1,
|
|
44
94
|
repository_ids: [42],
|
|
@@ -46,18 +96,24 @@ describe('OctokitGithubApiClient.createInstallationAccessToken', () => {
|
|
|
46
96
|
});
|
|
47
97
|
});
|
|
48
98
|
|
|
49
|
-
it('
|
|
99
|
+
it('passes through a stateful repository-scoped write token', async () => {
|
|
50
100
|
createInstallationAccessTokenMock.mockResolvedValue({
|
|
51
|
-
data: {
|
|
101
|
+
data: {
|
|
102
|
+
token: GITHUB_STATEFUL_INSTALLATION_TOKEN,
|
|
103
|
+
expires_at: '2026-06-10T12:00:00.000Z',
|
|
104
|
+
},
|
|
52
105
|
});
|
|
53
106
|
const client = createGithubApiClient();
|
|
54
107
|
|
|
55
|
-
await client.createInstallationAccessToken({
|
|
108
|
+
const result = await client.createInstallationAccessToken({
|
|
56
109
|
installationId: 1,
|
|
57
110
|
repositoryId: 42,
|
|
58
111
|
permissions: {contents: 'write'},
|
|
59
112
|
});
|
|
60
113
|
|
|
114
|
+
expect(result.token).toBe(GITHUB_STATEFUL_INSTALLATION_TOKEN);
|
|
115
|
+
expect(GITHUB_STATEFUL_INSTALLATION_TOKEN).toMatch(GITHUB_INSTALLATION_TOKEN_PATTERN);
|
|
116
|
+
expect(GITHUB_STATEFUL_INSTALLATION_TOKEN).not.toContain('.');
|
|
61
117
|
expect(createInstallationAccessTokenMock).toHaveBeenCalledWith({
|
|
62
118
|
installation_id: 1,
|
|
63
119
|
repository_ids: [42],
|
package/src/api/client.ts
CHANGED
|
@@ -4,6 +4,11 @@ import ky, {HTTPError, TimeoutError} from 'ky';
|
|
|
4
4
|
import {App, Octokit, RequestError} from 'octokit';
|
|
5
5
|
import {config, normalizedGithubApiBaseUrl, normalizedGithubPrivateKey} from '#config.js';
|
|
6
6
|
import {GithubIntegrationProviderError} from '#core/errors.js';
|
|
7
|
+
import {recordInstallationTokenFormat} from '#metrics/index.js';
|
|
8
|
+
import {
|
|
9
|
+
getGithubInstallationOctokit,
|
|
10
|
+
githubInstallationTokenFormatPlugin,
|
|
11
|
+
} from './github-octokit.js';
|
|
7
12
|
|
|
8
13
|
const NEXT_PAGE_RE = /[?&]page=(\d+)/;
|
|
9
14
|
const TRAILING_SLASHES_RE = /\/+$/;
|
|
@@ -186,7 +191,9 @@ class OctokitGithubApiClient implements GithubApiClient {
|
|
|
186
191
|
limit: number;
|
|
187
192
|
cursor?: string | undefined;
|
|
188
193
|
}): Promise<GithubRepositoryPage> {
|
|
189
|
-
const octokit = await
|
|
194
|
+
const octokit = await mapGithubError(() =>
|
|
195
|
+
getGithubInstallationOctokit(this.getApp(), input.installationId),
|
|
196
|
+
);
|
|
190
197
|
const page = cursorToPage(input.cursor);
|
|
191
198
|
const response = await mapGithubError(() =>
|
|
192
199
|
octokit.rest.apps.listReposAccessibleToInstallation({
|
|
@@ -205,7 +212,9 @@ class OctokitGithubApiClient implements GithubApiClient {
|
|
|
205
212
|
installationId: number;
|
|
206
213
|
repositoryId: number;
|
|
207
214
|
}): Promise<GithubRepository> {
|
|
208
|
-
const octokit = await
|
|
215
|
+
const octokit = await mapGithubError(() =>
|
|
216
|
+
getGithubInstallationOctokit(this.getApp(), input.installationId),
|
|
217
|
+
);
|
|
209
218
|
const response = await mapGithubError(() =>
|
|
210
219
|
octokit.request('GET /repositories/{repository_id}', {
|
|
211
220
|
repository_id: input.repositoryId,
|
|
@@ -223,7 +232,9 @@ class OctokitGithubApiClient implements GithubApiClient {
|
|
|
223
232
|
limit: number;
|
|
224
233
|
cursor?: string | undefined;
|
|
225
234
|
}): Promise<GithubFilePage> {
|
|
226
|
-
const octokit = await
|
|
235
|
+
const octokit = await mapGithubError(() =>
|
|
236
|
+
getGithubInstallationOctokit(this.getApp(), input.installationId),
|
|
237
|
+
);
|
|
227
238
|
const repository = await this.getRepository({
|
|
228
239
|
installationId: input.installationId,
|
|
229
240
|
repositoryId: input.repositoryId,
|
|
@@ -303,7 +314,9 @@ class OctokitGithubApiClient implements GithubApiClient {
|
|
|
303
314
|
ref: string;
|
|
304
315
|
path: string;
|
|
305
316
|
}): Promise<GithubFileContent> {
|
|
306
|
-
const octokit = await
|
|
317
|
+
const octokit = await mapGithubError(() =>
|
|
318
|
+
getGithubInstallationOctokit(this.getApp(), input.installationId),
|
|
319
|
+
);
|
|
307
320
|
const repository = await this.getRepository({
|
|
308
321
|
installationId: input.installationId,
|
|
309
322
|
repositoryId: input.repositoryId,
|
|
@@ -363,6 +376,8 @@ class OctokitGithubApiClient implements GithubApiClient {
|
|
|
363
376
|
);
|
|
364
377
|
}
|
|
365
378
|
|
|
379
|
+
recordInstallationTokenFormat(response.data.token);
|
|
380
|
+
|
|
366
381
|
const expiresAt = new Date(response.data.expires_at);
|
|
367
382
|
if (Number.isNaN(expiresAt.getTime())) {
|
|
368
383
|
throw new GithubIntegrationProviderError(
|
|
@@ -383,7 +398,9 @@ class OctokitGithubApiClient implements GithubApiClient {
|
|
|
383
398
|
this.app = new App({
|
|
384
399
|
appId: config.GITHUB_APP_ID,
|
|
385
400
|
privateKey: normalizedGithubPrivateKey(),
|
|
386
|
-
Octokit: Octokit.defaults({
|
|
401
|
+
Octokit: Octokit.plugin(githubInstallationTokenFormatPlugin).defaults({
|
|
402
|
+
baseUrl: normalizedGithubApiBaseUrl(),
|
|
403
|
+
}),
|
|
387
404
|
});
|
|
388
405
|
}
|
|
389
406
|
return this.app;
|
|
@@ -433,13 +450,19 @@ export async function mapGithubError<T>(
|
|
|
433
450
|
if (error instanceof GithubIntegrationProviderError) throw error;
|
|
434
451
|
if (error instanceof RequestError) {
|
|
435
452
|
if (error.status === 404) {
|
|
436
|
-
throw new GithubIntegrationProviderError(
|
|
453
|
+
throw new GithubIntegrationProviderError(
|
|
454
|
+
notFoundReason,
|
|
455
|
+
error.message,
|
|
456
|
+
undefined,
|
|
457
|
+
error.status,
|
|
458
|
+
);
|
|
437
459
|
}
|
|
438
460
|
if (error.status === 429 || isGithubRateLimitError(error)) {
|
|
439
461
|
throw new GithubIntegrationProviderError(
|
|
440
462
|
'rate-limited',
|
|
441
463
|
error.message,
|
|
442
464
|
retryAfterSeconds(error),
|
|
465
|
+
error.status,
|
|
443
466
|
);
|
|
444
467
|
}
|
|
445
468
|
if (error.status === 401 || error.status === 403) {
|
|
@@ -447,10 +470,24 @@ export async function mapGithubError<T>(
|
|
|
447
470
|
'access-denied',
|
|
448
471
|
error.message,
|
|
449
472
|
retryAfterSeconds(error),
|
|
473
|
+
error.status,
|
|
450
474
|
);
|
|
451
475
|
}
|
|
452
476
|
if (error.status >= 500) {
|
|
453
|
-
throw new GithubIntegrationProviderError(
|
|
477
|
+
throw new GithubIntegrationProviderError(
|
|
478
|
+
'provider-unavailable',
|
|
479
|
+
error.message,
|
|
480
|
+
undefined,
|
|
481
|
+
error.status,
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
if (error.status >= 400) {
|
|
485
|
+
throw new GithubIntegrationProviderError(
|
|
486
|
+
'provider-rejected',
|
|
487
|
+
error.message,
|
|
488
|
+
undefined,
|
|
489
|
+
error.status,
|
|
490
|
+
);
|
|
454
491
|
}
|
|
455
492
|
}
|
|
456
493
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import {generateKeyPairSync} from 'node:crypto';
|
|
2
|
+
import {once} from 'node:events';
|
|
3
|
+
import {createServer} from 'node:http';
|
|
4
|
+
import {App, Octokit} from 'octokit';
|
|
5
|
+
import {
|
|
6
|
+
GITHUB_STATEFUL_INSTALLATION_TOKEN,
|
|
7
|
+
GITHUB_STATELESS_INSTALLATION_TOKEN,
|
|
8
|
+
} from '#test/index.js';
|
|
9
|
+
import {
|
|
10
|
+
createGithubInstallationTokenFormatPlugin,
|
|
11
|
+
getGithubInstallationOctokit,
|
|
12
|
+
} from './github-octokit.js';
|
|
13
|
+
|
|
14
|
+
const {privateKey} = generateKeyPairSync('rsa', {
|
|
15
|
+
modulusLength: 2048,
|
|
16
|
+
publicKeyEncoding: {type: 'spki', format: 'pem'},
|
|
17
|
+
privateKeyEncoding: {type: 'pkcs8', format: 'pem'},
|
|
18
|
+
});
|
|
19
|
+
const BEARER_AUTHORIZATION = /^bearer /iu;
|
|
20
|
+
|
|
21
|
+
describe('GitHub installation Octokit', () => {
|
|
22
|
+
it.each([
|
|
23
|
+
{
|
|
24
|
+
format: 'stateless',
|
|
25
|
+
token: GITHUB_STATELESS_INSTALLATION_TOKEN,
|
|
26
|
+
override: 'enabled' as const,
|
|
27
|
+
authorization: `bearer ${GITHUB_STATELESS_INSTALLATION_TOKEN}`,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
format: 'stateful',
|
|
31
|
+
token: GITHUB_STATEFUL_INSTALLATION_TOKEN,
|
|
32
|
+
override: 'disabled' as const,
|
|
33
|
+
authorization: `token ${GITHUB_STATEFUL_INSTALLATION_TOKEN}`,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
format: 'stateful without an override',
|
|
37
|
+
token: GITHUB_STATEFUL_INSTALLATION_TOKEN,
|
|
38
|
+
override: undefined,
|
|
39
|
+
authorization: `token ${GITHUB_STATEFUL_INSTALLATION_TOKEN}`,
|
|
40
|
+
},
|
|
41
|
+
])('requests and authenticates with a $format token', async ({
|
|
42
|
+
token,
|
|
43
|
+
override,
|
|
44
|
+
authorization,
|
|
45
|
+
}) => {
|
|
46
|
+
const calls: Array<{
|
|
47
|
+
method: string | undefined;
|
|
48
|
+
path: string | undefined;
|
|
49
|
+
authorization: string | undefined;
|
|
50
|
+
tokenFormatOverride: string | undefined;
|
|
51
|
+
}> = [];
|
|
52
|
+
const server = createServer((request, response) => {
|
|
53
|
+
calls.push({
|
|
54
|
+
method: request.method,
|
|
55
|
+
path: request.url,
|
|
56
|
+
authorization: request.headers.authorization,
|
|
57
|
+
tokenFormatOverride: request.headers['x-github-stateless-s2s-token'] as string | undefined,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (request.method === 'POST' && request.url === '/app/installations/123/access_tokens') {
|
|
61
|
+
response.writeHead(201, {'content-type': 'application/json'}).end(
|
|
62
|
+
JSON.stringify({
|
|
63
|
+
token,
|
|
64
|
+
expires_at: '2099-01-01T00:00:00.000Z',
|
|
65
|
+
permissions: {metadata: 'read'},
|
|
66
|
+
repository_selection: 'all',
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (request.method === 'GET' && request.url === '/installation/repositories') {
|
|
73
|
+
response
|
|
74
|
+
.writeHead(200, {'content-type': 'application/json'})
|
|
75
|
+
.end(JSON.stringify({total_count: 0, repositories: []}));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
response.writeHead(404, {'content-type': 'application/json'}).end('{"message":"Not Found"}');
|
|
80
|
+
});
|
|
81
|
+
server.listen({host: '127.0.0.1', port: 0});
|
|
82
|
+
await once(server, 'listening');
|
|
83
|
+
const address = server.address();
|
|
84
|
+
if (!address || typeof address === 'string') throw new Error('Expected TCP server address.');
|
|
85
|
+
const baseUrl = `http://127.0.0.1:${address.port}`;
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const InstallationTokenOctokit = Octokit.plugin(
|
|
89
|
+
createGithubInstallationTokenFormatPlugin(override),
|
|
90
|
+
).defaults({baseUrl});
|
|
91
|
+
const app = new App({appId: 1, privateKey, Octokit: InstallationTokenOctokit});
|
|
92
|
+
const octokit = await getGithubInstallationOctokit(app, 123, baseUrl);
|
|
93
|
+
|
|
94
|
+
await octokit.rest.apps.listReposAccessibleToInstallation();
|
|
95
|
+
|
|
96
|
+
expect(calls).toEqual([
|
|
97
|
+
{
|
|
98
|
+
method: 'POST',
|
|
99
|
+
path: '/app/installations/123/access_tokens',
|
|
100
|
+
authorization: expect.stringMatching(BEARER_AUTHORIZATION),
|
|
101
|
+
tokenFormatOverride: override,
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
method: 'GET',
|
|
105
|
+
path: '/installation/repositories',
|
|
106
|
+
authorization,
|
|
107
|
+
tokenFormatOverride: undefined,
|
|
108
|
+
},
|
|
109
|
+
]);
|
|
110
|
+
} finally {
|
|
111
|
+
server.close();
|
|
112
|
+
await once(server, 'close');
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import {type App, Octokit} from 'octokit';
|
|
2
|
+
import {config, normalizedGithubApiBaseUrl} from '#config.js';
|
|
3
|
+
import {GithubIntegrationProviderError} from '#core/errors.js';
|
|
4
|
+
import {recordInstallationTokenFormat} from '#metrics/index.js';
|
|
5
|
+
|
|
6
|
+
const INSTALLATION_TOKEN_REQUEST_PATH = /\/app\/installations\/[^/]+\/access_tokens(?:\?|$)/u;
|
|
7
|
+
type GithubInstallationTokenFormatOverride = 'enabled' | 'disabled' | undefined;
|
|
8
|
+
|
|
9
|
+
export function createGithubInstallationTokenFormatPlugin(
|
|
10
|
+
override: GithubInstallationTokenFormatOverride,
|
|
11
|
+
): Parameters<typeof Octokit.plugin>[0] {
|
|
12
|
+
return (octokit) => {
|
|
13
|
+
octokit.hook.before('request', (options) => {
|
|
14
|
+
if (
|
|
15
|
+
!override ||
|
|
16
|
+
options.method !== 'POST' ||
|
|
17
|
+
!INSTALLATION_TOKEN_REQUEST_PATH.test(options.url)
|
|
18
|
+
) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
options.headers['x-github-stateless-s2s-token'] = override;
|
|
23
|
+
});
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const githubInstallationTokenFormatPlugin: Parameters<typeof Octokit.plugin>[0] =
|
|
28
|
+
createGithubInstallationTokenFormatPlugin(config.GITHUB_INSTALLATION_TOKEN_FORMAT_OVERRIDE);
|
|
29
|
+
|
|
30
|
+
export async function getGithubInstallationOctokit(
|
|
31
|
+
app: App,
|
|
32
|
+
installationId: number,
|
|
33
|
+
baseUrl = normalizedGithubApiBaseUrl(),
|
|
34
|
+
): Promise<Octokit> {
|
|
35
|
+
const authentication = (await app.octokit.auth({
|
|
36
|
+
type: 'installation',
|
|
37
|
+
installationId,
|
|
38
|
+
})) as {token?: unknown};
|
|
39
|
+
|
|
40
|
+
if (typeof authentication.token !== 'string') {
|
|
41
|
+
throw new GithubIntegrationProviderError(
|
|
42
|
+
'malformed-provider-response',
|
|
43
|
+
'GitHub installation authentication did not include a token',
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
recordInstallationTokenFormat(authentication.token);
|
|
48
|
+
return new Octokit({auth: authentication.token, baseUrl});
|
|
49
|
+
}
|
|
@@ -19,15 +19,21 @@ const providerErrorReasons = [
|
|
|
19
19
|
'rate-limited',
|
|
20
20
|
'timeout',
|
|
21
21
|
'provider-unavailable',
|
|
22
|
+
'provider-rejected',
|
|
22
23
|
'malformed-provider-response',
|
|
23
24
|
'content-too-large',
|
|
24
25
|
'too-many-files',
|
|
25
26
|
] as const satisfies readonly IntegrationProviderErrorReason[];
|
|
26
27
|
|
|
27
28
|
const providerErrorReasonSchema = z.enum(providerErrorReasons);
|
|
29
|
+
const backoffErrorSchema = z.object({
|
|
30
|
+
message: z.string(),
|
|
31
|
+
status: z.number().int().optional(),
|
|
32
|
+
});
|
|
28
33
|
const terminalMintErrorReasons = new Set<IntegrationProviderErrorReason>([
|
|
29
34
|
'access-denied',
|
|
30
35
|
'installation-not-found',
|
|
36
|
+
'provider-rejected',
|
|
31
37
|
'malformed-provider-response',
|
|
32
38
|
]);
|
|
33
39
|
|
|
@@ -44,6 +50,7 @@ const installationTokenEnvelopeSchema = z.object({
|
|
|
44
50
|
permissions: z.record(z.string(), z.enum(['read', 'write', 'admin'])).optional(),
|
|
45
51
|
backoffUntil: z.string().datetime().optional(),
|
|
46
52
|
backoffReason: providerErrorReasonSchema.optional(),
|
|
53
|
+
backoffError: backoffErrorSchema.optional(),
|
|
47
54
|
});
|
|
48
55
|
|
|
49
56
|
export interface InstallationTokenEnvelope {
|
|
@@ -52,6 +59,12 @@ export interface InstallationTokenEnvelope {
|
|
|
52
59
|
permissions?: Record<string, 'read' | 'write' | 'admin'> | undefined;
|
|
53
60
|
backoffUntil?: Date | undefined;
|
|
54
61
|
backoffReason?: IntegrationProviderErrorReason | undefined;
|
|
62
|
+
backoffError?:
|
|
63
|
+
| {
|
|
64
|
+
message: string;
|
|
65
|
+
status?: number | undefined;
|
|
66
|
+
}
|
|
67
|
+
| undefined;
|
|
55
68
|
}
|
|
56
69
|
|
|
57
70
|
export type MintErrorClass = 'transient' | 'terminal';
|
|
@@ -75,6 +88,7 @@ export function encodeInstallationTokenEnvelope(envelope: InstallationTokenEnvel
|
|
|
75
88
|
backoffUntil: envelope.backoffUntil.toISOString(),
|
|
76
89
|
}),
|
|
77
90
|
...(envelope.backoffReason !== undefined && {backoffReason: envelope.backoffReason}),
|
|
91
|
+
...(envelope.backoffError !== undefined && {backoffError: envelope.backoffError}),
|
|
78
92
|
});
|
|
79
93
|
}
|
|
80
94
|
|
|
@@ -95,6 +109,7 @@ export function parseInstallationTokenEnvelope(raw: string): InstallationTokenEn
|
|
|
95
109
|
permissions: result.data.permissions,
|
|
96
110
|
backoffUntil: result.data.backoffUntil ? new Date(result.data.backoffUntil) : undefined,
|
|
97
111
|
backoffReason: result.data.backoffReason,
|
|
112
|
+
backoffError: result.data.backoffError,
|
|
98
113
|
};
|
|
99
114
|
}
|
|
100
115
|
|
|
@@ -151,18 +166,25 @@ export function backoffMs(classified: ClassifiedMintError): number {
|
|
|
151
166
|
export function providerErrorFromBackoff(
|
|
152
167
|
reason: IntegrationProviderErrorReason,
|
|
153
168
|
retryAfterMs: number,
|
|
169
|
+
backoffError?: InstallationTokenEnvelope['backoffError'],
|
|
154
170
|
): GithubIntegrationProviderError {
|
|
155
171
|
return new GithubIntegrationProviderError(
|
|
156
172
|
reason,
|
|
157
|
-
`GitHub installation token mint is backed off after ${reason}`,
|
|
173
|
+
backoffError?.message ?? `GitHub installation token mint is backed off after ${reason}`,
|
|
158
174
|
Math.max(1, Math.ceil(retryAfterMs / 1000)),
|
|
175
|
+
backoffError?.status,
|
|
159
176
|
);
|
|
160
177
|
}
|
|
161
178
|
|
|
162
179
|
export function toProviderError(error: unknown): GithubIntegrationProviderError {
|
|
163
180
|
if (error instanceof GithubIntegrationProviderError) return error;
|
|
164
181
|
if (error instanceof IntegrationProviderError) {
|
|
165
|
-
return new GithubIntegrationProviderError(
|
|
182
|
+
return new GithubIntegrationProviderError(
|
|
183
|
+
error.reason,
|
|
184
|
+
error.message,
|
|
185
|
+
error.retryAfterSeconds,
|
|
186
|
+
error.status,
|
|
187
|
+
);
|
|
166
188
|
}
|
|
167
189
|
return new GithubIntegrationProviderError(
|
|
168
190
|
'provider-unavailable',
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import type {GetIntegrationConnectionByIdFn} from '@shipfox/api-integration-spi';
|
|
2
2
|
import {GithubIntegrationProviderError} from '#core/errors.js';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
GITHUB_STATEFUL_INSTALLATION_TOKEN,
|
|
5
|
+
GITHUB_STATELESS_INSTALLATION_TOKEN,
|
|
6
|
+
githubInstallationFactory,
|
|
7
|
+
} from '#test/index.js';
|
|
4
8
|
import {encodeInstallationTokenEnvelope} from './installation-token-envelope.js';
|
|
5
9
|
import {createGithubInstallationTokenProvider} from './installation-token-provider.js';
|
|
6
10
|
|
|
11
|
+
const GITHUB_INSTALLATION_TOKEN_PATTERN = /^ghs_[A-Za-z0-9._-]{36,}$/u;
|
|
12
|
+
|
|
7
13
|
const {appOptions, createInstallationAccessTokenMock, RequestErrorMock} = vi.hoisted(() => ({
|
|
8
14
|
appOptions: [] as unknown[],
|
|
9
15
|
createInstallationAccessTokenMock: vi.fn(),
|
|
@@ -28,6 +34,9 @@ vi.mock('octokit', () => ({
|
|
|
28
34
|
}
|
|
29
35
|
},
|
|
30
36
|
Octokit: {
|
|
37
|
+
plugin() {
|
|
38
|
+
return this;
|
|
39
|
+
},
|
|
31
40
|
defaults(options: unknown) {
|
|
32
41
|
return {defaults: options};
|
|
33
42
|
},
|
|
@@ -47,26 +56,49 @@ describe('GithubInstallationTokenProvider', () => {
|
|
|
47
56
|
|
|
48
57
|
it('mints a broad installation token on a cache miss', async () => {
|
|
49
58
|
createInstallationAccessTokenMock.mockResolvedValue({
|
|
50
|
-
data: {
|
|
59
|
+
data: {
|
|
60
|
+
token: GITHUB_STATELESS_INSTALLATION_TOKEN,
|
|
61
|
+
expires_at: '2026-06-10T12:00:00.000Z',
|
|
62
|
+
},
|
|
51
63
|
});
|
|
52
64
|
const provider = createGithubInstallationTokenProvider();
|
|
53
65
|
|
|
54
66
|
const result = await provider.getInstallationAccessToken(1);
|
|
55
67
|
|
|
56
68
|
expect(result).toEqual({
|
|
57
|
-
token:
|
|
69
|
+
token: GITHUB_STATELESS_INSTALLATION_TOKEN,
|
|
58
70
|
expiresAt: new Date('2026-06-10T12:00:00.000Z'),
|
|
59
71
|
});
|
|
72
|
+
expect(GITHUB_STATELESS_INSTALLATION_TOKEN).toMatch(GITHUB_INSTALLATION_TOKEN_PATTERN);
|
|
73
|
+
expect(GITHUB_STATELESS_INSTALLATION_TOKEN.slice(4).split('.')).toHaveLength(3);
|
|
60
74
|
expect(createInstallationAccessTokenMock).toHaveBeenCalledWith({
|
|
61
75
|
installation_id: 1,
|
|
62
76
|
});
|
|
63
77
|
});
|
|
64
78
|
|
|
79
|
+
it('passes through a stateful broad installation token', async () => {
|
|
80
|
+
createInstallationAccessTokenMock.mockResolvedValue({
|
|
81
|
+
data: {
|
|
82
|
+
token: GITHUB_STATEFUL_INSTALLATION_TOKEN,
|
|
83
|
+
expires_at: '2026-06-10T12:00:00.000Z',
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
const provider = createGithubInstallationTokenProvider();
|
|
87
|
+
|
|
88
|
+
const result = await provider.getInstallationAccessToken(1);
|
|
89
|
+
|
|
90
|
+
expect(result.token).toBe(GITHUB_STATEFUL_INSTALLATION_TOKEN);
|
|
91
|
+
expect(GITHUB_STATEFUL_INSTALLATION_TOKEN).not.toContain('.');
|
|
92
|
+
});
|
|
93
|
+
|
|
65
94
|
it('returns a cached token without a second mint', async () => {
|
|
66
95
|
vi.useFakeTimers();
|
|
67
96
|
vi.setSystemTime(new Date('2026-06-10T11:00:00.000Z'));
|
|
68
97
|
createInstallationAccessTokenMock.mockResolvedValue({
|
|
69
|
-
data: {
|
|
98
|
+
data: {
|
|
99
|
+
token: GITHUB_STATELESS_INSTALLATION_TOKEN,
|
|
100
|
+
expires_at: '2026-06-10T12:00:00.000Z',
|
|
101
|
+
},
|
|
70
102
|
});
|
|
71
103
|
const provider = createGithubInstallationTokenProvider();
|
|
72
104
|
|
|
@@ -151,7 +183,10 @@ describe('GithubInstallationTokenProvider', () => {
|
|
|
151
183
|
updatedAt: new Date(),
|
|
152
184
|
});
|
|
153
185
|
createInstallationAccessTokenMock.mockResolvedValue({
|
|
154
|
-
data: {
|
|
186
|
+
data: {
|
|
187
|
+
token: GITHUB_STATELESS_INSTALLATION_TOKEN,
|
|
188
|
+
expires_at: '2026-06-10T12:00:00.000Z',
|
|
189
|
+
},
|
|
155
190
|
});
|
|
156
191
|
const provider = createGithubInstallationTokenProvider({
|
|
157
192
|
getIntegrationConnectionById,
|
|
@@ -174,6 +209,10 @@ describe('GithubInstallationTokenProvider', () => {
|
|
|
174
209
|
const second = await provider.getInstallationAccessToken(installationId);
|
|
175
210
|
|
|
176
211
|
expect(first).toEqual(second);
|
|
212
|
+
expect(first.token).toBe(GITHUB_STATELESS_INSTALLATION_TOKEN);
|
|
213
|
+
expect(values.get(`${workspaceId}:${installationId}`)).toContain(
|
|
214
|
+
GITHUB_STATELESS_INSTALLATION_TOKEN,
|
|
215
|
+
);
|
|
177
216
|
expect(lockCalls).toBe(1);
|
|
178
217
|
expect(createInstallationAccessTokenMock).toHaveBeenCalledTimes(1);
|
|
179
218
|
});
|
|
@@ -4,8 +4,9 @@ import {config, normalizedGithubApiBaseUrl, normalizedGithubPrivateKey} from '#c
|
|
|
4
4
|
import {GithubIntegrationProviderError} from '#core/errors.js';
|
|
5
5
|
import {withInstallationTokenLock} from '#db/installation-token-lock.js';
|
|
6
6
|
import {getGithubInstallationByInstallationId} from '#db/installations.js';
|
|
7
|
-
import {recordInstallationTokenLookup} from '#metrics/index.js';
|
|
7
|
+
import {recordInstallationTokenFormat, recordInstallationTokenLookup} from '#metrics/index.js';
|
|
8
8
|
import {type GithubInstallationAccessToken, mapGithubError} from './client.js';
|
|
9
|
+
import {githubInstallationTokenFormatPlugin} from './github-octokit.js';
|
|
9
10
|
import {
|
|
10
11
|
githubInstallationTokenNamespace,
|
|
11
12
|
TOKEN_REFRESH_MARGIN_MS,
|
|
@@ -66,6 +67,8 @@ class OctokitGithubInstallationTokenProvider implements GithubInstallationTokenP
|
|
|
66
67
|
);
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
recordInstallationTokenFormat(response.data.token);
|
|
71
|
+
|
|
69
72
|
const expiresAt = new Date(response.data.expires_at);
|
|
70
73
|
if (Number.isNaN(expiresAt.getTime())) {
|
|
71
74
|
throw new GithubIntegrationProviderError(
|
|
@@ -86,7 +89,7 @@ class OctokitGithubInstallationTokenProvider implements GithubInstallationTokenP
|
|
|
86
89
|
this.app = new App({
|
|
87
90
|
appId: config.GITHUB_APP_ID,
|
|
88
91
|
privateKey: normalizedGithubPrivateKey(),
|
|
89
|
-
Octokit: Octokit.defaults({
|
|
92
|
+
Octokit: Octokit.plugin(githubInstallationTokenFormatPlugin).defaults({
|
|
90
93
|
baseUrl: normalizedGithubApiBaseUrl(),
|
|
91
94
|
throttle: {
|
|
92
95
|
onRateLimit: (
|
|
@@ -190,6 +190,34 @@ describe('SharedInstallationTokenCache', () => {
|
|
|
190
190
|
expect(mint).toHaveBeenCalledTimes(1);
|
|
191
191
|
});
|
|
192
192
|
|
|
193
|
+
it('preserves terminal provider rejection details through backoff', async () => {
|
|
194
|
+
const store = createStore();
|
|
195
|
+
const mint = vi
|
|
196
|
+
.fn()
|
|
197
|
+
.mockRejectedValue(
|
|
198
|
+
new GithubIntegrationProviderError(
|
|
199
|
+
'provider-rejected',
|
|
200
|
+
'commit_id is missing',
|
|
201
|
+
undefined,
|
|
202
|
+
422,
|
|
203
|
+
),
|
|
204
|
+
);
|
|
205
|
+
const shared = cache({store});
|
|
206
|
+
|
|
207
|
+
await expect(shared.getOrMint(installationId, mint)).rejects.toMatchObject({
|
|
208
|
+
reason: 'provider-rejected',
|
|
209
|
+
message: 'commit_id is missing',
|
|
210
|
+
status: 422,
|
|
211
|
+
});
|
|
212
|
+
await expect(shared.getOrMint(installationId, mint)).rejects.toMatchObject({
|
|
213
|
+
reason: 'provider-rejected',
|
|
214
|
+
message: 'commit_id is missing',
|
|
215
|
+
status: 422,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
expect(mint).toHaveBeenCalledTimes(1);
|
|
219
|
+
});
|
|
220
|
+
|
|
193
221
|
it('serves stale when refresh minting fails while the token is still valid', async () => {
|
|
194
222
|
const store = createStore();
|
|
195
223
|
setEnvelope(store, token('ghs_existing', '2026-06-10T11:04:30.000Z'));
|