@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.
Files changed (39) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +23 -0
  3. package/dist/api/client.d.ts +21 -3
  4. package/dist/api/client.d.ts.map +1 -1
  5. package/dist/api/client.js +115 -6
  6. package/dist/api/client.js.map +1 -1
  7. package/dist/api/installation-token-envelope.d.ts.map +1 -1
  8. package/dist/api/installation-token-envelope.js.map +1 -1
  9. package/dist/core/agent-tools.d.ts.map +1 -1
  10. package/dist/core/agent-tools.js +2 -6
  11. package/dist/core/agent-tools.js.map +1 -1
  12. package/dist/core/bot-identity.d.ts +4 -0
  13. package/dist/core/bot-identity.d.ts.map +1 -0
  14. package/dist/core/bot-identity.js +15 -0
  15. package/dist/core/bot-identity.js.map +1 -0
  16. package/dist/core/source-control.d.ts +4 -2
  17. package/dist/core/source-control.d.ts.map +1 -1
  18. package/dist/core/source-control.js +42 -11
  19. package/dist/core/source-control.js.map +1 -1
  20. package/dist/index.d.ts +78 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/tsconfig.test.tsbuildinfo +1 -1
  25. package/package.json +3 -3
  26. package/src/api/client.test.ts +267 -9
  27. package/src/api/client.ts +184 -9
  28. package/src/api/installation-token-envelope.ts +3 -1
  29. package/src/core/agent-tools.test.ts +1 -0
  30. package/src/core/agent-tools.ts +2 -9
  31. package/src/core/bot-identity.test.ts +19 -0
  32. package/src/core/bot-identity.ts +19 -0
  33. package/src/core/install.test.ts +3 -0
  34. package/src/core/source-control.test.ts +180 -1
  35. package/src/core/source-control.ts +65 -11
  36. package/src/index.test.ts +3 -1
  37. package/src/index.ts +2 -0
  38. package/src/presentation/routes/install.test.ts +3 -0
  39. 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.7.0",
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": "1.1.1",
32
- "@shipfox/api-integration-github-dto": "12.2.0",
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",
@@ -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 {createInstallationAccessTokenMock, RequestErrorMock} = vi.hoisted(() => {
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 {createInstallationAccessTokenMock: vi.fn(), RequestErrorMock};
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
- rest: {apps: {createInstallationAccessToken: createInstallationAccessTokenMock}},
43
+ auth: authMock,
44
+ rest: {
45
+ apps: {createInstallationAccessToken: createInstallationAccessTokenMock},
46
+ users: {getByUsername: getByUsernameMock},
47
+ },
28
48
  };
29
49
  },
30
- Octokit: {
31
- plugin() {
32
- return this;
33
- },
34
- defaults(options: unknown) {
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 GithubApiClient {
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' = 'repository-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
  }
@@ -13,8 +13,9 @@ import {
13
13
  createGithubInstallationTokenProvider,
14
14
  type GithubInstallationTokenProvider,
15
15
  } from '#api/installation-token-provider.js';
16
- import {config, normalizedGithubApiBaseUrl} from '#config.js';
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
+ });