@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/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": "13.1.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -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} = vi.hoisted(() => {
11
- class RequestErrorMock extends Error {
12
- constructor(
13
- message: string,
14
- public readonly status: number,
15
- ) {
16
- super(message);
17
- this.name = 'HttpError';
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
- return {createInstallationAccessTokenMock: vi.fn(), RequestErrorMock};
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: {apps: {createInstallationAccessToken: createInstallationAccessTokenMock}},
33
+ rest: {
34
+ apps: {createInstallationAccessToken: createInstallationAccessTokenMock},
35
+ users: {getByUsername: getByUsernameMock},
36
+ },
28
37
  };
29
38
  },
30
- Octokit: {
31
- plugin() {
32
- return this;
33
- },
34
- defaults(options: unknown) {
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 GithubApiClient {
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' = 'repository-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
  }
@@ -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
+ });
@@ -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: '1+shipfox-test[bot]@users.noreply.github.com',
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 () => {