@shipfox/api-integration-github 12.7.0 → 13.1.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 +6 -0
- package/dist/api/client.d.ts +13 -3
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +83 -4
- package/dist/api/client.js.map +1 -1
- package/dist/core/agent-tools.d.ts.map +1 -1
- package/dist/core/agent-tools.js +2 -6
- package/dist/core/agent-tools.js.map +1 -1
- package/dist/core/bot-identity.d.ts +4 -0
- package/dist/core/bot-identity.d.ts.map +1 -0
- package/dist/core/bot-identity.js +15 -0
- package/dist/core/bot-identity.js.map +1 -0
- package/dist/core/source-control.d.ts +2 -1
- package/dist/core/source-control.d.ts.map +1 -1
- package/dist/core/source-control.js +15 -10
- package/dist/core/source-control.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/api/client.test.ts +206 -18
- package/src/api/client.ts +130 -8
- package/src/core/agent-tools.ts +2 -9
- package/src/core/bot-identity.test.ts +19 -0
- package/src/core/bot-identity.ts +19 -0
- package/src/core/source-control.test.ts +87 -1
- package/src/core/source-control.ts +27 -11
- package/tsconfig.build.tsbuildinfo +1 -1
package/package.json
CHANGED
package/src/api/client.test.ts
CHANGED
|
@@ -7,37 +7,211 @@ import {createGithubApiClient, mapGithubError} from './client.js';
|
|
|
7
7
|
|
|
8
8
|
const GITHUB_INSTALLATION_TOKEN_PATTERN = /^ghs_[A-Za-z0-9._-]{36,}$/u;
|
|
9
9
|
|
|
10
|
-
const {createInstallationAccessTokenMock, RequestErrorMock} =
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
10
|
+
const {createInstallationAccessTokenMock, getByUsernameMock, octokitOptionsMock, RequestErrorMock} =
|
|
11
|
+
vi.hoisted(() => {
|
|
12
|
+
class RequestErrorMock extends Error {
|
|
13
|
+
constructor(
|
|
14
|
+
message: string,
|
|
15
|
+
public readonly status: number,
|
|
16
|
+
) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = 'HttpError';
|
|
19
|
+
}
|
|
18
20
|
}
|
|
19
|
-
}
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
return {
|
|
23
|
+
createInstallationAccessTokenMock: vi.fn(),
|
|
24
|
+
getByUsernameMock: vi.fn(),
|
|
25
|
+
octokitOptionsMock: vi.fn(),
|
|
26
|
+
RequestErrorMock,
|
|
27
|
+
};
|
|
28
|
+
});
|
|
23
29
|
|
|
24
30
|
vi.mock('octokit', () => ({
|
|
25
31
|
App: class App {
|
|
26
32
|
octokit = {
|
|
27
|
-
rest: {
|
|
33
|
+
rest: {
|
|
34
|
+
apps: {createInstallationAccessToken: createInstallationAccessTokenMock},
|
|
35
|
+
users: {getByUsername: getByUsernameMock},
|
|
36
|
+
},
|
|
28
37
|
};
|
|
29
38
|
},
|
|
30
|
-
Octokit: {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
39
|
+
Octokit: class Octokit {
|
|
40
|
+
rest = {users: {getByUsername: getByUsernameMock}};
|
|
41
|
+
|
|
42
|
+
constructor(options: unknown) {
|
|
43
|
+
octokitOptionsMock(options);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
static plugin() {
|
|
47
|
+
return Octokit;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
static defaults(options: unknown) {
|
|
35
51
|
return {defaults: options};
|
|
36
|
-
}
|
|
52
|
+
}
|
|
37
53
|
},
|
|
38
54
|
RequestError: RequestErrorMock,
|
|
39
55
|
}));
|
|
40
56
|
|
|
57
|
+
describe('OctokitGithubApiClient.getBotUser', () => {
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
getByUsernameMock.mockReset();
|
|
60
|
+
octokitOptionsMock.mockReset();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('shares one lookup for concurrent requests with the same token and caches the bot', async () => {
|
|
64
|
+
getByUsernameMock.mockResolvedValue({
|
|
65
|
+
data: {id: 307_629_549, login: 'shipfox-ai[bot]', type: 'Bot'},
|
|
66
|
+
});
|
|
67
|
+
const client = createGithubApiClient();
|
|
68
|
+
|
|
69
|
+
const firstLookup = client.getBotUser({
|
|
70
|
+
username: 'shipfox-ai[bot]',
|
|
71
|
+
installationAccessToken: 'ghs_first',
|
|
72
|
+
});
|
|
73
|
+
const secondLookup = client.getBotUser({
|
|
74
|
+
username: 'SHIPFOX-AI[BOT]',
|
|
75
|
+
installationAccessToken: 'ghs_first',
|
|
76
|
+
});
|
|
77
|
+
const [first, second] = await Promise.all([firstLookup, secondLookup]);
|
|
78
|
+
const cached = await client.getBotUser({
|
|
79
|
+
username: 'shipfox-ai[bot]',
|
|
80
|
+
installationAccessToken: 'ghs_third',
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(first).toEqual({id: 307_629_549, login: 'shipfox-ai[bot]'});
|
|
84
|
+
expect(second).toEqual(first);
|
|
85
|
+
expect(cached).toEqual(first);
|
|
86
|
+
expect(getByUsernameMock).toHaveBeenCalledTimes(1);
|
|
87
|
+
expect(getByUsernameMock).toHaveBeenCalledWith({
|
|
88
|
+
username: 'shipfox-ai[bot]',
|
|
89
|
+
request: {signal: expect.any(AbortSignal)},
|
|
90
|
+
});
|
|
91
|
+
expect(octokitOptionsMock).toHaveBeenCalledWith({
|
|
92
|
+
auth: 'ghs_first',
|
|
93
|
+
baseUrl: 'https://api.github.com',
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('does not share an in-flight lookup across installation tokens', async () => {
|
|
98
|
+
getByUsernameMock.mockResolvedValue({
|
|
99
|
+
data: {id: 307_629_549, login: 'shipfox-ai[bot]', type: 'Bot'},
|
|
100
|
+
});
|
|
101
|
+
const client = createGithubApiClient();
|
|
102
|
+
|
|
103
|
+
const firstLookup = client.getBotUser({
|
|
104
|
+
username: 'shipfox-ai[bot]',
|
|
105
|
+
installationAccessToken: 'ghs_first',
|
|
106
|
+
});
|
|
107
|
+
const secondLookup = client.getBotUser({
|
|
108
|
+
username: 'shipfox-ai[bot]',
|
|
109
|
+
installationAccessToken: 'ghs_second',
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await expect(Promise.all([firstLookup, secondLookup])).resolves.toEqual([
|
|
113
|
+
{id: 307_629_549, login: 'shipfox-ai[bot]'},
|
|
114
|
+
{id: 307_629_549, login: 'shipfox-ai[bot]'},
|
|
115
|
+
]);
|
|
116
|
+
expect(getByUsernameMock).toHaveBeenCalledTimes(2);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('evicts a failed lookup so a later request can retry', async () => {
|
|
120
|
+
getByUsernameMock
|
|
121
|
+
.mockRejectedValueOnce(new RequestErrorMock('GitHub unavailable', 503))
|
|
122
|
+
.mockResolvedValueOnce({
|
|
123
|
+
data: {id: 307_629_549, login: 'shipfox-ai[bot]', type: 'Bot'},
|
|
124
|
+
});
|
|
125
|
+
const client = createGithubApiClient();
|
|
126
|
+
|
|
127
|
+
const failed = client.getBotUser({
|
|
128
|
+
username: 'shipfox-ai[bot]',
|
|
129
|
+
installationAccessToken: 'ghs_first',
|
|
130
|
+
});
|
|
131
|
+
await expect(failed).rejects.toMatchObject({reason: 'provider-unavailable'});
|
|
132
|
+
const retried = await client.getBotUser({
|
|
133
|
+
username: 'shipfox-ai[bot]',
|
|
134
|
+
installationAccessToken: 'ghs_second',
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
expect(retried).toEqual({id: 307_629_549, login: 'shipfox-ai[bot]'});
|
|
138
|
+
expect(getByUsernameMock).toHaveBeenCalledTimes(2);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('maps a missing configured bot and retries after the username becomes available', async () => {
|
|
142
|
+
getByUsernameMock
|
|
143
|
+
.mockRejectedValueOnce(new RequestErrorMock('Not Found', 404))
|
|
144
|
+
.mockResolvedValueOnce({
|
|
145
|
+
data: {id: 307_629_549, login: 'shipfox-ai[bot]', type: 'Bot'},
|
|
146
|
+
});
|
|
147
|
+
const client = createGithubApiClient();
|
|
148
|
+
|
|
149
|
+
const missing = client.getBotUser({
|
|
150
|
+
username: 'shipfox-ai[bot]',
|
|
151
|
+
installationAccessToken: 'ghs_installationtoken',
|
|
152
|
+
});
|
|
153
|
+
await expect(missing).rejects.toMatchObject({
|
|
154
|
+
reason: 'provider-rejected',
|
|
155
|
+
message: 'Configured GitHub bot user shipfox-ai[bot] was not found',
|
|
156
|
+
status: 404,
|
|
157
|
+
});
|
|
158
|
+
const corrected = client.getBotUser({
|
|
159
|
+
username: 'shipfox-ai[bot]',
|
|
160
|
+
installationAccessToken: 'ghs_installationtoken',
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await expect(corrected).resolves.toEqual({
|
|
164
|
+
id: 307_629_549,
|
|
165
|
+
login: 'shipfox-ai[bot]',
|
|
166
|
+
});
|
|
167
|
+
expect(getByUsernameMock).toHaveBeenCalledTimes(2);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it.each([
|
|
171
|
+
['a null body', null, 'GitHub bot user response is missing required fields'],
|
|
172
|
+
[
|
|
173
|
+
'an empty login',
|
|
174
|
+
{id: 307_629_549, login: '', type: 'Bot'},
|
|
175
|
+
'GitHub bot user response is missing required fields',
|
|
176
|
+
],
|
|
177
|
+
[
|
|
178
|
+
'a zero id',
|
|
179
|
+
{id: 0, login: 'shipfox-ai[bot]', type: 'Bot'},
|
|
180
|
+
'GitHub bot user response is missing required fields',
|
|
181
|
+
],
|
|
182
|
+
[
|
|
183
|
+
'a negative id',
|
|
184
|
+
{id: -1, login: 'shipfox-ai[bot]', type: 'Bot'},
|
|
185
|
+
'GitHub bot user response is missing required fields',
|
|
186
|
+
],
|
|
187
|
+
[
|
|
188
|
+
'a fractional id',
|
|
189
|
+
{id: 1.5, login: 'shipfox-ai[bot]', type: 'Bot'},
|
|
190
|
+
'GitHub bot user response is missing required fields',
|
|
191
|
+
],
|
|
192
|
+
[
|
|
193
|
+
'a non-bot account',
|
|
194
|
+
{id: 307_629_549, login: 'shipfox-ai[bot]', type: 'User'},
|
|
195
|
+
'Configured GitHub username is not a bot account',
|
|
196
|
+
],
|
|
197
|
+
[
|
|
198
|
+
'a different bot account',
|
|
199
|
+
{id: 307_629_549, login: 'another-app[bot]', type: 'Bot'},
|
|
200
|
+
'GitHub bot user response did not match the configured username',
|
|
201
|
+
],
|
|
202
|
+
])('rejects %s', async (_label, data, message) => {
|
|
203
|
+
getByUsernameMock.mockResolvedValue({data});
|
|
204
|
+
const client = createGithubApiClient();
|
|
205
|
+
|
|
206
|
+
const result = client.getBotUser({
|
|
207
|
+
username: 'shipfox-ai[bot]',
|
|
208
|
+
installationAccessToken: 'ghs_installationtoken',
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
await expect(result).rejects.toMatchObject({reason: 'malformed-provider-response', message});
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
41
215
|
describe('mapGithubError', () => {
|
|
42
216
|
it.each([400, 409, 422])('maps HTTP %i to a terminal provider rejection', async (status) => {
|
|
43
217
|
const error = new RequestErrorMock(`GitHub rejected request with HTTP ${status}`, status);
|
|
@@ -62,6 +236,20 @@ describe('mapGithubError', () => {
|
|
|
62
236
|
status: 503,
|
|
63
237
|
});
|
|
64
238
|
});
|
|
239
|
+
|
|
240
|
+
it('maps a request timeout cause to timeout', async () => {
|
|
241
|
+
const timeout = new Error('The operation was aborted due to timeout');
|
|
242
|
+
timeout.name = 'TimeoutError';
|
|
243
|
+
const error = new RequestErrorMock('fetch failed', 500);
|
|
244
|
+
error.cause = timeout;
|
|
245
|
+
|
|
246
|
+
const result = mapGithubError(() => Promise.reject(error));
|
|
247
|
+
|
|
248
|
+
await expect(result).rejects.toMatchObject({
|
|
249
|
+
reason: 'timeout',
|
|
250
|
+
message: 'GitHub request timed out',
|
|
251
|
+
});
|
|
252
|
+
});
|
|
65
253
|
});
|
|
66
254
|
|
|
67
255
|
describe('OctokitGithubApiClient.createInstallationAccessToken', () => {
|
package/src/api/client.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {Buffer} from 'node:buffer';
|
|
2
|
-
import {MAX_REPOSITORY_FILE_BYTES} from '@shipfox/api-integration-spi';
|
|
2
|
+
import {isRecord, MAX_REPOSITORY_FILE_BYTES} from '@shipfox/api-integration-spi';
|
|
3
|
+
import {logger} from '@shipfox/node-opentelemetry';
|
|
3
4
|
import ky, {HTTPError, TimeoutError} from 'ky';
|
|
4
5
|
import {App, Octokit, RequestError} from 'octokit';
|
|
5
6
|
import {config, normalizedGithubApiBaseUrl, normalizedGithubPrivateKey} from '#config.js';
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
const NEXT_PAGE_RE = /[?&]page=(\d+)/;
|
|
14
15
|
const TRAILING_SLASHES_RE = /\/+$/;
|
|
15
16
|
const MAX_TREE_WALK_DEPTH = 10;
|
|
17
|
+
const GITHUB_API_TIMEOUT_MS = 10_000;
|
|
16
18
|
|
|
17
19
|
export interface GithubAccount {
|
|
18
20
|
login: string;
|
|
@@ -66,7 +68,16 @@ export interface GithubUserInstallationPage {
|
|
|
66
68
|
nextCursor: string | null;
|
|
67
69
|
}
|
|
68
70
|
|
|
69
|
-
export interface
|
|
71
|
+
export interface GithubBotUser {
|
|
72
|
+
id: number;
|
|
73
|
+
login: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface GithubBotUserClient {
|
|
77
|
+
getBotUser(input: {username: string; installationAccessToken: string}): Promise<GithubBotUser>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface GithubApiClient extends Partial<GithubBotUserClient> {
|
|
70
81
|
exchangeOAuthCode(code: string): Promise<string>;
|
|
71
82
|
listUserInstallations(input: {
|
|
72
83
|
userAccessToken: string;
|
|
@@ -106,12 +117,17 @@ export interface GithubInstallationAccessToken {
|
|
|
106
117
|
permissions?: Record<string, 'read' | 'write' | 'admin'> | undefined;
|
|
107
118
|
}
|
|
108
119
|
|
|
109
|
-
export function createGithubApiClient(): GithubApiClient {
|
|
120
|
+
export function createGithubApiClient(): GithubApiClient & GithubBotUserClient {
|
|
110
121
|
return new OctokitGithubApiClient();
|
|
111
122
|
}
|
|
112
123
|
|
|
113
|
-
class OctokitGithubApiClient implements GithubApiClient {
|
|
124
|
+
class OctokitGithubApiClient implements GithubApiClient, GithubBotUserClient {
|
|
114
125
|
private app: App | undefined;
|
|
126
|
+
private readonly botUsers = new Map<string, GithubBotUser>();
|
|
127
|
+
private readonly botUserLookups = new Map<
|
|
128
|
+
string,
|
|
129
|
+
{installationAccessToken: string; promise: Promise<GithubBotUser>}
|
|
130
|
+
>();
|
|
115
131
|
|
|
116
132
|
async exchangeOAuthCode(code: string): Promise<string> {
|
|
117
133
|
const body = await mapGithubOAuthError(() =>
|
|
@@ -136,6 +152,39 @@ class OctokitGithubApiClient implements GithubApiClient {
|
|
|
136
152
|
return body.access_token;
|
|
137
153
|
}
|
|
138
154
|
|
|
155
|
+
getBotUser(input: {username: string; installationAccessToken: string}): Promise<GithubBotUser> {
|
|
156
|
+
const cacheKey = input.username.trim().toLowerCase();
|
|
157
|
+
const cached = this.botUsers.get(cacheKey);
|
|
158
|
+
if (cached) return Promise.resolve(cached);
|
|
159
|
+
|
|
160
|
+
const pending = this.botUserLookups.get(cacheKey);
|
|
161
|
+
if (pending?.installationAccessToken === input.installationAccessToken) {
|
|
162
|
+
return pending.promise;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const lookup = this.fetchBotUser(input).then((botUser) => {
|
|
166
|
+
const resolved = this.botUsers.get(cacheKey);
|
|
167
|
+
if (resolved) return resolved;
|
|
168
|
+
|
|
169
|
+
this.botUsers.set(cacheKey, botUser);
|
|
170
|
+
logger().info(
|
|
171
|
+
{githubAppBotLogin: botUser.login, githubAppBotUserId: botUser.id},
|
|
172
|
+
'Resolved GitHub App bot identity',
|
|
173
|
+
);
|
|
174
|
+
return botUser;
|
|
175
|
+
});
|
|
176
|
+
const trackedLookup = lookup.finally(() => {
|
|
177
|
+
if (this.botUserLookups.get(cacheKey)?.promise === trackedLookup) {
|
|
178
|
+
this.botUserLookups.delete(cacheKey);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
this.botUserLookups.set(cacheKey, {
|
|
182
|
+
installationAccessToken: input.installationAccessToken,
|
|
183
|
+
promise: trackedLookup,
|
|
184
|
+
});
|
|
185
|
+
return trackedLookup;
|
|
186
|
+
}
|
|
187
|
+
|
|
139
188
|
async listUserInstallations(input: {
|
|
140
189
|
userAccessToken: string;
|
|
141
190
|
cursor?: string | undefined;
|
|
@@ -405,6 +454,72 @@ class OctokitGithubApiClient implements GithubApiClient {
|
|
|
405
454
|
}
|
|
406
455
|
return this.app;
|
|
407
456
|
}
|
|
457
|
+
|
|
458
|
+
private async fetchBotUser(input: {
|
|
459
|
+
username: string;
|
|
460
|
+
installationAccessToken: string;
|
|
461
|
+
}): Promise<GithubBotUser> {
|
|
462
|
+
const octokit = new Octokit({
|
|
463
|
+
auth: input.installationAccessToken,
|
|
464
|
+
baseUrl: normalizedGithubApiBaseUrl(),
|
|
465
|
+
});
|
|
466
|
+
let response: Awaited<ReturnType<typeof octokit.rest.users.getByUsername>>;
|
|
467
|
+
try {
|
|
468
|
+
response = await mapGithubError(
|
|
469
|
+
() =>
|
|
470
|
+
octokit.rest.users.getByUsername({
|
|
471
|
+
username: input.username,
|
|
472
|
+
request: {signal: AbortSignal.timeout(GITHUB_API_TIMEOUT_MS)},
|
|
473
|
+
}),
|
|
474
|
+
'provider-rejected',
|
|
475
|
+
);
|
|
476
|
+
} catch (error) {
|
|
477
|
+
if (error instanceof GithubIntegrationProviderError && error.status === 404) {
|
|
478
|
+
throw new GithubIntegrationProviderError(
|
|
479
|
+
'provider-rejected',
|
|
480
|
+
`Configured GitHub bot user ${input.username} was not found`,
|
|
481
|
+
undefined,
|
|
482
|
+
error.status,
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
throw error;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const data: unknown = response.data;
|
|
489
|
+
if (!isRecord(data)) {
|
|
490
|
+
throw new GithubIntegrationProviderError(
|
|
491
|
+
'malformed-provider-response',
|
|
492
|
+
'GitHub bot user response is missing required fields',
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
const {id, login, type} = data;
|
|
496
|
+
if (
|
|
497
|
+
typeof id !== 'number' ||
|
|
498
|
+
!Number.isSafeInteger(id) ||
|
|
499
|
+
id <= 0 ||
|
|
500
|
+
typeof login !== 'string' ||
|
|
501
|
+
login.trim().length === 0
|
|
502
|
+
) {
|
|
503
|
+
throw new GithubIntegrationProviderError(
|
|
504
|
+
'malformed-provider-response',
|
|
505
|
+
'GitHub bot user response is missing required fields',
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
if (type !== 'Bot') {
|
|
509
|
+
throw new GithubIntegrationProviderError(
|
|
510
|
+
'malformed-provider-response',
|
|
511
|
+
'Configured GitHub username is not a bot account',
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
const canonicalLogin = login.trim();
|
|
515
|
+
if (canonicalLogin.toLowerCase() !== input.username.trim().toLowerCase()) {
|
|
516
|
+
throw new GithubIntegrationProviderError(
|
|
517
|
+
'malformed-provider-response',
|
|
518
|
+
'GitHub bot user response did not match the configured username',
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
return {id, login: canonicalLogin};
|
|
522
|
+
}
|
|
408
523
|
}
|
|
409
524
|
|
|
410
525
|
async function mapGithubOAuthError<T>(operation: () => Promise<T>): Promise<T> {
|
|
@@ -442,12 +557,16 @@ export async function mapGithubError<T>(
|
|
|
442
557
|
notFoundReason:
|
|
443
558
|
| 'repository-not-found'
|
|
444
559
|
| 'installation-not-found'
|
|
445
|
-
| 'file-not-found'
|
|
560
|
+
| 'file-not-found'
|
|
561
|
+
| 'provider-rejected' = 'repository-not-found',
|
|
446
562
|
): Promise<T> {
|
|
447
563
|
try {
|
|
448
564
|
return await operation();
|
|
449
565
|
} catch (error) {
|
|
450
566
|
if (error instanceof GithubIntegrationProviderError) throw error;
|
|
567
|
+
if (isGithubTimeoutError(error)) {
|
|
568
|
+
throw new GithubIntegrationProviderError('timeout', 'GitHub request timed out');
|
|
569
|
+
}
|
|
451
570
|
if (error instanceof RequestError) {
|
|
452
571
|
if (error.status === 404) {
|
|
453
572
|
throw new GithubIntegrationProviderError(
|
|
@@ -490,13 +609,16 @@ export async function mapGithubError<T>(
|
|
|
490
609
|
);
|
|
491
610
|
}
|
|
492
611
|
}
|
|
493
|
-
if (error instanceof Error && error.name === 'AbortError') {
|
|
494
|
-
throw new GithubIntegrationProviderError('timeout', 'GitHub request timed out');
|
|
495
|
-
}
|
|
496
612
|
throw error;
|
|
497
613
|
}
|
|
498
614
|
}
|
|
499
615
|
|
|
616
|
+
function isGithubTimeoutError(error: unknown): boolean {
|
|
617
|
+
if (!(error instanceof Error)) return false;
|
|
618
|
+
if (error.name === 'AbortError' || error.name === 'TimeoutError') return true;
|
|
619
|
+
return error.cause instanceof Error && isGithubTimeoutError(error.cause);
|
|
620
|
+
}
|
|
621
|
+
|
|
500
622
|
function isGithubRateLimitError(error: RequestError): boolean {
|
|
501
623
|
return error.status === 403 && error.response?.headers['x-ratelimit-remaining'] === '0';
|
|
502
624
|
}
|
package/src/core/agent-tools.ts
CHANGED
|
@@ -13,8 +13,9 @@ import {
|
|
|
13
13
|
createGithubInstallationTokenProvider,
|
|
14
14
|
type GithubInstallationTokenProvider,
|
|
15
15
|
} from '#api/installation-token-provider.js';
|
|
16
|
-
import {
|
|
16
|
+
import {normalizedGithubApiBaseUrl} from '#config.js';
|
|
17
17
|
import type {GithubInstallation} from '#db/installations.js';
|
|
18
|
+
import {githubAppBotLogin} from './bot-identity.js';
|
|
18
19
|
import {GithubIntegrationProviderError} from './errors.js';
|
|
19
20
|
import {
|
|
20
21
|
type GithubAgentToolCatalogEntry,
|
|
@@ -59,7 +60,6 @@ const GITHUB_GRAPHQL_ROUTE = 'POST /graphql';
|
|
|
59
60
|
const GITHUB_ARTIFACT_ARCHIVE_FORMAT = 'zip';
|
|
60
61
|
const GITHUB_ARTIFACT_DOWNLOAD_ROUTE = `GET /repos/{owner}/{repo}/actions/artifacts/{resource_id}/${GITHUB_ARTIFACT_ARCHIVE_FORMAT}`;
|
|
61
62
|
const GITHUB_ARTIFACT_DOWNLOAD_TIMEOUT_MS = 30_000;
|
|
62
|
-
const GITHUB_APP_BOT_SUFFIX = '[bot]';
|
|
63
63
|
const PENDING_REVIEW_PAGE_SIZE = 100;
|
|
64
64
|
const PENDING_REVIEW_MAX_PAGE_REQUESTS = 5;
|
|
65
65
|
const PENDING_REVIEW_LOOKUP_TIMEOUT_MS = 15_000;
|
|
@@ -725,13 +725,6 @@ function latestPendingReviewOnPage(
|
|
|
725
725
|
return {malformed};
|
|
726
726
|
}
|
|
727
727
|
|
|
728
|
-
function githubAppBotLogin(): string {
|
|
729
|
-
const configuredUsername = config.GITHUB_APP_USERNAME?.trim() || config.GITHUB_APP_SLUG.trim();
|
|
730
|
-
return configuredUsername.toLowerCase().endsWith(GITHUB_APP_BOT_SUFFIX)
|
|
731
|
-
? configuredUsername
|
|
732
|
-
: `${configuredUsername}${GITHUB_APP_BOT_SUFFIX}`;
|
|
733
|
-
}
|
|
734
|
-
|
|
735
728
|
function githubToolResult(
|
|
736
729
|
toolId: GithubAgentToolId,
|
|
737
730
|
data: unknown,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import {configuredGithubAppBotLogin, githubAppBotLogin, githubBotLogin} from './bot-identity.js';
|
|
2
|
+
|
|
3
|
+
describe('GitHub App bot identity', () => {
|
|
4
|
+
it.each([
|
|
5
|
+
['shipfox-ai', 'shipfox-ai[bot]'],
|
|
6
|
+
['shipfox-ai[bot]', 'shipfox-ai[bot]'],
|
|
7
|
+
['Shipfox-AI[Bot]', 'Shipfox-AI[Bot]'],
|
|
8
|
+
])('normalizes %s to %s', (username, expected) => {
|
|
9
|
+
expect(githubBotLogin(username)).toBe(expected);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('uses the configured username for commit attribution', () => {
|
|
13
|
+
expect(configuredGithubAppBotLogin()).toBe('shipfox-test[bot]');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('uses the configured username for App review identity', () => {
|
|
17
|
+
expect(githubAppBotLogin()).toBe('shipfox-test[bot]');
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import {config} from '#config.js';
|
|
2
|
+
|
|
3
|
+
const GITHUB_APP_BOT_SUFFIX = '[bot]';
|
|
4
|
+
|
|
5
|
+
export function githubBotLogin(username: string): string {
|
|
6
|
+
const normalized = username.trim();
|
|
7
|
+
return normalized.toLowerCase().endsWith(GITHUB_APP_BOT_SUFFIX)
|
|
8
|
+
? normalized
|
|
9
|
+
: `${normalized}${GITHUB_APP_BOT_SUFFIX}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function configuredGithubAppBotLogin(): string | undefined {
|
|
13
|
+
const configuredUsername = config.GITHUB_APP_USERNAME?.trim();
|
|
14
|
+
return configuredUsername ? githubBotLogin(configuredUsername) : undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function githubAppBotLogin(): string {
|
|
18
|
+
return configuredGithubAppBotLogin() ?? githubBotLogin(config.GITHUB_APP_SLUG);
|
|
19
|
+
}
|
|
@@ -8,6 +8,7 @@ const VALID_COMMIT = 'a'.repeat(40);
|
|
|
8
8
|
function githubClient(overrides: Partial<GithubApiClient> = {}): GithubApiClient {
|
|
9
9
|
return {
|
|
10
10
|
exchangeOAuthCode: vi.fn(() => Promise.resolve('token')),
|
|
11
|
+
getBotUser: vi.fn(() => Promise.resolve({id: 12_345, login: 'shipfox-test[bot]'})),
|
|
11
12
|
listUserInstallations: vi.fn(() => Promise.resolve({installationIds: [], nextCursor: null})),
|
|
12
13
|
getInstallation: vi.fn(() => {
|
|
13
14
|
throw new Error('not used');
|
|
@@ -349,7 +350,7 @@ describe('GithubSourceControlProvider', () => {
|
|
|
349
350
|
},
|
|
350
351
|
gitAuthor: {
|
|
351
352
|
name: 'shipfox-test[bot]',
|
|
352
|
-
email: '
|
|
353
|
+
email: '12345+shipfox-test[bot]@users.noreply.github.com',
|
|
353
354
|
},
|
|
354
355
|
});
|
|
355
356
|
expect(result.repositoryUrl).not.toContain('ghs_installationtoken');
|
|
@@ -358,6 +359,91 @@ describe('GithubSourceControlProvider', () => {
|
|
|
358
359
|
repositoryId: 42,
|
|
359
360
|
permissions: {contents: 'write'},
|
|
360
361
|
});
|
|
362
|
+
expect(github.getBotUser).toHaveBeenCalledWith({
|
|
363
|
+
username: 'shipfox-test[bot]',
|
|
364
|
+
installationAccessToken: 'ghs_installationtoken',
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('propagates bot identity resolution failures for write checkouts', async () => {
|
|
369
|
+
await createInstallation();
|
|
370
|
+
const github = githubClient({
|
|
371
|
+
getBotUser: vi.fn(() =>
|
|
372
|
+
Promise.reject(
|
|
373
|
+
new GithubIntegrationProviderError('provider-rejected', 'bot user not found'),
|
|
374
|
+
),
|
|
375
|
+
),
|
|
376
|
+
});
|
|
377
|
+
const provider = new GithubSourceControlProvider(github);
|
|
378
|
+
|
|
379
|
+
const result = provider.createCheckoutSpec({
|
|
380
|
+
connection: connection(),
|
|
381
|
+
externalRepositoryId: 'github:42',
|
|
382
|
+
permissions: {contents: 'write'},
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
await expect(result).rejects.toMatchObject({reason: 'provider-rejected'});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('propagates unexpected bot lookup errors', async () => {
|
|
389
|
+
await createInstallation();
|
|
390
|
+
const github = githubClient({
|
|
391
|
+
getBotUser: vi.fn(() => Promise.reject(new Error('unexpected lookup failure'))),
|
|
392
|
+
});
|
|
393
|
+
const provider = new GithubSourceControlProvider(github);
|
|
394
|
+
|
|
395
|
+
const result = provider.createCheckoutSpec({
|
|
396
|
+
connection: connection(),
|
|
397
|
+
externalRepositoryId: 'github:42',
|
|
398
|
+
permissions: {contents: 'write'},
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
await expect(result).rejects.toThrow('unexpected lookup failure');
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('rejects write checkout when the bot identity resolver is unavailable', async () => {
|
|
405
|
+
await createInstallation();
|
|
406
|
+
const github = githubClient();
|
|
407
|
+
delete github.getBotUser;
|
|
408
|
+
const provider = new GithubSourceControlProvider(github);
|
|
409
|
+
|
|
410
|
+
const result = provider.createCheckoutSpec({
|
|
411
|
+
connection: connection(),
|
|
412
|
+
externalRepositoryId: 'github:42',
|
|
413
|
+
permissions: {contents: 'write'},
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
await expect(result).rejects.toMatchObject({reason: 'provider-unavailable'});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('omits the author and bot lookup for read-only checkouts', async () => {
|
|
420
|
+
await createInstallation();
|
|
421
|
+
const github = githubClient();
|
|
422
|
+
const provider = new GithubSourceControlProvider(github);
|
|
423
|
+
|
|
424
|
+
const result = await provider.createCheckoutSpec({
|
|
425
|
+
connection: connection(),
|
|
426
|
+
externalRepositoryId: 'github:42',
|
|
427
|
+
permissions: {contents: 'read'},
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
expect(result.gitAuthor).toBeUndefined();
|
|
431
|
+
expect(github.getBotUser).not.toHaveBeenCalled();
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('omits the author and bot lookup when the App username is unset', async () => {
|
|
435
|
+
await createInstallation();
|
|
436
|
+
const github = githubClient();
|
|
437
|
+
const provider = new GithubSourceControlProvider(github, () => undefined);
|
|
438
|
+
|
|
439
|
+
const result = await provider.createCheckoutSpec({
|
|
440
|
+
connection: connection(),
|
|
441
|
+
externalRepositoryId: 'github:42',
|
|
442
|
+
permissions: {contents: 'write'},
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
expect(result.gitAuthor).toBeUndefined();
|
|
446
|
+
expect(github.getBotUser).not.toHaveBeenCalled();
|
|
361
447
|
});
|
|
362
448
|
|
|
363
449
|
it('defaults the checkout ref to the repository default branch', async () => {
|