2025-09-18 03:34:10 +00:00
|
|
|
import * as Sentry from "@sentry/node";
|
|
|
|
|
import { PrismaClient, Repo, RepoPermissionSyncStatus } from "@sourcebot/db";
|
2025-09-17 05:02:04 +00:00
|
|
|
import { createLogger } from "@sourcebot/logger";
|
|
|
|
|
import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/bitbucket.type";
|
|
|
|
|
import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type";
|
|
|
|
|
import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type";
|
|
|
|
|
import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type";
|
|
|
|
|
import { Job, Queue, Worker } from 'bullmq';
|
|
|
|
|
import { Redis } from 'ioredis';
|
2025-09-18 03:34:10 +00:00
|
|
|
import { env } from "./env.js";
|
2025-09-17 05:02:04 +00:00
|
|
|
import { createOctokitFromConfig, getUserIdsWithReadAccessToRepo } from "./github.js";
|
|
|
|
|
import { RepoWithConnections } from "./types.js";
|
|
|
|
|
|
|
|
|
|
type RepoPermissionSyncJob = {
|
|
|
|
|
repoId: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const QUEUE_NAME = 'repoPermissionSyncQueue';
|
|
|
|
|
|
|
|
|
|
const logger = createLogger('permission-syncer');
|
|
|
|
|
|
2025-09-18 03:34:10 +00:00
|
|
|
const SUPPORTED_CODE_HOST_TYPES = ['github'];
|
|
|
|
|
|
2025-09-17 05:02:04 +00:00
|
|
|
export class RepoPermissionSyncer {
|
|
|
|
|
private queue: Queue<RepoPermissionSyncJob>;
|
|
|
|
|
private worker: Worker<RepoPermissionSyncJob>;
|
|
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
private db: PrismaClient,
|
|
|
|
|
redis: Redis,
|
|
|
|
|
) {
|
|
|
|
|
this.queue = new Queue<RepoPermissionSyncJob>(QUEUE_NAME, {
|
|
|
|
|
connection: redis,
|
|
|
|
|
});
|
|
|
|
|
this.worker = new Worker<RepoPermissionSyncJob>(QUEUE_NAME, this.runJob.bind(this), {
|
|
|
|
|
connection: redis,
|
2025-09-18 03:34:10 +00:00
|
|
|
concurrency: 1,
|
2025-09-17 05:02:04 +00:00
|
|
|
});
|
|
|
|
|
this.worker.on('completed', this.onJobCompleted.bind(this));
|
|
|
|
|
this.worker.on('failed', this.onJobFailed.bind(this));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public startScheduler() {
|
|
|
|
|
logger.debug('Starting scheduler');
|
|
|
|
|
|
|
|
|
|
return setInterval(async () => {
|
2025-09-18 03:34:10 +00:00
|
|
|
// @todo: make this configurable
|
|
|
|
|
const thresholdDate = new Date(Date.now() - 1000 * 60 * 60 * 24);
|
2025-09-17 05:02:04 +00:00
|
|
|
const repos = await this.db.repo.findMany({
|
2025-09-18 03:34:10 +00:00
|
|
|
// Repos need their permissions to be synced against the code host when...
|
2025-09-17 05:02:04 +00:00
|
|
|
where: {
|
2025-09-18 03:34:10 +00:00
|
|
|
// They belong to a code host that supports permissions syncing
|
|
|
|
|
AND: [
|
|
|
|
|
{
|
|
|
|
|
external_codeHostType: {
|
|
|
|
|
in: SUPPORTED_CODE_HOST_TYPES,
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
// and, they either require a sync (SYNC_NEEDED) or have been in a completed state (SYNCED or FAILED)
|
|
|
|
|
// for > some duration (default 24 hours)
|
|
|
|
|
{
|
|
|
|
|
OR: [
|
|
|
|
|
{
|
|
|
|
|
permissionSyncStatus: RepoPermissionSyncStatus.SYNC_NEEDED
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
AND: [
|
|
|
|
|
{
|
|
|
|
|
OR: [
|
|
|
|
|
{ permissionSyncStatus: RepoPermissionSyncStatus.SYNCED },
|
|
|
|
|
{ permissionSyncStatus: RepoPermissionSyncStatus.FAILED },
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
OR: [
|
|
|
|
|
{ permissionSyncJobLastCompletedAt: null },
|
|
|
|
|
{ permissionSyncJobLastCompletedAt: { lt: thresholdDate } }
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
]
|
2025-09-17 05:02:04 +00:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-09-18 03:34:10 +00:00
|
|
|
await this.schedulePermissionSync(repos);
|
|
|
|
|
}, 1000 * 30);
|
2025-09-17 05:02:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public dispose() {
|
|
|
|
|
this.worker.close();
|
|
|
|
|
this.queue.close();
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-18 03:34:10 +00:00
|
|
|
private async schedulePermissionSync(repos: Repo[]) {
|
|
|
|
|
await this.db.$transaction(async (tx) => {
|
|
|
|
|
await tx.repo.updateMany({
|
|
|
|
|
where: { id: { in: repos.map(repo => repo.id) } },
|
|
|
|
|
data: { permissionSyncStatus: RepoPermissionSyncStatus.IN_SYNC_QUEUE },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await this.queue.addBulk(repos.map(repo => ({
|
|
|
|
|
name: 'repoPermissionSyncJob',
|
|
|
|
|
data: {
|
|
|
|
|
repoId: repo.id,
|
|
|
|
|
},
|
|
|
|
|
opts: {
|
|
|
|
|
removeOnComplete: env.REDIS_REMOVE_ON_COMPLETE,
|
|
|
|
|
removeOnFail: env.REDIS_REMOVE_ON_FAIL,
|
|
|
|
|
}
|
|
|
|
|
})))
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-17 05:02:04 +00:00
|
|
|
private async runJob(job: Job<RepoPermissionSyncJob>) {
|
|
|
|
|
const id = job.data.repoId;
|
2025-09-18 03:34:10 +00:00
|
|
|
const repo = await this.db.repo.update({
|
2025-09-17 05:02:04 +00:00
|
|
|
where: {
|
2025-09-18 03:34:10 +00:00
|
|
|
id
|
|
|
|
|
},
|
|
|
|
|
data: {
|
|
|
|
|
permissionSyncStatus: RepoPermissionSyncStatus.SYNCING,
|
2025-09-17 05:02:04 +00:00
|
|
|
},
|
|
|
|
|
include: {
|
|
|
|
|
connections: {
|
|
|
|
|
include: {
|
|
|
|
|
connection: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!repo) {
|
|
|
|
|
throw new Error(`Repo ${id} not found`);
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-18 03:34:10 +00:00
|
|
|
logger.info(`Syncing permissions for repo ${repo.displayName}...`);
|
|
|
|
|
|
2025-09-17 05:02:04 +00:00
|
|
|
const connection = getFirstConnectionWithToken(repo);
|
|
|
|
|
if (!connection) {
|
|
|
|
|
throw new Error(`No connection with token found for repo ${id}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const userIds = await (async () => {
|
|
|
|
|
if (connection.connectionType === 'github') {
|
|
|
|
|
const config = connection.config as unknown as GithubConnectionConfig;
|
|
|
|
|
const { octokit } = await createOctokitFromConfig(config, repo.orgId, this.db);
|
|
|
|
|
|
|
|
|
|
// @nocheckin - need to handle when repo displayName is not set.
|
|
|
|
|
const [owner, repoName] = repo.displayName!.split('/');
|
|
|
|
|
|
|
|
|
|
const githubUserIds = await getUserIdsWithReadAccessToRepo(owner, repoName, octokit);
|
|
|
|
|
|
|
|
|
|
const accounts = await this.db.account.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
provider: 'github',
|
|
|
|
|
providerAccountId: {
|
|
|
|
|
in: githubUserIds,
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
select: {
|
|
|
|
|
userId: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return accounts.map(account => account.userId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [];
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
await this.db.repo.update({
|
|
|
|
|
where: {
|
|
|
|
|
id: repo.id,
|
|
|
|
|
},
|
|
|
|
|
data: {
|
|
|
|
|
permittedUsers: {
|
|
|
|
|
deleteMany: {},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await this.db.userToRepoPermission.createMany({
|
|
|
|
|
data: userIds.map(userId => ({
|
|
|
|
|
userId,
|
|
|
|
|
repoId: repo.id,
|
|
|
|
|
})),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async onJobCompleted(job: Job<RepoPermissionSyncJob>) {
|
2025-09-18 03:34:10 +00:00
|
|
|
const repo = await this.db.repo.update({
|
|
|
|
|
where: {
|
|
|
|
|
id: job.data.repoId,
|
|
|
|
|
},
|
|
|
|
|
data: {
|
|
|
|
|
permissionSyncStatus: RepoPermissionSyncStatus.SYNCED,
|
|
|
|
|
permissionSyncJobLastCompletedAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
logger.info(`Permissions synced for repo ${repo.displayName ?? repo.name}`);
|
2025-09-17 05:02:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async onJobFailed(job: Job<RepoPermissionSyncJob> | undefined, err: Error) {
|
2025-09-18 03:34:10 +00:00
|
|
|
Sentry.captureException(err, {
|
|
|
|
|
tags: {
|
|
|
|
|
repoId: job?.data.repoId,
|
|
|
|
|
queue: QUEUE_NAME,
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const errorMessage = (repoName: string) => `Repo permission sync job failed for repo ${repoName}: ${err}`;
|
|
|
|
|
|
|
|
|
|
if (job) {
|
|
|
|
|
const repo = await this.db.repo.update({
|
|
|
|
|
where: {
|
|
|
|
|
id: job?.data.repoId,
|
|
|
|
|
},
|
|
|
|
|
data: {
|
|
|
|
|
permissionSyncStatus: RepoPermissionSyncStatus.FAILED,
|
|
|
|
|
permissionSyncJobLastCompletedAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
logger.error(errorMessage(repo.displayName ?? repo.name));
|
|
|
|
|
} else {
|
|
|
|
|
logger.error(errorMessage('unknown repo (id not found)'));
|
|
|
|
|
}
|
2025-09-17 05:02:04 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const getFirstConnectionWithToken = (repo: RepoWithConnections) => {
|
|
|
|
|
for (const { connection } of repo.connections) {
|
|
|
|
|
if (connection.connectionType === 'github') {
|
|
|
|
|
const config = connection.config as unknown as GithubConnectionConfig;
|
|
|
|
|
if (config.token) {
|
|
|
|
|
return connection;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (connection.connectionType === 'gitlab') {
|
|
|
|
|
const config = connection.config as unknown as GitlabConnectionConfig;
|
|
|
|
|
if (config.token) {
|
|
|
|
|
return connection;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (connection.connectionType === 'gitea') {
|
|
|
|
|
const config = connection.config as unknown as GiteaConnectionConfig;
|
|
|
|
|
if (config.token) {
|
|
|
|
|
return connection;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (connection.connectionType === 'bitbucket') {
|
|
|
|
|
const config = connection.config as unknown as BitbucketConnectionConfig;
|
|
|
|
|
if (config.token) {
|
|
|
|
|
return connection;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|