Skip to content

Commit 67ede87

Browse files
Merge branch 'main' into bkellam/fix-SOU-316
2 parents 89ce0bc + 19164fe commit 67ede87

31 files changed

Lines changed: 451 additions & 84 deletions

File tree

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Fixed
11+
- Fixed issue where the branch filter in the repos detail page would not return any results. [#851](https://github.com/sourcebot-dev/sourcebot/pull/851)
1112
- Fixed token refresh error "Provider config not found or invalid for: x" when a sso is configured using deprecated env vars. [#841](https://github.com/sourcebot-dev/sourcebot/pull/841)
1213

14+
## [4.10.25] - 2026-02-04
15+
16+
### Fixed
17+
- Fixed issue where opening GitLab file links would result in a 404. [#846](https://github.com/sourcebot-dev/sourcebot/pull/846)
18+
- Fixed issue where file references in copied chat answers were relative paths instead of full browse URLs. [#847](https://github.com/sourcebot-dev/sourcebot/pull/847)
19+
- [EE] Fixed issue where account driven permission syncing would fail when attempting to authenticate with a GitHub App user token. [#850](https://github.com/sourcebot-dev/sourcebot/pull/850)
20+
21+
### Added
22+
- [EE] Added `AUTH_EE_ALLOW_EMAIL_ACCOUNT_LINKING` env var that, when enabled, will automatically link SSO accounts with the same email address. [#849](https://github.com/sourcebot-dev/sourcebot/pull/849)
23+
24+
## [4.10.24] - 2026-02-03
25+
26+
### Fixed
27+
- Fixed issue where external links would use internal service DNS names in k8s deployments, making them inaccessible. [#844](https://github.com/sourcebot-dev/sourcebot/pull/844)
28+
29+
## [4.10.23] - 2026-02-02
30+
31+
### Added
32+
- Added `listCommits` tool to Ask agent. [#843](https://github.com/sourcebot-dev/sourcebot/pull/843)
33+
34+
## [4.10.22] - 2026-02-02
35+
36+
### Added
37+
- Added `maxAccountPermissionSyncJobConcurrency` and `maxRepoPermissionSyncJobConcurrency` settings to configure concurrency for permission sync jobs (default: 8). [#840](https://github.com/sourcebot-dev/sourcebot/pull/840)
38+
1339
## [4.10.21] - 2026-02-02
1440

1541
### Added

docs/docs/configuration/config-file.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ The following are settings that can be provided in your config file to modify So
5252
| `enablePublicAccess` **(deprecated)** | boolean | false || Use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead. |
5353
| `experiment_repoDrivenPermissionSyncIntervalMs` | number | 24 hours | 1 | Interval at which the repo permission syncer should run. |
5454
| `experiment_userDrivenPermissionSyncIntervalMs` | number | 24 hours | 1 | Interval at which the user permission syncer should run. |
55+
| `maxAccountPermissionSyncJobConcurrency` | number | 8 | 1 | Concurrent account permission sync jobs. |
56+
| `maxRepoPermissionSyncJobConcurrency` | number | 8 | 1 | Concurrent repo permission sync jobs. |
5557

5658
# Tokens
5759

docs/docs/configuration/environment-variables.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ The following environment variables allow you to configure your Sourcebot deploy
6262
| `AUTH_EE_GCP_IAP_ENABLED` | `false` | <p>When enabled, allows Sourcebot to automatically register/login from a successful GCP IAP redirect</p> |
6363
| `AUTH_EE_GCP_IAP_AUDIENCE` | - | <p>The GCP IAP audience to use when verifying JWT tokens. Must be set to enable GCP IAP JIT provisioning</p> |
6464
| `EXPERIMENT_EE_PERMISSION_SYNC_ENABLED` | `false` | <p>Enables [permission syncing](/docs/features/permission-syncing).</p> |
65+
| `AUTH_EE_ALLOW_EMAIL_ACCOUNT_LINKING` | `false` | <p>When enabled, different SSO accounts with the same email address will automatically be linked.</p> |
6566

6667

6768
### Review Agent Environment Variables

docs/snippets/schemas/v3/index.schema.mdx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,16 @@
7979
"type": "number",
8080
"description": "The interval (in milliseconds) at which the user permission syncer should run. Defaults to 24 hours.",
8181
"minimum": 1
82+
},
83+
"maxAccountPermissionSyncJobConcurrency": {
84+
"type": "number",
85+
"description": "The number of account permission sync jobs to run concurrently. Defaults to 8.",
86+
"minimum": 1
87+
},
88+
"maxRepoPermissionSyncJobConcurrency": {
89+
"type": "number",
90+
"description": "The number of repo permission sync jobs to run concurrently. Defaults to 8.",
91+
"minimum": 1
8292
}
8393
},
8494
"additionalProperties": false
@@ -215,6 +225,16 @@
215225
"type": "number",
216226
"description": "The interval (in milliseconds) at which the user permission syncer should run. Defaults to 24 hours.",
217227
"minimum": 1
228+
},
229+
"maxAccountPermissionSyncJobConcurrency": {
230+
"type": "number",
231+
"description": "The number of account permission sync jobs to run concurrently. Defaults to 8.",
232+
"minimum": 1
233+
},
234+
"maxRepoPermissionSyncJobConcurrency": {
235+
"type": "number",
236+
"description": "The number of repo permission sync jobs to run concurrently. Defaults to 8.",
237+
"minimum": 1
218238
}
219239
},
220240
"additionalProperties": false

packages/backend/src/ee/accountPermissionSyncer.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export class AccountPermissionSyncer {
4242
});
4343
this.worker = new Worker<AccountPermissionSyncJob>(QUEUE_NAME, this.runJob.bind(this), {
4444
connection: redis,
45-
concurrency: 1,
45+
concurrency: this.settings.maxAccountPermissionSyncJobConcurrency,
4646
});
4747
this.worker.on('completed', this.onJobCompleted.bind(this));
4848
this.worker.on('failed', this.onJobFailed.bind(this));
@@ -179,15 +179,33 @@ export class AccountPermissionSyncer {
179179
url: baseUrl,
180180
});
181181

182-
const scopes = await getGitHubOAuthScopesForAuthenticatedUser(octokit);
183-
if (!scopes.includes('repo')) {
184-
throw new Error(`OAuth token with scopes [${scopes.join(', ')}] is missing the 'repo' scope required for permission syncing.`);
182+
const scopes = await getGitHubOAuthScopesForAuthenticatedUser(octokit, account.access_token);
183+
184+
// Token supports scope introspection (classic PAT or OAuth app token)
185+
if (scopes !== null) {
186+
if (!scopes.includes('repo')) {
187+
throw new Error(`OAuth token with scopes [${scopes.join(', ')}] is missing the 'repo' scope required for permission syncing. Please re-authorize with GitHub to grant the required scope.`);
188+
}
185189
}
186190

187191
// @note: we only care about the private repos since we don't need to build a mapping
188192
// for public repos.
189193
// @see: packages/web/src/prisma.ts
190-
const githubRepos = await getReposForAuthenticatedUser(/* visibility = */ 'private', octokit);
194+
let githubRepos;
195+
try {
196+
githubRepos = await getReposForAuthenticatedUser(/* visibility = */ 'private', octokit);
197+
} catch (error) {
198+
if (error && typeof error === 'object' && 'status' in error) {
199+
const status = (error as { status: number }).status;
200+
if (status === 401 || status === 403) {
201+
throw new Error(
202+
`GitHub API returned ${status} error. Your token may have expired or lacks the required permissions. ` +
203+
`Please re-authorize with GitHub to grant the necessary access.`
204+
);
205+
}
206+
}
207+
throw error;
208+
}
191209
const gitHubRepoIds = githubRepos.map(repo => repo.id.toString());
192210

193211
const repos = await this.db.repo.findMany({

packages/backend/src/ee/repoPermissionSyncer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export class RepoPermissionSyncer {
3535
});
3636
this.worker = new Worker<RepoPermissionSyncJob>(QUEUE_NAME, this.runJob.bind(this), {
3737
connection: redis,
38-
concurrency: 1,
38+
concurrency: this.settings.maxRepoPermissionSyncJobConcurrency,
3939
});
4040
this.worker.on('completed', this.onJobCompleted.bind(this));
4141
this.worker.on('failed', this.onJobFailed.bind(this));

packages/backend/src/github.test.ts

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,64 @@
1-
import { expect, test } from 'vitest';
2-
import { OctokitRepository, shouldExcludeRepo } from './github';
1+
import { expect, test, describe } from 'vitest';
2+
import {
3+
OctokitRepository,
4+
shouldExcludeRepo,
5+
detectGitHubTokenType,
6+
supportsOAuthScopeIntrospection,
7+
} from './github';
8+
9+
describe('detectGitHubTokenType', () => {
10+
test('detects classic PAT (ghp_)', () => {
11+
expect(detectGitHubTokenType('ghp_abc123def456')).toBe('classic_pat');
12+
});
13+
14+
test('detects OAuth app user token (gho_)', () => {
15+
expect(detectGitHubTokenType('gho_abc123def456')).toBe('oauth_user');
16+
});
17+
18+
test('detects GitHub App user token (ghu_)', () => {
19+
expect(detectGitHubTokenType('ghu_abc123def456')).toBe('app_user');
20+
});
21+
22+
test('detects GitHub App installation token (ghs_)', () => {
23+
expect(detectGitHubTokenType('ghs_abc123def456')).toBe('app_installation');
24+
});
25+
26+
test('detects fine-grained PAT (github_pat_)', () => {
27+
expect(detectGitHubTokenType('github_pat_abc123def456')).toBe('fine_grained_pat');
28+
});
29+
30+
test('returns unknown for unrecognized token format', () => {
31+
expect(detectGitHubTokenType('some_random_token')).toBe('unknown');
32+
expect(detectGitHubTokenType('')).toBe('unknown');
33+
expect(detectGitHubTokenType('v1.abc123')).toBe('unknown');
34+
});
35+
});
36+
37+
describe('supportsOAuthScopeIntrospection', () => {
38+
test('returns true for classic PAT', () => {
39+
expect(supportsOAuthScopeIntrospection('classic_pat')).toBe(true);
40+
});
41+
42+
test('returns true for OAuth app user token', () => {
43+
expect(supportsOAuthScopeIntrospection('oauth_user')).toBe(true);
44+
});
45+
46+
test('returns false for GitHub App user token', () => {
47+
expect(supportsOAuthScopeIntrospection('app_user')).toBe(false);
48+
});
49+
50+
test('returns false for GitHub App installation token', () => {
51+
expect(supportsOAuthScopeIntrospection('app_installation')).toBe(false);
52+
});
53+
54+
test('returns false for fine-grained PAT', () => {
55+
expect(supportsOAuthScopeIntrospection('fine_grained_pat')).toBe(false);
56+
});
57+
58+
test('returns false for unknown token type', () => {
59+
expect(supportsOAuthScopeIntrospection('unknown')).toBe(false);
60+
});
61+
});
362

463
test('shouldExcludeRepo returns true when clone_url is undefined', () => {
564
const repo = { full_name: 'test/repo' } as OctokitRepository;

packages/backend/src/github.ts

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,43 @@ import { fetchWithRetry, measure } from "./utils.js";
1212

1313
export const GITHUB_CLOUD_HOSTNAME = "github.com";
1414

15+
/**
16+
* GitHub token types and their prefixes.
17+
* @see https://github.blog/2021-04-05-behind-githubs-new-authentication-token-formats/
18+
*/
19+
export type GitHubTokenType =
20+
| 'classic_pat' // ghp_ - Personal Access Token (classic)
21+
| 'oauth_user' // gho_ - OAuth App user token
22+
| 'app_user' // ghu_ - GitHub App user token
23+
| 'app_installation' // ghs_ - GitHub App installation token
24+
| 'fine_grained_pat' // github_pat_ - Fine-grained PAT
25+
| 'unknown';
26+
27+
/**
28+
* Token types that support scope introspection via x-oauth-scopes header.
29+
*/
30+
export const SCOPE_INTROSPECTABLE_TOKEN_TYPES: GitHubTokenType[] = ['classic_pat', 'oauth_user'];
31+
32+
/**
33+
* Detects the GitHub token type based on its prefix.
34+
* @see https://github.blog/2021-04-05-behind-githubs-new-authentication-token-formats/
35+
*/
36+
export const detectGitHubTokenType = (token: string): GitHubTokenType => {
37+
if (token.startsWith('ghp_')) return 'classic_pat';
38+
if (token.startsWith('gho_')) return 'oauth_user';
39+
if (token.startsWith('ghu_')) return 'app_user';
40+
if (token.startsWith('ghs_')) return 'app_installation';
41+
if (token.startsWith('github_pat_')) return 'fine_grained_pat';
42+
return 'unknown';
43+
};
44+
45+
/**
46+
* Checks if a token type supports OAuth scope introspection via x-oauth-scopes header.
47+
*/
48+
export const supportsOAuthScopeIntrospection = (tokenType: GitHubTokenType): boolean => {
49+
return SCOPE_INTROSPECTABLE_TOKEN_TYPES.includes(tokenType);
50+
};
51+
1552
// Limit concurrent GitHub requests to avoid hitting rate limits and overwhelming installations.
1653
const MAX_CONCURRENT_GITHUB_QUERIES = 5;
1754
const githubQueryLimit = pLimit(MAX_CONCURRENT_GITHUB_QUERIES);
@@ -182,6 +219,10 @@ export const getRepoCollaborators = async (owner: string, repo: string, octokit:
182219
}
183220
}
184221

222+
/**
223+
* Lists repositories that the authenticated user has explicit permission (:read, :write, or :admin) to access.
224+
* @see: https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#list-repositories-for-the-authenticated-user
225+
*/
185226
export const getReposForAuthenticatedUser = async (visibility: 'all' | 'private' | 'public' = 'all', octokit: Octokit) => {
186227
try {
187228
const fetchFn = () => octokit.paginate(octokit.repos.listForAuthenticatedUser, {
@@ -198,9 +239,30 @@ export const getReposForAuthenticatedUser = async (visibility: 'all' | 'private'
198239
}
199240
}
200241

201-
// Gets oauth scopes
202-
// @see: https://github.com/octokit/auth-token.js/?tab=readme-ov-file#find-out-what-scopes-are-enabled-for-oauth-tokens
203-
export const getOAuthScopesForAuthenticatedUser = async (octokit: Octokit) => {
242+
/**
243+
* Gets OAuth scopes for a GitHub token.
244+
*
245+
* Returns `null` for token types that don't support scope introspection:
246+
* - GitHub App user tokens (ghu_)
247+
* - GitHub App installation tokens (ghs_)
248+
* - Fine-grained PATs (github_pat_)
249+
*
250+
* Returns scope array for tokens that support introspection:
251+
* - Classic PATs (ghp_)
252+
* - OAuth App user tokens (gho_)
253+
*
254+
* @see https://github.com/octokit/auth-token.js/?tab=readme-ov-file#find-out-what-scopes-are-enabled-for-oauth-tokens
255+
* @see https://github.blog/2021-04-05-behind-githubs-new-authentication-token-formats/
256+
*/
257+
export const getOAuthScopesForAuthenticatedUser = async (octokit: Octokit, token?: string): Promise<string[] | null> => {
258+
// If token is provided, check if it supports scope introspection
259+
if (token) {
260+
const tokenType = detectGitHubTokenType(token);
261+
if (!supportsOAuthScopeIntrospection(tokenType)) {
262+
return null;
263+
}
264+
}
265+
204266
try {
205267
const response = await octokit.request("HEAD /");
206268
const scopes = response.headers["x-oauth-scopes"]?.split(/,\s+/) || [];

packages/schemas/src/v3/index.schema.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,16 @@ const schema = {
7878
"type": "number",
7979
"description": "The interval (in milliseconds) at which the user permission syncer should run. Defaults to 24 hours.",
8080
"minimum": 1
81+
},
82+
"maxAccountPermissionSyncJobConcurrency": {
83+
"type": "number",
84+
"description": "The number of account permission sync jobs to run concurrently. Defaults to 8.",
85+
"minimum": 1
86+
},
87+
"maxRepoPermissionSyncJobConcurrency": {
88+
"type": "number",
89+
"description": "The number of repo permission sync jobs to run concurrently. Defaults to 8.",
90+
"minimum": 1
8191
}
8292
},
8393
"additionalProperties": false
@@ -214,6 +224,16 @@ const schema = {
214224
"type": "number",
215225
"description": "The interval (in milliseconds) at which the user permission syncer should run. Defaults to 24 hours.",
216226
"minimum": 1
227+
},
228+
"maxAccountPermissionSyncJobConcurrency": {
229+
"type": "number",
230+
"description": "The number of account permission sync jobs to run concurrently. Defaults to 8.",
231+
"minimum": 1
232+
},
233+
"maxRepoPermissionSyncJobConcurrency": {
234+
"type": "number",
235+
"description": "The number of repo permission sync jobs to run concurrently. Defaults to 8.",
236+
"minimum": 1
217237
}
218238
},
219239
"additionalProperties": false

packages/schemas/src/v3/index.type.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,14 @@ export interface Settings {
129129
* The interval (in milliseconds) at which the user permission syncer should run. Defaults to 24 hours.
130130
*/
131131
experiment_userDrivenPermissionSyncIntervalMs?: number;
132+
/**
133+
* The number of account permission sync jobs to run concurrently. Defaults to 8.
134+
*/
135+
maxAccountPermissionSyncJobConcurrency?: number;
136+
/**
137+
* The number of repo permission sync jobs to run concurrently. Defaults to 8.
138+
*/
139+
maxRepoPermissionSyncJobConcurrency?: number;
132140
}
133141
/**
134142
* Search context

0 commit comments

Comments
 (0)