Skip to content

Commit 0f82e6b

Browse files
Merge remote-tracking branch 'upstream/main' into typecheck-workflow
2 parents f169296 + af6f1e6 commit 0f82e6b

33 files changed

Lines changed: 790 additions & 207 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
- [EE] Verified signed online license assertions before granting paid feature entitlements. [#1442](https://github.com/sourcebot-dev/sourcebot/pull/1442)
12+
- [EE] Fixed worker startup races that could disable GitHub App authentication and permission syncing until restart after an online license refresh. [#1454](https://github.com/sourcebot-dev/sourcebot/pull/1454)
13+
- [EE] Fixed GitHub connection sync jobs to fail safely when GitHub App authentication is configured without the required entitlement. [#1454](https://github.com/sourcebot-dev/sourcebot/pull/1454)
14+
1015
## [5.1.1] - 2026-07-14
1116

1217
- Add book a call button to sidebar. [#1441](https://github.com/sourcebot-dev/sourcebot/pull/1441)

packages/backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,6 @@
5555
"prom-client": "^15.1.3",
5656
"redlock": "5.0.0-beta.2",
5757
"simple-git": "^3.36.0",
58-
"zod": "^3.25.74"
58+
"zod": "^3.25.76"
5959
}
6060
}

packages/backend/src/ee/accountPermissionSyncer.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { PrismaClient, AccountPermissionSyncJobStatus, Account, PermissionSyncSo
33
import { env, createLogger, getIdentityProviderConfig, PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS } from "@sourcebot/shared";
44
import { hasEntitlement } from "../entitlements.js";
55
import { ensureFreshAccountToken } from "./tokenRefresh.js";
6-
import { Job, Queue, Worker } from "bullmq";
6+
import { DelayedError, Job, Queue, Worker } from "bullmq";
77
import { Redis } from "ioredis";
88
import {
99
createOctokitFromToken,
@@ -26,6 +26,7 @@ const createJobLogger = (jobId: string) => createLogger(`${LOG_TAG}:job:${jobId}
2626

2727
const QUEUE_NAME = 'accountPermissionSyncQueue';
2828
const POLLING_INTERVAL_MS = 1000;
29+
const ENTITLEMENT_RETRY_DELAY_MS = 30 * 1000;
2930

3031
type AccountPermissionSyncJob = {
3132
jobId: string;
@@ -59,13 +60,13 @@ export class AccountPermissionSyncer {
5960
}
6061

6162
public async startScheduler() {
62-
if (!await hasEntitlement('permission-syncing')) {
63-
throw new Error('Permission syncing is not supported in current plan.');
64-
}
65-
6663
logger.debug('Starting scheduler');
6764

6865
this.interval = setIntervalAsync(async () => {
66+
if (!await hasEntitlement('permission-syncing')) {
67+
return;
68+
}
69+
6970
const thresholdDate = new Date(Date.now() - this.settings.userDrivenPermissionSyncIntervalMs);
7071

7172
const accounts = await this.db.account.findMany({
@@ -168,6 +169,11 @@ export class AccountPermissionSyncer {
168169
}
169170

170171
private async runJob(job: Job<AccountPermissionSyncJob>) {
172+
if (!await hasEntitlement('permission-syncing')) {
173+
await job.moveToDelayed(Date.now() + ENTITLEMENT_RETRY_DELAY_MS, job.token);
174+
throw new DelayedError('Permission syncing entitlement is not currently available.');
175+
}
176+
171177
const id = job.data.jobId;
172178
const logger = createJobLogger(id);
173179

@@ -443,4 +449,4 @@ export class AccountPermissionSyncer {
443449
logger.error(errorMessage('unknown account (id not found)', 'unknown user (id not found)'));
444450
}
445451
}
446-
}
452+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { beforeEach, describe, expect, test, vi } from 'vitest';
2+
3+
const mocks = vi.hoisted(() => ({
4+
loadConfig: vi.fn(),
5+
}));
6+
7+
vi.mock('@sourcebot/shared', () => ({
8+
createLogger: vi.fn(() => ({
9+
debug: vi.fn(),
10+
error: vi.fn(),
11+
info: vi.fn(),
12+
warn: vi.fn(),
13+
})),
14+
env: { CONFIG_PATH: '/tmp/config.json' },
15+
getTokenFromConfig: vi.fn(),
16+
loadConfig: mocks.loadConfig,
17+
}));
18+
19+
vi.mock('@octokit/app', () => ({
20+
App: vi.fn(),
21+
}));
22+
23+
const getManager = async () => {
24+
const { GithubAppManager } = await import('./githubAppManager.js');
25+
return GithubAppManager.getInstance();
26+
};
27+
28+
describe('GithubAppManager.ensureInitialized', () => {
29+
beforeEach(() => {
30+
vi.resetModules();
31+
mocks.loadConfig.mockReset();
32+
});
33+
34+
test('shares initialization across concurrent callers', async () => {
35+
mocks.loadConfig.mockResolvedValue({});
36+
const manager = await getManager();
37+
38+
await Promise.all([
39+
manager.ensureInitialized(),
40+
manager.ensureInitialized(),
41+
]);
42+
43+
expect(mocks.loadConfig).toHaveBeenCalledTimes(1);
44+
});
45+
46+
test('retries initialization after a transient failure', async () => {
47+
mocks.loadConfig
48+
.mockRejectedValueOnce(new Error('GitHub unavailable'))
49+
.mockResolvedValueOnce({});
50+
const manager = await getManager();
51+
52+
await expect(manager.ensureInitialized()).rejects.toThrow('GitHub unavailable');
53+
await expect(manager.ensureInitialized()).resolves.toBeUndefined();
54+
55+
expect(mocks.loadConfig).toHaveBeenCalledTimes(2);
56+
});
57+
});

packages/backend/src/ee/githubAppManager.ts

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { App } from "@octokit/app";
22
import { getTokenFromConfig } from "@sourcebot/shared";
3-
import { PrismaClient } from "@sourcebot/db";
43
import { createLogger } from "@sourcebot/shared";
54
import { GitHubAppConfig } from "@sourcebot/schemas/v3/index.type";
65
import { env, loadConfig } from "@sourcebot/shared";
@@ -21,8 +20,8 @@ export class GithubAppManager {
2120
private static instance: GithubAppManager | null = null;
2221
private octokitApps: Map<number, App>;
2322
private installationMap: Map<string, Installation>;
24-
private db: PrismaClient | null = null;
2523
private initialized: boolean = false;
24+
private initializationPromise: Promise<void> | null = null;
2625

2726
private constructor() {
2827
this.octokitApps = new Map<number, App>();
@@ -36,16 +35,33 @@ export class GithubAppManager {
3635
return GithubAppManager.instance;
3736
}
3837

39-
private ensureInitialized(): void {
38+
private assertInitialized(): void {
4039
if (!this.initialized) {
41-
throw new Error('GithubAppManager must be initialized before use. Call init() first.');
40+
throw new Error('GithubAppManager must be initialized before use. Call ensureInitialized() first.');
4241
}
4342
}
4443

45-
public async init(db: PrismaClient) {
46-
this.db = db;
44+
public async ensureInitialized(): Promise<void> {
45+
if (this.initialized) {
46+
return;
47+
}
48+
49+
if (!this.initializationPromise) {
50+
this.initializationPromise = this.init().catch((error) => {
51+
// Allow a later operation to retry after a transient GitHub or
52+
// secret-resolution failure.
53+
this.initializationPromise = null;
54+
throw error;
55+
});
56+
}
57+
58+
await this.initializationPromise;
59+
}
60+
61+
private async init(): Promise<void> {
4762
const config = await loadConfig(env.CONFIG_PATH);
4863
if (!config.apps) {
64+
this.initialized = true;
4965
return;
5066
}
5167

@@ -86,12 +102,12 @@ export class GithubAppManager {
86102
this.installationMap.set(this.generateMapKey(owner, deploymentHostname), installation);
87103
}
88104
}
89-
105+
90106
this.initialized = true;
91107
}
92108

93109
public async getInstallationToken(owner: string, deploymentHostname: string = GITHUB_DEFAULT_DEPLOYMENT_HOSTNAME): Promise<string> {
94-
this.ensureInitialized();
110+
this.assertInitialized();
95111

96112
const key = this.generateMapKey(owner, deploymentHostname);
97113
const installation = this.installationMap.get(key) as Installation | undefined;
@@ -112,4 +128,4 @@ export class GithubAppManager {
112128
private generateMapKey(owner: string, deploymentHostname: string): string {
113129
return `${deploymentHostname}/${owner}`;
114130
}
115-
}
131+
}

packages/backend/src/ee/repoPermissionSyncer.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { PermissionSyncSource, PrismaClient, Repo, RepoPermissionSyncJobStatus }
33
import { createLogger, PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES } from "@sourcebot/shared";
44
import { env } from "@sourcebot/shared";
55
import { hasEntitlement } from "../entitlements.js";
6-
import { Job, Queue, Worker } from 'bullmq';
6+
import { DelayedError, Job, Queue, Worker } from 'bullmq';
77
import { Redis } from 'ioredis';
88
import { createOctokitFromToken, getRepoCollaborators, GITHUB_CLOUD_HOSTNAME } from "../github.js";
99
import { createGitLabFromPersonalAccessToken, getProjectMembers } from "../gitlab.js";
@@ -19,6 +19,7 @@ type RepoPermissionSyncJob = {
1919

2020
const QUEUE_NAME = 'repoPermissionSyncQueue';
2121
const POLLING_INTERVAL_MS = 1000;
22+
const ENTITLEMENT_RETRY_DELAY_MS = 30 * 1000;
2223
const LOG_TAG = 'repo-permission-syncer';
2324

2425
const logger = createLogger(LOG_TAG);
@@ -46,13 +47,13 @@ export class RepoPermissionSyncer {
4647
}
4748

4849
public async startScheduler() {
49-
if (!await hasEntitlement('permission-syncing')) {
50-
throw new Error('Permission syncing is not supported in current plan.');
51-
}
52-
5350
logger.debug('Starting scheduler');
5451

5552
this.interval = setIntervalAsync(async () => {
53+
if (!await hasEntitlement('permission-syncing')) {
54+
return;
55+
}
56+
5657
// @todo: make this configurable
5758
const thresholdDate = new Date(Date.now() - this.settings.repoDrivenPermissionSyncIntervalMs);
5859

@@ -160,6 +161,11 @@ export class RepoPermissionSyncer {
160161
}
161162

162163
private async runJob(job: Job<RepoPermissionSyncJob>) {
164+
if (!await hasEntitlement('permission-syncing')) {
165+
await job.moveToDelayed(Date.now() + ENTITLEMENT_RETRY_DELAY_MS, job.token);
166+
throw new DelayedError('Permission syncing entitlement is not currently available.');
167+
}
168+
163169
const id = job.data.jobId;
164170
const logger = createJobLogger(id);
165171

packages/backend/src/github.ts

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -116,36 +116,37 @@ export const createOctokitFromToken = async ({ token, url }: { token?: string, u
116116
}
117117

118118
/**
119-
* Helper function to get an authenticated Octokit instance using GitHub App if available,
120-
* otherwise falls back to the provided octokit instance.
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.
121122
*/
122-
const getOctokitWithGithubApp = async (
123+
export const getOctokitWithGithubApp = async (
123124
octokit: Octokit,
124125
owner: string,
125126
url: string | undefined,
126127
context: string
127128
): Promise<Octokit> => {
128-
if (!await hasEntitlement('github-app') || !GithubAppManager.getInstance().appsConfigured()) {
129+
const githubAppManager = GithubAppManager.getInstance();
130+
await githubAppManager.ensureInitialized();
131+
if (!githubAppManager.appsConfigured()) {
129132
return octokit;
130133
}
131134

135+
if (!await hasEntitlement('github-app')) {
136+
throw new Error(`GitHub App authentication is not currently licensed for ${context}.`);
137+
}
138+
132139
try {
133140
const hostname = url ? new URL(url).hostname : GITHUB_CLOUD_HOSTNAME;
134-
const token = await GithubAppManager.getInstance().getInstallationToken(owner, hostname);
135-
const { octokit: octokitFromToken, isAuthenticated } = await createOctokitFromToken({
141+
const token = await githubAppManager.getInstallationToken(owner, hostname);
142+
const { octokit: octokitFromToken } = await createOctokitFromToken({
136143
token,
137144
url,
138145
});
139-
140-
if (isAuthenticated) {
141-
return octokitFromToken;
142-
} else {
143-
logger.error(`Failed to authenticate with GitHub App for ${context}. Falling back to legacy token resolution.`);
144-
return octokit;
145-
}
146+
return octokitFromToken;
146147
} catch (error) {
147-
logger.error(`Error getting GitHub App token for ${context}. Falling back to legacy token resolution.`, error);
148-
return octokit;
148+
logger.error(`Error getting GitHub App token for ${context}.`, error);
149+
throw error;
149150
}
150151
}
151152

0 commit comments

Comments
 (0)