@shipfox/api-integration-github 12.3.0 → 12.5.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 +11 -0
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +7 -4
- package/dist/api/client.js.map +1 -1
- 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/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/core/agent-tools.d.ts +11 -4
- package/dist/core/agent-tools.d.ts.map +1 -1
- package/dist/core/agent-tools.js +182 -17
- 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/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/api/client.test.ts +41 -5
- package/src/api/client.ts +22 -2
- package/src/api/installation-token-envelope.ts +24 -2
- package/src/api/shared-installation-token-cache.test.ts +28 -0
- package/src/api/shared-installation-token-cache.ts +7 -0
- package/src/core/agent-tools.test.ts +862 -8
- package/src/core/agent-tools.ts +271 -19
- package/src/core/github-agent-tool-catalog.ts +1 -1
- 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.5.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,19 @@
|
|
|
1
1
|
import {GithubIntegrationProviderError} from '#core/errors.js';
|
|
2
|
-
import {createGithubApiClient} from './client.js';
|
|
2
|
+
import {createGithubApiClient, mapGithubError} from './client.js';
|
|
3
3
|
|
|
4
|
-
const {createInstallationAccessTokenMock} = vi.hoisted(() =>
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
const {createInstallationAccessTokenMock, RequestErrorMock} = vi.hoisted(() => {
|
|
5
|
+
class RequestErrorMock extends Error {
|
|
6
|
+
constructor(
|
|
7
|
+
message: string,
|
|
8
|
+
public readonly status: number,
|
|
9
|
+
) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'HttpError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return {createInstallationAccessTokenMock: vi.fn(), RequestErrorMock};
|
|
16
|
+
});
|
|
7
17
|
|
|
8
18
|
vi.mock('octokit', () => ({
|
|
9
19
|
App: class App {
|
|
@@ -16,9 +26,35 @@ vi.mock('octokit', () => ({
|
|
|
16
26
|
return {defaults: options};
|
|
17
27
|
},
|
|
18
28
|
},
|
|
19
|
-
RequestError:
|
|
29
|
+
RequestError: RequestErrorMock,
|
|
20
30
|
}));
|
|
21
31
|
|
|
32
|
+
describe('mapGithubError', () => {
|
|
33
|
+
it.each([400, 409, 422])('maps HTTP %i to a terminal provider rejection', async (status) => {
|
|
34
|
+
const error = new RequestErrorMock(`GitHub rejected request with HTTP ${status}`, status);
|
|
35
|
+
|
|
36
|
+
const result = mapGithubError(() => Promise.reject(error));
|
|
37
|
+
|
|
38
|
+
await expect(result).rejects.toMatchObject({
|
|
39
|
+
reason: 'provider-rejected',
|
|
40
|
+
message: `GitHub rejected request with HTTP ${status}`,
|
|
41
|
+
status,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('preserves the status when mapping provider unavailability', async () => {
|
|
46
|
+
const error = new RequestErrorMock('GitHub is unavailable', 503);
|
|
47
|
+
|
|
48
|
+
const result = mapGithubError(() => Promise.reject(error));
|
|
49
|
+
|
|
50
|
+
await expect(result).rejects.toMatchObject({
|
|
51
|
+
reason: 'provider-unavailable',
|
|
52
|
+
message: 'GitHub is unavailable',
|
|
53
|
+
status: 503,
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
22
58
|
describe('OctokitGithubApiClient.createInstallationAccessToken', () => {
|
|
23
59
|
beforeEach(() => {
|
|
24
60
|
createInstallationAccessTokenMock.mockReset();
|
package/src/api/client.ts
CHANGED
|
@@ -433,13 +433,19 @@ export async function mapGithubError<T>(
|
|
|
433
433
|
if (error instanceof GithubIntegrationProviderError) throw error;
|
|
434
434
|
if (error instanceof RequestError) {
|
|
435
435
|
if (error.status === 404) {
|
|
436
|
-
throw new GithubIntegrationProviderError(
|
|
436
|
+
throw new GithubIntegrationProviderError(
|
|
437
|
+
notFoundReason,
|
|
438
|
+
error.message,
|
|
439
|
+
undefined,
|
|
440
|
+
error.status,
|
|
441
|
+
);
|
|
437
442
|
}
|
|
438
443
|
if (error.status === 429 || isGithubRateLimitError(error)) {
|
|
439
444
|
throw new GithubIntegrationProviderError(
|
|
440
445
|
'rate-limited',
|
|
441
446
|
error.message,
|
|
442
447
|
retryAfterSeconds(error),
|
|
448
|
+
error.status,
|
|
443
449
|
);
|
|
444
450
|
}
|
|
445
451
|
if (error.status === 401 || error.status === 403) {
|
|
@@ -447,10 +453,24 @@ export async function mapGithubError<T>(
|
|
|
447
453
|
'access-denied',
|
|
448
454
|
error.message,
|
|
449
455
|
retryAfterSeconds(error),
|
|
456
|
+
error.status,
|
|
450
457
|
);
|
|
451
458
|
}
|
|
452
459
|
if (error.status >= 500) {
|
|
453
|
-
throw new GithubIntegrationProviderError(
|
|
460
|
+
throw new GithubIntegrationProviderError(
|
|
461
|
+
'provider-unavailable',
|
|
462
|
+
error.message,
|
|
463
|
+
undefined,
|
|
464
|
+
error.status,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
if (error.status >= 400) {
|
|
468
|
+
throw new GithubIntegrationProviderError(
|
|
469
|
+
'provider-rejected',
|
|
470
|
+
error.message,
|
|
471
|
+
undefined,
|
|
472
|
+
error.status,
|
|
473
|
+
);
|
|
454
474
|
}
|
|
455
475
|
}
|
|
456
476
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
@@ -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',
|
|
@@ -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'));
|
|
@@ -113,6 +113,7 @@ export class SharedInstallationTokenCache implements InstallationTokenCache {
|
|
|
113
113
|
throw providerErrorFromBackoff(
|
|
114
114
|
envelope?.backoffReason ?? 'provider-unavailable',
|
|
115
115
|
(envelope?.backoffUntil?.getTime() ?? now.getTime()) - now.getTime(),
|
|
116
|
+
envelope?.backoffError,
|
|
116
117
|
);
|
|
117
118
|
}
|
|
118
119
|
|
|
@@ -131,6 +132,10 @@ export class SharedInstallationTokenCache implements InstallationTokenCache {
|
|
|
131
132
|
permissions: envelope?.permissions,
|
|
132
133
|
backoffUntil: until,
|
|
133
134
|
backoffReason: classified.reason,
|
|
135
|
+
backoffError: {
|
|
136
|
+
message: providerError.message,
|
|
137
|
+
...(providerError.status === undefined ? {} : {status: providerError.status}),
|
|
138
|
+
},
|
|
134
139
|
}).catch((writeError) => {
|
|
135
140
|
logger().warn(
|
|
136
141
|
{installationId: params.installationId, reason: classified.reason, error: writeError},
|
|
@@ -215,6 +220,7 @@ export class SharedInstallationTokenCache implements InstallationTokenCache {
|
|
|
215
220
|
throw providerErrorFromBackoff(
|
|
216
221
|
params.envelope.backoffReason,
|
|
217
222
|
params.envelope.backoffUntil.getTime() - initialNow.getTime(),
|
|
223
|
+
params.envelope.backoffError,
|
|
218
224
|
);
|
|
219
225
|
}
|
|
220
226
|
|
|
@@ -231,6 +237,7 @@ export class SharedInstallationTokenCache implements InstallationTokenCache {
|
|
|
231
237
|
throw providerErrorFromBackoff(
|
|
232
238
|
envelope?.backoffReason ?? 'provider-unavailable',
|
|
233
239
|
(envelope?.backoffUntil?.getTime() ?? now.getTime()) - now.getTime(),
|
|
240
|
+
envelope?.backoffError,
|
|
234
241
|
);
|
|
235
242
|
}
|
|
236
243
|
}
|