Skip to content

Commit 25cc775

Browse files
refactor(backend): centralize Octokit creation
1 parent a07413a commit 25cc775

4 files changed

Lines changed: 113 additions & 91 deletions

File tree

packages/backend/src/ee/accountPermissionSyncer.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { ensureFreshAccountToken, TokenRefreshError } from "./tokenRefresh.js";
1212
import { DelayedError, Job, Queue, Worker } from "bullmq";
1313
import { Redis } from "ioredis";
1414
import {
15-
createOctokitFromToken,
15+
createOctokit,
1616
getOAuthScopesForAuthenticatedUser as getGitHubOAuthScopesForAuthenticatedUser,
1717
getReposForAuthenticatedUser,
1818
} from "../github.js";
@@ -296,7 +296,7 @@ export class AccountPermissionSyncer {
296296
}
297297

298298
if (idpConfig.provider === 'github') {
299-
const { octokit } = await createOctokitFromToken({
299+
const { octokit } = await createOctokit({
300300
token: accessToken,
301301
url: idpConfig.baseUrl,
302302
});

packages/backend/src/ee/repoPermissionSyncer.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { env } from "@sourcebot/shared";
55
import { hasEntitlement } from "../entitlements.js";
66
import { DelayedError, Job, Queue, Worker } from 'bullmq';
77
import { Redis } from 'ioredis';
8-
import { createOctokitFromToken, getRepoCollaborators, GITHUB_CLOUD_HOSTNAME } from "../github.js";
8+
import { createOctokit, getRepoCollaborators, GITHUB_CLOUD_HOSTNAME } from "../github.js";
99
import { createGitLabFromPersonalAccessToken, getProjectMembers } from "../gitlab.js";
1010
import { createBitbucketCloudClient, createBitbucketServerClient, getExplicitUserPermissionsForCloudRepo, getUserPermissionsForServerRepo } from "../bitbucket.js";
1111
import { repoMetadataSchema } from "@sourcebot/shared";
@@ -209,7 +209,7 @@ export class RepoPermissionSyncer {
209209
}> => {
210210
if (repo.external_codeHostType === 'github') {
211211
const isGitHubCloud = credentials.hostUrl ? new URL(credentials.hostUrl).hostname === GITHUB_CLOUD_HOSTNAME : true;
212-
const { octokit } = await createOctokitFromToken({
212+
const { octokit } = await createOctokit({
213213
token: credentials.token,
214214
url: isGitHubCloud ? undefined : credentials.hostUrl,
215215
});

packages/backend/src/github.ts

Lines changed: 70 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -100,60 +100,60 @@ const isHttpError = (error: unknown, status: number): boolean => {
100100
&& error.status === status;
101101
}
102102

103-
export const createOctokitFromToken = async ({ token, url }: { token?: string, url?: string }): Promise<{ octokit: Octokit, isAuthenticated: boolean }> => {
103+
/**
104+
* Creates an Octokit client using a GitHub App installation when one is
105+
* configured for the requested owner, or falls back to the provided token.
106+
*/
107+
export const createOctokit = async ({
108+
token,
109+
url,
110+
owner,
111+
context,
112+
}: {
113+
token?: string,
114+
url?: string,
115+
owner?: string,
116+
context?: string,
117+
}): Promise<{ octokit: Octokit, isAuthenticated: boolean }> => {
118+
let resolvedToken = token;
119+
120+
if (owner) {
121+
const githubAppManager = GithubAppManager.getInstance();
122+
await githubAppManager.ensureInitialized();
123+
124+
if (githubAppManager.appsConfigured()) {
125+
if (!await hasEntitlement('github-app')) {
126+
throw new Error(`GitHub App authentication is not currently licensed for ${context ?? owner}.`);
127+
}
128+
129+
try {
130+
const hostname = url ? new URL(url).hostname : GITHUB_CLOUD_HOSTNAME;
131+
resolvedToken = await githubAppManager.getInstallationToken(owner, hostname);
132+
} catch (error) {
133+
if (error instanceof GithubAppInstallationNotFoundError) {
134+
throw error;
135+
}
136+
137+
logger.error(`Error getting GitHub App token for ${context ?? owner}.`, error);
138+
throw error;
139+
}
140+
}
141+
}
142+
104143
const isGitHubCloud = url ? new URL(url).hostname === GITHUB_CLOUD_HOSTNAME : true;
105144
const octokit = new Octokit({
106-
auth: token,
145+
auth: resolvedToken,
107146
...(url && !isGitHubCloud ? {
108147
baseUrl: `${url}/api/v3`
109148
} : {}),
110149
});
111150

112151
return {
113152
octokit,
114-
isAuthenticated: !!token,
153+
isAuthenticated: !!resolvedToken,
115154
};
116155
}
117156

118-
/**
119-
* Uses GitHub App authentication when an app is configured. App initialization
120-
* and token failures are propagated so callers cannot mistake a partial,
121-
* unauthenticated response for an authoritative repository list.
122-
*/
123-
export const getOctokitWithGithubApp = async (
124-
octokit: Octokit,
125-
owner: string,
126-
url: string | undefined,
127-
context: string
128-
): Promise<Octokit> => {
129-
const githubAppManager = GithubAppManager.getInstance();
130-
await githubAppManager.ensureInitialized();
131-
if (!githubAppManager.appsConfigured()) {
132-
return octokit;
133-
}
134-
135-
if (!await hasEntitlement('github-app')) {
136-
throw new Error(`GitHub App authentication is not currently licensed for ${context}.`);
137-
}
138-
139-
try {
140-
const hostname = url ? new URL(url).hostname : GITHUB_CLOUD_HOSTNAME;
141-
const token = await githubAppManager.getInstallationToken(owner, hostname);
142-
const { octokit: octokitFromToken } = await createOctokitFromToken({
143-
token,
144-
url,
145-
});
146-
return octokitFromToken;
147-
} catch (error) {
148-
if (error instanceof GithubAppInstallationNotFoundError) {
149-
throw error;
150-
}
151-
152-
logger.error(`Error getting GitHub App token for ${context}.`, error);
153-
throw error;
154-
}
155-
}
156-
157157
export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, signal: AbortSignal): Promise<{ repos: OctokitRepository[], warnings: string[] }> => {
158158
const hostname = config.url ?
159159
new URL(config.url).hostname :
@@ -165,7 +165,7 @@ export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, s
165165
env.FALLBACK_GITHUB_CLOUD_TOKEN :
166166
undefined;
167167

168-
const { octokit, isAuthenticated } = await createOctokitFromToken({
168+
const { octokit, isAuthenticated } = await createOctokit({
169169
token,
170170
url: config.url,
171171
});
@@ -185,19 +185,19 @@ export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, s
185185
let allWarnings: string[] = [];
186186

187187
if (config.orgs) {
188-
const { repos, warnings } = await getReposForOrgs(config.orgs, octokit, signal, config.url);
188+
const { repos, warnings } = await getReposForOrgs(config.orgs, token, signal, config.url);
189189
allRepos = allRepos.concat(repos);
190190
allWarnings = allWarnings.concat(warnings);
191191
}
192192

193193
if (config.repos) {
194-
const { repos, warnings } = await getRepos(config.repos, octokit, signal, config.url);
194+
const { repos, warnings } = await getRepos(config.repos, token, signal, config.url);
195195
allRepos = allRepos.concat(repos);
196196
allWarnings = allWarnings.concat(warnings);
197197
}
198198

199199
if (config.users) {
200-
const { repos, warnings } = await getReposOwnedByUsers(config.users, octokit, signal, config.url);
200+
const { repos, warnings } = await getReposOwnedByUsers(config.users, token, signal, config.url);
201201
allRepos = allRepos.concat(repos);
202202
allWarnings = allWarnings.concat(warnings);
203203
}
@@ -295,12 +295,17 @@ export const getOAuthScopesForAuthenticatedUser = async (octokit: Octokit, token
295295
}
296296
}
297297

298-
const getReposOwnedByUsers = async (users: string[], octokit: Octokit, signal: AbortSignal, url?: string) => {
298+
const getReposOwnedByUsers = async (users: string[], token: string | undefined, signal: AbortSignal, url?: string) => {
299299
const results = await Promise.allSettled(users.map((user) => githubQueryLimit(async () => {
300300
try {
301301
logger.debug(`Fetching repository info for user ${user}...`);
302302

303-
const octokitToUse = await getOctokitWithGithubApp(octokit, user, url, `user ${user}`);
303+
const { octokit } = await createOctokit({
304+
token,
305+
url,
306+
owner: user,
307+
context: `user ${user}`,
308+
});
304309
const { durationMs, data } = await measure(async () => {
305310
const fetchFn = async () => {
306311
let query = `user:${user}`;
@@ -317,7 +322,7 @@ const getReposOwnedByUsers = async (users: string[], octokit: Octokit, signal: A
317322
// signal.aborted between pages. paginate() only passes the signal to
318323
// individual fetch requests but doesn't check abort state between pages.
319324
const allRepos: OctokitRepository[] = [];
320-
const iterator = octokitToUse.paginate.iterator(octokitToUse.rest.search.repos, {
325+
const iterator = octokit.paginate.iterator(octokit.rest.search.repos, {
321326
q: query,
322327
per_page: 100,
323328
request: {
@@ -368,19 +373,24 @@ const getReposOwnedByUsers = async (users: string[], octokit: Octokit, signal: A
368373
};
369374
}
370375

371-
const getReposForOrgs = async (orgs: string[], octokit: Octokit, signal: AbortSignal, url?: string) => {
376+
const getReposForOrgs = async (orgs: string[], token: string | undefined, signal: AbortSignal, url?: string) => {
372377
const results = await Promise.allSettled(orgs.map((org) => githubQueryLimit(async () => {
373378
try {
374379
logger.debug(`Fetching repository info for org ${org}...`);
375380

376-
const octokitToUse = await getOctokitWithGithubApp(octokit, org, url, `org ${org}`);
381+
const { octokit } = await createOctokit({
382+
token,
383+
url,
384+
owner: org,
385+
context: `org ${org}`,
386+
});
377387
const { durationMs, data } = await measure(async () => {
378388
// @note: We use paginate.iterator() instead of paginate() to check
379389
// signal.aborted between pages. paginate() only passes the signal to
380390
// individual fetch requests but doesn't check abort state between pages.
381391
const fetchFn = async () => {
382392
const allRepos: OctokitRepository[] = [];
383-
const iterator = octokitToUse.paginate.iterator(octokitToUse.repos.listForOrg, {
393+
const iterator = octokit.paginate.iterator(octokit.repos.listForOrg, {
384394
org: org,
385395
per_page: 100,
386396
request: {
@@ -440,15 +450,20 @@ const getReposForOrgs = async (orgs: string[], octokit: Octokit, signal: AbortSi
440450
};
441451
}
442452

443-
const getRepos = async (repoList: string[], octokit: Octokit, signal: AbortSignal, url?: string) => {
453+
const getRepos = async (repoList: string[], token: string | undefined, signal: AbortSignal, url?: string) => {
444454
const results = await Promise.allSettled(repoList.map((repo) => githubQueryLimit(async () => {
445455
try {
446456
const [owner, repoName] = repo.split('/');
447457
logger.debug(`Fetching repository info for ${repo}...`);
448458

449-
const octokitToUse = await getOctokitWithGithubApp(octokit, owner, url, `repo ${repo}`);
459+
const { octokit } = await createOctokit({
460+
token,
461+
url,
462+
owner,
463+
context: `repo ${repo}`,
464+
});
450465
const { durationMs, data: result } = await measure(async () => {
451-
const fetchFn = () => octokitToUse.repos.get({
466+
const fetchFn = () => octokit.repos.get({
452467
owner,
453468
repo: repoName,
454469
request: {

packages/backend/src/githubAppAuth.test.ts

Lines changed: 39 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,15 @@ const mocks = vi.hoisted(() => ({
1717
this.name = 'GithubAppInstallationNotFoundError';
1818
}
1919
},
20+
octokitOptions: [] as Array<{ auth?: string, baseUrl?: string }>,
2021
}));
2122

2223
vi.mock('@octokit/rest', () => ({
2324
Octokit: class {
25+
constructor(options: { auth?: string, baseUrl?: string }) {
26+
mocks.octokitOptions.push(options);
27+
}
28+
2429
public paginate = {
2530
iterator: async function* (_request: unknown, options: { org: string }) {
2631
yield {
@@ -77,10 +82,9 @@ vi.mock('./ee/githubAppManager.js', () => ({
7782
},
7883
}));
7984

80-
import type { Octokit } from '@octokit/rest';
81-
import { getGitHubReposFromConfig, getOctokitWithGithubApp } from './github.js';
85+
import { createOctokit, getGitHubReposFromConfig } from './github.js';
8286

83-
describe('getOctokitWithGithubApp', () => {
87+
describe('createOctokit', () => {
8488
beforeEach(() => {
8589
mocks.appsConfigured.mockReset().mockReturnValue(true);
8690
mocks.ensureInitialized.mockReset().mockResolvedValue(undefined);
@@ -90,45 +94,48 @@ describe('getOctokitWithGithubApp', () => {
9094
mocks.logger.error.mockReset();
9195
mocks.logger.info.mockReset();
9296
mocks.logger.warn.mockReset();
97+
mocks.octokitOptions.length = 0;
9398
});
9499

95100
test('fails safely, then uses the GitHub App when the entitlement appears after startup', async () => {
96-
const fallbackOctokit = {} as Octokit;
97101
mocks.hasEntitlement
98102
.mockResolvedValueOnce(false)
99103
.mockResolvedValueOnce(true);
100104

101-
await expect(getOctokitWithGithubApp(
102-
fallbackOctokit,
103-
'example',
104-
undefined,
105-
'org example',
106-
)).rejects.toThrow('GitHub App authentication is not currently licensed for org example.');
107-
108-
const entitledOctokit = await getOctokitWithGithubApp(
109-
fallbackOctokit,
110-
'example',
111-
undefined,
112-
'org example',
113-
);
105+
await expect(createOctokit({
106+
token: 'legacy-token',
107+
owner: 'example',
108+
context: 'org example',
109+
})).rejects.toThrow('GitHub App authentication is not currently licensed for org example.');
110+
111+
const result = await createOctokit({
112+
token: 'legacy-token',
113+
owner: 'example',
114+
context: 'org example',
115+
});
114116

115117
expect(mocks.ensureInitialized).toHaveBeenCalledTimes(2);
116118
expect(mocks.getInstallationToken).toHaveBeenCalledWith('example', 'github.com');
117-
expect(entitledOctokit).not.toBe(fallbackOctokit);
119+
expect(result.isAuthenticated).toBe(true);
120+
expect(mocks.octokitOptions).toEqual([{
121+
auth: 'installation-token',
122+
}]);
118123
});
119124

120-
test('uses legacy authentication when no GitHub App is configured', async () => {
121-
const fallbackOctokit = {} as Octokit;
125+
test('falls back to token authentication when no GitHub App is configured', async () => {
122126
mocks.appsConfigured.mockReturnValue(false);
123127
mocks.hasEntitlement.mockResolvedValue(false);
124128

125-
await expect(getOctokitWithGithubApp(
126-
fallbackOctokit,
127-
'example',
128-
undefined,
129-
'org example',
130-
)).resolves.toBe(fallbackOctokit);
129+
const result = await createOctokit({
130+
token: 'legacy-token',
131+
owner: 'example',
132+
context: 'org example',
133+
});
131134

135+
expect(result.isAuthenticated).toBe(true);
136+
expect(mocks.octokitOptions).toEqual([{
137+
auth: 'legacy-token',
138+
}]);
132139
expect(mocks.hasEntitlement).not.toHaveBeenCalled();
133140
});
134141

@@ -137,12 +144,12 @@ describe('getOctokitWithGithubApp', () => {
137144
mocks.hasEntitlement.mockResolvedValue(true);
138145
mocks.getInstallationToken.mockRejectedValue(error);
139146

140-
await expect(getOctokitWithGithubApp(
141-
{} as Octokit,
142-
'example',
143-
undefined,
144-
'org example',
145-
)).rejects.toBe(error);
147+
await expect(createOctokit({
148+
token: 'legacy-token',
149+
owner: 'example',
150+
context: 'org example',
151+
})).rejects.toBe(error);
152+
expect(mocks.octokitOptions).toEqual([]);
146153
});
147154

148155
test('warns and continues when the GitHub App is not installed for one organization', async () => {

0 commit comments

Comments
 (0)