@shipfox/api-integration-github 12.6.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 +12 -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 +93 -8
- 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/github-agent-tool-catalog.d.ts +1 -1
- package/dist/core/github-agent-tool-catalog.d.ts.map +1 -1
- package/dist/core/github-agent-tool-catalog.js +19 -0
- package/dist/core/github-agent-tool-catalog.js.map +1 -1
- 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.test.ts +134 -0
- package/src/core/agent-tools.ts +110 -13
- package/src/core/bot-identity.test.ts +19 -0
- package/src/core/bot-identity.ts +19 -0
- package/src/core/github-agent-tool-catalog.ts +35 -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
|
}
|
|
@@ -83,6 +83,7 @@ const expectedCatalogRows = [
|
|
|
83
83
|
'get_files',
|
|
84
84
|
'get_commits',
|
|
85
85
|
'get_review_comments',
|
|
86
|
+
'get_review_threads',
|
|
86
87
|
'get_reviews',
|
|
87
88
|
'get_comments',
|
|
88
89
|
'get_check_runs',
|
|
@@ -148,6 +149,14 @@ const expectedCatalogRows = [
|
|
|
148
149
|
requiredScope: [{permission: 'pull_requests', access: 'write'}],
|
|
149
150
|
methods: ['create', 'submit_pending', 'delete_pending'],
|
|
150
151
|
},
|
|
152
|
+
{
|
|
153
|
+
id: 'pull_request_review_thread_write',
|
|
154
|
+
category: 'pull_requests',
|
|
155
|
+
sensitivity: 'write',
|
|
156
|
+
sensitive: false,
|
|
157
|
+
requiredScope: [{permission: 'pull_requests', access: 'write'}],
|
|
158
|
+
methods: ['resolve'],
|
|
159
|
+
},
|
|
151
160
|
{
|
|
152
161
|
id: 'add_comment_to_pending_review',
|
|
153
162
|
category: 'pull_requests',
|
|
@@ -351,6 +360,12 @@ const githubOperationRouteCases = [
|
|
|
351
360
|
args: {pull_number: 1},
|
|
352
361
|
expectedRoute: 'GET /repos/{owner}/{repo}/pulls/{pull_number}/comments',
|
|
353
362
|
},
|
|
363
|
+
{
|
|
364
|
+
toolId: 'pull_request_read',
|
|
365
|
+
method: 'get_review_threads',
|
|
366
|
+
args: {pull_number: 1},
|
|
367
|
+
expectedRoute: 'POST /graphql',
|
|
368
|
+
},
|
|
354
369
|
{
|
|
355
370
|
toolId: 'pull_request_read',
|
|
356
371
|
method: 'get_reviews',
|
|
@@ -429,6 +444,12 @@ const githubOperationRouteCases = [
|
|
|
429
444
|
runtimeInjectedProperties: ['review_id'],
|
|
430
445
|
expectedRoute: 'DELETE /repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}',
|
|
431
446
|
},
|
|
447
|
+
{
|
|
448
|
+
toolId: 'pull_request_review_thread_write',
|
|
449
|
+
method: 'resolve',
|
|
450
|
+
args: {thread_id: 'PRRT_kwDOExample'},
|
|
451
|
+
expectedRoute: 'POST /graphql',
|
|
452
|
+
},
|
|
432
453
|
{
|
|
433
454
|
toolId: 'add_comment_to_pending_review',
|
|
434
455
|
args: {pull_number: 1, path: 'src/index.ts', body: 'Comment'},
|
|
@@ -663,6 +684,7 @@ describe('github agent tool catalog', () => {
|
|
|
663
684
|
{properties: {method: {const: 'get_files'}}, required: []},
|
|
664
685
|
{properties: {method: {const: 'get_commits'}}, required: []},
|
|
665
686
|
{properties: {method: {const: 'get_review_comments'}}, required: []},
|
|
687
|
+
{properties: {method: {const: 'get_review_threads'}}, required: []},
|
|
666
688
|
{properties: {method: {const: 'get_reviews'}}, required: []},
|
|
667
689
|
{properties: {method: {const: 'get_comments'}}, required: []},
|
|
668
690
|
{properties: {method: {const: 'get_check_runs'}}, required: ['ref']},
|
|
@@ -841,6 +863,104 @@ describe('github agent tool catalog', () => {
|
|
|
841
863
|
});
|
|
842
864
|
});
|
|
843
865
|
|
|
866
|
+
it('reads pull request review threads through GraphQL', async () => {
|
|
867
|
+
const request = vi.fn();
|
|
868
|
+
const data = {
|
|
869
|
+
repository: {
|
|
870
|
+
pullRequest: {
|
|
871
|
+
reviewThreads: {
|
|
872
|
+
nodes: [
|
|
873
|
+
{
|
|
874
|
+
id: 'PRRT_kwDOExample',
|
|
875
|
+
isResolved: false,
|
|
876
|
+
comments: {
|
|
877
|
+
nodes: [
|
|
878
|
+
{
|
|
879
|
+
id: 'PRRC_kwDOExample',
|
|
880
|
+
databaseId: 7,
|
|
881
|
+
body: 'Please handle this.',
|
|
882
|
+
author: {login: 'reviewer'},
|
|
883
|
+
path: 'src/index.ts',
|
|
884
|
+
line: 42,
|
|
885
|
+
},
|
|
886
|
+
],
|
|
887
|
+
},
|
|
888
|
+
},
|
|
889
|
+
],
|
|
890
|
+
},
|
|
891
|
+
},
|
|
892
|
+
},
|
|
893
|
+
};
|
|
894
|
+
const graphql = vi.fn().mockResolvedValueOnce(data);
|
|
895
|
+
const provider = createAgentToolsProvider({request, graphql});
|
|
896
|
+
const session = await provider.openSession({
|
|
897
|
+
connection: connection(),
|
|
898
|
+
tools: [pullRequestReadTool()],
|
|
899
|
+
scope: undefined,
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
const result = await session.call({
|
|
903
|
+
toolId: 'pull_request_read',
|
|
904
|
+
arguments: {
|
|
905
|
+
method: 'get_review_threads',
|
|
906
|
+
owner: 'shipfox',
|
|
907
|
+
repo: 'platform',
|
|
908
|
+
pull_number: 2,
|
|
909
|
+
cursor: 'cursor-1',
|
|
910
|
+
},
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
expect(request).not.toHaveBeenCalled();
|
|
914
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
915
|
+
expect.stringContaining('reviewThreads(first: 100, after: $after)'),
|
|
916
|
+
{owner: 'shipfox', repo: 'platform', pullNumber: 2, after: 'cursor-1'},
|
|
917
|
+
);
|
|
918
|
+
const query = graphql.mock.calls[0]?.[0];
|
|
919
|
+
expect(query).toContain('isResolved');
|
|
920
|
+
expect(query).toContain('author');
|
|
921
|
+
expect(query).toContain('path');
|
|
922
|
+
expect(query).toContain('line');
|
|
923
|
+
expect(result).toEqual({
|
|
924
|
+
content: [{type: 'text', text: JSON.stringify(data)}],
|
|
925
|
+
structuredContent: data,
|
|
926
|
+
});
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
it('resolves a pull request review thread through GraphQL', async () => {
|
|
930
|
+
const request = vi.fn();
|
|
931
|
+
const data = {
|
|
932
|
+
resolveReviewThread: {
|
|
933
|
+
thread: {id: 'PRRT_kwDOExample', isResolved: true},
|
|
934
|
+
},
|
|
935
|
+
};
|
|
936
|
+
const graphql = vi.fn().mockResolvedValueOnce(data);
|
|
937
|
+
const provider = createAgentToolsProvider({request, graphql});
|
|
938
|
+
const session = await provider.openSession({
|
|
939
|
+
connection: connection(),
|
|
940
|
+
tools: [pullRequestReviewThreadWriteTool()],
|
|
941
|
+
scope: undefined,
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
const result = await session.call({
|
|
945
|
+
toolId: 'pull_request_review_thread_write',
|
|
946
|
+
arguments: {
|
|
947
|
+
method: 'resolve',
|
|
948
|
+
owner: 'shipfox',
|
|
949
|
+
repo: 'platform',
|
|
950
|
+
thread_id: 'PRRT_kwDOExample',
|
|
951
|
+
},
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
expect(request).not.toHaveBeenCalled();
|
|
955
|
+
expect(graphql).toHaveBeenCalledWith(expect.stringContaining('resolveReviewThread'), {
|
|
956
|
+
input: {threadId: 'PRRT_kwDOExample'},
|
|
957
|
+
});
|
|
958
|
+
expect(result).toEqual({
|
|
959
|
+
content: [{type: 'text', text: JSON.stringify(data)}],
|
|
960
|
+
structuredContent: data,
|
|
961
|
+
});
|
|
962
|
+
});
|
|
963
|
+
|
|
844
964
|
it('projects issue comment reactions through the provider session', async () => {
|
|
845
965
|
const request = vi.fn(() => Promise.resolve({data: {id: 7}}));
|
|
846
966
|
const result = await callGithubToolWithRequest(
|
|
@@ -1546,6 +1666,20 @@ function pendingReviewTool() {
|
|
|
1546
1666
|
return tool;
|
|
1547
1667
|
}
|
|
1548
1668
|
|
|
1669
|
+
function pullRequestReadTool() {
|
|
1670
|
+
const tool = githubAgentToolCatalog.find((entry) => entry.id === 'pull_request_read');
|
|
1671
|
+
if (!tool) throw new Error('Missing pull_request_read tool');
|
|
1672
|
+
return tool;
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
function pullRequestReviewThreadWriteTool() {
|
|
1676
|
+
const tool = githubAgentToolCatalog.find(
|
|
1677
|
+
(entry) => entry.id === 'pull_request_review_thread_write',
|
|
1678
|
+
);
|
|
1679
|
+
if (!tool) throw new Error('Missing pull_request_review_thread_write tool');
|
|
1680
|
+
return tool;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1549
1683
|
function connection() {
|
|
1550
1684
|
return {
|
|
1551
1685
|
id: 'connection-1',
|