add better visualization for connection/repo errors and warnings (#201)

* replace upsert with seperate create many and raw update many calls

* add bulk repo status update and queue addition with priority

* add support for managed redis

* add note for changing raw sql on schema change

* add error package and use BackendException in connection manager

* handle connection failure display on web app

* add warning banner for not found orgs/repos/users

* add failure handling for gerrit

* add gitea notfound warning support

* add warning icon in connections list

* style nits

* add failed repo vis in connections list

* added retry failed repo index buttons

* move nav indicators to client with polling

* fix indicator flash issue and truncate large list results

* display error nav better

* truncate failed repo list in connection list item

* fix merge error

* fix merge bug

* add connection util file [wip]

* refactor notfound fetch logic and add missing error package to dockerfile

* move repeated logic to function and add zod schema for syncStatusMetadata
This commit is contained in:
Michael Sukkarieh 2025-02-19 18:10:22 -08:00 committed by GitHub
parent b99a648670
commit fdd71cfcfe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 1736 additions and 375 deletions

View file

@ -18,9 +18,11 @@ COPY package.json yarn.lock* ./
COPY ./packages/db ./packages/db COPY ./packages/db ./packages/db
COPY ./packages/schemas ./packages/schemas COPY ./packages/schemas ./packages/schemas
COPY ./packages/crypto ./packages/crypto COPY ./packages/crypto ./packages/crypto
COPY ./packages/error ./packages/error
RUN yarn workspace @sourcebot/db install --frozen-lockfile RUN yarn workspace @sourcebot/db install --frozen-lockfile
RUN yarn workspace @sourcebot/schemas install --frozen-lockfile RUN yarn workspace @sourcebot/schemas install --frozen-lockfile
RUN yarn workspace @sourcebot/crypto install --frozen-lockfile RUN yarn workspace @sourcebot/crypto install --frozen-lockfile
RUN yarn workspace @sourcebot/error install --frozen-lockfile
# ------ Build Web ------ # ------ Build Web ------
FROM node-alpine AS web-builder FROM node-alpine AS web-builder
@ -33,6 +35,7 @@ COPY --from=shared-libs-builder /app/node_modules ./node_modules
COPY --from=shared-libs-builder /app/packages/db ./packages/db COPY --from=shared-libs-builder /app/packages/db ./packages/db
COPY --from=shared-libs-builder /app/packages/schemas ./packages/schemas COPY --from=shared-libs-builder /app/packages/schemas ./packages/schemas
COPY --from=shared-libs-builder /app/packages/crypto ./packages/crypto COPY --from=shared-libs-builder /app/packages/crypto ./packages/crypto
COPY --from=shared-libs-builder /app/packages/error ./packages/error
# Fixes arm64 timeouts # Fixes arm64 timeouts
RUN yarn config set registry https://registry.npmjs.org/ RUN yarn config set registry https://registry.npmjs.org/
@ -66,6 +69,7 @@ COPY --from=shared-libs-builder /app/node_modules ./node_modules
COPY --from=shared-libs-builder /app/packages/db ./packages/db COPY --from=shared-libs-builder /app/packages/db ./packages/db
COPY --from=shared-libs-builder /app/packages/schemas ./packages/schemas COPY --from=shared-libs-builder /app/packages/schemas ./packages/schemas
COPY --from=shared-libs-builder /app/packages/crypto ./packages/crypto COPY --from=shared-libs-builder /app/packages/crypto ./packages/crypto
COPY --from=shared-libs-builder /app/packages/error ./packages/error
RUN yarn workspace @sourcebot/backend install --frozen-lockfile RUN yarn workspace @sourcebot/backend install --frozen-lockfile
RUN yarn workspace @sourcebot/backend build RUN yarn workspace @sourcebot/backend build
@ -138,6 +142,7 @@ COPY --from=shared-libs-builder /app/node_modules ./node_modules
COPY --from=shared-libs-builder /app/packages/db ./packages/db COPY --from=shared-libs-builder /app/packages/db ./packages/db
COPY --from=shared-libs-builder /app/packages/schemas ./packages/schemas COPY --from=shared-libs-builder /app/packages/schemas ./packages/schemas
COPY --from=shared-libs-builder /app/packages/crypto ./packages/crypto COPY --from=shared-libs-builder /app/packages/crypto ./packages/crypto
COPY --from=shared-libs-builder /app/packages/error ./packages/error
# Configure the database # Configure the database
RUN mkdir -p /run/postgresql && \ RUN mkdir -p /run/postgresql && \

View file

@ -26,6 +26,8 @@ clean:
packages/schemas/dist \ packages/schemas/dist \
packages/crypto/node_modules \ packages/crypto/node_modules \
packages/crypto/dist \ packages/crypto/dist \
packages/error/node_modules \
packages/error/dist \
.sourcebot .sourcebot
soft-reset: soft-reset:

View file

@ -34,6 +34,7 @@
"@sourcebot/crypto": "^0.1.0", "@sourcebot/crypto": "^0.1.0",
"@sourcebot/db": "^0.1.0", "@sourcebot/db": "^0.1.0",
"@sourcebot/schemas": "^0.1.0", "@sourcebot/schemas": "^0.1.0",
"@sourcebot/error": "^0.1.0",
"simple-git": "^3.27.0", "simple-git": "^3.27.0",
"strip-json-comments": "^5.0.1", "strip-json-comments": "^5.0.1",
"winston": "^3.15.0", "winston": "^3.15.0",

View file

@ -6,6 +6,7 @@ import { createLogger } from "./logger.js";
import os from 'os'; import os from 'os';
import { Redis } from 'ioredis'; import { Redis } from 'ioredis';
import { RepoData, compileGithubConfig, compileGitlabConfig, compileGiteaConfig, compileGerritConfig } from "./repoCompileUtils.js"; import { RepoData, compileGithubConfig, compileGitlabConfig, compileGiteaConfig, compileGerritConfig } from "./repoCompileUtils.js";
import { BackendError, BackendException } from "@sourcebot/error";
interface IConnectionManager { interface IConnectionManager {
scheduleConnectionSync: (connection: Connection) => Promise<void>; scheduleConnectionSync: (connection: Connection) => Promise<void>;
@ -81,7 +82,47 @@ export class ConnectionManager implements IConnectionManager {
// @note: We aren't actually doing anything with this atm. // @note: We aren't actually doing anything with this atm.
const abortController = new AbortController(); const abortController = new AbortController();
const repoData: RepoData[] = await (async () => { const connection = await this.db.connection.findUnique({
where: {
id: job.data.connectionId,
},
});
if (!connection) {
throw new BackendException(BackendError.CONNECTION_SYNC_CONNECTION_NOT_FOUND, {
message: `Connection ${job.data.connectionId} not found`,
});
}
// Reset the syncStatusMetadata to an empty object at the start of the sync job
await this.db.connection.update({
where: {
id: job.data.connectionId,
},
data: {
syncStatusMetadata: {}
}
})
let result: {
repoData: RepoData[],
notFound: {
users: string[],
orgs: string[],
repos: string[],
}
} = {
repoData: [],
notFound: {
users: [],
orgs: [],
repos: [],
}
};
try {
result = await (async () => {
switch (config.type) { switch (config.type) {
case 'github': { case 'github': {
return await compileGithubConfig(config, job.data.connectionId, orgId, this.db, abortController); return await compileGithubConfig(config, job.data.connectionId, orgId, this.db, abortController);
@ -96,10 +137,37 @@ export class ConnectionManager implements IConnectionManager {
return await compileGerritConfig(config, job.data.connectionId, orgId); return await compileGerritConfig(config, job.data.connectionId, orgId);
} }
default: { default: {
return []; return {repoData: [], notFound: {
users: [],
orgs: [],
repos: [],
}};
} }
} }
})(); })();
} catch (err) {
this.logger.error(`Failed to compile repo data for connection ${job.data.connectionId}: ${err}`);
if (err instanceof BackendException) {
throw err;
} else {
throw new BackendException(BackendError.CONNECTION_SYNC_SYSTEM_ERROR, {
message: `Failed to compile repo data for connection ${job.data.connectionId}`,
});
}
}
const { repoData, notFound } = result;
// Push the information regarding not found users, orgs, and repos to the connection's syncStatusMetadata. Note that
// this won't be overwritten even if the connection job fails
await this.db.connection.update({
where: {
id: job.data.connectionId,
},
data: {
syncStatusMetadata: { notFound }
}
});
// Filter out any duplicates by external_id and external_codeHostUrl. // Filter out any duplicates by external_id and external_codeHostUrl.
repoData.filter((repo, index, self) => { repoData.filter((repo, index, self) => {
@ -265,16 +333,37 @@ export class ConnectionManager implements IConnectionManager {
private async onSyncJobFailed(job: Job | undefined, err: unknown) { private async onSyncJobFailed(job: Job | undefined, err: unknown) {
this.logger.info(`Connection sync job failed with error: ${err}`); this.logger.info(`Connection sync job failed with error: ${err}`);
if (job) { if (job) {
// We may have pushed some metadata during the execution of the job, so we make sure to not overwrite the metadata here
const { connectionId } = job.data; const { connectionId } = job.data;
let syncStatusMetadata: Record<string, unknown> = (await this.db.connection.findUnique({
where: { id: connectionId },
select: { syncStatusMetadata: true }
}))?.syncStatusMetadata as Record<string, unknown> ?? {};
if (err instanceof BackendException) {
syncStatusMetadata = {
...syncStatusMetadata,
error: err.code,
...err.metadata,
}
} else {
syncStatusMetadata = {
...syncStatusMetadata,
error: 'UNKNOWN',
}
}
await this.db.connection.update({ await this.db.connection.update({
where: { where: {
id: connectionId, id: connectionId,
}, },
data: { data: {
syncStatus: ConnectionSyncStatus.FAILED, syncStatus: ConnectionSyncStatus.FAILED,
syncedAt: new Date() syncedAt: new Date(),
syncStatusMetadata: syncStatusMetadata as Prisma.InputJsonValue,
} }
}) });
} }
} }

View file

@ -0,0 +1,44 @@
type ValidResult<T> = {
type: 'valid';
data: T[];
};
type NotFoundResult = {
type: 'notFound';
value: string;
};
type CustomResult<T> = ValidResult<T> | NotFoundResult;
export function processPromiseResults<T>(
results: PromiseSettledResult<CustomResult<T>>[],
): {
validItems: T[];
notFoundItems: string[];
} {
const validItems: T[] = [];
const notFoundItems: string[] = [];
results.forEach(result => {
if (result.status === 'fulfilled') {
const value = result.value;
if (value.type === 'valid') {
validItems.push(...value.data);
} else {
notFoundItems.push(value.value);
}
}
});
return {
validItems,
notFoundItems,
};
}
export function throwIfAnyFailed<T>(results: PromiseSettledResult<T>[]) {
const failedResult = results.find(result => result.status === 'rejected');
if (failedResult) {
throw failedResult.reason;
}
}

View file

@ -3,6 +3,8 @@ import { GerritConfig } from "@sourcebot/schemas/v2/index.type"
import { createLogger } from './logger.js'; import { createLogger } from './logger.js';
import micromatch from "micromatch"; import micromatch from "micromatch";
import { measure, marshalBool, excludeReposByName, includeReposByName, fetchWithRetry } from './utils.js'; import { measure, marshalBool, excludeReposByName, includeReposByName, fetchWithRetry } from './utils.js';
import { BackendError } from '@sourcebot/error';
import { BackendException } from '@sourcebot/error';
// https://gerrit-review.googlesource.com/Documentation/rest-api.html // https://gerrit-review.googlesource.com/Documentation/rest-api.html
interface GerritProjects { interface GerritProjects {
@ -38,6 +40,10 @@ export const getGerritReposFromConfig = async (config: GerritConfig): Promise<Ge
const fetchFn = () => fetchAllProjects(url); const fetchFn = () => fetchAllProjects(url);
return fetchWithRetry(fetchFn, `projects from ${url}`, logger); return fetchWithRetry(fetchFn, `projects from ${url}`, logger);
} catch (err) { } catch (err) {
if (err instanceof BackendException) {
throw err;
}
logger.error(`Failed to fetch projects from ${url}`, err); logger.error(`Failed to fetch projects from ${url}`, err);
return null; return null;
} }
@ -78,9 +84,25 @@ const fetchAllProjects = async (url: string): Promise<GerritProject[]> => {
const endpointWithParams = `${projectsEndpoint}?S=${start}`; const endpointWithParams = `${projectsEndpoint}?S=${start}`;
logger.debug(`Fetching projects from Gerrit at ${endpointWithParams}`); logger.debug(`Fetching projects from Gerrit at ${endpointWithParams}`);
const response = await fetch(endpointWithParams); let response: Response;
try {
response = await fetch(endpointWithParams);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch projects from Gerrit: ${response.statusText}`); console.log(`Failed to fetch projects from Gerrit at ${endpointWithParams} with status ${response.status}`);
throw new BackendException(BackendError.CONNECTION_SYNC_FAILED_TO_FETCH_GERRIT_PROJECTS, {
status: response.status,
});
}
} catch (err) {
if (err instanceof BackendException) {
throw err;
}
const status = (err as any).code;
console.log(`Failed to fetch projects from Gerrit at ${endpointWithParams} with status ${status}`);
throw new BackendException(BackendError.CONNECTION_SYNC_FAILED_TO_FETCH_GERRIT_PROJECTS, {
status: status,
});
} }
const text = await response.text(); const text = await response.text();

View file

@ -6,43 +6,45 @@ import { createLogger } from './logger.js';
import micromatch from 'micromatch'; import micromatch from 'micromatch';
import { PrismaClient } from '@sourcebot/db'; import { PrismaClient } from '@sourcebot/db';
import { FALLBACK_GITEA_TOKEN } from './environment.js'; import { FALLBACK_GITEA_TOKEN } from './environment.js';
import { processPromiseResults, throwIfAnyFailed } from './connectionUtils.js';
const logger = createLogger('Gitea'); const logger = createLogger('Gitea');
export const getGiteaReposFromConfig = async (config: GiteaConnectionConfig, orgId: number, db: PrismaClient) => { export const getGiteaReposFromConfig = async (config: GiteaConnectionConfig, orgId: number, db: PrismaClient) => {
const token = config.token ? await getTokenFromConfig(config.token, orgId, db) : undefined; const tokenResult = config.token ? await getTokenFromConfig(config.token, orgId, db) : undefined;
const token = tokenResult?.token ?? FALLBACK_GITEA_TOKEN;
const api = giteaApi(config.url ?? 'https://gitea.com', { const api = giteaApi(config.url ?? 'https://gitea.com', {
token: token ?? FALLBACK_GITEA_TOKEN, token: token,
customFetch: fetch, customFetch: fetch,
}); });
let allRepos: GiteaRepository[] = []; let allRepos: GiteaRepository[] = [];
let notFound: {
users: string[],
orgs: string[],
repos: string[],
} = {
users: [],
orgs: [],
repos: [],
};
if (config.orgs) { if (config.orgs) {
const _repos = await fetchWithRetry( const { validRepos, notFoundOrgs } = await getReposForOrgs(config.orgs, api);
() => getReposForOrgs(config.orgs!, api), allRepos = allRepos.concat(validRepos);
`orgs ${config.orgs.join(', ')}`, notFound.orgs = notFoundOrgs;
logger
);
allRepos = allRepos.concat(_repos);
} }
if (config.repos) { if (config.repos) {
const _repos = await fetchWithRetry( const { validRepos, notFoundRepos } = await getRepos(config.repos, api);
() => getRepos(config.repos!, api), allRepos = allRepos.concat(validRepos);
`repos ${config.repos.join(', ')}`, notFound.repos = notFoundRepos;
logger
);
allRepos = allRepos.concat(_repos);
} }
if (config.users) { if (config.users) {
const _repos = await fetchWithRetry( const { validRepos, notFoundUsers } = await getReposOwnedByUsers(config.users, api);
() => getReposOwnedByUsers(config.users!, api), allRepos = allRepos.concat(validRepos);
`users ${config.users.join(', ')}`, notFound.users = notFoundUsers;
logger
);
allRepos = allRepos.concat(_repos);
} }
allRepos = allRepos.filter(repo => repo.full_name !== undefined); allRepos = allRepos.filter(repo => repo.full_name !== undefined);
@ -108,7 +110,10 @@ export const getGiteaReposFromConfig = async (config: GiteaConnectionConfig, org
}); });
logger.debug(`Found ${repos.length} total repositories.`); logger.debug(`Found ${repos.length} total repositories.`);
return repos; return {
validRepos: repos,
notFound,
};
} }
const shouldExcludeRepo = ({ const shouldExcludeRepo = ({
@ -186,7 +191,7 @@ const getBranchesForRepo = async <T>(owner: string, repo: string, api: Api<T>) =
} }
const getReposOwnedByUsers = async <T>(users: string[], api: Api<T>) => { const getReposOwnedByUsers = async <T>(users: string[], api: Api<T>) => {
const repos = (await Promise.all(users.map(async (user) => { const results = await Promise.allSettled(users.map(async (user) => {
try { try {
logger.debug(`Fetching repos for user ${user}...`); logger.debug(`Fetching repos for user ${user}...`);
@ -197,18 +202,33 @@ const getReposOwnedByUsers = async <T>(users: string[], api: Api<T>) => {
); );
logger.debug(`Found ${data.length} repos owned by user ${user} in ${durationMs}ms.`); logger.debug(`Found ${data.length} repos owned by user ${user} in ${durationMs}ms.`);
return data; return {
} catch (e) { type: 'valid' as const,
logger.error(`Failed to fetch repos for user ${user}.`, e); data
};
} catch (e: any) {
if (e?.status === 404) {
logger.error(`User ${user} not found or no access`);
return {
type: 'notFound' as const,
value: user
};
}
throw e; throw e;
} }
}))).flat(); }));
return repos; throwIfAnyFailed(results);
const { validItems: validRepos, notFoundItems: notFoundUsers } = processPromiseResults<GiteaRepository>(results);
return {
validRepos,
notFoundUsers,
};
} }
const getReposForOrgs = async <T>(orgs: string[], api: Api<T>) => { const getReposForOrgs = async <T>(orgs: string[], api: Api<T>) => {
return (await Promise.all(orgs.map(async (org) => { const results = await Promise.allSettled(orgs.map(async (org) => {
try { try {
logger.debug(`Fetching repos for org ${org}...`); logger.debug(`Fetching repos for org ${org}...`);
@ -220,16 +240,33 @@ const getReposForOrgs = async <T>(orgs: string[], api: Api<T>) => {
); );
logger.debug(`Found ${data.length} repos for org ${org} in ${durationMs}ms.`); logger.debug(`Found ${data.length} repos for org ${org} in ${durationMs}ms.`);
return data; return {
} catch (e) { type: 'valid' as const,
logger.error(`Failed to fetch repos for org ${org}.`, e); data
};
} catch (e: any) {
if (e?.status === 404) {
logger.error(`Organization ${org} not found or no access`);
return {
type: 'notFound' as const,
value: org
};
}
throw e; throw e;
} }
}))).flat(); }));
throwIfAnyFailed(results);
const { validItems: validRepos, notFoundItems: notFoundOrgs } = processPromiseResults<GiteaRepository>(results);
return {
validRepos,
notFoundOrgs,
};
} }
const getRepos = async <T>(repos: string[], api: Api<T>) => { const getRepos = async <T>(repos: string[], api: Api<T>) => {
return (await Promise.all(repos.map(async (repo) => { const results = await Promise.allSettled(repos.map(async (repo) => {
try { try {
logger.debug(`Fetching repository info for ${repo}...`); logger.debug(`Fetching repository info for ${repo}...`);
@ -239,13 +276,29 @@ const getRepos = async <T>(repos: string[], api: Api<T>) => {
); );
logger.debug(`Found repo ${repo} in ${durationMs}ms.`); logger.debug(`Found repo ${repo} in ${durationMs}ms.`);
return {
return [response.data]; type: 'valid' as const,
} catch (e) { data: [response.data]
logger.error(`Failed to fetch repository info for ${repo}.`, e); };
} catch (e: any) {
if (e?.status === 404) {
logger.error(`Repository ${repo} not found or no access`);
return {
type: 'notFound' as const,
value: repo
};
}
throw e; throw e;
} }
}))).flat(); }));
throwIfAnyFailed(results);
const { validItems: validRepos, notFoundItems: notFoundRepos } = processPromiseResults<GiteaRepository>(results);
return {
validRepos,
notFoundRepos,
};
} }
// @see : https://docs.gitea.com/development/api-usage#pagination // @see : https://docs.gitea.com/development/api-usage#pagination

View file

@ -5,6 +5,8 @@ import { getTokenFromConfig, measure, fetchWithRetry } from "./utils.js";
import micromatch from "micromatch"; import micromatch from "micromatch";
import { PrismaClient } from "@sourcebot/db"; import { PrismaClient } from "@sourcebot/db";
import { FALLBACK_GITHUB_TOKEN } from "./environment.js"; import { FALLBACK_GITHUB_TOKEN } from "./environment.js";
import { BackendException, BackendError } from "@sourcebot/error";
import { processPromiseResults, throwIfAnyFailed } from "./connectionUtils.js";
const logger = createLogger("GitHub"); const logger = createLogger("GitHub");
export type OctokitRepository = { export type OctokitRepository = {
@ -28,8 +30,17 @@ export type OctokitRepository = {
} }
} }
const isHttpError = (error: unknown, status: number): boolean => {
return error !== null
&& typeof error === 'object'
&& 'status' in error
&& error.status === status;
}
export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, orgId: number, db: PrismaClient, signal: AbortSignal) => { export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, orgId: number, db: PrismaClient, signal: AbortSignal) => {
const token = config.token ? await getTokenFromConfig(config.token, orgId, db) : undefined; const tokenResult = config.token ? await getTokenFromConfig(config.token, orgId, db) : undefined;
const token = tokenResult?.token;
const secretKey = tokenResult?.secretKey;
const octokit = new Octokit({ const octokit = new Octokit({
auth: token ?? FALLBACK_GITHUB_TOKEN, auth: token ?? FALLBACK_GITHUB_TOKEN,
@ -38,25 +49,52 @@ export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, o
} : {}), } : {}),
}); });
if (token) {
try {
await octokit.rest.users.getAuthenticated();
} catch (error) {
if (isHttpError(error, 401)) {
throw new BackendException(BackendError.CONNECTION_SYNC_INVALID_TOKEN, {
secretKey,
});
}
throw new BackendException(BackendError.CONNECTION_SYNC_SYSTEM_ERROR, {
message: `Failed to authenticate with GitHub`,
});
}
}
let allRepos: OctokitRepository[] = []; let allRepos: OctokitRepository[] = [];
let notFound: {
users: string[],
orgs: string[],
repos: string[],
} = {
users: [],
orgs: [],
repos: [],
};
if (config.orgs) { if (config.orgs) {
const _repos = await getReposForOrgs(config.orgs, octokit, signal); const { validRepos, notFoundOrgs } = await getReposForOrgs(config.orgs, octokit, signal);
allRepos = allRepos.concat(_repos); allRepos = allRepos.concat(validRepos);
notFound.orgs = notFoundOrgs;
} }
if (config.repos) { if (config.repos) {
const _repos = await getRepos(config.repos, octokit, signal); const { validRepos, notFoundRepos } = await getRepos(config.repos, octokit, signal);
allRepos = allRepos.concat(_repos); allRepos = allRepos.concat(validRepos);
notFound.repos = notFoundRepos;
} }
if (config.users) { if (config.users) {
const isAuthenticated = config.token !== undefined; const isAuthenticated = config.token !== undefined;
const _repos = await getReposOwnedByUsers(config.users, isAuthenticated, octokit, signal); const { validRepos, notFoundUsers } = await getReposOwnedByUsers(config.users, isAuthenticated, octokit, signal);
allRepos = allRepos.concat(_repos); allRepos = allRepos.concat(validRepos);
notFound.users = notFoundUsers;
} }
// Marshall results to our type
let repos = allRepos let repos = allRepos
.filter((repo) => { .filter((repo) => {
const isExcluded = shouldExcludeRepo({ const isExcluded = shouldExcludeRepo({
@ -72,21 +110,10 @@ export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, o
logger.debug(`Found ${repos.length} total repositories.`); logger.debug(`Found ${repos.length} total repositories.`);
return repos; return {
} validRepos: repos,
notFound,
export const getGitHubRepoFromId = async (id: string, hostURL: string, token?: string) => { };
const octokit = new Octokit({
auth: token ?? FALLBACK_GITHUB_TOKEN,
...(hostURL !== 'https://github.com' ? {
baseUrl: `${hostURL}/api/v3`
} : {})
});
const repo = await octokit.request('GET /repositories/:id', {
id,
});
return repo;
} }
export const shouldExcludeRepo = ({ export const shouldExcludeRepo = ({
@ -176,7 +203,7 @@ export const shouldExcludeRepo = ({
} }
const getReposOwnedByUsers = async (users: string[], isAuthenticated: boolean, octokit: Octokit, signal: AbortSignal) => { const getReposOwnedByUsers = async (users: string[], isAuthenticated: boolean, octokit: Octokit, signal: AbortSignal) => {
const repos = (await Promise.all(users.map(async (user) => { const results = await Promise.allSettled(users.map(async (user) => {
try { try {
logger.debug(`Fetching repository info for user ${user}...`); logger.debug(`Fetching repository info for user ${user}...`);
@ -207,18 +234,33 @@ const getReposOwnedByUsers = async (users: string[], isAuthenticated: boolean, o
}); });
logger.debug(`Found ${data.length} owned by user ${user} in ${durationMs}ms.`); logger.debug(`Found ${data.length} owned by user ${user} in ${durationMs}ms.`);
return data; return {
} catch (e) { type: 'valid' as const,
logger.error(`Failed to fetch repository info for user ${user}.`, e); data
throw e; };
} catch (error) {
if (isHttpError(error, 404)) {
logger.error(`User ${user} not found or no access`);
return {
type: 'notFound' as const,
value: user
};
} }
}))).flat(); throw error;
}
}));
return repos; throwIfAnyFailed(results);
const { validItems: validRepos, notFoundItems: notFoundUsers } = processPromiseResults<OctokitRepository>(results);
return {
validRepos,
notFoundUsers,
};
} }
const getReposForOrgs = async (orgs: string[], octokit: Octokit, signal: AbortSignal) => { const getReposForOrgs = async (orgs: string[], octokit: Octokit, signal: AbortSignal) => {
const repos = (await Promise.all(orgs.map(async (org) => { const results = await Promise.allSettled(orgs.map(async (org) => {
try { try {
logger.info(`Fetching repository info for org ${org}...`); logger.info(`Fetching repository info for org ${org}...`);
@ -235,18 +277,33 @@ const getReposForOrgs = async (orgs: string[], octokit: Octokit, signal: AbortSi
}); });
logger.info(`Found ${data.length} in org ${org} in ${durationMs}ms.`); logger.info(`Found ${data.length} in org ${org} in ${durationMs}ms.`);
return data; return {
} catch (e) { type: 'valid' as const,
logger.error(`Failed to fetch repository info for org ${org}.`, e); data
throw e; };
} catch (error) {
if (isHttpError(error, 404)) {
logger.error(`Organization ${org} not found or no access`);
return {
type: 'notFound' as const,
value: org
};
} }
}))).flat(); throw error;
}
}));
return repos; throwIfAnyFailed(results);
const { validItems: validRepos, notFoundItems: notFoundOrgs } = processPromiseResults<OctokitRepository>(results);
return {
validRepos,
notFoundOrgs,
};
} }
const getRepos = async (repoList: string[], octokit: Octokit, signal: AbortSignal) => { const getRepos = async (repoList: string[], octokit: Octokit, signal: AbortSignal) => {
const repos = (await Promise.all(repoList.map(async (repo) => { const results = await Promise.allSettled(repoList.map(async (repo) => {
try { try {
const [owner, repoName] = repo.split('/'); const [owner, repoName] = repo.split('/');
logger.info(`Fetching repository info for ${repo}...`); logger.info(`Fetching repository info for ${repo}...`);
@ -264,13 +321,28 @@ const getRepos = async (repoList: string[], octokit: Octokit, signal: AbortSigna
}); });
logger.info(`Found info for repository ${repo} in ${durationMs}ms`); logger.info(`Found info for repository ${repo} in ${durationMs}ms`);
return {
type: 'valid' as const,
data: [result.data]
};
return [result.data]; } catch (error) {
} catch (e) { if (isHttpError(error, 404)) {
logger.error(`Failed to fetch repository info for ${repo}.`, e); logger.error(`Repository ${repo} not found or no access`);
throw e; return {
type: 'notFound' as const,
value: repo
};
} }
}))).flat(); throw error;
}
}));
return repos; throwIfAnyFailed(results);
const { validItems: validRepos, notFoundItems: notFoundRepos } = processPromiseResults<OctokitRepository>(results);
return {
validRepos,
notFoundRepos,
};
} }

View file

@ -5,24 +5,35 @@ import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type"
import { getTokenFromConfig, measure, fetchWithRetry } from "./utils.js"; import { getTokenFromConfig, measure, fetchWithRetry } from "./utils.js";
import { PrismaClient } from "@sourcebot/db"; import { PrismaClient } from "@sourcebot/db";
import { FALLBACK_GITLAB_TOKEN } from "./environment.js"; import { FALLBACK_GITLAB_TOKEN } from "./environment.js";
import { processPromiseResults, throwIfAnyFailed } from "./connectionUtils.js";
const logger = createLogger("GitLab"); const logger = createLogger("GitLab");
export const GITLAB_CLOUD_HOSTNAME = "gitlab.com"; export const GITLAB_CLOUD_HOSTNAME = "gitlab.com";
export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, orgId: number, db: PrismaClient) => { export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, orgId: number, db: PrismaClient) => {
const token = config.token ? await getTokenFromConfig(config.token, orgId, db) : undefined; const tokenResult = config.token ? await getTokenFromConfig(config.token, orgId, db) : undefined;
const token = tokenResult?.token ?? FALLBACK_GITLAB_TOKEN;
const secretKey = tokenResult?.secretKey;
const api = new Gitlab({ const api = new Gitlab({
...(token ? { ...(token ? {
token, token,
} : { } : {}),
token: FALLBACK_GITLAB_TOKEN,
}),
...(config.url ? { ...(config.url ? {
host: config.url, host: config.url,
} : {}), } : {}),
}); });
const hostname = config.url ? new URL(config.url).hostname : GITLAB_CLOUD_HOSTNAME; const hostname = config.url ? new URL(config.url).hostname : GITLAB_CLOUD_HOSTNAME;
let allProjects: ProjectSchema[] = []; let allRepos: ProjectSchema[] = [];
let notFound: {
orgs: string[],
users: string[],
repos: string[],
} = {
orgs: [],
users: [],
repos: [],
};
if (config.all === true) { if (config.all === true) {
if (hostname !== GITLAB_CLOUD_HOSTNAME) { if (hostname !== GITLAB_CLOUD_HOSTNAME) {
@ -35,7 +46,7 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o
return fetchWithRetry(fetchFn, `all projects in ${config.url}`, logger); return fetchWithRetry(fetchFn, `all projects in ${config.url}`, logger);
}); });
logger.debug(`Found ${_projects.length} projects in ${durationMs}ms.`); logger.debug(`Found ${_projects.length} projects in ${durationMs}ms.`);
allProjects = allProjects.concat(_projects); allRepos = allRepos.concat(_projects);
} catch (e) { } catch (e) {
logger.error(`Failed to fetch all projects visible in ${config.url}.`, e); logger.error(`Failed to fetch all projects visible in ${config.url}.`, e);
throw e; throw e;
@ -46,7 +57,7 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o
} }
if (config.groups) { if (config.groups) {
const _projects = (await Promise.all(config.groups.map(async (group) => { const results = await Promise.allSettled(config.groups.map(async (group) => {
try { try {
logger.debug(`Fetching project info for group ${group}...`); logger.debug(`Fetching project info for group ${group}...`);
const { durationMs, data } = await measure(async () => { const { durationMs, data } = await measure(async () => {
@ -57,18 +68,31 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o
return fetchWithRetry(fetchFn, `group ${group}`, logger); return fetchWithRetry(fetchFn, `group ${group}`, logger);
}); });
logger.debug(`Found ${data.length} projects in group ${group} in ${durationMs}ms.`); logger.debug(`Found ${data.length} projects in group ${group} in ${durationMs}ms.`);
return data; return {
} catch (e) { type: 'valid' as const,
logger.error(`Failed to fetch project info for group ${group}.`, e); data
};
} catch (e: any) {
const status = e?.cause?.response?.status;
if (status === 404) {
logger.error(`Group ${group} not found or no access`);
return {
type: 'notFound' as const,
value: group
};
}
throw e; throw e;
} }
}))).flat(); }));
allProjects = allProjects.concat(_projects); throwIfAnyFailed(results);
const { validItems: validRepos, notFoundItems: notFoundOrgs } = processPromiseResults(results);
allRepos = allRepos.concat(validRepos);
notFound.orgs = notFoundOrgs;
} }
if (config.users) { if (config.users) {
const _projects = (await Promise.all(config.users.map(async (user) => { const results = await Promise.allSettled(config.users.map(async (user) => {
try { try {
logger.debug(`Fetching project info for user ${user}...`); logger.debug(`Fetching project info for user ${user}...`);
const { durationMs, data } = await measure(async () => { const { durationMs, data } = await measure(async () => {
@ -78,18 +102,31 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o
return fetchWithRetry(fetchFn, `user ${user}`, logger); return fetchWithRetry(fetchFn, `user ${user}`, logger);
}); });
logger.debug(`Found ${data.length} projects owned by user ${user} in ${durationMs}ms.`); logger.debug(`Found ${data.length} projects owned by user ${user} in ${durationMs}ms.`);
return data; return {
} catch (e) { type: 'valid' as const,
logger.error(`Failed to fetch project info for user ${user}.`, e); data
};
} catch (e: any) {
const status = e?.cause?.response?.status;
if (status === 404) {
logger.error(`User ${user} not found or no access`);
return {
type: 'notFound' as const,
value: user
};
}
throw e; throw e;
} }
}))).flat(); }));
allProjects = allProjects.concat(_projects); throwIfAnyFailed(results);
const { validItems: validRepos, notFoundItems: notFoundUsers } = processPromiseResults(results);
allRepos = allRepos.concat(validRepos);
notFound.users = notFoundUsers;
} }
if (config.projects) { if (config.projects) {
const _projects = (await Promise.all(config.projects.map(async (project) => { const results = await Promise.allSettled(config.projects.map(async (project) => {
try { try {
logger.debug(`Fetching project info for project ${project}...`); logger.debug(`Fetching project info for project ${project}...`);
const { durationMs, data } = await measure(async () => { const { durationMs, data } = await measure(async () => {
@ -97,17 +134,31 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o
return fetchWithRetry(fetchFn, `project ${project}`, logger); return fetchWithRetry(fetchFn, `project ${project}`, logger);
}); });
logger.debug(`Found project ${project} in ${durationMs}ms.`); logger.debug(`Found project ${project} in ${durationMs}ms.`);
return [data]; return {
} catch (e) { type: 'valid' as const,
logger.error(`Failed to fetch project info for project ${project}.`, e); data: [data]
};
} catch (e: any) {
const status = e?.cause?.response?.status;
if (status === 404) {
logger.error(`Project ${project} not found or no access`);
return {
type: 'notFound' as const,
value: project
};
}
throw e; throw e;
} }
}))).flat(); }));
allProjects = allProjects.concat(_projects); throwIfAnyFailed(results);
const { validItems: validRepos, notFoundItems: notFoundRepos } = processPromiseResults(results);
allRepos = allRepos.concat(validRepos);
notFound.repos = notFoundRepos;
} }
let repos = allProjects let repos = allRepos
.filter((project) => { .filter((project) => {
const isExcluded = shouldExcludeProject({ const isExcluded = shouldExcludeProject({
project, project,
@ -122,7 +173,10 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o
logger.debug(`Found ${repos.length} total repositories.`); logger.debug(`Found ${repos.length} total repositories.`);
return repos; return {
validRepos: repos,
notFound,
};
} }
export const shouldExcludeProject = ({ export const shouldExcludeProject = ({

View file

@ -15,12 +15,22 @@ export const compileGithubConfig = async (
connectionId: number, connectionId: number,
orgId: number, orgId: number,
db: PrismaClient, db: PrismaClient,
abortController: AbortController): Promise<RepoData[]> => { abortController: AbortController): Promise<{
const gitHubRepos = await getGitHubReposFromConfig(config, orgId, db, abortController.signal); repoData: RepoData[],
notFound: {
users: string[],
orgs: string[],
repos: string[],
}
}> => {
const gitHubReposResult = await getGitHubReposFromConfig(config, orgId, db, abortController.signal);
const gitHubRepos = gitHubReposResult.validRepos;
const notFound = gitHubReposResult.notFound;
const hostUrl = config.url ?? 'https://github.com'; const hostUrl = config.url ?? 'https://github.com';
const hostname = config.url ? new URL(config.url).hostname : 'github.com'; const hostname = config.url ? new URL(config.url).hostname : 'github.com';
return gitHubRepos.map((repo) => { const repos = gitHubRepos.map((repo) => {
const repoName = `${hostname}/${repo.full_name}`; const repoName = `${hostname}/${repo.full_name}`;
const cloneUrl = new URL(repo.clone_url!); const cloneUrl = new URL(repo.clone_url!);
@ -59,6 +69,11 @@ export const compileGithubConfig = async (
return record; return record;
}) })
return {
repoData: repos,
notFound,
};
} }
export const compileGitlabConfig = async ( export const compileGitlabConfig = async (
@ -67,10 +82,13 @@ export const compileGitlabConfig = async (
orgId: number, orgId: number,
db: PrismaClient) => { db: PrismaClient) => {
const gitlabRepos = await getGitLabReposFromConfig(config, orgId, db); const gitlabReposResult = await getGitLabReposFromConfig(config, orgId, db);
const gitlabRepos = gitlabReposResult.validRepos;
const notFound = gitlabReposResult.notFound;
const hostUrl = config.url ?? 'https://gitlab.com'; const hostUrl = config.url ?? 'https://gitlab.com';
return gitlabRepos.map((project) => { const repos = gitlabRepos.map((project) => {
const projectUrl = `${hostUrl}/${project.path_with_namespace}`; const projectUrl = `${hostUrl}/${project.path_with_namespace}`;
const cloneUrl = new URL(project.http_url_to_repo); const cloneUrl = new URL(project.http_url_to_repo);
const isFork = project.forked_from_project !== undefined; const isFork = project.forked_from_project !== undefined;
@ -108,6 +126,11 @@ export const compileGitlabConfig = async (
return record; return record;
}) })
return {
repoData: repos,
notFound,
};
} }
export const compileGiteaConfig = async ( export const compileGiteaConfig = async (
@ -116,10 +139,13 @@ export const compileGiteaConfig = async (
orgId: number, orgId: number,
db: PrismaClient) => { db: PrismaClient) => {
const giteaRepos = await getGiteaReposFromConfig(config, orgId, db); const giteaReposResult = await getGiteaReposFromConfig(config, orgId, db);
const giteaRepos = giteaReposResult.validRepos;
const notFound = giteaReposResult.notFound;
const hostUrl = config.url ?? 'https://gitea.com'; const hostUrl = config.url ?? 'https://gitea.com';
return giteaRepos.map((repo) => { const repos = giteaRepos.map((repo) => {
const cloneUrl = new URL(repo.clone_url!); const cloneUrl = new URL(repo.clone_url!);
const record: RepoData = { const record: RepoData = {
@ -153,6 +179,11 @@ export const compileGiteaConfig = async (
return record; return record;
}) })
return {
repoData: repos,
notFound,
};
} }
export const compileGerritConfig = async ( export const compileGerritConfig = async (
@ -164,7 +195,7 @@ export const compileGerritConfig = async (
const hostUrl = config.url ?? 'https://gerritcodereview.com'; const hostUrl = config.url ?? 'https://gerritcodereview.com';
const hostname = new URL(hostUrl).hostname; const hostname = new URL(hostUrl).hostname;
return gerritRepos.map((project) => { const repos = gerritRepos.map((project) => {
const repoId = `${hostname}/${project.name}`; const repoId = `${hostname}/${project.name}`;
const cloneUrl = new URL(`${config.url}/${encodeURIComponent(project.name)}`); const cloneUrl = new URL(`${config.url}/${encodeURIComponent(project.name)}`);
@ -207,4 +238,13 @@ export const compileGerritConfig = async (
return record; return record;
}) })
return {
repoData: repos,
notFound: {
users: [],
orgs: [],
repos: [],
}
};
} }

View file

@ -207,7 +207,8 @@ export class RepoManager implements IRepoManager {
const config = connection.config as unknown as GithubConnectionConfig | GitlabConnectionConfig | GiteaConnectionConfig; const config = connection.config as unknown as GithubConnectionConfig | GitlabConnectionConfig | GiteaConnectionConfig;
if (config.token) { if (config.token) {
token = await getTokenFromConfig(config.token, connection.orgId, db); const tokenResult = await getTokenFromConfig(config.token, connection.orgId, db);
token = tokenResult?.token;
if (token) { if (token) {
break; break;
} }

View file

@ -5,6 +5,7 @@ import micromatch from "micromatch";
import { PrismaClient, Repo } from "@sourcebot/db"; import { PrismaClient, Repo } from "@sourcebot/db";
import { decrypt } from "@sourcebot/crypto"; import { decrypt } from "@sourcebot/crypto";
import { Token } from "@sourcebot/schemas/v3/shared.type"; import { Token } from "@sourcebot/schemas/v3/shared.type";
import { BackendException, BackendError } from "@sourcebot/error";
export const measure = async <T>(cb: () => Promise<T>) => { export const measure = async <T>(cb: () => Promise<T>) => {
const start = Date.now(); const start = Date.now();
@ -90,7 +91,9 @@ export const excludeReposByTopic = <T extends Repository>(repos: T[], excludedRe
export const getTokenFromConfig = async (token: Token, orgId: number, db?: PrismaClient) => { export const getTokenFromConfig = async (token: Token, orgId: number, db?: PrismaClient) => {
if (!db) { if (!db) {
throw new Error(`Database connection required to retrieve secret`); throw new BackendException(BackendError.CONNECTION_SYNC_SYSTEM_ERROR, {
message: `No database connection provided.`,
});
} }
const secretKey = token.secret; const secretKey = token.secret;
@ -104,11 +107,16 @@ export const getTokenFromConfig = async (token: Token, orgId: number, db?: Prism
}); });
if (!secret) { if (!secret) {
throw new Error(`Secret with key ${secretKey} not found for org ${orgId}`); throw new BackendException(BackendError.CONNECTION_SYNC_SECRET_DNE, {
message: `Secret with key ${secretKey} not found for org ${orgId}`,
});
} }
const decryptedSecret = decrypt(secret.iv, secret.encryptedValue); const decryptedSecret = decrypt(secret.iv, secret.encryptedValue);
return decryptedSecret; return {
token: decryptedSecret,
secretKey,
};
} }
export const isRemotePath = (path: string) => { export const isRemotePath = (path: string) => {

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Connection" ADD COLUMN "syncStatusMetadata" JSONB;

View file

@ -67,6 +67,7 @@ model Connection {
syncedAt DateTime? syncedAt DateTime?
repos RepoToConnection[] repos RepoToConnection[]
syncStatus ConnectionSyncStatus @default(SYNC_NEEDED) syncStatus ConnectionSyncStatus @default(SYNC_NEEDED)
syncStatusMetadata Json?
// The type of connection (e.g., github, gitlab, etc.) // The type of connection (e.g., github, gitlab, etc.)
connectionType String connectionType String

View file

@ -0,0 +1,13 @@
{
"name": "@sourcebot/error",
"main": "dist/index.js",
"version": "0.1.0",
"scripts": {
"build": "tsc",
"postinstall": "yarn build"
},
"devDependencies": {
"@types/node": "^22.7.5",
"typescript": "^5.7.3"
}
}

View file

@ -0,0 +1,17 @@
export enum BackendError {
CONNECTION_SYNC_SECRET_DNE = 'CONNECTION_SYNC_SECRET_DNE',
CONNECTION_SYNC_INVALID_TOKEN = 'CONNECTION_SYNC_INVALID_TOKEN',
CONNECTION_SYNC_SYSTEM_ERROR = 'CONNECTION_SYNC_SYSTEM_ERROR',
CONNECTION_SYNC_FAILED_TO_FETCH_GERRIT_PROJECTS = 'CONNECTION_SYNC_FAILED_TO_FETCH_GERRIT_PROJECTS',
CONNECTION_SYNC_CONNECTION_NOT_FOUND = 'CONNECTION_SYNC_CONNECTION_NOT_FOUND',
}
export class BackendException extends Error {
constructor(
public readonly code: BackendError,
public readonly metadata: Record<string, unknown> = {}
) {
super(code);
this.name = 'BackendException';
}
}

View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES6",
"module": "CommonJS",
"lib": ["ES6"],
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"moduleResolution": "node",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"isolatedModules": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -12,7 +12,9 @@
}, },
"aliases": { "aliases": {
"components": "@/components", "components": "@/components",
"hooks": "@/components/hooks", "utils": "@/lib/utils",
"utils": "@/lib/utils" "ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
} }
} }

View file

@ -27,7 +27,11 @@ const nextConfig = {
{ {
protocol: 'https', protocol: 'https',
hostname: 'avatars.githubusercontent.com', hostname: 'avatars.githubusercontent.com',
} },
{
protocol: 'https',
hostname: 'gitlab.com',
},
] ]
} }

View file

@ -67,6 +67,7 @@
"@shopify/lang-jsonc": "^1.0.0", "@shopify/lang-jsonc": "^1.0.0",
"@sourcebot/crypto": "^0.1.0", "@sourcebot/crypto": "^0.1.0",
"@sourcebot/db": "^0.1.0", "@sourcebot/db": "^0.1.0",
"@sourcebot/error": "^0.1.0",
"@sourcebot/schemas": "^0.1.0", "@sourcebot/schemas": "^0.1.0",
"@ssddanbrown/codemirror-lang-twig": "^1.0.0", "@ssddanbrown/codemirror-lang-twig": "^1.0.0",
"@stripe/react-stripe-js": "^3.1.1", "@stripe/react-stripe-js": "^3.1.1",

View file

@ -14,7 +14,7 @@ import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema";
import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, GerritConnectionConfig, ConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, GerritConnectionConfig, ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
import { encrypt } from "@sourcebot/crypto" import { encrypt } from "@sourcebot/crypto"
import { getConnection, getLinkedRepos } from "./data/connection"; import { getConnection, getLinkedRepos } from "./data/connection";
import { ConnectionSyncStatus, Prisma, Invite, OrgRole, Connection, Repo, Org } from "@sourcebot/db"; import { ConnectionSyncStatus, Prisma, Invite, OrgRole, Connection, Repo, Org, RepoIndexingStatus } from "@sourcebot/db";
import { headers } from "next/headers" import { headers } from "next/headers"
import { getStripe } from "@/lib/stripe" import { getStripe } from "@/lib/stripe"
import { getUser } from "@/data/user"; import { getUser } from "@/data/user";
@ -22,6 +22,7 @@ import { Session } from "next-auth";
import { STRIPE_PRODUCT_ID, CONFIG_MAX_REPOS_NO_TOKEN } from "@/lib/environment"; import { STRIPE_PRODUCT_ID, CONFIG_MAX_REPOS_NO_TOKEN } from "@/lib/environment";
import { StripeSubscriptionStatus } from "@sourcebot/db"; import { StripeSubscriptionStatus } from "@sourcebot/db";
import Stripe from "stripe"; import Stripe from "stripe";
import { SyncStatusMetadataSchema, type NotFoundData } from "@/lib/syncStatusMetadataSchema";
const ajv = new Ajv({ const ajv = new Ajv({
validateFormats: false, validateFormats: false,
}); });
@ -179,6 +180,7 @@ export const getConnections = async (domain: string): Promise<
id: number, id: number,
name: string, name: string,
syncStatus: ConnectionSyncStatus, syncStatus: ConnectionSyncStatus,
syncStatusMetadata: Prisma.JsonValue,
connectionType: string, connectionType: string,
updatedAt: Date, updatedAt: Date,
syncedAt?: Date syncedAt?: Date
@ -196,6 +198,7 @@ export const getConnections = async (domain: string): Promise<
id: connection.id, id: connection.id,
name: connection.name, name: connection.name,
syncStatus: connection.syncStatus, syncStatus: connection.syncStatus,
syncStatusMetadata: connection.syncStatusMetadata,
connectionType: connection.connectionType, connectionType: connection.connectionType,
updatedAt: connection.updatedAt, updatedAt: connection.updatedAt,
syncedAt: connection.syncedAt ?? undefined, syncedAt: connection.syncedAt ?? undefined,
@ -203,6 +206,40 @@ export const getConnections = async (domain: string): Promise<
}) })
); );
export const getConnectionFailedRepos = async (connectionId: number, domain: string): Promise<{ repoId: number, repoName: string }[] | ServiceError> =>
withAuth((session) =>
withOrgMembership(session, domain, async ({ orgId }) => {
const connection = await getConnection(connectionId, orgId);
if (!connection) {
return notFound();
}
const linkedRepos = await getLinkedRepos(connectionId, orgId);
return linkedRepos.filter((repo) => repo.repo.repoIndexingStatus === RepoIndexingStatus.FAILED).map((repo) => ({
repoId: repo.repo.id,
repoName: repo.repo.name,
}));
})
);
export const getConnectionInProgressRepos = async (connectionId: number, domain: string): Promise<{ repoId: number, repoName: string }[] | ServiceError> =>
withAuth((session) =>
withOrgMembership(session, domain, async ({ orgId }) => {
const connection = await getConnection(connectionId, orgId);
if (!connection) {
return notFound();
}
const linkedRepos = await getLinkedRepos(connectionId, orgId);
return linkedRepos.filter((repo) => repo.repo.repoIndexingStatus === RepoIndexingStatus.IN_INDEX_QUEUE || repo.repo.repoIndexingStatus === RepoIndexingStatus.INDEXING).map((repo) => ({
repoId: repo.repo.id,
repoName: repo.repo.name,
}));
})
);
export const createConnection = async (name: string, type: string, connectionConfig: string, domain: string): Promise<{ id: number } | ServiceError> => export const createConnection = async (name: string, type: string, connectionConfig: string, domain: string): Promise<{ id: number } | ServiceError> =>
withAuth((session) => withAuth((session) =>
@ -331,14 +368,6 @@ export const flagConnectionForSync = async (connectionId: number, domain: string
return notFound(); return notFound();
} }
if (connection.syncStatus !== "FAILED") {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.CONNECTION_NOT_FAILED,
message: "Connection is not in a failed state. Cannot flag for sync.",
} satisfies ServiceError;
}
await prisma.connection.update({ await prisma.connection.update({
where: { where: {
id: connection.id, id: connection.id,
@ -353,6 +382,36 @@ export const flagConnectionForSync = async (connectionId: number, domain: string
} }
})); }));
export const flagRepoForIndex = async (repoId: number, domain: string): Promise<{ success: boolean } | ServiceError> =>
withAuth((session) =>
withOrgMembership(session, domain, async () => {
const repo = await prisma.repo.findUnique({
where: {
id: repoId,
},
});
if (!repo) {
return notFound();
}
await prisma.repo.update({
where: {
id: repoId,
},
data: {
repoIndexingStatus: RepoIndexingStatus.NEW,
}
});
return {
success: true,
}
})
);
export const deleteConnection = async (connectionId: number, domain: string): Promise<{ success: boolean } | ServiceError> => export const deleteConnection = async (connectionId: number, domain: string): Promise<{ success: boolean } | ServiceError> =>
withAuth((session) => withAuth((session) =>
withOrgMembership(session, domain, async ({ orgId }) => { withOrgMembership(session, domain, async ({ orgId }) => {

View file

@ -0,0 +1,160 @@
"use client";
import Link from "next/link";
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card";
import { CircleXIcon } from "lucide-react";
import { useDomain } from "@/hooks/useDomain";
import { getConnectionFailedRepos, getConnections } from "@/actions";
import { useState, useEffect } from "react";
import { isServiceError } from "@/lib/utils";
enum ConnectionErrorType {
SYNC_FAILED = "SYNC_FAILED",
REPO_INDEXING_FAILED = "REPO_INDEXING_FAILED",
}
interface Error {
connectionId?: number;
connectionName?: string;
errorType: ConnectionErrorType;
numRepos?: number;
}
export const ErrorNavIndicator = () => {
const domain = useDomain();
const [errors, setErrors] = useState<Error[]>([]);
useEffect(() => {
const fetchErrors = async () => {
const connections = await getConnections(domain);
const errors: Error[] = [];
if (!isServiceError(connections)) {
for (const connection of connections) {
if (connection.syncStatus === 'FAILED') {
errors.push({
connectionId: connection.id,
connectionName: connection.name,
errorType: ConnectionErrorType.SYNC_FAILED
});
}
const failedRepos = await getConnectionFailedRepos(connection.id, domain);
if (!isServiceError(failedRepos) && failedRepos.length > 0) {
errors.push({
connectionId: connection.id,
connectionName: connection.name,
numRepos: failedRepos.length,
errorType: ConnectionErrorType.REPO_INDEXING_FAILED
});
}
}
}
setErrors(prevErrors => {
// Only update if the errors have actually changed
const errorsChanged = prevErrors.length !== errors.length ||
prevErrors.some((error, idx) =>
error.connectionId !== errors[idx]?.connectionId ||
error.connectionName !== errors[idx]?.connectionName ||
error.errorType !== errors[idx]?.errorType
);
return errorsChanged ? errors : prevErrors;
});
};
fetchErrors();
const intervalId = setInterval(fetchErrors, 1000);
return () => clearInterval(intervalId);
}, [domain]);
if (errors.length === 0) return null;
return (
<Link href={`/${domain}/connections`}>
<HoverCard>
<HoverCardTrigger asChild>
<div className="flex items-center gap-2 px-3 py-1.5 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 rounded-full text-red-700 dark:text-red-400 text-xs font-medium hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors cursor-pointer">
<CircleXIcon className="h-4 w-4" />
{errors.reduce((acc, error) => acc + (error.numRepos || 0), 0) > 0 && (
<span>{errors.reduce((acc, error) => acc + (error.numRepos || 0), 0)}</span>
)}
</div>
</HoverCardTrigger>
<HoverCardContent className="w-80 border border-red-200 dark:border-red-800 rounded-lg">
<div className="flex flex-col gap-6 p-5">
{errors.filter(e => e.errorType === 'SYNC_FAILED').length > 0 && (
<div className="flex flex-col gap-4 border-b border-red-200 dark:border-red-800 pb-6">
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-red-500"></div>
<h3 className="text-sm font-medium text-red-700 dark:text-red-400">Connection Sync Issues</h3>
</div>
<p className="text-sm text-red-600/90 dark:text-red-300/90 leading-relaxed">
The following connections have failed to sync:
</p>
<div className="flex flex-col gap-2 pl-4">
{errors
.filter(e => e.errorType === 'SYNC_FAILED')
.slice(0, 10)
.map(error => (
<Link key={error.connectionName} href={`/${domain}/connections/${error.connectionId}`}>
<div className="flex items-center gap-2 px-3 py-2 bg-red-50 dark:bg-red-900/20
rounded-md text-sm text-red-700 dark:text-red-300
border border-red-200/50 dark:border-red-800/50
hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors">
<span className="font-medium">{error.connectionName}</span>
</div>
</Link>
))}
{errors.filter(e => e.errorType === 'SYNC_FAILED').length > 10 && (
<div className="text-sm text-red-600/90 dark:text-red-300/90 pl-3 pt-1">
And {errors.filter(e => e.errorType === 'SYNC_FAILED').length - 10} more...
</div>
)}
</div>
</div>
)}
{errors.filter(e => e.errorType === 'REPO_INDEXING_FAILED').length > 0 && (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-red-500"></div>
<h3 className="text-sm font-medium text-red-700 dark:text-red-400">Repository Indexing Issues</h3>
</div>
<p className="text-sm text-red-600/90 dark:text-red-300/90 leading-relaxed">
The following connections have repositories that failed to index:
</p>
<div className="flex flex-col gap-2 pl-4">
{errors
.filter(e => e.errorType === 'REPO_INDEXING_FAILED')
.slice(0, 10)
.map(error => (
<Link key={error.connectionName} href={`/${domain}/connections/${error.connectionId}`}>
<div className="flex items-center justify-between px-3 py-2
bg-red-50 dark:bg-red-900/20 rounded-md
border border-red-200/50 dark:border-red-800/50
hover:bg-red-100 dark:hover:bg-red-900/30
transition-colors">
<span className="text-sm font-medium text-red-700 dark:text-red-300">
{error.connectionName}
</span>
<span className="text-xs font-medium px-2.5 py-1 rounded-full
bg-red-100/80 dark:bg-red-800/60
text-red-600 dark:text-red-300">
{error.numRepos}
</span>
</div>
</Link>
))}
{errors.filter(e => e.errorType === 'REPO_INDEXING_FAILED').length > 10 && (
<div className="text-sm text-red-600/90 dark:text-red-300/90 pl-3 pt-1">
And {errors.filter(e => e.errorType === 'REPO_INDEXING_FAILED').length - 10} more...
</div>
)}
</div>
</div>
)}
</div>
</HoverCardContent>
</HoverCard>
</Link>
);
};

View file

@ -8,6 +8,9 @@ import { redirect } from "next/navigation";
import { OrgSelector } from "./orgSelector"; import { OrgSelector } from "./orgSelector";
import { getSubscriptionData } from "@/actions"; import { getSubscriptionData } from "@/actions";
import { isServiceError } from "@/lib/utils"; import { isServiceError } from "@/lib/utils";
import { ErrorNavIndicator } from "./errorNavIndicator";
import { WarningNavIndicator } from "./warningNavIndicator";
import { ProgressNavIndicator } from "./progressNavIndicator";
import { SourcebotLogo } from "@/app/components/sourcebotLogo"; import { SourcebotLogo } from "@/app/components/sourcebotLogo";
const SOURCEBOT_DISCORD_URL = "https://discord.gg/6Fhp27x7Pb"; const SOURCEBOT_DISCORD_URL = "https://discord.gg/6Fhp27x7Pb";
@ -83,10 +86,13 @@ export const NavigationMenu = async ({
</div> </div>
<div className="flex flex-row items-center gap-2"> <div className="flex flex-row items-center gap-2">
<ProgressNavIndicator />
<WarningNavIndicator />
<ErrorNavIndicator />
{!isServiceError(subscription) && subscription.status === "trialing" && ( {!isServiceError(subscription) && subscription.status === "trialing" && (
<Link href={`/${domain}/settings/billing`}> <Link href={`/${domain}/settings/billing`}>
<div className="flex items-center gap-2 px-3 py-1.5 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-full text-yellow-700 dark:text-yellow-400 text-xs font-medium hover:bg-yellow-100 dark:hover:bg-yellow-900/30 transition-colors cursor-pointer"> <div className="flex items-center gap-2 px-3 py-1.5 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-full text-blue-700 dark:text-blue-400 text-xs font-medium hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors cursor-pointer">
<span className="inline-block w-2 h-2 bg-yellow-400 dark:bg-yellow-500 rounded-full"></span> <span className="inline-block w-2 h-2 bg-blue-400 dark:bg-blue-500 rounded-full"></span>
<span> <span>
{Math.ceil((subscription.nextBillingDate * 1000 - Date.now()) / (1000 * 60 * 60 * 24))} days left in {Math.ceil((subscription.nextBillingDate * 1000 - Date.now()) / (1000 * 60 * 60 * 24))} days left in
trial trial

View file

@ -0,0 +1,97 @@
"use client";
import Link from "next/link";
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card";
import { Loader2Icon } from "lucide-react";
import { useEffect, useState } from "react";
import { useDomain } from "@/hooks/useDomain";
import { getConnectionInProgressRepos, getConnections } from "@/actions";
import { isServiceError } from "@/lib/utils";
interface InProgress {
connectionId: number;
repoId: number;
repoName: string;
}
export const ProgressNavIndicator = () => {
const domain = useDomain();
const [inProgressJobs, setInProgressJobs] = useState<InProgress[]>([]);
useEffect(() => {
const fetchInProgressJobs = async () => {
const connections = await getConnections(domain);
if (!isServiceError(connections)) {
const allInProgressRepos: InProgress[] = [];
for (const connection of connections) {
const inProgressRepos = await getConnectionInProgressRepos(connection.id, domain);
if (!isServiceError(inProgressRepos)) {
allInProgressRepos.push(...inProgressRepos.map(repo => ({
connectionId: connection.id,
...repo
})));
}
}
setInProgressJobs(prevJobs => {
// Only update if the jobs have actually changed
const jobsChanged = prevJobs.length !== allInProgressRepos.length ||
prevJobs.some((job, idx) =>
job.repoId !== allInProgressRepos[idx]?.repoId ||
job.repoName !== allInProgressRepos[idx]?.repoName
);
return jobsChanged ? allInProgressRepos : prevJobs;
});
}
};
fetchInProgressJobs();
const intervalId = setInterval(fetchInProgressJobs, 1000);
return () => clearInterval(intervalId);
}, [domain]);
if (inProgressJobs.length === 0) {
return null;
}
return (
<Link href={`/${domain}/connections`}>
<HoverCard>
<HoverCardTrigger asChild>
<div className="flex items-center gap-2 px-3 py-1.5 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 rounded-full text-green-700 dark:text-green-400 text-xs font-medium hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors cursor-pointer">
<Loader2Icon className="h-4 w-4 animate-spin" />
<span>{inProgressJobs.length}</span>
</div>
</HoverCardTrigger>
<HoverCardContent className="w-80 border border-green-200 dark:border-green-800 rounded-lg">
<div className="flex flex-col gap-4 p-5">
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-green-500 animate-pulse"></div>
<h3 className="text-sm font-medium text-green-700 dark:text-green-400">Indexing in Progress</h3>
</div>
<p className="text-sm text-green-600/90 dark:text-green-300/90 leading-relaxed">
The following repositories are currently being indexed:
</p>
<div className="flex flex-col gap-2 pl-4">
{inProgressJobs.slice(0, 10).map(item => (
<Link key={item.repoId} href={`/${domain}/connections/${item.connectionId}`}>
<div className="flex items-center gap-2 px-3 py-2 bg-green-50 dark:bg-green-900/20
rounded-md text-sm text-green-700 dark:text-green-300
border border-green-200/50 dark:border-green-800/50
hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors">
<span className="font-medium truncate">{item.repoName}</span>
</div>
</Link>
))}
{inProgressJobs.length > 10 && (
<div className="text-sm text-green-600/90 dark:text-green-300/90 pl-3 pt-1">
And {inProgressJobs.length - 10} more...
</div>
)}
</div>
</div>
</HoverCardContent>
</HoverCard>
</Link>
);
};

View file

@ -0,0 +1,97 @@
"use client";
import Link from "next/link";
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card";
import { AlertTriangleIcon } from "lucide-react";
import { useDomain } from "@/hooks/useDomain";
import { getConnections } from "@/actions";
import { useState } from "react";
import { useEffect } from "react";
import { isServiceError } from "@/lib/utils";
import { SyncStatusMetadataSchema } from "@/lib/syncStatusMetadataSchema";
interface Warning {
connectionId?: number;
connectionName?: string;
}
export const WarningNavIndicator = () => {
const domain = useDomain();
const [warnings, setWarnings] = useState<Warning[]>([]);
useEffect(() => {
const fetchWarnings = async () => {
const connections = await getConnections(domain);
const warnings: Warning[] = [];
if (!isServiceError(connections)) {
for (const connection of connections) {
const parseResult = SyncStatusMetadataSchema.safeParse(connection.syncStatusMetadata);
if (parseResult.success && parseResult.data.notFound) {
const { notFound } = parseResult.data;
if (notFound.users.length > 0 || notFound.orgs.length > 0 || notFound.repos.length > 0) {
warnings.push({ connectionId: connection.id, connectionName: connection.name });
}
}
}
}
setWarnings(prevWarnings => {
// Only update if the warnings have actually changed
const warningsChanged = prevWarnings.length !== warnings.length ||
prevWarnings.some((warning, idx) =>
warning.connectionId !== warnings[idx]?.connectionId ||
warning.connectionName !== warnings[idx]?.connectionName
);
return warningsChanged ? warnings : prevWarnings;
});
};
fetchWarnings();
const intervalId = setInterval(fetchWarnings, 1000);
return () => clearInterval(intervalId);
}, [domain]);
if (warnings.length === 0) {
return null;
}
return (
<Link href={`/${domain}/connections`}>
<HoverCard>
<HoverCardTrigger asChild>
<div className="flex items-center gap-2 px-3 py-1.5 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-full text-yellow-700 dark:text-yellow-400 text-xs font-medium hover:bg-yellow-100 dark:hover:bg-yellow-900/30 transition-colors cursor-pointer">
<AlertTriangleIcon className="h-4 w-4" />
<span>{warnings.length}</span>
</div>
</HoverCardTrigger>
<HoverCardContent className="w-80 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<div className="flex flex-col gap-4 p-5">
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-yellow-500"></div>
<h3 className="text-sm font-medium text-yellow-700 dark:text-yellow-400">Missing References</h3>
</div>
<p className="text-sm text-yellow-600/90 dark:text-yellow-300/90 leading-relaxed">
The following connections have references that could not be found:
</p>
<div className="flex flex-col gap-2 pl-4">
{warnings.slice(0, 10).map(warning => (
<Link key={warning.connectionName} href={`/${domain}/connections/${warning.connectionId}`}>
<div className="flex items-center gap-2 px-3 py-2 bg-yellow-50 dark:bg-yellow-900/20
rounded-md text-sm text-yellow-700 dark:text-yellow-300
border border-yellow-200/50 dark:border-yellow-800/50
hover:bg-yellow-100 dark:hover:bg-yellow-900/30 transition-colors">
<span className="font-medium">{warning.connectionName}</span>
</div>
</Link>
))}
{warnings.length > 10 && (
<div className="text-sm text-yellow-600/90 dark:text-yellow-300/90 pl-3 pt-1">
And {warnings.length - 10} more...
</div>
)}
</div>
</div>
</HoverCardContent>
</HoverCard>
</Link>
);
};

View file

@ -0,0 +1,110 @@
"use client"
import { BackendError } from "@sourcebot/error";
import { Prisma } from "@sourcebot/db";
export function DisplayConnectionError({ syncStatusMetadata, onSecretsClick }: { syncStatusMetadata: Prisma.JsonValue, onSecretsClick: () => void }) {
const errorCode = syncStatusMetadata && typeof syncStatusMetadata === 'object' && 'error' in syncStatusMetadata
? (syncStatusMetadata.error as string)
: undefined;
switch (errorCode) {
case BackendError.CONNECTION_SYNC_INVALID_TOKEN:
return <InvalidTokenError syncStatusMetadata={syncStatusMetadata} onSecretsClick={onSecretsClick} />
case BackendError.CONNECTION_SYNC_SECRET_DNE:
return <SecretNotFoundError syncStatusMetadata={syncStatusMetadata} onSecretsClick={onSecretsClick} />
case BackendError.CONNECTION_SYNC_SYSTEM_ERROR:
return <SystemError />
case BackendError.CONNECTION_SYNC_FAILED_TO_FETCH_GERRIT_PROJECTS:
return <FailedToFetchGerritProjects syncStatusMetadata={syncStatusMetadata} />
default:
return <UnknownError />
}
}
function SecretNotFoundError({ syncStatusMetadata, onSecretsClick }: { syncStatusMetadata: Prisma.JsonValue, onSecretsClick: () => void }) {
const secretKey = syncStatusMetadata && typeof syncStatusMetadata === 'object' && 'secretKey' in syncStatusMetadata
? (syncStatusMetadata.secretKey as string)
: undefined;
return (
<div className="space-y-1">
<h4 className="text-sm font-semibold">Secret Not Found</h4>
<p className="text-sm text-muted-foreground">
The secret key provided for this connection was not found. Please ensure your config is referencing a secret
that exists in your{" "}
<button onClick={onSecretsClick} className="text-primary hover:underline">
organization&apos;s secrets
</button>
, and try again.
</p>
{secretKey && (
<p className="text-sm text-muted-foreground">
Secret Key: <span className="text-red-500">{secretKey}</span>
</p>
)}
</div>
);
}
function InvalidTokenError({ syncStatusMetadata, onSecretsClick }: { syncStatusMetadata: Prisma.JsonValue, onSecretsClick: () => void }) {
const secretKey = syncStatusMetadata && typeof syncStatusMetadata === 'object' && 'secretKey' in syncStatusMetadata
? (syncStatusMetadata.secretKey as string)
: undefined;
return (
<div className="space-y-1">
<h4 className="text-sm font-semibold">Invalid Authentication Token</h4>
<p className="text-sm text-muted-foreground">
The authentication token provided for this connection is invalid. Please update your config with a valid token and try again.
</p>
{secretKey && (
<p className="text-sm text-muted-foreground">
Secret Key: <button onClick={onSecretsClick} className="text-red-500 hover:underline">{secretKey}</button>
</p>
)}
</div>
);
}
function SystemError() {
return (
<div className="space-y-1">
<h4 className="text-sm font-semibold">System Error</h4>
<p className="text-sm text-muted-foreground">
An error occurred while syncing this connection. Please try again later.
</p>
</div>
)
}
function FailedToFetchGerritProjects({ syncStatusMetadata }: { syncStatusMetadata: Prisma.JsonValue}) {
const status = syncStatusMetadata && typeof syncStatusMetadata === 'object' && 'status' in syncStatusMetadata
? (syncStatusMetadata.status as number)
: undefined;
return (
<div className="space-y-1">
<h4 className="text-sm font-semibold">Failed to Fetch Gerrit Projects</h4>
<p className="text-sm text-muted-foreground">
An error occurred while syncing this connection. Please try again later.
</p>
{status && (
<p className="text-sm text-muted-foreground">
Status: <span className="text-red-500">{status}</span>
</p>
)}
</div>
)
}
function UnknownError() {
return (
<div className="space-y-1">
<h4 className="text-sm font-semibold">Unknown Error</h4>
<p className="text-sm text-muted-foreground">
An unknown error occurred while syncing this connection. Please try again later.
</p>
</div>
)
}

View file

@ -0,0 +1,64 @@
import { AlertTriangle } from "lucide-react"
import { Prisma } from "@sourcebot/db"
import { RetrySyncButton } from "./retrySyncButton"
import { SyncStatusMetadataSchema } from "@/lib/syncStatusMetadataSchema"
interface NotFoundWarningProps {
syncStatusMetadata: Prisma.JsonValue
onSecretsClick: () => void
connectionId: number
domain: string
connectionType: string
}
export const NotFoundWarning = ({ syncStatusMetadata, onSecretsClick, connectionId, domain, connectionType }: NotFoundWarningProps) => {
const parseResult = SyncStatusMetadataSchema.safeParse(syncStatusMetadata);
if (!parseResult.success || !parseResult.data.notFound) {
return null;
}
const { notFound } = parseResult.data;
if (notFound.users.length === 0 && notFound.orgs.length === 0 && notFound.repos.length === 0) {
return null;
}
return (
<div className="flex flex-col items-start gap-4 border border-yellow-200 dark:border-yellow-800 bg-yellow-50 dark:bg-yellow-900/20 px-5 py-5 text-yellow-700 dark:text-yellow-400 rounded-lg">
<div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 flex-shrink-0" />
<h3 className="font-semibold">Unable to fetch all references</h3>
</div>
<p className="text-sm text-yellow-600/90 dark:text-yellow-300/90 leading-relaxed">
Some requested references couldn&apos;t be found. Please ensure you&apos;ve provided the information listed below correctly, and that you&apos;ve provided a{" "}
<button onClick={onSecretsClick} className="text-yellow-500 dark:text-yellow-400 font-bold hover:underline">
valid token
</button>{" "}
to access them if they&apos;re private.
</p>
<ul className="w-full space-y-2 text-sm">
{notFound.users.length > 0 && (
<li className="flex items-center gap-2 px-3 py-2 bg-yellow-100/50 dark:bg-yellow-900/30 rounded-md border border-yellow-200/50 dark:border-yellow-800/50">
<span className="font-medium">Users:</span>
<span className="text-yellow-600 dark:text-yellow-300">{notFound.users.join(', ')}</span>
</li>
)}
{notFound.orgs.length > 0 && (
<li className="flex items-center gap-2 px-3 py-2 bg-yellow-100/50 dark:bg-yellow-900/30 rounded-md border border-yellow-200/50 dark:border-yellow-800/50">
<span className="font-medium">{connectionType === "gitlab" ? "Groups" : "Organizations"}:</span>
<span className="text-yellow-600 dark:text-yellow-300">{notFound.orgs.join(', ')}</span>
</li>
)}
{notFound.repos.length > 0 && (
<li className="flex items-center gap-2 px-3 py-2 bg-yellow-100/50 dark:bg-yellow-900/30 rounded-md border border-yellow-200/50 dark:border-yellow-800/50">
<span className="font-medium">{connectionType === "gitlab" ? "Projects" : "Repositories"}:</span>
<span className="text-yellow-600 dark:text-yellow-300">{notFound.repos.join(', ')}</span>
</li>
)}
</ul>
<div className="w-full flex justify-center">
<RetrySyncButton connectionId={connectionId} domain={domain} />
</div>
</div>
)
}

View file

@ -3,6 +3,7 @@ import Image from "next/image";
import { StatusIcon } from "../../components/statusIcon"; import { StatusIcon } from "../../components/statusIcon";
import { RepoIndexingStatus } from "@sourcebot/db"; import { RepoIndexingStatus } from "@sourcebot/db";
import { useMemo } from "react"; import { useMemo } from "react";
import { RetryRepoIndexButton } from "./repoRetryIndexButton";
interface RepoListItemProps { interface RepoListItemProps {
@ -10,6 +11,8 @@ interface RepoListItemProps {
status: RepoIndexingStatus; status: RepoIndexingStatus;
imageUrl?: string; imageUrl?: string;
indexedAt?: Date; indexedAt?: Date;
repoId: number;
domain: string;
} }
export const RepoListItem = ({ export const RepoListItem = ({
@ -17,6 +20,8 @@ export const RepoListItem = ({
name, name,
indexedAt, indexedAt,
status, status,
repoId,
domain,
}: RepoListItemProps) => { }: RepoListItemProps) => {
const statusDisplayName = useMemo(() => { const statusDisplayName = useMemo(() => {
switch (status) { switch (status) {
@ -47,7 +52,11 @@ export const RepoListItem = ({
/> />
<p className="font-medium">{name}</p> <p className="font-medium">{name}</p>
</div> </div>
<div className="flex flex-row items-center"> <div className="flex flex-row items-center gap-4">
{status === RepoIndexingStatus.FAILED && (
<RetryRepoIndexButton repoId={repoId} domain={domain} />
)}
<div className="flex flex-row items-center gap-0">
<StatusIcon <StatusIcon
status={convertIndexingStatus(status)} status={convertIndexingStatus(status)}
className="w-4 h-4 mr-1" className="w-4 h-4 mr-1"
@ -65,6 +74,7 @@ export const RepoListItem = ({
</p> </p>
</div> </div>
</div> </div>
</div>
) )
} }

View file

@ -0,0 +1,37 @@
"use client";
import { Button } from "@/components/ui/button";
import { ReloadIcon } from "@radix-ui/react-icons"
import { toast } from "@/components/hooks/use-toast";
import { flagRepoForIndex } from "@/actions";
import { isServiceError } from "@/lib/utils";
interface RetryRepoIndexButtonProps {
repoId: number;
domain: string;
}
export const RetryRepoIndexButton = ({ repoId, domain }: RetryRepoIndexButtonProps) => {
return (
<Button
variant="outline"
size="sm"
className="ml-2"
onClick={async () => {
const result = await flagRepoForIndex(repoId, domain);
if (isServiceError(result)) {
toast({
description: `❌ Failed to flag repository for indexing.`,
});
} else {
toast({
description: "✅ Repository flagged for indexing.",
});
}
}}
>
<ReloadIcon className="h-4 w-4 mr-2" />
Retry Index
</Button>
);
};

View file

@ -0,0 +1,60 @@
"use client";
import { Button } from "@/components/ui/button";
import { ReloadIcon } from "@radix-ui/react-icons"
import { toast } from "@/components/hooks/use-toast";
import { flagRepoForIndex, getConnectionFailedRepos } from "@/actions";
import { isServiceError } from "@/lib/utils";
interface RetryAllFailedReposButtonProps {
connectionId: number;
domain: string;
}
export const RetryAllFailedReposButton = ({ connectionId, domain }: RetryAllFailedReposButtonProps) => {
return (
<Button
variant="outline"
size="sm"
className="ml-2"
onClick={async () => {
const failedRepos = await getConnectionFailedRepos(connectionId, domain);
if (isServiceError(failedRepos)) {
toast({
description: `❌ Failed to get failed repositories.`,
});
return;
}
let successCount = 0;
let failureCount = 0;
for (const repo of failedRepos) {
const result = await flagRepoForIndex(repo.repoId, domain);
if (isServiceError(result)) {
failureCount++;
} else {
successCount++;
}
}
if (failureCount > 0) {
toast({
description: `⚠️ ${successCount} repositories flagged for indexing, ${failureCount} failed.`,
});
} else if (successCount > 0) {
toast({
description: `${successCount} repositories flagged for indexing.`,
});
} else {
toast({
description: " No failed repositories to retry.",
});
}
}}
>
<ReloadIcon className="h-4 w-4 mr-2" />
Retry All Failed
</Button>
);
};

View file

@ -0,0 +1,37 @@
"use client";
import { Button } from "@/components/ui/button";
import { ReloadIcon } from "@radix-ui/react-icons"
import { toast } from "@/components/hooks/use-toast";
import { flagConnectionForSync } from "@/actions";
import { isServiceError } from "@/lib/utils";
interface RetrySyncButtonProps {
connectionId: number;
domain: string;
}
export const RetrySyncButton = ({ connectionId, domain }: RetrySyncButtonProps) => {
return (
<Button
variant="outline"
size="sm"
className="ml-2"
onClick={async () => {
const result = await flagConnectionForSync(connectionId, domain);
if (isServiceError(result)) {
toast({
description: `❌ Failed to flag connection for sync.`,
});
} else {
toast({
description: "✅ Connection flagged for sync.",
});
}
}}
>
<ReloadIcon className="h-4 w-4 mr-2" />
Retry Sync
</Button>
);
};

View file

@ -1,6 +1,6 @@
"use client"; "use client"
import { NotFound } from "@/app/[domain]/components/notFound"; import { NotFound } from "@/app/[domain]/components/notFound"
import { import {
Breadcrumb, Breadcrumb,
BreadcrumbItem, BreadcrumbItem,
@ -8,122 +8,125 @@ import {
BreadcrumbList, BreadcrumbList,
BreadcrumbPage, BreadcrumbPage,
BreadcrumbSeparator, BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"; } from "@/components/ui/breadcrumb"
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area"
import { TabSwitcher } from "@/components/ui/tab-switcher"; import { TabSwitcher } from "@/components/ui/tab-switcher"
import { Tabs, TabsContent } from "@/components/ui/tabs"; import { Tabs, TabsContent } from "@/components/ui/tabs"
import { ConnectionIcon } from "../components/connectionIcon"; import { ConnectionIcon } from "../components/connectionIcon"
import { Header } from "../../components/header"; import { Header } from "../../components/header"
import { ConfigSetting } from "./components/configSetting"; import { ConfigSetting } from "./components/configSetting"
import { DeleteConnectionSetting } from "./components/deleteConnectionSetting"; import { DeleteConnectionSetting } from "./components/deleteConnectionSetting"
import { DisplayNameSetting } from "./components/displayNameSetting"; import { DisplayNameSetting } from "./components/displayNameSetting"
import { RepoListItem } from "./components/repoListItem"; import { RepoListItem } from "./components/repoListItem"
import { useParams, useSearchParams } from "next/navigation"; import { useParams, useSearchParams, useRouter } from "next/navigation"
import { useEffect, useState } from "react"; import { useEffect, useState } from "react"
import { Connection, Repo, Org } from "@sourcebot/db"; import type { Connection, Repo, Org } from "@sourcebot/db"
import { getConnectionInfoAction, getOrgFromDomainAction, flagConnectionForSync } from "@/actions"; import { getConnectionInfoAction, getOrgFromDomainAction } from "@/actions"
import { isServiceError } from "@/lib/utils"; import { isServiceError } from "@/lib/utils"
import { Button } from "@/components/ui/button"; import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
import { ReloadIcon } from "@radix-ui/react-icons"; import { DisplayConnectionError } from "./components/connectionError"
import { useToast } from "@/components/hooks/use-toast"; import { NotFoundWarning } from "./components/notFoundWarning"
import { RetrySyncButton } from "./components/retrySyncButton"
import { RetryAllFailedReposButton } from "./components/retryAllFailedReposButton"
export default function ConnectionManagementPage() { export default function ConnectionManagementPage() {
const params = useParams(); const params = useParams()
const searchParams = useSearchParams(); const searchParams = useSearchParams()
const { toast } = useToast(); const router = useRouter()
const [org, setOrg] = useState<Org | null>(null); const [org, setOrg] = useState<Org | null>(null)
const [connection, setConnection] = useState<Connection | null>(null); const [connection, setConnection] = useState<Connection | null>(null)
const [linkedRepos, setLinkedRepos] = useState<Repo[]>([]); const [linkedRepos, setLinkedRepos] = useState<Repo[]>([])
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null)
const handleSecretsNavigation = () => {
router.push(`/${params.domain}/secrets`)
}
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
try { try {
const orgResult = await getOrgFromDomainAction(params.domain as string); const orgResult = await getOrgFromDomainAction(params.domain as string)
if (isServiceError(orgResult)) { if (isServiceError(orgResult)) {
setError(orgResult.message); setError(orgResult.message)
setLoading(false); setLoading(false)
return; return
} }
setOrg(orgResult); setOrg(orgResult)
const connectionId = Number(params.id); const connectionId = Number(params.id)
if (isNaN(connectionId)) { if (isNaN(connectionId)) {
setError("Invalid connection ID"); setError("Invalid connection ID")
setLoading(false); setLoading(false)
return; return
} }
const connectionInfoResult = await getConnectionInfoAction(connectionId, params.domain as string); const connectionInfoResult = await getConnectionInfoAction(connectionId, params.domain as string)
if (isServiceError(connectionInfoResult)) { if (isServiceError(connectionInfoResult)) {
setError(connectionInfoResult.message); setError(connectionInfoResult.message)
setLoading(false); setLoading(false)
return; return
} }
connectionInfoResult.linkedRepos.sort((a, b) => { connectionInfoResult.linkedRepos.sort((a, b) => {
// Helper function to get priority of indexing status // Helper function to get priority of indexing status
const getPriority = (status: string) => { const getPriority = (status: string) => {
switch (status) { switch (status) {
case 'FAILED': return 0; case "FAILED":
case 'IN_INDEX_QUEUE': return 0
case 'INDEXING': return 1; case "IN_INDEX_QUEUE":
case 'INDEXED': return 2; case "INDEXING":
default: return 3; return 1
case "INDEXED":
return 2
default:
return 3
}
} }
};
const priorityA = getPriority(a.repoIndexingStatus); const priorityA = getPriority(a.repoIndexingStatus)
const priorityB = getPriority(b.repoIndexingStatus); const priorityB = getPriority(b.repoIndexingStatus)
// First sort by priority // First sort by priority
if (priorityA !== priorityB) { if (priorityA !== priorityB) {
return priorityA - priorityB; return priorityA - priorityB
} }
// If same priority, sort by createdAt // If same priority, sort by createdAt
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); return new Date(a.indexedAt ?? new Date()).getTime() - new Date(b.indexedAt ?? new Date()).getTime()
}); })
setConnection(connectionInfoResult.connection); setConnection(connectionInfoResult.connection)
setLinkedRepos(connectionInfoResult.linkedRepos); setLinkedRepos(connectionInfoResult.linkedRepos)
setLoading(false); setLoading(false)
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "An error occurred while loading the connection. If the problem persists, please contact us at team@sourcebot.dev"); setError(
setLoading(false); err instanceof Error
? err.message
: "An error occurred while loading the connection. If the problem persists, please contact us at team@sourcebot.dev",
)
setLoading(false)
}
} }
};
loadData(); loadData()
const intervalId = setInterval(loadData, 1000); const intervalId = setInterval(loadData, 1000)
return () => clearInterval(intervalId); return () => clearInterval(intervalId)
}, [params.domain, params.id]); }, [params.domain, params.id])
if (loading) { if (loading) {
return <div>Loading...</div>; return <div>Loading...</div>
} }
if (error || !org || !connection) { if (error || !org || !connection) {
return ( return <NotFound className="flex w-full h-full items-center justify-center" message={error || "Not found"} />
<NotFound
className="flex w-full h-full items-center justify-center"
message={error || "Not found"}
/>
);
} }
const currentTab = searchParams.get("tab") || "overview"; const currentTab = searchParams.get("tab") || "overview"
return ( return (
<Tabs <Tabs value={currentTab} className="w-full">
value={currentTab} <Header className="mb-6" withTopMargin={false}>
className="w-full"
>
<Header
className="mb-6"
withTopMargin={false}
>
<Breadcrumb> <Breadcrumb>
<BreadcrumbList> <BreadcrumbList>
<BreadcrumbItem> <BreadcrumbItem>
@ -135,10 +138,8 @@ export default function ConnectionManagementPage() {
</BreadcrumbItem> </BreadcrumbItem>
</BreadcrumbList> </BreadcrumbList>
</Breadcrumb> </Breadcrumb>
<div className="mt-6 flex flex-row items-center gap-4"> <div className="mt-6 flex flex-row items-center gap-4 w-full">
<ConnectionIcon <ConnectionIcon type={connection.connectionType} />
type={connection.connectionType}
/>
<h1 className="text-lg font-semibold">{connection.name}</h1> <h1 className="text-lg font-semibold">{connection.name}</h1>
</div> </div>
<TabSwitcher <TabSwitcher
@ -160,7 +161,9 @@ export default function ConnectionManagementPage() {
</div> </div>
<div className="rounded-lg border border-border p-4 bg-background"> <div className="rounded-lg border border-border p-4 bg-background">
<h2 className="text-sm font-medium text-muted-foreground">Last Synced At</h2> <h2 className="text-sm font-medium text-muted-foreground">Last Synced At</h2>
<p className="mt-2 text-sm">{connection.syncedAt ? new Date(connection.syncedAt).toLocaleDateString() : 'never'}</p> <p className="mt-2 text-sm">
{connection.syncedAt ? new Date(connection.syncedAt).toLocaleDateString() : "never"}
</p>
</div> </div>
<div className="rounded-lg border border-border p-4 bg-background"> <div className="rounded-lg border border-border p-4 bg-background">
<h2 className="text-sm font-medium text-muted-foreground">Linked Repositories</h2> <h2 className="text-sm font-medium text-muted-foreground">Linked Repositories</h2>
@ -168,74 +171,65 @@ export default function ConnectionManagementPage() {
</div> </div>
<div className="rounded-lg border border-border p-4 bg-background"> <div className="rounded-lg border border-border p-4 bg-background">
<h2 className="text-sm font-medium text-muted-foreground">Status</h2> <h2 className="text-sm font-medium text-muted-foreground">Status</h2>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 mt-2">
<p className="mt-2 text-sm">{connection.syncStatus}</p> {connection.syncStatus === "FAILED" ? (
<HoverCard openDelay={50}>
<HoverCardTrigger asChild>
<div className="flex items-center">
<span className="inline-flex items-center rounded-full bg-red-50 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-600/20 cursor-help hover:text-red-600 hover:bg-red-100 transition-colors duration-200">
{connection.syncStatus}
</span>
</div>
</HoverCardTrigger>
<HoverCardContent className="w-80">
<DisplayConnectionError
syncStatusMetadata={connection.syncStatusMetadata}
onSecretsClick={handleSecretsNavigation}
/>
</HoverCardContent>
</HoverCard>
) : (
<span className="inline-flex items-center rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">
{connection.syncStatus}
</span>
)}
{connection.syncStatus === "FAILED" && ( {connection.syncStatus === "FAILED" && (
<Button <RetrySyncButton connectionId={connection.id} domain={params.domain as string} />
variant="outline"
size="sm"
className="mt-2 rounded-full"
onClick={async () => {
const result = await flagConnectionForSync(connection.id, params.domain as string);
if (isServiceError(result)) {
toast({
description: `❌ Failed to flag connection for sync. Reason: ${result.message}`,
})
} else {
toast({
description: "✅ Connection flagged for sync.",
})
}
}}
>
<ReloadIcon className="h-4 w-4" />
</Button>
)} )}
</div> </div>
</div> </div>
</div> </div>
<NotFoundWarning syncStatusMetadata={connection.syncStatusMetadata} onSecretsClick={handleSecretsNavigation} connectionId={connection.id} connectionType={connection.connectionType} domain={params.domain as string} />
</div> </div>
<h1 className="font-semibold text-lg mt-8">Linked Repositories</h1> <div className="flex justify-between items-center mt-8">
<ScrollArea <h1 className="font-semibold text-lg">Linked Repositories</h1>
className="mt-4 max-h-96 overflow-scroll" <RetryAllFailedReposButton connectionId={connection.id} domain={params.domain as string} />
> </div>
<ScrollArea className="mt-4 max-h-96 overflow-scroll">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{linkedRepos {linkedRepos.map((repo) => (
.sort((a, b) => {
const aIndexedAt = a.indexedAt ?? new Date();
const bIndexedAt = b.indexedAt ?? new Date();
return bIndexedAt.getTime() - aIndexedAt.getTime();
})
.map((repo) => (
<RepoListItem <RepoListItem
key={repo.id} key={repo.id}
imageUrl={repo.imageUrl ?? undefined} imageUrl={repo.imageUrl ?? undefined}
name={repo.name} name={repo.name}
indexedAt={repo.indexedAt ?? undefined} indexedAt={repo.indexedAt ?? undefined}
status={repo.repoIndexingStatus} status={repo.repoIndexingStatus}
repoId={repo.id}
domain={params.domain as string}
/> />
))} ))}
</div> </div>
</ScrollArea> </ScrollArea>
</TabsContent> </TabsContent>
<TabsContent <TabsContent value="settings" className="flex flex-col gap-6">
value="settings" <DisplayNameSetting connectionId={connection.id} name={connection.name} />
className="flex flex-col gap-6"
>
<DisplayNameSetting
connectionId={connection.id}
name={connection.name}
/>
<ConfigSetting <ConfigSetting
connectionId={connection.id} connectionId={connection.id}
type={connection.connectionType} type={connection.connectionType}
config={JSON.stringify(connection.config, null, 2)} config={JSON.stringify(connection.config, null, 2)}
/> />
<DeleteConnectionSetting <DeleteConnectionSetting connectionId={connection.id} />
connectionId={connection.id}
/>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
); )
} }

View file

@ -1,11 +1,11 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { getDisplayTime } from "@/lib/utils"; import { getDisplayTime } from "@/lib/utils";
import Link from "next/link";
import { useMemo } from "react"; import { useMemo } from "react";
import { ConnectionIcon } from "../connectionIcon"; import { ConnectionIcon } from "../connectionIcon";
import { ConnectionSyncStatus } from "@sourcebot/db"; import { ConnectionSyncStatus, Prisma } from "@sourcebot/db";
import { StatusIcon } from "../statusIcon"; import { StatusIcon } from "../statusIcon";
import { AlertTriangle, CircleX} from "lucide-react";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
const convertSyncStatus = (status: ConnectionSyncStatus) => { const convertSyncStatus = (status: ConnectionSyncStatus) => {
switch (status) { switch (status) {
@ -26,8 +26,10 @@ interface ConnectionListItemProps {
name: string; name: string;
type: string; type: string;
status: ConnectionSyncStatus; status: ConnectionSyncStatus;
syncStatusMetadata: Prisma.JsonValue;
editedAt: Date; editedAt: Date;
syncedAt?: Date; syncedAt?: Date;
failedRepos?: { repoId: number, repoName: string }[];
} }
export const ConnectionListItem = ({ export const ConnectionListItem = ({
@ -35,8 +37,10 @@ export const ConnectionListItem = ({
name, name,
type, type,
status, status,
syncStatusMetadata,
editedAt, editedAt,
syncedAt, syncedAt,
failedRepos,
}: ConnectionListItemProps) => { }: ConnectionListItemProps) => {
const statusDisplayName = useMemo(() => { const statusDisplayName = useMemo(() => {
switch (status) { switch (status) {
@ -52,10 +56,23 @@ export const ConnectionListItem = ({
} }
}, [status]); }, [status]);
const { notFoundData, displayNotFoundWarning } = useMemo(() => {
if (!syncStatusMetadata || typeof syncStatusMetadata !== 'object' || !('notFound' in syncStatusMetadata)) {
return { notFoundData: null, displayNotFoundWarning: false };
}
const notFoundData = syncStatusMetadata.notFound as {
users: string[],
orgs: string[],
repos: string[],
}
return { notFoundData, displayNotFoundWarning: notFoundData.users.length > 0 || notFoundData.orgs.length > 0 || notFoundData.repos.length > 0 };
}, [syncStatusMetadata]);
return ( return (
<Link href={`connections/${id}`}>
<div <div
className="flex flex-row justify-between items-center border p-4 rounded-lg cursor-pointer bg-background" className="flex flex-row justify-between items-center border p-4 rounded-lg bg-background"
> >
<div className="flex flex-row items-center gap-3"> <div className="flex flex-row items-center gap-3">
<ConnectionIcon <ConnectionIcon
@ -66,6 +83,92 @@ export const ConnectionListItem = ({
<p className="font-medium">{name}</p> <p className="font-medium">{name}</p>
<span className="text-sm text-muted-foreground">{`Edited ${getDisplayTime(editedAt)}`}</span> <span className="text-sm text-muted-foreground">{`Edited ${getDisplayTime(editedAt)}`}</span>
</div> </div>
{failedRepos && failedRepos.length > 0 && (
<HoverCard openDelay={50}>
<HoverCardTrigger asChild>
<CircleX
className="h-5 w-5 text-red-700 dark:text-red-400 cursor-help hover:text-red-600 dark:hover:text-red-300 transition-colors"
onClick={() => window.location.href = `connections/${id}`}
/>
</HoverCardTrigger>
<HoverCardContent className="w-80 border border-red-200 dark:border-red-800 rounded-lg">
<div className="flex flex-col space-y-3">
<div className="flex items-center gap-2 pb-2 border-b border-red-200 dark:border-red-800">
<CircleX className="h-4 w-4 text-red-700 dark:text-red-400" />
<h3 className="text-sm font-semibold text-red-700 dark:text-red-400">Failed to Index Repositories</h3>
</div>
<div className="text-sm text-red-600/90 dark:text-red-300/90 space-y-3">
<p>
{failedRepos.length} {failedRepos.length === 1 ? 'repository' : 'repositories'} failed to index. This is likely due to temporary server load.
</p>
<div className="space-y-2 text-sm bg-red-50 dark:bg-red-900/20 rounded-md p-3 border border-red-200/50 dark:border-red-800/50">
<div className="flex flex-col gap-1.5">
{failedRepos.slice(0, 10).map(repo => (
<span key={repo.repoId} className="text-red-700 dark:text-red-300">{repo.repoName}</span>
))}
{failedRepos.length > 10 && (
<span className="text-red-600/75 dark:text-red-400/75 text-xs pt-1">
And {failedRepos.length - 10} more...
</span>
)}
</div>
</div>
<p className="text-xs">
Navigate to the connection for more details and to retry indexing.
</p>
</div>
</div>
</HoverCardContent>
</HoverCard>
)}
{(notFoundData && displayNotFoundWarning) && (
<HoverCard openDelay={50}>
<HoverCardTrigger asChild>
<AlertTriangle
className="h-5 w-5 text-yellow-700 dark:text-yellow-400 cursor-help hover:text-yellow-600 dark:hover:text-yellow-300 transition-colors"
onClick={() => window.location.href = `connections/${id}`}
/>
</HoverCardTrigger>
<HoverCardContent className="w-80 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<div className="flex flex-col space-y-3">
<div className="flex items-center gap-2 pb-2 border-b border-yellow-200 dark:border-yellow-800">
<AlertTriangle className="h-4 w-4 text-yellow-700 dark:text-yellow-400" />
<h3 className="text-sm font-semibold text-yellow-700 dark:text-yellow-400">Unable to fetch all references</h3>
</div>
<p className="text-sm text-yellow-600/90 dark:text-yellow-300/90">
Some requested references couldn&apos;t be found. Verify the details below and ensure your connection is using a {" "}
<button
onClick={() => window.location.href = `secrets`}
className="font-medium text-yellow-700 dark:text-yellow-400 hover:text-yellow-600 dark:hover:text-yellow-300 transition-colors"
>
valid access token
</button>{" "}
that has access to any private references.
</p>
<ul className="space-y-2 text-sm bg-yellow-50 dark:bg-yellow-900/20 rounded-md p-3 border border-yellow-200/50 dark:border-yellow-800/50">
{notFoundData.users.length > 0 && (
<li className="flex items-center gap-2">
<span className="font-medium text-yellow-700 dark:text-yellow-400">Users:</span>
<span className="text-yellow-700 dark:text-yellow-300">{notFoundData.users.join(', ')}</span>
</li>
)}
{notFoundData.orgs.length > 0 && (
<li className="flex items-center gap-2">
<span className="font-medium text-yellow-700 dark:text-yellow-400">{type === "gitlab" ? "Groups" : "Organizations"}:</span>
<span className="text-yellow-700 dark:text-yellow-300">{notFoundData.orgs.join(', ')}</span>
</li>
)}
{notFoundData.repos.length > 0 && (
<li className="flex items-center gap-2">
<span className="font-medium text-yellow-700 dark:text-yellow-400">{type === "gitlab" ? "Projects" : "Repositories"}:</span>
<span className="text-yellow-700 dark:text-yellow-300">{notFoundData.repos.join(', ')}</span>
</li>
)}
</ul>
</div>
</HoverCardContent>
</HoverCard>
)}
</div> </div>
<div className="flex flex-row items-center"> <div className="flex flex-row items-center">
<StatusIcon <StatusIcon
@ -87,11 +190,11 @@ export const ConnectionListItem = ({
variant="outline" variant="outline"
size={"sm"} size={"sm"}
className="ml-4" className="ml-4"
onClick={() => window.location.href = `connections/${id}`}
> >
Manage Manage
</Button> </Button>
</div> </div>
</div> </div>
</Link>
) )
} }

View file

@ -5,8 +5,8 @@ import { cn } from "@/lib/utils";
import { useEffect } from "react"; import { useEffect } from "react";
import { InfoCircledIcon } from "@radix-ui/react-icons"; import { InfoCircledIcon } from "@radix-ui/react-icons";
import { useState } from "react"; import { useState } from "react";
import { ConnectionSyncStatus } from "@sourcebot/db"; import { ConnectionSyncStatus, Prisma } from "@sourcebot/db";
import { getConnections } from "@/actions"; import { getConnectionFailedRepos, getConnections } from "@/actions";
import { isServiceError } from "@/lib/utils"; import { isServiceError } from "@/lib/utils";
interface ConnectionListProps { interface ConnectionListProps {
@ -22,8 +22,10 @@ export const ConnectionList = ({
name: string; name: string;
connectionType: string; connectionType: string;
syncStatus: ConnectionSyncStatus; syncStatus: ConnectionSyncStatus;
syncStatusMetadata: Prisma.JsonValue;
updatedAt: Date; updatedAt: Date;
syncedAt?: Date; syncedAt?: Date;
failedRepos?: { repoId: number, repoName: string }[];
}[]>([]); }[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -35,7 +37,19 @@ export const ConnectionList = ({
if (isServiceError(result)) { if (isServiceError(result)) {
setError(result.message); setError(result.message);
} else { } else {
setConnections(result); const connectionsWithFailedRepos = [];
for (const connection of result) {
const failedRepos = await getConnectionFailedRepos(connection.id, domain);
if (isServiceError(failedRepos)) {
setError(`An error occured while fetching the failed repositories for connection ${connection.name}. If the problem persists, please contact us at team@sourcebot.dev`);
} else {
connectionsWithFailedRepos.push({
...connection,
failedRepos,
});
}
}
setConnections(connectionsWithFailedRepos);
} }
setLoading(false); setLoading(false);
} catch (err) { } catch (err) {
@ -69,8 +83,10 @@ export const ConnectionList = ({
name={connection.name} name={connection.name}
type={connection.connectionType} type={connection.connectionType}
status={connection.syncStatus} status={connection.syncStatus}
syncStatusMetadata={connection.syncStatusMetadata}
editedAt={connection.updatedAt} editedAt={connection.updatedAt}
syncedAt={connection.syncedAt ?? undefined} syncedAt={connection.syncedAt ?? undefined}
failedRepos={connection.failedRepos}
/> />
)) ))
) : ( ) : (

View file

@ -1,6 +1,5 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Cross2Icon } from "@radix-ui/react-icons"; import { CircleCheckIcon, CircleXIcon } from "lucide-react";
import { CircleCheckIcon } from "lucide-react";
import { useMemo } from "react"; import { useMemo } from "react";
import { FiLoader } from "react-icons/fi"; import { FiLoader } from "react-icons/fi";
@ -18,7 +17,7 @@ export const StatusIcon = ({
case 'succeeded': case 'succeeded':
return <CircleCheckIcon className={cn('text-green-600', className)} />; return <CircleCheckIcon className={cn('text-green-600', className)} />;
case 'failed': case 'failed':
return <Cross2Icon className={cn(className)} />; return <CircleXIcon className={cn('text-destructive', className)} />;
} }
}, [className, status]); }, [className, status]);

View file

@ -79,6 +79,16 @@ export const gitlabQuickActions: QuickAction<GitlabConnectionConfig>[] = [
] ]
}), }),
name: "Add a project", name: "Add a project",
},
{
fn: (previous: GitlabConnectionConfig) => ({
...previous,
users: [
...previous.users ?? [],
""
]
}),
name: "Add a user",
} }
] ]

View file

@ -31,14 +31,17 @@ export const SecretsTable = ({ initialSecrets }: SecretsTableProps) => {
const domain = useDomain(); const domain = useDomain();
useEffect(() => { useEffect(() => {
getSecrets(domain).then((keys) => { const fetchSecretKeys = async () => {
if ('keys' in keys) { const keys = await getSecrets(domain);
setSecrets(keys); if (isServiceError(keys)) {
} else { console.error("Failed to fetch secrets:", keys);
console.error(keys); return;
} }
}) setSecrets(keys);
}, []); };
fetchSecretKeys();
}, [domain]);
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),

View file

@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

View file

@ -0,0 +1,17 @@
import { z } from "zod";
export const NotFoundSchema = z.object({
users: z.array(z.string()),
orgs: z.array(z.string()),
repos: z.array(z.string()),
});
export const SyncStatusMetadataSchema = z.object({
notFound: NotFoundSchema.optional(),
error: z.string().optional(),
secretKey: z.string().optional(),
status: z.number().optional(),
});
export type NotFoundData = z.infer<typeof NotFoundSchema>;
export type SyncStatusMetadata = z.infer<typeof SyncStatusMetadataSchema>;