mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 04:15:30 +00:00
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:
parent
b99a648670
commit
fdd71cfcfe
39 changed files with 1736 additions and 375 deletions
|
|
@ -18,9 +18,11 @@ COPY package.json yarn.lock* ./
|
|||
COPY ./packages/db ./packages/db
|
||||
COPY ./packages/schemas ./packages/schemas
|
||||
COPY ./packages/crypto ./packages/crypto
|
||||
COPY ./packages/error ./packages/error
|
||||
RUN yarn workspace @sourcebot/db install --frozen-lockfile
|
||||
RUN yarn workspace @sourcebot/schemas install --frozen-lockfile
|
||||
RUN yarn workspace @sourcebot/crypto install --frozen-lockfile
|
||||
RUN yarn workspace @sourcebot/error install --frozen-lockfile
|
||||
|
||||
# ------ Build Web ------
|
||||
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/schemas ./packages/schemas
|
||||
COPY --from=shared-libs-builder /app/packages/crypto ./packages/crypto
|
||||
COPY --from=shared-libs-builder /app/packages/error ./packages/error
|
||||
|
||||
# Fixes arm64 timeouts
|
||||
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/schemas ./packages/schemas
|
||||
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 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/schemas ./packages/schemas
|
||||
COPY --from=shared-libs-builder /app/packages/crypto ./packages/crypto
|
||||
COPY --from=shared-libs-builder /app/packages/error ./packages/error
|
||||
|
||||
# Configure the database
|
||||
RUN mkdir -p /run/postgresql && \
|
||||
|
|
|
|||
2
Makefile
2
Makefile
|
|
@ -26,6 +26,8 @@ clean:
|
|||
packages/schemas/dist \
|
||||
packages/crypto/node_modules \
|
||||
packages/crypto/dist \
|
||||
packages/error/node_modules \
|
||||
packages/error/dist \
|
||||
.sourcebot
|
||||
|
||||
soft-reset:
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@
|
|||
"@sourcebot/crypto": "^0.1.0",
|
||||
"@sourcebot/db": "^0.1.0",
|
||||
"@sourcebot/schemas": "^0.1.0",
|
||||
"@sourcebot/error": "^0.1.0",
|
||||
"simple-git": "^3.27.0",
|
||||
"strip-json-comments": "^5.0.1",
|
||||
"winston": "^3.15.0",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { createLogger } from "./logger.js";
|
|||
import os from 'os';
|
||||
import { Redis } from 'ioredis';
|
||||
import { RepoData, compileGithubConfig, compileGitlabConfig, compileGiteaConfig, compileGerritConfig } from "./repoCompileUtils.js";
|
||||
import { BackendError, BackendException } from "@sourcebot/error";
|
||||
|
||||
interface IConnectionManager {
|
||||
scheduleConnectionSync: (connection: Connection) => Promise<void>;
|
||||
|
|
@ -81,26 +82,93 @@ export class ConnectionManager implements IConnectionManager {
|
|||
// @note: We aren't actually doing anything with this atm.
|
||||
const abortController = new AbortController();
|
||||
|
||||
const repoData: RepoData[] = await (async () => {
|
||||
switch (config.type) {
|
||||
case 'github': {
|
||||
return await compileGithubConfig(config, job.data.connectionId, orgId, this.db, abortController);
|
||||
}
|
||||
case 'gitlab': {
|
||||
return await compileGitlabConfig(config, job.data.connectionId, orgId, this.db);
|
||||
}
|
||||
case 'gitea': {
|
||||
return await compileGiteaConfig(config, job.data.connectionId, orgId, this.db);
|
||||
}
|
||||
case 'gerrit': {
|
||||
return await compileGerritConfig(config, job.data.connectionId, orgId);
|
||||
}
|
||||
default: {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
})();
|
||||
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) {
|
||||
case 'github': {
|
||||
return await compileGithubConfig(config, job.data.connectionId, orgId, this.db, abortController);
|
||||
}
|
||||
case 'gitlab': {
|
||||
return await compileGitlabConfig(config, job.data.connectionId, orgId, this.db);
|
||||
}
|
||||
case 'gitea': {
|
||||
return await compileGiteaConfig(config, job.data.connectionId, orgId, this.db);
|
||||
}
|
||||
case 'gerrit': {
|
||||
return await compileGerritConfig(config, job.data.connectionId, orgId);
|
||||
}
|
||||
default: {
|
||||
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.
|
||||
repoData.filter((repo, index, self) => {
|
||||
return index === self.findIndex(r =>
|
||||
|
|
@ -265,16 +333,37 @@ export class ConnectionManager implements IConnectionManager {
|
|||
private async onSyncJobFailed(job: Job | undefined, err: unknown) {
|
||||
this.logger.info(`Connection sync job failed with error: ${err}`);
|
||||
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;
|
||||
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({
|
||||
where: {
|
||||
id: connectionId,
|
||||
},
|
||||
data: {
|
||||
syncStatus: ConnectionSyncStatus.FAILED,
|
||||
syncedAt: new Date()
|
||||
syncedAt: new Date(),
|
||||
syncStatusMetadata: syncStatusMetadata as Prisma.InputJsonValue,
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
44
packages/backend/src/connectionUtils.ts
Normal file
44
packages/backend/src/connectionUtils.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@ import { GerritConfig } from "@sourcebot/schemas/v2/index.type"
|
|||
import { createLogger } from './logger.js';
|
||||
import micromatch from "micromatch";
|
||||
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
|
||||
interface GerritProjects {
|
||||
|
|
@ -38,6 +40,10 @@ export const getGerritReposFromConfig = async (config: GerritConfig): Promise<Ge
|
|||
const fetchFn = () => fetchAllProjects(url);
|
||||
return fetchWithRetry(fetchFn, `projects from ${url}`, logger);
|
||||
} catch (err) {
|
||||
if (err instanceof BackendException) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
logger.error(`Failed to fetch projects from ${url}`, err);
|
||||
return null;
|
||||
}
|
||||
|
|
@ -78,9 +84,25 @@ const fetchAllProjects = async (url: string): Promise<GerritProject[]> => {
|
|||
const endpointWithParams = `${projectsEndpoint}?S=${start}`;
|
||||
logger.debug(`Fetching projects from Gerrit at ${endpointWithParams}`);
|
||||
|
||||
const response = await fetch(endpointWithParams);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch projects from Gerrit: ${response.statusText}`);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(endpointWithParams);
|
||||
if (!response.ok) {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -6,43 +6,45 @@ import { createLogger } from './logger.js';
|
|||
import micromatch from 'micromatch';
|
||||
import { PrismaClient } from '@sourcebot/db';
|
||||
import { FALLBACK_GITEA_TOKEN } from './environment.js';
|
||||
import { processPromiseResults, throwIfAnyFailed } from './connectionUtils.js';
|
||||
const logger = createLogger('Gitea');
|
||||
|
||||
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', {
|
||||
token: token ?? FALLBACK_GITEA_TOKEN,
|
||||
token: token,
|
||||
customFetch: fetch,
|
||||
});
|
||||
|
||||
let allRepos: GiteaRepository[] = [];
|
||||
let notFound: {
|
||||
users: string[],
|
||||
orgs: string[],
|
||||
repos: string[],
|
||||
} = {
|
||||
users: [],
|
||||
orgs: [],
|
||||
repos: [],
|
||||
};
|
||||
|
||||
if (config.orgs) {
|
||||
const _repos = await fetchWithRetry(
|
||||
() => getReposForOrgs(config.orgs!, api),
|
||||
`orgs ${config.orgs.join(', ')}`,
|
||||
logger
|
||||
);
|
||||
allRepos = allRepos.concat(_repos);
|
||||
const { validRepos, notFoundOrgs } = await getReposForOrgs(config.orgs, api);
|
||||
allRepos = allRepos.concat(validRepos);
|
||||
notFound.orgs = notFoundOrgs;
|
||||
}
|
||||
|
||||
if (config.repos) {
|
||||
const _repos = await fetchWithRetry(
|
||||
() => getRepos(config.repos!, api),
|
||||
`repos ${config.repos.join(', ')}`,
|
||||
logger
|
||||
);
|
||||
allRepos = allRepos.concat(_repos);
|
||||
const { validRepos, notFoundRepos } = await getRepos(config.repos, api);
|
||||
allRepos = allRepos.concat(validRepos);
|
||||
notFound.repos = notFoundRepos;
|
||||
}
|
||||
|
||||
if (config.users) {
|
||||
const _repos = await fetchWithRetry(
|
||||
() => getReposOwnedByUsers(config.users!, api),
|
||||
`users ${config.users.join(', ')}`,
|
||||
logger
|
||||
);
|
||||
allRepos = allRepos.concat(_repos);
|
||||
const { validRepos, notFoundUsers } = await getReposOwnedByUsers(config.users, api);
|
||||
allRepos = allRepos.concat(validRepos);
|
||||
notFound.users = notFoundUsers;
|
||||
}
|
||||
|
||||
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.`);
|
||||
return repos;
|
||||
return {
|
||||
validRepos: repos,
|
||||
notFound,
|
||||
};
|
||||
}
|
||||
|
||||
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 repos = (await Promise.all(users.map(async (user) => {
|
||||
const results = await Promise.allSettled(users.map(async (user) => {
|
||||
try {
|
||||
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.`);
|
||||
return data;
|
||||
} catch (e) {
|
||||
logger.error(`Failed to fetch repos for user ${user}.`, e);
|
||||
return {
|
||||
type: 'valid' as const,
|
||||
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;
|
||||
}
|
||||
}))).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>) => {
|
||||
return (await Promise.all(orgs.map(async (org) => {
|
||||
const results = await Promise.allSettled(orgs.map(async (org) => {
|
||||
try {
|
||||
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.`);
|
||||
return data;
|
||||
} catch (e) {
|
||||
logger.error(`Failed to fetch repos for org ${org}.`, e);
|
||||
return {
|
||||
type: 'valid' as const,
|
||||
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;
|
||||
}
|
||||
}))).flat();
|
||||
}));
|
||||
|
||||
throwIfAnyFailed(results);
|
||||
const { validItems: validRepos, notFoundItems: notFoundOrgs } = processPromiseResults<GiteaRepository>(results);
|
||||
|
||||
return {
|
||||
validRepos,
|
||||
notFoundOrgs,
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
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.`);
|
||||
|
||||
return [response.data];
|
||||
} catch (e) {
|
||||
logger.error(`Failed to fetch repository info for ${repo}.`, e);
|
||||
return {
|
||||
type: 'valid' as const,
|
||||
data: [response.data]
|
||||
};
|
||||
} 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;
|
||||
}
|
||||
}))).flat();
|
||||
}));
|
||||
|
||||
throwIfAnyFailed(results);
|
||||
const { validItems: validRepos, notFoundItems: notFoundRepos } = processPromiseResults<GiteaRepository>(results);
|
||||
|
||||
return {
|
||||
validRepos,
|
||||
notFoundRepos,
|
||||
};
|
||||
}
|
||||
|
||||
// @see : https://docs.gitea.com/development/api-usage#pagination
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { getTokenFromConfig, measure, fetchWithRetry } from "./utils.js";
|
|||
import micromatch from "micromatch";
|
||||
import { PrismaClient } from "@sourcebot/db";
|
||||
import { FALLBACK_GITHUB_TOKEN } from "./environment.js";
|
||||
import { BackendException, BackendError } from "@sourcebot/error";
|
||||
import { processPromiseResults, throwIfAnyFailed } from "./connectionUtils.js";
|
||||
const logger = createLogger("GitHub");
|
||||
|
||||
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) => {
|
||||
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({
|
||||
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 notFound: {
|
||||
users: string[],
|
||||
orgs: string[],
|
||||
repos: string[],
|
||||
} = {
|
||||
users: [],
|
||||
orgs: [],
|
||||
repos: [],
|
||||
};
|
||||
|
||||
if (config.orgs) {
|
||||
const _repos = await getReposForOrgs(config.orgs, octokit, signal);
|
||||
allRepos = allRepos.concat(_repos);
|
||||
const { validRepos, notFoundOrgs } = await getReposForOrgs(config.orgs, octokit, signal);
|
||||
allRepos = allRepos.concat(validRepos);
|
||||
notFound.orgs = notFoundOrgs;
|
||||
}
|
||||
|
||||
if (config.repos) {
|
||||
const _repos = await getRepos(config.repos, octokit, signal);
|
||||
allRepos = allRepos.concat(_repos);
|
||||
const { validRepos, notFoundRepos } = await getRepos(config.repos, octokit, signal);
|
||||
allRepos = allRepos.concat(validRepos);
|
||||
notFound.repos = notFoundRepos;
|
||||
}
|
||||
|
||||
if (config.users) {
|
||||
const isAuthenticated = config.token !== undefined;
|
||||
const _repos = await getReposOwnedByUsers(config.users, isAuthenticated, octokit, signal);
|
||||
allRepos = allRepos.concat(_repos);
|
||||
const { validRepos, notFoundUsers } = await getReposOwnedByUsers(config.users, isAuthenticated, octokit, signal);
|
||||
allRepos = allRepos.concat(validRepos);
|
||||
notFound.users = notFoundUsers;
|
||||
}
|
||||
|
||||
// Marshall results to our type
|
||||
let repos = allRepos
|
||||
.filter((repo) => {
|
||||
const isExcluded = shouldExcludeRepo({
|
||||
|
|
@ -72,21 +110,10 @@ export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, o
|
|||
|
||||
logger.debug(`Found ${repos.length} total repositories.`);
|
||||
|
||||
return repos;
|
||||
}
|
||||
|
||||
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;
|
||||
return {
|
||||
validRepos: repos,
|
||||
notFound,
|
||||
};
|
||||
}
|
||||
|
||||
export const shouldExcludeRepo = ({
|
||||
|
|
@ -176,7 +203,7 @@ export const shouldExcludeRepo = ({
|
|||
}
|
||||
|
||||
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 {
|
||||
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.`);
|
||||
return data;
|
||||
} catch (e) {
|
||||
logger.error(`Failed to fetch repository info for user ${user}.`, e);
|
||||
throw e;
|
||||
return {
|
||||
type: 'valid' as const,
|
||||
data
|
||||
};
|
||||
} catch (error) {
|
||||
if (isHttpError(error, 404)) {
|
||||
logger.error(`User ${user} not found or no access`);
|
||||
return {
|
||||
type: 'notFound' as const,
|
||||
value: user
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}))).flat();
|
||||
}));
|
||||
|
||||
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 repos = (await Promise.all(orgs.map(async (org) => {
|
||||
const results = await Promise.allSettled(orgs.map(async (org) => {
|
||||
try {
|
||||
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.`);
|
||||
return data;
|
||||
} catch (e) {
|
||||
logger.error(`Failed to fetch repository info for org ${org}.`, e);
|
||||
throw e;
|
||||
return {
|
||||
type: 'valid' as const,
|
||||
data
|
||||
};
|
||||
} catch (error) {
|
||||
if (isHttpError(error, 404)) {
|
||||
logger.error(`Organization ${org} not found or no access`);
|
||||
return {
|
||||
type: 'notFound' as const,
|
||||
value: org
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}))).flat();
|
||||
}));
|
||||
|
||||
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 repos = (await Promise.all(repoList.map(async (repo) => {
|
||||
const results = await Promise.allSettled(repoList.map(async (repo) => {
|
||||
try {
|
||||
const [owner, repoName] = repo.split('/');
|
||||
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`);
|
||||
return {
|
||||
type: 'valid' as const,
|
||||
data: [result.data]
|
||||
};
|
||||
|
||||
return [result.data];
|
||||
} catch (e) {
|
||||
logger.error(`Failed to fetch repository info for ${repo}.`, e);
|
||||
throw e;
|
||||
} catch (error) {
|
||||
if (isHttpError(error, 404)) {
|
||||
logger.error(`Repository ${repo} not found or no access`);
|
||||
return {
|
||||
type: 'notFound' as const,
|
||||
value: repo
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}))).flat();
|
||||
}));
|
||||
|
||||
return repos;
|
||||
throwIfAnyFailed(results);
|
||||
const { validItems: validRepos, notFoundItems: notFoundRepos } = processPromiseResults<OctokitRepository>(results);
|
||||
|
||||
return {
|
||||
validRepos,
|
||||
notFoundRepos,
|
||||
};
|
||||
}
|
||||
|
|
@ -5,24 +5,35 @@ import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type"
|
|||
import { getTokenFromConfig, measure, fetchWithRetry } from "./utils.js";
|
||||
import { PrismaClient } from "@sourcebot/db";
|
||||
import { FALLBACK_GITLAB_TOKEN } from "./environment.js";
|
||||
import { processPromiseResults, throwIfAnyFailed } from "./connectionUtils.js";
|
||||
const logger = createLogger("GitLab");
|
||||
export const GITLAB_CLOUD_HOSTNAME = "gitlab.com";
|
||||
|
||||
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({
|
||||
...(token ? {
|
||||
token,
|
||||
} : {
|
||||
token: FALLBACK_GITLAB_TOKEN,
|
||||
}),
|
||||
} : {}),
|
||||
...(config.url ? {
|
||||
host: config.url,
|
||||
} : {}),
|
||||
});
|
||||
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 (hostname !== GITLAB_CLOUD_HOSTNAME) {
|
||||
|
|
@ -35,7 +46,7 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o
|
|||
return fetchWithRetry(fetchFn, `all projects in ${config.url}`, logger);
|
||||
});
|
||||
logger.debug(`Found ${_projects.length} projects in ${durationMs}ms.`);
|
||||
allProjects = allProjects.concat(_projects);
|
||||
allRepos = allRepos.concat(_projects);
|
||||
} catch (e) {
|
||||
logger.error(`Failed to fetch all projects visible in ${config.url}.`, e);
|
||||
throw e;
|
||||
|
|
@ -46,7 +57,7 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o
|
|||
}
|
||||
|
||||
if (config.groups) {
|
||||
const _projects = (await Promise.all(config.groups.map(async (group) => {
|
||||
const results = await Promise.allSettled(config.groups.map(async (group) => {
|
||||
try {
|
||||
logger.debug(`Fetching project info for group ${group}...`);
|
||||
const { durationMs, data } = await measure(async () => {
|
||||
|
|
@ -57,18 +68,31 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o
|
|||
return fetchWithRetry(fetchFn, `group ${group}`, logger);
|
||||
});
|
||||
logger.debug(`Found ${data.length} projects in group ${group} in ${durationMs}ms.`);
|
||||
return data;
|
||||
} catch (e) {
|
||||
logger.error(`Failed to fetch project info for group ${group}.`, e);
|
||||
return {
|
||||
type: 'valid' as const,
|
||||
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;
|
||||
}
|
||||
}))).flat();
|
||||
}));
|
||||
|
||||
allProjects = allProjects.concat(_projects);
|
||||
throwIfAnyFailed(results);
|
||||
const { validItems: validRepos, notFoundItems: notFoundOrgs } = processPromiseResults(results);
|
||||
allRepos = allRepos.concat(validRepos);
|
||||
notFound.orgs = notFoundOrgs;
|
||||
}
|
||||
|
||||
if (config.users) {
|
||||
const _projects = (await Promise.all(config.users.map(async (user) => {
|
||||
const results = await Promise.allSettled(config.users.map(async (user) => {
|
||||
try {
|
||||
logger.debug(`Fetching project info for user ${user}...`);
|
||||
const { durationMs, data } = await measure(async () => {
|
||||
|
|
@ -78,18 +102,31 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o
|
|||
return fetchWithRetry(fetchFn, `user ${user}`, logger);
|
||||
});
|
||||
logger.debug(`Found ${data.length} projects owned by user ${user} in ${durationMs}ms.`);
|
||||
return data;
|
||||
} catch (e) {
|
||||
logger.error(`Failed to fetch project info for user ${user}.`, e);
|
||||
return {
|
||||
type: 'valid' as const,
|
||||
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;
|
||||
}
|
||||
}))).flat();
|
||||
}));
|
||||
|
||||
allProjects = allProjects.concat(_projects);
|
||||
throwIfAnyFailed(results);
|
||||
const { validItems: validRepos, notFoundItems: notFoundUsers } = processPromiseResults(results);
|
||||
allRepos = allRepos.concat(validRepos);
|
||||
notFound.users = notFoundUsers;
|
||||
}
|
||||
|
||||
if (config.projects) {
|
||||
const _projects = (await Promise.all(config.projects.map(async (project) => {
|
||||
const results = await Promise.allSettled(config.projects.map(async (project) => {
|
||||
try {
|
||||
logger.debug(`Fetching project info for project ${project}...`);
|
||||
const { durationMs, data } = await measure(async () => {
|
||||
|
|
@ -97,17 +134,31 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o
|
|||
return fetchWithRetry(fetchFn, `project ${project}`, logger);
|
||||
});
|
||||
logger.debug(`Found project ${project} in ${durationMs}ms.`);
|
||||
return [data];
|
||||
} catch (e) {
|
||||
logger.error(`Failed to fetch project info for project ${project}.`, e);
|
||||
return {
|
||||
type: 'valid' as const,
|
||||
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;
|
||||
}
|
||||
}))).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) => {
|
||||
const isExcluded = shouldExcludeProject({
|
||||
project,
|
||||
|
|
@ -122,7 +173,10 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o
|
|||
|
||||
logger.debug(`Found ${repos.length} total repositories.`);
|
||||
|
||||
return repos;
|
||||
return {
|
||||
validRepos: repos,
|
||||
notFound,
|
||||
};
|
||||
}
|
||||
|
||||
export const shouldExcludeProject = ({
|
||||
|
|
|
|||
|
|
@ -15,12 +15,22 @@ export const compileGithubConfig = async (
|
|||
connectionId: number,
|
||||
orgId: number,
|
||||
db: PrismaClient,
|
||||
abortController: AbortController): Promise<RepoData[]> => {
|
||||
const gitHubRepos = await getGitHubReposFromConfig(config, orgId, db, abortController.signal);
|
||||
abortController: AbortController): Promise<{
|
||||
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 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 cloneUrl = new URL(repo.clone_url!);
|
||||
|
||||
|
|
@ -59,6 +69,11 @@ export const compileGithubConfig = async (
|
|||
|
||||
return record;
|
||||
})
|
||||
|
||||
return {
|
||||
repoData: repos,
|
||||
notFound,
|
||||
};
|
||||
}
|
||||
|
||||
export const compileGitlabConfig = async (
|
||||
|
|
@ -67,10 +82,13 @@ export const compileGitlabConfig = async (
|
|||
orgId: number,
|
||||
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';
|
||||
|
||||
return gitlabRepos.map((project) => {
|
||||
const repos = gitlabRepos.map((project) => {
|
||||
const projectUrl = `${hostUrl}/${project.path_with_namespace}`;
|
||||
const cloneUrl = new URL(project.http_url_to_repo);
|
||||
const isFork = project.forked_from_project !== undefined;
|
||||
|
|
@ -108,6 +126,11 @@ export const compileGitlabConfig = async (
|
|||
|
||||
return record;
|
||||
})
|
||||
|
||||
return {
|
||||
repoData: repos,
|
||||
notFound,
|
||||
};
|
||||
}
|
||||
|
||||
export const compileGiteaConfig = async (
|
||||
|
|
@ -116,10 +139,13 @@ export const compileGiteaConfig = async (
|
|||
orgId: number,
|
||||
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';
|
||||
|
||||
return giteaRepos.map((repo) => {
|
||||
const repos = giteaRepos.map((repo) => {
|
||||
const cloneUrl = new URL(repo.clone_url!);
|
||||
|
||||
const record: RepoData = {
|
||||
|
|
@ -153,6 +179,11 @@ export const compileGiteaConfig = async (
|
|||
|
||||
return record;
|
||||
})
|
||||
|
||||
return {
|
||||
repoData: repos,
|
||||
notFound,
|
||||
};
|
||||
}
|
||||
|
||||
export const compileGerritConfig = async (
|
||||
|
|
@ -164,7 +195,7 @@ export const compileGerritConfig = async (
|
|||
const hostUrl = config.url ?? 'https://gerritcodereview.com';
|
||||
const hostname = new URL(hostUrl).hostname;
|
||||
|
||||
return gerritRepos.map((project) => {
|
||||
const repos = gerritRepos.map((project) => {
|
||||
const repoId = `${hostname}/${project.name}`;
|
||||
const cloneUrl = new URL(`${config.url}/${encodeURIComponent(project.name)}`);
|
||||
|
||||
|
|
@ -207,4 +238,13 @@ export const compileGerritConfig = async (
|
|||
|
||||
return record;
|
||||
})
|
||||
|
||||
return {
|
||||
repoData: repos,
|
||||
notFound: {
|
||||
users: [],
|
||||
orgs: [],
|
||||
repos: [],
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -207,7 +207,8 @@ export class RepoManager implements IRepoManager {
|
|||
|
||||
const config = connection.config as unknown as GithubConnectionConfig | GitlabConnectionConfig | GiteaConnectionConfig;
|
||||
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) {
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import micromatch from "micromatch";
|
|||
import { PrismaClient, Repo } from "@sourcebot/db";
|
||||
import { decrypt } from "@sourcebot/crypto";
|
||||
import { Token } from "@sourcebot/schemas/v3/shared.type";
|
||||
import { BackendException, BackendError } from "@sourcebot/error";
|
||||
|
||||
export const measure = async <T>(cb: () => Promise<T>) => {
|
||||
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) => {
|
||||
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;
|
||||
|
|
@ -104,11 +107,16 @@ export const getTokenFromConfig = async (token: Token, orgId: number, db?: Prism
|
|||
});
|
||||
|
||||
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);
|
||||
return decryptedSecret;
|
||||
return {
|
||||
token: decryptedSecret,
|
||||
secretKey,
|
||||
};
|
||||
}
|
||||
|
||||
export const isRemotePath = (path: string) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Connection" ADD COLUMN "syncStatusMetadata" JSONB;
|
||||
|
|
@ -59,21 +59,22 @@ model Repo {
|
|||
}
|
||||
|
||||
model Connection {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
config Json
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
syncedAt DateTime?
|
||||
repos RepoToConnection[]
|
||||
syncStatus ConnectionSyncStatus @default(SYNC_NEEDED)
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
config Json
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
syncedAt DateTime?
|
||||
repos RepoToConnection[]
|
||||
syncStatus ConnectionSyncStatus @default(SYNC_NEEDED)
|
||||
syncStatusMetadata Json?
|
||||
|
||||
// The type of connection (e.g., github, gitlab, etc.)
|
||||
connectionType String
|
||||
connectionType String
|
||||
|
||||
// The organization that owns this connection
|
||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
orgId Int
|
||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
orgId Int
|
||||
}
|
||||
|
||||
model RepoToConnection {
|
||||
|
|
|
|||
13
packages/error/package.json
Normal file
13
packages/error/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
17
packages/error/src/index.ts
Normal file
17
packages/error/src/index.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
22
packages/error/tsconfig.json
Normal file
22
packages/error/tsconfig.json
Normal 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"]
|
||||
}
|
||||
|
|
@ -12,7 +12,9 @@
|
|||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"hooks": "@/components/hooks",
|
||||
"utils": "@/lib/utils"
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
|
|
@ -27,7 +27,11 @@ const nextConfig = {
|
|||
{
|
||||
protocol: 'https',
|
||||
hostname: 'avatars.githubusercontent.com',
|
||||
}
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'gitlab.com',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@
|
|||
"@shopify/lang-jsonc": "^1.0.0",
|
||||
"@sourcebot/crypto": "^0.1.0",
|
||||
"@sourcebot/db": "^0.1.0",
|
||||
"@sourcebot/error": "^0.1.0",
|
||||
"@sourcebot/schemas": "^0.1.0",
|
||||
"@ssddanbrown/codemirror-lang-twig": "^1.0.0",
|
||||
"@stripe/react-stripe-js": "^3.1.1",
|
||||
|
|
|
|||
|
|
@ -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 { encrypt } from "@sourcebot/crypto"
|
||||
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 { getStripe } from "@/lib/stripe"
|
||||
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 { StripeSubscriptionStatus } from "@sourcebot/db";
|
||||
import Stripe from "stripe";
|
||||
import { SyncStatusMetadataSchema, type NotFoundData } from "@/lib/syncStatusMetadataSchema";
|
||||
const ajv = new Ajv({
|
||||
validateFormats: false,
|
||||
});
|
||||
|
|
@ -179,6 +180,7 @@ export const getConnections = async (domain: string): Promise<
|
|||
id: number,
|
||||
name: string,
|
||||
syncStatus: ConnectionSyncStatus,
|
||||
syncStatusMetadata: Prisma.JsonValue,
|
||||
connectionType: string,
|
||||
updatedAt: Date,
|
||||
syncedAt?: Date
|
||||
|
|
@ -196,6 +198,7 @@ export const getConnections = async (domain: string): Promise<
|
|||
id: connection.id,
|
||||
name: connection.name,
|
||||
syncStatus: connection.syncStatus,
|
||||
syncStatusMetadata: connection.syncStatusMetadata,
|
||||
connectionType: connection.connectionType,
|
||||
updatedAt: connection.updatedAt,
|
||||
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> =>
|
||||
withAuth((session) =>
|
||||
|
|
@ -331,14 +368,6 @@ export const flagConnectionForSync = async (connectionId: number, domain: string
|
|||
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({
|
||||
where: {
|
||||
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> =>
|
||||
withAuth((session) =>
|
||||
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||
|
|
|
|||
160
packages/web/src/app/[domain]/components/errorNavIndicator.tsx
Normal file
160
packages/web/src/app/[domain]/components/errorNavIndicator.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -8,6 +8,9 @@ import { redirect } from "next/navigation";
|
|||
import { OrgSelector } from "./orgSelector";
|
||||
import { getSubscriptionData } from "@/actions";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
import { ErrorNavIndicator } from "./errorNavIndicator";
|
||||
import { WarningNavIndicator } from "./warningNavIndicator";
|
||||
import { ProgressNavIndicator } from "./progressNavIndicator";
|
||||
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
||||
|
||||
const SOURCEBOT_DISCORD_URL = "https://discord.gg/6Fhp27x7Pb";
|
||||
|
|
@ -83,10 +86,13 @@ export const NavigationMenu = async ({
|
|||
</div>
|
||||
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ProgressNavIndicator />
|
||||
<WarningNavIndicator />
|
||||
<ErrorNavIndicator />
|
||||
{!isServiceError(subscription) && subscription.status === "trialing" && (
|
||||
<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">
|
||||
<span className="inline-block w-2 h-2 bg-yellow-400 dark:bg-yellow-500 rounded-full"></span>
|
||||
<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-blue-400 dark:bg-blue-500 rounded-full"></span>
|
||||
<span>
|
||||
{Math.ceil((subscription.nextBillingDate * 1000 - Date.now()) / (1000 * 60 * 60 * 24))} days left in
|
||||
trial
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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'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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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't be found. Please ensure you've provided the information listed below correctly, and that you'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'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>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import Image from "next/image";
|
|||
import { StatusIcon } from "../../components/statusIcon";
|
||||
import { RepoIndexingStatus } from "@sourcebot/db";
|
||||
import { useMemo } from "react";
|
||||
import { RetryRepoIndexButton } from "./repoRetryIndexButton";
|
||||
|
||||
|
||||
interface RepoListItemProps {
|
||||
|
|
@ -10,6 +11,8 @@ interface RepoListItemProps {
|
|||
status: RepoIndexingStatus;
|
||||
imageUrl?: string;
|
||||
indexedAt?: Date;
|
||||
repoId: number;
|
||||
domain: string;
|
||||
}
|
||||
|
||||
export const RepoListItem = ({
|
||||
|
|
@ -17,6 +20,8 @@ export const RepoListItem = ({
|
|||
name,
|
||||
indexedAt,
|
||||
status,
|
||||
repoId,
|
||||
domain,
|
||||
}: RepoListItemProps) => {
|
||||
const statusDisplayName = useMemo(() => {
|
||||
switch (status) {
|
||||
|
|
@ -47,22 +52,27 @@ export const RepoListItem = ({
|
|||
/>
|
||||
<p className="font-medium">{name}</p>
|
||||
</div>
|
||||
<div className="flex flex-row items-center">
|
||||
<StatusIcon
|
||||
status={convertIndexingStatus(status)}
|
||||
className="w-4 h-4 mr-1"
|
||||
/>
|
||||
<p className="text-sm">
|
||||
<span>{statusDisplayName}</span>
|
||||
{
|
||||
(
|
||||
status === RepoIndexingStatus.INDEXED ||
|
||||
status === RepoIndexingStatus.FAILED
|
||||
) && indexedAt && (
|
||||
<span>{` ${getDisplayTime(indexedAt)}`}</span>
|
||||
)
|
||||
}
|
||||
</p>
|
||||
<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
|
||||
status={convertIndexingStatus(status)}
|
||||
className="w-4 h-4 mr-1"
|
||||
/>
|
||||
<p className="text-sm">
|
||||
<span>{statusDisplayName}</span>
|
||||
{
|
||||
(
|
||||
status === RepoIndexingStatus.INDEXED ||
|
||||
status === RepoIndexingStatus.FAILED
|
||||
) && indexedAt && (
|
||||
<span>{` ${getDisplayTime(indexedAt)}`}</span>
|
||||
)
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
"use client"
|
||||
|
||||
import { NotFound } from "@/app/[domain]/components/notFound";
|
||||
import { NotFound } from "@/app/[domain]/components/notFound"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
|
|
@ -8,122 +8,125 @@ import {
|
|||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { TabSwitcher } from "@/components/ui/tab-switcher";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
import { ConnectionIcon } from "../components/connectionIcon";
|
||||
import { Header } from "../../components/header";
|
||||
import { ConfigSetting } from "./components/configSetting";
|
||||
import { DeleteConnectionSetting } from "./components/deleteConnectionSetting";
|
||||
import { DisplayNameSetting } from "./components/displayNameSetting";
|
||||
import { RepoListItem } from "./components/repoListItem";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Connection, Repo, Org } from "@sourcebot/db";
|
||||
import { getConnectionInfoAction, getOrgFromDomainAction, flagConnectionForSync } from "@/actions";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ReloadIcon } from "@radix-ui/react-icons";
|
||||
import { useToast } from "@/components/hooks/use-toast";
|
||||
} from "@/components/ui/breadcrumb"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { TabSwitcher } from "@/components/ui/tab-switcher"
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs"
|
||||
import { ConnectionIcon } from "../components/connectionIcon"
|
||||
import { Header } from "../../components/header"
|
||||
import { ConfigSetting } from "./components/configSetting"
|
||||
import { DeleteConnectionSetting } from "./components/deleteConnectionSetting"
|
||||
import { DisplayNameSetting } from "./components/displayNameSetting"
|
||||
import { RepoListItem } from "./components/repoListItem"
|
||||
import { useParams, useSearchParams, useRouter } from "next/navigation"
|
||||
import { useEffect, useState } from "react"
|
||||
import type { Connection, Repo, Org } from "@sourcebot/db"
|
||||
import { getConnectionInfoAction, getOrgFromDomainAction } from "@/actions"
|
||||
import { isServiceError } from "@/lib/utils"
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
|
||||
import { DisplayConnectionError } from "./components/connectionError"
|
||||
import { NotFoundWarning } from "./components/notFoundWarning"
|
||||
import { RetrySyncButton } from "./components/retrySyncButton"
|
||||
import { RetryAllFailedReposButton } from "./components/retryAllFailedReposButton"
|
||||
|
||||
export default function ConnectionManagementPage() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const { toast } = useToast();
|
||||
const [org, setOrg] = useState<Org | null>(null);
|
||||
const [connection, setConnection] = useState<Connection | null>(null);
|
||||
const [linkedRepos, setLinkedRepos] = useState<Repo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const params = useParams()
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const [org, setOrg] = useState<Org | null>(null)
|
||||
const [connection, setConnection] = useState<Connection | null>(null)
|
||||
const [linkedRepos, setLinkedRepos] = useState<Repo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSecretsNavigation = () => {
|
||||
router.push(`/${params.domain}/secrets`)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const orgResult = await getOrgFromDomainAction(params.domain as string);
|
||||
const orgResult = await getOrgFromDomainAction(params.domain as string)
|
||||
if (isServiceError(orgResult)) {
|
||||
setError(orgResult.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
setError(orgResult.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
setOrg(orgResult);
|
||||
setOrg(orgResult)
|
||||
|
||||
const connectionId = Number(params.id);
|
||||
const connectionId = Number(params.id)
|
||||
if (isNaN(connectionId)) {
|
||||
setError("Invalid connection ID");
|
||||
setLoading(false);
|
||||
return;
|
||||
setError("Invalid connection ID")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const connectionInfoResult = await getConnectionInfoAction(connectionId, params.domain as string);
|
||||
const connectionInfoResult = await getConnectionInfoAction(connectionId, params.domain as string)
|
||||
if (isServiceError(connectionInfoResult)) {
|
||||
setError(connectionInfoResult.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
setError(connectionInfoResult.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
connectionInfoResult.linkedRepos.sort((a, b) => {
|
||||
// Helper function to get priority of indexing status
|
||||
const getPriority = (status: string) => {
|
||||
switch (status) {
|
||||
case 'FAILED': return 0;
|
||||
case 'IN_INDEX_QUEUE':
|
||||
case 'INDEXING': return 1;
|
||||
case 'INDEXED': return 2;
|
||||
default: return 3;
|
||||
case "FAILED":
|
||||
return 0
|
||||
case "IN_INDEX_QUEUE":
|
||||
case "INDEXING":
|
||||
return 1
|
||||
case "INDEXED":
|
||||
return 2
|
||||
default:
|
||||
return 3
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const priorityA = getPriority(a.repoIndexingStatus);
|
||||
const priorityB = getPriority(b.repoIndexingStatus);
|
||||
const priorityA = getPriority(a.repoIndexingStatus)
|
||||
const priorityB = getPriority(b.repoIndexingStatus)
|
||||
|
||||
// First sort by priority
|
||||
if (priorityA !== priorityB) {
|
||||
return priorityA - priorityB;
|
||||
return priorityA - priorityB
|
||||
}
|
||||
|
||||
// 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);
|
||||
setLinkedRepos(connectionInfoResult.linkedRepos);
|
||||
setLoading(false);
|
||||
setConnection(connectionInfoResult.connection)
|
||||
setLinkedRepos(connectionInfoResult.linkedRepos)
|
||||
setLoading(false)
|
||||
} 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");
|
||||
setLoading(false);
|
||||
setError(
|
||||
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();
|
||||
const intervalId = setInterval(loadData, 1000);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [params.domain, params.id]);
|
||||
loadData()
|
||||
const intervalId = setInterval(loadData, 1000)
|
||||
return () => clearInterval(intervalId)
|
||||
}, [params.domain, params.id])
|
||||
|
||||
if (loading) {
|
||||
return <div>Loading...</div>;
|
||||
return <div>Loading...</div>
|
||||
}
|
||||
|
||||
if (error || !org || !connection) {
|
||||
return (
|
||||
<NotFound
|
||||
className="flex w-full h-full items-center justify-center"
|
||||
message={error || "Not found"}
|
||||
/>
|
||||
);
|
||||
return <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 (
|
||||
<Tabs
|
||||
value={currentTab}
|
||||
className="w-full"
|
||||
>
|
||||
<Header
|
||||
className="mb-6"
|
||||
withTopMargin={false}
|
||||
>
|
||||
<Tabs value={currentTab} className="w-full">
|
||||
<Header className="mb-6" withTopMargin={false}>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
|
|
@ -135,10 +138,8 @@ export default function ConnectionManagementPage() {
|
|||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="mt-6 flex flex-row items-center gap-4">
|
||||
<ConnectionIcon
|
||||
type={connection.connectionType}
|
||||
/>
|
||||
<div className="mt-6 flex flex-row items-center gap-4 w-full">
|
||||
<ConnectionIcon type={connection.connectionType} />
|
||||
<h1 className="text-lg font-semibold">{connection.name}</h1>
|
||||
</div>
|
||||
<TabSwitcher
|
||||
|
|
@ -160,7 +161,9 @@ export default function ConnectionManagementPage() {
|
|||
</div>
|
||||
<div className="rounded-lg border border-border p-4 bg-background">
|
||||
<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 className="rounded-lg border border-border p-4 bg-background">
|
||||
<h2 className="text-sm font-medium text-muted-foreground">Linked Repositories</h2>
|
||||
|
|
@ -168,74 +171,65 @@ export default function ConnectionManagementPage() {
|
|||
</div>
|
||||
<div className="rounded-lg border border-border p-4 bg-background">
|
||||
<h2 className="text-sm font-medium text-muted-foreground">Status</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="mt-2 text-sm">{connection.syncStatus}</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{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" && (
|
||||
<Button
|
||||
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>
|
||||
<RetrySyncButton connectionId={connection.id} domain={params.domain as string} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NotFoundWarning syncStatusMetadata={connection.syncStatusMetadata} onSecretsClick={handleSecretsNavigation} connectionId={connection.id} connectionType={connection.connectionType} domain={params.domain as string} />
|
||||
</div>
|
||||
<h1 className="font-semibold text-lg mt-8">Linked Repositories</h1>
|
||||
<ScrollArea
|
||||
className="mt-4 max-h-96 overflow-scroll"
|
||||
>
|
||||
<div className="flex justify-between items-center mt-8">
|
||||
<h1 className="font-semibold text-lg">Linked Repositories</h1>
|
||||
<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">
|
||||
{linkedRepos
|
||||
.sort((a, b) => {
|
||||
const aIndexedAt = a.indexedAt ?? new Date();
|
||||
const bIndexedAt = b.indexedAt ?? new Date();
|
||||
|
||||
return bIndexedAt.getTime() - aIndexedAt.getTime();
|
||||
})
|
||||
.map((repo) => (
|
||||
<RepoListItem
|
||||
key={repo.id}
|
||||
imageUrl={repo.imageUrl ?? undefined}
|
||||
name={repo.name}
|
||||
indexedAt={repo.indexedAt ?? undefined}
|
||||
status={repo.repoIndexingStatus}
|
||||
/>
|
||||
))}
|
||||
{linkedRepos.map((repo) => (
|
||||
<RepoListItem
|
||||
key={repo.id}
|
||||
imageUrl={repo.imageUrl ?? undefined}
|
||||
name={repo.name}
|
||||
indexedAt={repo.indexedAt ?? undefined}
|
||||
status={repo.repoIndexingStatus}
|
||||
repoId={repo.id}
|
||||
domain={params.domain as string}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="settings"
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
<DisplayNameSetting
|
||||
connectionId={connection.id}
|
||||
name={connection.name}
|
||||
/>
|
||||
<TabsContent value="settings" className="flex flex-col gap-6">
|
||||
<DisplayNameSetting connectionId={connection.id} name={connection.name} />
|
||||
<ConfigSetting
|
||||
connectionId={connection.id}
|
||||
type={connection.connectionType}
|
||||
config={JSON.stringify(connection.config, null, 2)}
|
||||
/>
|
||||
<DeleteConnectionSetting
|
||||
connectionId={connection.id}
|
||||
/>
|
||||
<DeleteConnectionSetting connectionId={connection.id} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { getDisplayTime } from "@/lib/utils";
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { ConnectionIcon } from "../connectionIcon";
|
||||
import { ConnectionSyncStatus } from "@sourcebot/db";
|
||||
import { ConnectionSyncStatus, Prisma } from "@sourcebot/db";
|
||||
import { StatusIcon } from "../statusIcon";
|
||||
|
||||
import { AlertTriangle, CircleX} from "lucide-react";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
||||
|
||||
const convertSyncStatus = (status: ConnectionSyncStatus) => {
|
||||
switch (status) {
|
||||
|
|
@ -26,8 +26,10 @@ interface ConnectionListItemProps {
|
|||
name: string;
|
||||
type: string;
|
||||
status: ConnectionSyncStatus;
|
||||
syncStatusMetadata: Prisma.JsonValue;
|
||||
editedAt: Date;
|
||||
syncedAt?: Date;
|
||||
failedRepos?: { repoId: number, repoName: string }[];
|
||||
}
|
||||
|
||||
export const ConnectionListItem = ({
|
||||
|
|
@ -35,8 +37,10 @@ export const ConnectionListItem = ({
|
|||
name,
|
||||
type,
|
||||
status,
|
||||
syncStatusMetadata,
|
||||
editedAt,
|
||||
syncedAt,
|
||||
failedRepos,
|
||||
}: ConnectionListItemProps) => {
|
||||
const statusDisplayName = useMemo(() => {
|
||||
switch (status) {
|
||||
|
|
@ -52,46 +56,145 @@ export const ConnectionListItem = ({
|
|||
}
|
||||
}, [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 (
|
||||
<Link href={`connections/${id}`}>
|
||||
<div
|
||||
className="flex flex-row justify-between items-center border p-4 rounded-lg cursor-pointer bg-background"
|
||||
>
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<ConnectionIcon
|
||||
type={type}
|
||||
className="w-8 h-8"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<p className="font-medium">{name}</p>
|
||||
<span className="text-sm text-muted-foreground">{`Edited ${getDisplayTime(editedAt)}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row items-center">
|
||||
<StatusIcon
|
||||
status={convertSyncStatus(status)}
|
||||
className="w-4 h-4 mr-1"
|
||||
/>
|
||||
<p className="text-sm">
|
||||
<span>{statusDisplayName}</span>
|
||||
{
|
||||
(
|
||||
status === ConnectionSyncStatus.SYNCED ||
|
||||
status === ConnectionSyncStatus.FAILED
|
||||
) && syncedAt && (
|
||||
<span>{` ${getDisplayTime(syncedAt)}`}</span>
|
||||
)
|
||||
}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size={"sm"}
|
||||
className="ml-4"
|
||||
>
|
||||
Manage
|
||||
</Button>
|
||||
<div
|
||||
className="flex flex-row justify-between items-center border p-4 rounded-lg bg-background"
|
||||
>
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<ConnectionIcon
|
||||
type={type}
|
||||
className="w-8 h-8"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<p className="font-medium">{name}</p>
|
||||
<span className="text-sm text-muted-foreground">{`Edited ${getDisplayTime(editedAt)}`}</span>
|
||||
</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'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>
|
||||
</Link>
|
||||
<div className="flex flex-row items-center">
|
||||
<StatusIcon
|
||||
status={convertSyncStatus(status)}
|
||||
className="w-4 h-4 mr-1"
|
||||
/>
|
||||
<p className="text-sm">
|
||||
<span>{statusDisplayName}</span>
|
||||
{
|
||||
(
|
||||
status === ConnectionSyncStatus.SYNCED ||
|
||||
status === ConnectionSyncStatus.FAILED
|
||||
) && syncedAt && (
|
||||
<span>{` ${getDisplayTime(syncedAt)}`}</span>
|
||||
)
|
||||
}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size={"sm"}
|
||||
className="ml-4"
|
||||
onClick={() => window.location.href = `connections/${id}`}
|
||||
>
|
||||
Manage
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import { cn } from "@/lib/utils";
|
|||
import { useEffect } from "react";
|
||||
import { InfoCircledIcon } from "@radix-ui/react-icons";
|
||||
import { useState } from "react";
|
||||
import { ConnectionSyncStatus } from "@sourcebot/db";
|
||||
import { getConnections } from "@/actions";
|
||||
import { ConnectionSyncStatus, Prisma } from "@sourcebot/db";
|
||||
import { getConnectionFailedRepos, getConnections } from "@/actions";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
|
||||
interface ConnectionListProps {
|
||||
|
|
@ -22,8 +22,10 @@ export const ConnectionList = ({
|
|||
name: string;
|
||||
connectionType: string;
|
||||
syncStatus: ConnectionSyncStatus;
|
||||
syncStatusMetadata: Prisma.JsonValue;
|
||||
updatedAt: Date;
|
||||
syncedAt?: Date;
|
||||
failedRepos?: { repoId: number, repoName: string }[];
|
||||
}[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -35,7 +37,19 @@ export const ConnectionList = ({
|
|||
if (isServiceError(result)) {
|
||||
setError(result.message);
|
||||
} 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);
|
||||
} catch (err) {
|
||||
|
|
@ -69,8 +83,10 @@ export const ConnectionList = ({
|
|||
name={connection.name}
|
||||
type={connection.connectionType}
|
||||
status={connection.syncStatus}
|
||||
syncStatusMetadata={connection.syncStatusMetadata}
|
||||
editedAt={connection.updatedAt}
|
||||
syncedAt={connection.syncedAt ?? undefined}
|
||||
failedRepos={connection.failedRepos}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
import { Cross2Icon } from "@radix-ui/react-icons";
|
||||
import { CircleCheckIcon } from "lucide-react";
|
||||
import { CircleCheckIcon, CircleXIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { FiLoader } from "react-icons/fi";
|
||||
|
||||
|
|
@ -18,7 +17,7 @@ export const StatusIcon = ({
|
|||
case 'succeeded':
|
||||
return <CircleCheckIcon className={cn('text-green-600', className)} />;
|
||||
case 'failed':
|
||||
return <Cross2Icon className={cn(className)} />;
|
||||
return <CircleXIcon className={cn('text-destructive', className)} />;
|
||||
|
||||
}
|
||||
}, [className, status]);
|
||||
|
|
|
|||
|
|
@ -79,6 +79,16 @@ export const gitlabQuickActions: QuickAction<GitlabConnectionConfig>[] = [
|
|||
]
|
||||
}),
|
||||
name: "Add a project",
|
||||
},
|
||||
{
|
||||
fn: (previous: GitlabConnectionConfig) => ({
|
||||
...previous,
|
||||
users: [
|
||||
...previous.users ?? [],
|
||||
""
|
||||
]
|
||||
}),
|
||||
name: "Add a user",
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -31,14 +31,17 @@ export const SecretsTable = ({ initialSecrets }: SecretsTableProps) => {
|
|||
const domain = useDomain();
|
||||
|
||||
useEffect(() => {
|
||||
getSecrets(domain).then((keys) => {
|
||||
if ('keys' in keys) {
|
||||
setSecrets(keys);
|
||||
} else {
|
||||
console.error(keys);
|
||||
const fetchSecretKeys = async () => {
|
||||
const keys = await getSecrets(domain);
|
||||
if (isServiceError(keys)) {
|
||||
console.error("Failed to fetch secrets:", keys);
|
||||
return;
|
||||
}
|
||||
})
|
||||
}, []);
|
||||
setSecrets(keys);
|
||||
};
|
||||
|
||||
fetchSecretKeys();
|
||||
}, [domain]);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
|
|
|
|||
29
packages/web/src/components/ui/hover-card.tsx
Normal file
29
packages/web/src/components/ui/hover-card.tsx
Normal 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 }
|
||||
17
packages/web/src/lib/syncStatusMetadataSchema.ts
Normal file
17
packages/web/src/lib/syncStatusMetadataSchema.ts
Normal 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>;
|
||||
Loading…
Reference in a new issue