@shipfox/api-integration-github 12.7.0 → 14.0.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 +23 -0
- package/dist/api/client.d.ts +21 -3
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +115 -6
- package/dist/api/client.js.map +1 -1
- package/dist/api/installation-token-envelope.d.ts.map +1 -1
- package/dist/api/installation-token-envelope.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 +4 -2
- package/dist/core/source-control.d.ts.map +1 -1
- package/dist/core/source-control.js +42 -11
- package/dist/core/source-control.js.map +1 -1
- package/dist/index.d.ts +78 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/api/client.test.ts +267 -9
- package/src/api/client.ts +184 -9
- package/src/api/installation-token-envelope.ts +3 -1
- package/src/core/agent-tools.test.ts +1 -0
- 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/install.test.ts +3 -0
- package/src/core/source-control.test.ts +180 -1
- package/src/core/source-control.ts +65 -11
- package/src/index.test.ts +3 -1
- package/src/index.ts +2 -0
- package/src/presentation/routes/install.test.ts +3 -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": "
|
|
4
|
+
"version": "14.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -28,8 +28,8 @@
|
|
|
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": "
|
|
32
|
-
"@shipfox/api-integration-github-dto": "
|
|
31
|
+
"@shipfox/api-integration-spi": "2.0.0",
|
|
32
|
+
"@shipfox/api-integration-github-dto": "14.0.0",
|
|
33
33
|
"@shipfox/config": "1.2.4",
|
|
34
34
|
"@shipfox/node-drizzle": "0.3.5",
|
|
35
35
|
"@shipfox/node-fastify": "0.4.2",
|
package/src/api/client.test.ts
CHANGED
|
@@ -7,7 +7,15 @@ 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 {
|
|
10
|
+
const {
|
|
11
|
+
authMock,
|
|
12
|
+
createInstallationAccessTokenMock,
|
|
13
|
+
getByUsernameMock,
|
|
14
|
+
listRepositoryCommitsMock,
|
|
15
|
+
octokitOptionsMock,
|
|
16
|
+
requestMock,
|
|
17
|
+
RequestErrorMock,
|
|
18
|
+
} = vi.hoisted(() => {
|
|
11
19
|
class RequestErrorMock extends Error {
|
|
12
20
|
constructor(
|
|
13
21
|
message: string,
|
|
@@ -18,26 +26,207 @@ const {createInstallationAccessTokenMock, RequestErrorMock} = vi.hoisted(() => {
|
|
|
18
26
|
}
|
|
19
27
|
}
|
|
20
28
|
|
|
21
|
-
return {
|
|
29
|
+
return {
|
|
30
|
+
authMock: vi.fn(),
|
|
31
|
+
createInstallationAccessTokenMock: vi.fn(),
|
|
32
|
+
getByUsernameMock: vi.fn(),
|
|
33
|
+
listRepositoryCommitsMock: vi.fn(),
|
|
34
|
+
octokitOptionsMock: vi.fn(),
|
|
35
|
+
requestMock: vi.fn(),
|
|
36
|
+
RequestErrorMock,
|
|
37
|
+
};
|
|
22
38
|
});
|
|
23
39
|
|
|
24
40
|
vi.mock('octokit', () => ({
|
|
25
41
|
App: class App {
|
|
26
42
|
octokit = {
|
|
27
|
-
|
|
43
|
+
auth: authMock,
|
|
44
|
+
rest: {
|
|
45
|
+
apps: {createInstallationAccessToken: createInstallationAccessTokenMock},
|
|
46
|
+
users: {getByUsername: getByUsernameMock},
|
|
47
|
+
},
|
|
28
48
|
};
|
|
29
49
|
},
|
|
30
|
-
Octokit: {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
50
|
+
Octokit: class Octokit {
|
|
51
|
+
rest = {
|
|
52
|
+
repos: {listCommits: listRepositoryCommitsMock},
|
|
53
|
+
users: {getByUsername: getByUsernameMock},
|
|
54
|
+
};
|
|
55
|
+
request = requestMock;
|
|
56
|
+
|
|
57
|
+
constructor(options: unknown) {
|
|
58
|
+
octokitOptionsMock(options);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
static plugin() {
|
|
62
|
+
return Octokit;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
static defaults(options: unknown) {
|
|
35
66
|
return {defaults: options};
|
|
36
|
-
}
|
|
67
|
+
}
|
|
37
68
|
},
|
|
38
69
|
RequestError: RequestErrorMock,
|
|
39
70
|
}));
|
|
40
71
|
|
|
72
|
+
describe('OctokitGithubApiClient.getBotUser', () => {
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
getByUsernameMock.mockReset();
|
|
75
|
+
octokitOptionsMock.mockReset();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('shares one lookup for concurrent requests with the same token and caches the bot', async () => {
|
|
79
|
+
getByUsernameMock.mockResolvedValue({
|
|
80
|
+
data: {id: 307_629_549, login: 'shipfox-ai[bot]', type: 'Bot'},
|
|
81
|
+
});
|
|
82
|
+
const client = createGithubApiClient();
|
|
83
|
+
|
|
84
|
+
const firstLookup = client.getBotUser({
|
|
85
|
+
username: 'shipfox-ai[bot]',
|
|
86
|
+
installationAccessToken: 'ghs_first',
|
|
87
|
+
});
|
|
88
|
+
const secondLookup = client.getBotUser({
|
|
89
|
+
username: 'SHIPFOX-AI[BOT]',
|
|
90
|
+
installationAccessToken: 'ghs_first',
|
|
91
|
+
});
|
|
92
|
+
const [first, second] = await Promise.all([firstLookup, secondLookup]);
|
|
93
|
+
const cached = await client.getBotUser({
|
|
94
|
+
username: 'shipfox-ai[bot]',
|
|
95
|
+
installationAccessToken: 'ghs_third',
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(first).toEqual({id: 307_629_549, login: 'shipfox-ai[bot]'});
|
|
99
|
+
expect(second).toEqual(first);
|
|
100
|
+
expect(cached).toEqual(first);
|
|
101
|
+
expect(getByUsernameMock).toHaveBeenCalledTimes(1);
|
|
102
|
+
expect(getByUsernameMock).toHaveBeenCalledWith({
|
|
103
|
+
username: 'shipfox-ai[bot]',
|
|
104
|
+
request: {signal: expect.any(AbortSignal)},
|
|
105
|
+
});
|
|
106
|
+
expect(octokitOptionsMock).toHaveBeenCalledWith({
|
|
107
|
+
auth: 'ghs_first',
|
|
108
|
+
baseUrl: 'https://api.github.com',
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('does not share an in-flight lookup across installation tokens', async () => {
|
|
113
|
+
getByUsernameMock.mockResolvedValue({
|
|
114
|
+
data: {id: 307_629_549, login: 'shipfox-ai[bot]', type: 'Bot'},
|
|
115
|
+
});
|
|
116
|
+
const client = createGithubApiClient();
|
|
117
|
+
|
|
118
|
+
const firstLookup = client.getBotUser({
|
|
119
|
+
username: 'shipfox-ai[bot]',
|
|
120
|
+
installationAccessToken: 'ghs_first',
|
|
121
|
+
});
|
|
122
|
+
const secondLookup = client.getBotUser({
|
|
123
|
+
username: 'shipfox-ai[bot]',
|
|
124
|
+
installationAccessToken: 'ghs_second',
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
await expect(Promise.all([firstLookup, secondLookup])).resolves.toEqual([
|
|
128
|
+
{id: 307_629_549, login: 'shipfox-ai[bot]'},
|
|
129
|
+
{id: 307_629_549, login: 'shipfox-ai[bot]'},
|
|
130
|
+
]);
|
|
131
|
+
expect(getByUsernameMock).toHaveBeenCalledTimes(2);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('evicts a failed lookup so a later request can retry', async () => {
|
|
135
|
+
getByUsernameMock
|
|
136
|
+
.mockRejectedValueOnce(new RequestErrorMock('GitHub unavailable', 503))
|
|
137
|
+
.mockResolvedValueOnce({
|
|
138
|
+
data: {id: 307_629_549, login: 'shipfox-ai[bot]', type: 'Bot'},
|
|
139
|
+
});
|
|
140
|
+
const client = createGithubApiClient();
|
|
141
|
+
|
|
142
|
+
const failed = client.getBotUser({
|
|
143
|
+
username: 'shipfox-ai[bot]',
|
|
144
|
+
installationAccessToken: 'ghs_first',
|
|
145
|
+
});
|
|
146
|
+
await expect(failed).rejects.toMatchObject({reason: 'provider-unavailable'});
|
|
147
|
+
const retried = await client.getBotUser({
|
|
148
|
+
username: 'shipfox-ai[bot]',
|
|
149
|
+
installationAccessToken: 'ghs_second',
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(retried).toEqual({id: 307_629_549, login: 'shipfox-ai[bot]'});
|
|
153
|
+
expect(getByUsernameMock).toHaveBeenCalledTimes(2);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('maps a missing configured bot and retries after the username becomes available', async () => {
|
|
157
|
+
getByUsernameMock
|
|
158
|
+
.mockRejectedValueOnce(new RequestErrorMock('Not Found', 404))
|
|
159
|
+
.mockResolvedValueOnce({
|
|
160
|
+
data: {id: 307_629_549, login: 'shipfox-ai[bot]', type: 'Bot'},
|
|
161
|
+
});
|
|
162
|
+
const client = createGithubApiClient();
|
|
163
|
+
|
|
164
|
+
const missing = client.getBotUser({
|
|
165
|
+
username: 'shipfox-ai[bot]',
|
|
166
|
+
installationAccessToken: 'ghs_installationtoken',
|
|
167
|
+
});
|
|
168
|
+
await expect(missing).rejects.toMatchObject({
|
|
169
|
+
reason: 'provider-rejected',
|
|
170
|
+
message: 'Configured GitHub bot user shipfox-ai[bot] was not found',
|
|
171
|
+
status: 404,
|
|
172
|
+
});
|
|
173
|
+
const corrected = client.getBotUser({
|
|
174
|
+
username: 'shipfox-ai[bot]',
|
|
175
|
+
installationAccessToken: 'ghs_installationtoken',
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await expect(corrected).resolves.toEqual({
|
|
179
|
+
id: 307_629_549,
|
|
180
|
+
login: 'shipfox-ai[bot]',
|
|
181
|
+
});
|
|
182
|
+
expect(getByUsernameMock).toHaveBeenCalledTimes(2);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it.each([
|
|
186
|
+
['a null body', null, 'GitHub bot user response is missing required fields'],
|
|
187
|
+
[
|
|
188
|
+
'an empty login',
|
|
189
|
+
{id: 307_629_549, login: '', type: 'Bot'},
|
|
190
|
+
'GitHub bot user response is missing required fields',
|
|
191
|
+
],
|
|
192
|
+
[
|
|
193
|
+
'a zero id',
|
|
194
|
+
{id: 0, login: 'shipfox-ai[bot]', type: 'Bot'},
|
|
195
|
+
'GitHub bot user response is missing required fields',
|
|
196
|
+
],
|
|
197
|
+
[
|
|
198
|
+
'a negative id',
|
|
199
|
+
{id: -1, login: 'shipfox-ai[bot]', type: 'Bot'},
|
|
200
|
+
'GitHub bot user response is missing required fields',
|
|
201
|
+
],
|
|
202
|
+
[
|
|
203
|
+
'a fractional id',
|
|
204
|
+
{id: 1.5, login: 'shipfox-ai[bot]', type: 'Bot'},
|
|
205
|
+
'GitHub bot user response is missing required fields',
|
|
206
|
+
],
|
|
207
|
+
[
|
|
208
|
+
'a non-bot account',
|
|
209
|
+
{id: 307_629_549, login: 'shipfox-ai[bot]', type: 'User'},
|
|
210
|
+
'Configured GitHub username is not a bot account',
|
|
211
|
+
],
|
|
212
|
+
[
|
|
213
|
+
'a different bot account',
|
|
214
|
+
{id: 307_629_549, login: 'another-app[bot]', type: 'Bot'},
|
|
215
|
+
'GitHub bot user response did not match the configured username',
|
|
216
|
+
],
|
|
217
|
+
])('rejects %s', async (_label, data, message) => {
|
|
218
|
+
getByUsernameMock.mockResolvedValue({data});
|
|
219
|
+
const client = createGithubApiClient();
|
|
220
|
+
|
|
221
|
+
const result = client.getBotUser({
|
|
222
|
+
username: 'shipfox-ai[bot]',
|
|
223
|
+
installationAccessToken: 'ghs_installationtoken',
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
await expect(result).rejects.toMatchObject({reason: 'malformed-provider-response', message});
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
41
230
|
describe('mapGithubError', () => {
|
|
42
231
|
it.each([400, 409, 422])('maps HTTP %i to a terminal provider rejection', async (status) => {
|
|
43
232
|
const error = new RequestErrorMock(`GitHub rejected request with HTTP ${status}`, status);
|
|
@@ -62,6 +251,75 @@ describe('mapGithubError', () => {
|
|
|
62
251
|
status: 503,
|
|
63
252
|
});
|
|
64
253
|
});
|
|
254
|
+
|
|
255
|
+
it('maps a request timeout cause to timeout', async () => {
|
|
256
|
+
const timeout = new Error('The operation was aborted due to timeout');
|
|
257
|
+
timeout.name = 'TimeoutError';
|
|
258
|
+
const error = new RequestErrorMock('fetch failed', 500);
|
|
259
|
+
error.cause = timeout;
|
|
260
|
+
|
|
261
|
+
const result = mapGithubError(() => Promise.reject(error));
|
|
262
|
+
|
|
263
|
+
await expect(result).rejects.toMatchObject({
|
|
264
|
+
reason: 'timeout',
|
|
265
|
+
message: 'GitHub request timed out',
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe('OctokitGithubApiClient.listRepositoryCommits', () => {
|
|
271
|
+
beforeEach(() => {
|
|
272
|
+
authMock.mockReset();
|
|
273
|
+
listRepositoryCommitsMock.mockReset();
|
|
274
|
+
requestMock.mockReset();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it.each([
|
|
278
|
+
[404, 'repository-not-found'],
|
|
279
|
+
[409, 'ref-not-found'],
|
|
280
|
+
[422, 'ref-not-found'],
|
|
281
|
+
] as const)('maps HTTP %i commit lookup failures to %s', async (status, reason) => {
|
|
282
|
+
authMock.mockResolvedValue({token: 'ghs_installationtoken'});
|
|
283
|
+
requestMock.mockResolvedValue({
|
|
284
|
+
data: {
|
|
285
|
+
id: 42,
|
|
286
|
+
owner: {login: 'shipfox'},
|
|
287
|
+
name: 'platform',
|
|
288
|
+
full_name: 'shipfox/platform',
|
|
289
|
+
default_branch: 'main',
|
|
290
|
+
private: true,
|
|
291
|
+
clone_url: 'https://github.com/shipfox/platform.git',
|
|
292
|
+
html_url: 'https://github.com/shipfox/platform',
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
listRepositoryCommitsMock.mockRejectedValue(
|
|
296
|
+
new RequestErrorMock('No commit found for SHA', status),
|
|
297
|
+
);
|
|
298
|
+
const client = createGithubApiClient();
|
|
299
|
+
|
|
300
|
+
const result = client.listRepositoryCommits({
|
|
301
|
+
installationId: 1,
|
|
302
|
+
repositoryId: 42,
|
|
303
|
+
ref: 'refs/heads/missing',
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
await expect(result).rejects.toMatchObject({
|
|
307
|
+
reason,
|
|
308
|
+
status,
|
|
309
|
+
message: 'No commit found for SHA',
|
|
310
|
+
});
|
|
311
|
+
expect(requestMock).toHaveBeenCalledWith('GET /repositories/{repository_id}', {
|
|
312
|
+
repository_id: 42,
|
|
313
|
+
request: {signal: expect.any(AbortSignal)},
|
|
314
|
+
});
|
|
315
|
+
expect(listRepositoryCommitsMock).toHaveBeenCalledWith({
|
|
316
|
+
owner: 'shipfox',
|
|
317
|
+
repo: 'platform',
|
|
318
|
+
sha: 'refs/heads/missing',
|
|
319
|
+
per_page: 1,
|
|
320
|
+
request: {signal: expect.any(AbortSignal)},
|
|
321
|
+
});
|
|
322
|
+
});
|
|
65
323
|
});
|
|
66
324
|
|
|
67
325
|
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;
|
|
@@ -61,12 +63,25 @@ export interface GithubFileContent {
|
|
|
61
63
|
size: number;
|
|
62
64
|
}
|
|
63
65
|
|
|
66
|
+
export interface GithubCommit {
|
|
67
|
+
sha: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
64
70
|
export interface GithubUserInstallationPage {
|
|
65
71
|
installationIds: number[];
|
|
66
72
|
nextCursor: string | null;
|
|
67
73
|
}
|
|
68
74
|
|
|
69
|
-
export interface
|
|
75
|
+
export interface GithubBotUser {
|
|
76
|
+
id: number;
|
|
77
|
+
login: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface GithubBotUserClient {
|
|
81
|
+
getBotUser(input: {username: string; installationAccessToken: string}): Promise<GithubBotUser>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface GithubApiClient extends Partial<GithubBotUserClient> {
|
|
70
85
|
exchangeOAuthCode(code: string): Promise<string>;
|
|
71
86
|
listUserInstallations(input: {
|
|
72
87
|
userAccessToken: string;
|
|
@@ -93,6 +108,11 @@ export interface GithubApiClient {
|
|
|
93
108
|
ref: string;
|
|
94
109
|
path: string;
|
|
95
110
|
}): Promise<GithubFileContent>;
|
|
111
|
+
listRepositoryCommits(input: {
|
|
112
|
+
installationId: number;
|
|
113
|
+
repositoryId: number;
|
|
114
|
+
ref: string;
|
|
115
|
+
}): Promise<GithubCommit[]>;
|
|
96
116
|
createInstallationAccessToken(input: {
|
|
97
117
|
installationId: number;
|
|
98
118
|
repositoryId: number;
|
|
@@ -106,12 +126,17 @@ export interface GithubInstallationAccessToken {
|
|
|
106
126
|
permissions?: Record<string, 'read' | 'write' | 'admin'> | undefined;
|
|
107
127
|
}
|
|
108
128
|
|
|
109
|
-
export function createGithubApiClient(): GithubApiClient {
|
|
129
|
+
export function createGithubApiClient(): GithubApiClient & GithubBotUserClient {
|
|
110
130
|
return new OctokitGithubApiClient();
|
|
111
131
|
}
|
|
112
132
|
|
|
113
|
-
class OctokitGithubApiClient implements GithubApiClient {
|
|
133
|
+
class OctokitGithubApiClient implements GithubApiClient, GithubBotUserClient {
|
|
114
134
|
private app: App | undefined;
|
|
135
|
+
private readonly botUsers = new Map<string, GithubBotUser>();
|
|
136
|
+
private readonly botUserLookups = new Map<
|
|
137
|
+
string,
|
|
138
|
+
{installationAccessToken: string; promise: Promise<GithubBotUser>}
|
|
139
|
+
>();
|
|
115
140
|
|
|
116
141
|
async exchangeOAuthCode(code: string): Promise<string> {
|
|
117
142
|
const body = await mapGithubOAuthError(() =>
|
|
@@ -136,6 +161,39 @@ class OctokitGithubApiClient implements GithubApiClient {
|
|
|
136
161
|
return body.access_token;
|
|
137
162
|
}
|
|
138
163
|
|
|
164
|
+
getBotUser(input: {username: string; installationAccessToken: string}): Promise<GithubBotUser> {
|
|
165
|
+
const cacheKey = input.username.trim().toLowerCase();
|
|
166
|
+
const cached = this.botUsers.get(cacheKey);
|
|
167
|
+
if (cached) return Promise.resolve(cached);
|
|
168
|
+
|
|
169
|
+
const pending = this.botUserLookups.get(cacheKey);
|
|
170
|
+
if (pending?.installationAccessToken === input.installationAccessToken) {
|
|
171
|
+
return pending.promise;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const lookup = this.fetchBotUser(input).then((botUser) => {
|
|
175
|
+
const resolved = this.botUsers.get(cacheKey);
|
|
176
|
+
if (resolved) return resolved;
|
|
177
|
+
|
|
178
|
+
this.botUsers.set(cacheKey, botUser);
|
|
179
|
+
logger().info(
|
|
180
|
+
{githubAppBotLogin: botUser.login, githubAppBotUserId: botUser.id},
|
|
181
|
+
'Resolved GitHub App bot identity',
|
|
182
|
+
);
|
|
183
|
+
return botUser;
|
|
184
|
+
});
|
|
185
|
+
const trackedLookup = lookup.finally(() => {
|
|
186
|
+
if (this.botUserLookups.get(cacheKey)?.promise === trackedLookup) {
|
|
187
|
+
this.botUserLookups.delete(cacheKey);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
this.botUserLookups.set(cacheKey, {
|
|
191
|
+
installationAccessToken: input.installationAccessToken,
|
|
192
|
+
promise: trackedLookup,
|
|
193
|
+
});
|
|
194
|
+
return trackedLookup;
|
|
195
|
+
}
|
|
196
|
+
|
|
139
197
|
async listUserInstallations(input: {
|
|
140
198
|
userAccessToken: string;
|
|
141
199
|
cursor?: string | undefined;
|
|
@@ -218,6 +276,7 @@ class OctokitGithubApiClient implements GithubApiClient {
|
|
|
218
276
|
const response = await mapGithubError(() =>
|
|
219
277
|
octokit.request('GET /repositories/{repository_id}', {
|
|
220
278
|
repository_id: input.repositoryId,
|
|
279
|
+
request: {signal: AbortSignal.timeout(GITHUB_API_TIMEOUT_MS)},
|
|
221
280
|
}),
|
|
222
281
|
);
|
|
223
282
|
|
|
@@ -356,6 +415,40 @@ class OctokitGithubApiClient implements GithubApiClient {
|
|
|
356
415
|
};
|
|
357
416
|
}
|
|
358
417
|
|
|
418
|
+
async listRepositoryCommits(input: {
|
|
419
|
+
installationId: number;
|
|
420
|
+
repositoryId: number;
|
|
421
|
+
ref: string;
|
|
422
|
+
}): Promise<GithubCommit[]> {
|
|
423
|
+
const octokit = await mapGithubError(() =>
|
|
424
|
+
getGithubInstallationOctokit(this.getApp(), input.installationId),
|
|
425
|
+
);
|
|
426
|
+
const repository = await this.getRepository({
|
|
427
|
+
installationId: input.installationId,
|
|
428
|
+
repositoryId: input.repositoryId,
|
|
429
|
+
});
|
|
430
|
+
const response = await mapGithubError(
|
|
431
|
+
() =>
|
|
432
|
+
octokit.rest.repos.listCommits({
|
|
433
|
+
owner: repository.ownerLogin,
|
|
434
|
+
repo: repository.name,
|
|
435
|
+
sha: input.ref,
|
|
436
|
+
per_page: 1,
|
|
437
|
+
request: {signal: AbortSignal.timeout(GITHUB_API_TIMEOUT_MS)},
|
|
438
|
+
}),
|
|
439
|
+
'ref-not-found',
|
|
440
|
+
);
|
|
441
|
+
return response.data.map((commit) => {
|
|
442
|
+
if (typeof commit.sha !== 'string') {
|
|
443
|
+
throw new GithubIntegrationProviderError(
|
|
444
|
+
'malformed-provider-response',
|
|
445
|
+
'GitHub commit response is missing the commit sha',
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
return {sha: commit.sha};
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
359
452
|
async createInstallationAccessToken(input: {
|
|
360
453
|
installationId: number;
|
|
361
454
|
repositoryId: number;
|
|
@@ -405,6 +498,72 @@ class OctokitGithubApiClient implements GithubApiClient {
|
|
|
405
498
|
}
|
|
406
499
|
return this.app;
|
|
407
500
|
}
|
|
501
|
+
|
|
502
|
+
private async fetchBotUser(input: {
|
|
503
|
+
username: string;
|
|
504
|
+
installationAccessToken: string;
|
|
505
|
+
}): Promise<GithubBotUser> {
|
|
506
|
+
const octokit = new Octokit({
|
|
507
|
+
auth: input.installationAccessToken,
|
|
508
|
+
baseUrl: normalizedGithubApiBaseUrl(),
|
|
509
|
+
});
|
|
510
|
+
let response: Awaited<ReturnType<typeof octokit.rest.users.getByUsername>>;
|
|
511
|
+
try {
|
|
512
|
+
response = await mapGithubError(
|
|
513
|
+
() =>
|
|
514
|
+
octokit.rest.users.getByUsername({
|
|
515
|
+
username: input.username,
|
|
516
|
+
request: {signal: AbortSignal.timeout(GITHUB_API_TIMEOUT_MS)},
|
|
517
|
+
}),
|
|
518
|
+
'provider-rejected',
|
|
519
|
+
);
|
|
520
|
+
} catch (error) {
|
|
521
|
+
if (error instanceof GithubIntegrationProviderError && error.status === 404) {
|
|
522
|
+
throw new GithubIntegrationProviderError(
|
|
523
|
+
'provider-rejected',
|
|
524
|
+
`Configured GitHub bot user ${input.username} was not found`,
|
|
525
|
+
undefined,
|
|
526
|
+
error.status,
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
throw error;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const data: unknown = response.data;
|
|
533
|
+
if (!isRecord(data)) {
|
|
534
|
+
throw new GithubIntegrationProviderError(
|
|
535
|
+
'malformed-provider-response',
|
|
536
|
+
'GitHub bot user response is missing required fields',
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
const {id, login, type} = data;
|
|
540
|
+
if (
|
|
541
|
+
typeof id !== 'number' ||
|
|
542
|
+
!Number.isSafeInteger(id) ||
|
|
543
|
+
id <= 0 ||
|
|
544
|
+
typeof login !== 'string' ||
|
|
545
|
+
login.trim().length === 0
|
|
546
|
+
) {
|
|
547
|
+
throw new GithubIntegrationProviderError(
|
|
548
|
+
'malformed-provider-response',
|
|
549
|
+
'GitHub bot user response is missing required fields',
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
if (type !== 'Bot') {
|
|
553
|
+
throw new GithubIntegrationProviderError(
|
|
554
|
+
'malformed-provider-response',
|
|
555
|
+
'Configured GitHub username is not a bot account',
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
const canonicalLogin = login.trim();
|
|
559
|
+
if (canonicalLogin.toLowerCase() !== input.username.trim().toLowerCase()) {
|
|
560
|
+
throw new GithubIntegrationProviderError(
|
|
561
|
+
'malformed-provider-response',
|
|
562
|
+
'GitHub bot user response did not match the configured username',
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
return {id, login: canonicalLogin};
|
|
566
|
+
}
|
|
408
567
|
}
|
|
409
568
|
|
|
410
569
|
async function mapGithubOAuthError<T>(operation: () => Promise<T>): Promise<T> {
|
|
@@ -442,16 +601,29 @@ export async function mapGithubError<T>(
|
|
|
442
601
|
notFoundReason:
|
|
443
602
|
| 'repository-not-found'
|
|
444
603
|
| 'installation-not-found'
|
|
445
|
-
| 'file-not-found'
|
|
604
|
+
| 'file-not-found'
|
|
605
|
+
| 'ref-not-found'
|
|
606
|
+
| 'provider-rejected' = 'repository-not-found',
|
|
446
607
|
): Promise<T> {
|
|
447
608
|
try {
|
|
448
609
|
return await operation();
|
|
449
610
|
} catch (error) {
|
|
450
611
|
if (error instanceof GithubIntegrationProviderError) throw error;
|
|
612
|
+
if (isGithubTimeoutError(error)) {
|
|
613
|
+
throw new GithubIntegrationProviderError('timeout', 'GitHub request timed out');
|
|
614
|
+
}
|
|
451
615
|
if (error instanceof RequestError) {
|
|
452
616
|
if (error.status === 404) {
|
|
453
617
|
throw new GithubIntegrationProviderError(
|
|
454
|
-
notFoundReason,
|
|
618
|
+
notFoundReason === 'ref-not-found' ? 'repository-not-found' : notFoundReason,
|
|
619
|
+
error.message,
|
|
620
|
+
undefined,
|
|
621
|
+
error.status,
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
if (notFoundReason === 'ref-not-found' && (error.status === 409 || error.status === 422)) {
|
|
625
|
+
throw new GithubIntegrationProviderError(
|
|
626
|
+
'ref-not-found',
|
|
455
627
|
error.message,
|
|
456
628
|
undefined,
|
|
457
629
|
error.status,
|
|
@@ -490,13 +662,16 @@ export async function mapGithubError<T>(
|
|
|
490
662
|
);
|
|
491
663
|
}
|
|
492
664
|
}
|
|
493
|
-
if (error instanceof Error && error.name === 'AbortError') {
|
|
494
|
-
throw new GithubIntegrationProviderError('timeout', 'GitHub request timed out');
|
|
495
|
-
}
|
|
496
665
|
throw error;
|
|
497
666
|
}
|
|
498
667
|
}
|
|
499
668
|
|
|
669
|
+
function isGithubTimeoutError(error: unknown): boolean {
|
|
670
|
+
if (!(error instanceof Error)) return false;
|
|
671
|
+
if (error.name === 'AbortError' || error.name === 'TimeoutError') return true;
|
|
672
|
+
return error.cause instanceof Error && isGithubTimeoutError(error.cause);
|
|
673
|
+
}
|
|
674
|
+
|
|
500
675
|
function isGithubRateLimitError(error: RequestError): boolean {
|
|
501
676
|
return error.status === 403 && error.response?.headers['x-ratelimit-remaining'] === '0';
|
|
502
677
|
}
|
|
@@ -37,9 +37,11 @@ const terminalMintErrorReasons = new Set<IntegrationProviderErrorReason>([
|
|
|
37
37
|
'malformed-provider-response',
|
|
38
38
|
]);
|
|
39
39
|
|
|
40
|
+
// Ref reasons describe ref-resolution failures, which a token mint never
|
|
41
|
+
// observes; keep them out of the envelope schema and acknowledge them here.
|
|
40
42
|
type MissingProviderErrorReason = Exclude<
|
|
41
43
|
IntegrationProviderErrorReason,
|
|
42
|
-
(typeof providerErrorReasons)[number]
|
|
44
|
+
(typeof providerErrorReasons)[number] | 'ref-not-found' | 'ref-invalid'
|
|
43
45
|
>;
|
|
44
46
|
const providerErrorReasonSchemaCoversUnion: Record<MissingProviderErrorReason, never> = {};
|
|
45
47
|
void providerErrorReasonSchemaCoversUnion;
|
|
@@ -1734,6 +1734,7 @@ function githubClient(): GithubApiClient {
|
|
|
1734
1734
|
getRepository: vi.fn(() => Promise.reject(new Error('not used'))),
|
|
1735
1735
|
listRepositoryFiles: vi.fn(() => Promise.reject(new Error('not used'))),
|
|
1736
1736
|
fetchRepositoryFile: vi.fn(() => Promise.reject(new Error('not used'))),
|
|
1737
|
+
listRepositoryCommits: vi.fn(() => Promise.reject(new Error('not used'))),
|
|
1737
1738
|
createInstallationAccessToken: vi.fn(() => Promise.reject(new Error('not used'))),
|
|
1738
1739
|
};
|
|
1739
1740
|
}
|
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
|
+
});
|