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/db ./packages/db
|
||||||
COPY ./packages/schemas ./packages/schemas
|
COPY ./packages/schemas ./packages/schemas
|
||||||
COPY ./packages/crypto ./packages/crypto
|
COPY ./packages/crypto ./packages/crypto
|
||||||
|
COPY ./packages/error ./packages/error
|
||||||
RUN yarn workspace @sourcebot/db install --frozen-lockfile
|
RUN yarn workspace @sourcebot/db install --frozen-lockfile
|
||||||
RUN yarn workspace @sourcebot/schemas install --frozen-lockfile
|
RUN yarn workspace @sourcebot/schemas install --frozen-lockfile
|
||||||
RUN yarn workspace @sourcebot/crypto install --frozen-lockfile
|
RUN yarn workspace @sourcebot/crypto install --frozen-lockfile
|
||||||
|
RUN yarn workspace @sourcebot/error install --frozen-lockfile
|
||||||
|
|
||||||
# ------ Build Web ------
|
# ------ Build Web ------
|
||||||
FROM node-alpine AS web-builder
|
FROM node-alpine AS web-builder
|
||||||
|
|
@ -33,6 +35,7 @@ COPY --from=shared-libs-builder /app/node_modules ./node_modules
|
||||||
COPY --from=shared-libs-builder /app/packages/db ./packages/db
|
COPY --from=shared-libs-builder /app/packages/db ./packages/db
|
||||||
COPY --from=shared-libs-builder /app/packages/schemas ./packages/schemas
|
COPY --from=shared-libs-builder /app/packages/schemas ./packages/schemas
|
||||||
COPY --from=shared-libs-builder /app/packages/crypto ./packages/crypto
|
COPY --from=shared-libs-builder /app/packages/crypto ./packages/crypto
|
||||||
|
COPY --from=shared-libs-builder /app/packages/error ./packages/error
|
||||||
|
|
||||||
# Fixes arm64 timeouts
|
# Fixes arm64 timeouts
|
||||||
RUN yarn config set registry https://registry.npmjs.org/
|
RUN yarn config set registry https://registry.npmjs.org/
|
||||||
|
|
@ -66,6 +69,7 @@ COPY --from=shared-libs-builder /app/node_modules ./node_modules
|
||||||
COPY --from=shared-libs-builder /app/packages/db ./packages/db
|
COPY --from=shared-libs-builder /app/packages/db ./packages/db
|
||||||
COPY --from=shared-libs-builder /app/packages/schemas ./packages/schemas
|
COPY --from=shared-libs-builder /app/packages/schemas ./packages/schemas
|
||||||
COPY --from=shared-libs-builder /app/packages/crypto ./packages/crypto
|
COPY --from=shared-libs-builder /app/packages/crypto ./packages/crypto
|
||||||
|
COPY --from=shared-libs-builder /app/packages/error ./packages/error
|
||||||
RUN yarn workspace @sourcebot/backend install --frozen-lockfile
|
RUN yarn workspace @sourcebot/backend install --frozen-lockfile
|
||||||
RUN yarn workspace @sourcebot/backend build
|
RUN yarn workspace @sourcebot/backend build
|
||||||
|
|
||||||
|
|
@ -138,6 +142,7 @@ COPY --from=shared-libs-builder /app/node_modules ./node_modules
|
||||||
COPY --from=shared-libs-builder /app/packages/db ./packages/db
|
COPY --from=shared-libs-builder /app/packages/db ./packages/db
|
||||||
COPY --from=shared-libs-builder /app/packages/schemas ./packages/schemas
|
COPY --from=shared-libs-builder /app/packages/schemas ./packages/schemas
|
||||||
COPY --from=shared-libs-builder /app/packages/crypto ./packages/crypto
|
COPY --from=shared-libs-builder /app/packages/crypto ./packages/crypto
|
||||||
|
COPY --from=shared-libs-builder /app/packages/error ./packages/error
|
||||||
|
|
||||||
# Configure the database
|
# Configure the database
|
||||||
RUN mkdir -p /run/postgresql && \
|
RUN mkdir -p /run/postgresql && \
|
||||||
|
|
|
||||||
2
Makefile
2
Makefile
|
|
@ -26,6 +26,8 @@ clean:
|
||||||
packages/schemas/dist \
|
packages/schemas/dist \
|
||||||
packages/crypto/node_modules \
|
packages/crypto/node_modules \
|
||||||
packages/crypto/dist \
|
packages/crypto/dist \
|
||||||
|
packages/error/node_modules \
|
||||||
|
packages/error/dist \
|
||||||
.sourcebot
|
.sourcebot
|
||||||
|
|
||||||
soft-reset:
|
soft-reset:
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@
|
||||||
"@sourcebot/crypto": "^0.1.0",
|
"@sourcebot/crypto": "^0.1.0",
|
||||||
"@sourcebot/db": "^0.1.0",
|
"@sourcebot/db": "^0.1.0",
|
||||||
"@sourcebot/schemas": "^0.1.0",
|
"@sourcebot/schemas": "^0.1.0",
|
||||||
|
"@sourcebot/error": "^0.1.0",
|
||||||
"simple-git": "^3.27.0",
|
"simple-git": "^3.27.0",
|
||||||
"strip-json-comments": "^5.0.1",
|
"strip-json-comments": "^5.0.1",
|
||||||
"winston": "^3.15.0",
|
"winston": "^3.15.0",
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { createLogger } from "./logger.js";
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import { Redis } from 'ioredis';
|
import { Redis } from 'ioredis';
|
||||||
import { RepoData, compileGithubConfig, compileGitlabConfig, compileGiteaConfig, compileGerritConfig } from "./repoCompileUtils.js";
|
import { RepoData, compileGithubConfig, compileGitlabConfig, compileGiteaConfig, compileGerritConfig } from "./repoCompileUtils.js";
|
||||||
|
import { BackendError, BackendException } from "@sourcebot/error";
|
||||||
|
|
||||||
interface IConnectionManager {
|
interface IConnectionManager {
|
||||||
scheduleConnectionSync: (connection: Connection) => Promise<void>;
|
scheduleConnectionSync: (connection: Connection) => Promise<void>;
|
||||||
|
|
@ -81,26 +82,93 @@ export class ConnectionManager implements IConnectionManager {
|
||||||
// @note: We aren't actually doing anything with this atm.
|
// @note: We aren't actually doing anything with this atm.
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
|
||||||
const repoData: RepoData[] = await (async () => {
|
const connection = await this.db.connection.findUnique({
|
||||||
switch (config.type) {
|
where: {
|
||||||
case 'github': {
|
id: job.data.connectionId,
|
||||||
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 [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
|
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.
|
// Filter out any duplicates by external_id and external_codeHostUrl.
|
||||||
repoData.filter((repo, index, self) => {
|
repoData.filter((repo, index, self) => {
|
||||||
return index === self.findIndex(r =>
|
return index === self.findIndex(r =>
|
||||||
|
|
@ -265,16 +333,37 @@ export class ConnectionManager implements IConnectionManager {
|
||||||
private async onSyncJobFailed(job: Job | undefined, err: unknown) {
|
private async onSyncJobFailed(job: Job | undefined, err: unknown) {
|
||||||
this.logger.info(`Connection sync job failed with error: ${err}`);
|
this.logger.info(`Connection sync job failed with error: ${err}`);
|
||||||
if (job) {
|
if (job) {
|
||||||
|
|
||||||
|
// We may have pushed some metadata during the execution of the job, so we make sure to not overwrite the metadata here
|
||||||
const { connectionId } = job.data;
|
const { connectionId } = job.data;
|
||||||
|
let syncStatusMetadata: Record<string, unknown> = (await this.db.connection.findUnique({
|
||||||
|
where: { id: connectionId },
|
||||||
|
select: { syncStatusMetadata: true }
|
||||||
|
}))?.syncStatusMetadata as Record<string, unknown> ?? {};
|
||||||
|
|
||||||
|
if (err instanceof BackendException) {
|
||||||
|
syncStatusMetadata = {
|
||||||
|
...syncStatusMetadata,
|
||||||
|
error: err.code,
|
||||||
|
...err.metadata,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
syncStatusMetadata = {
|
||||||
|
...syncStatusMetadata,
|
||||||
|
error: 'UNKNOWN',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await this.db.connection.update({
|
await this.db.connection.update({
|
||||||
where: {
|
where: {
|
||||||
id: connectionId,
|
id: connectionId,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
syncStatus: ConnectionSyncStatus.FAILED,
|
syncStatus: ConnectionSyncStatus.FAILED,
|
||||||
syncedAt: new Date()
|
syncedAt: new Date(),
|
||||||
|
syncStatusMetadata: syncStatusMetadata as Prisma.InputJsonValue,
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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 { createLogger } from './logger.js';
|
||||||
import micromatch from "micromatch";
|
import micromatch from "micromatch";
|
||||||
import { measure, marshalBool, excludeReposByName, includeReposByName, fetchWithRetry } from './utils.js';
|
import { measure, marshalBool, excludeReposByName, includeReposByName, fetchWithRetry } from './utils.js';
|
||||||
|
import { BackendError } from '@sourcebot/error';
|
||||||
|
import { BackendException } from '@sourcebot/error';
|
||||||
|
|
||||||
// https://gerrit-review.googlesource.com/Documentation/rest-api.html
|
// https://gerrit-review.googlesource.com/Documentation/rest-api.html
|
||||||
interface GerritProjects {
|
interface GerritProjects {
|
||||||
|
|
@ -38,6 +40,10 @@ export const getGerritReposFromConfig = async (config: GerritConfig): Promise<Ge
|
||||||
const fetchFn = () => fetchAllProjects(url);
|
const fetchFn = () => fetchAllProjects(url);
|
||||||
return fetchWithRetry(fetchFn, `projects from ${url}`, logger);
|
return fetchWithRetry(fetchFn, `projects from ${url}`, logger);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof BackendException) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
logger.error(`Failed to fetch projects from ${url}`, err);
|
logger.error(`Failed to fetch projects from ${url}`, err);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -78,9 +84,25 @@ const fetchAllProjects = async (url: string): Promise<GerritProject[]> => {
|
||||||
const endpointWithParams = `${projectsEndpoint}?S=${start}`;
|
const endpointWithParams = `${projectsEndpoint}?S=${start}`;
|
||||||
logger.debug(`Fetching projects from Gerrit at ${endpointWithParams}`);
|
logger.debug(`Fetching projects from Gerrit at ${endpointWithParams}`);
|
||||||
|
|
||||||
const response = await fetch(endpointWithParams);
|
let response: Response;
|
||||||
if (!response.ok) {
|
try {
|
||||||
throw new Error(`Failed to fetch projects from Gerrit: ${response.statusText}`);
|
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();
|
const text = await response.text();
|
||||||
|
|
|
||||||
|
|
@ -6,43 +6,45 @@ import { createLogger } from './logger.js';
|
||||||
import micromatch from 'micromatch';
|
import micromatch from 'micromatch';
|
||||||
import { PrismaClient } from '@sourcebot/db';
|
import { PrismaClient } from '@sourcebot/db';
|
||||||
import { FALLBACK_GITEA_TOKEN } from './environment.js';
|
import { FALLBACK_GITEA_TOKEN } from './environment.js';
|
||||||
|
import { processPromiseResults, throwIfAnyFailed } from './connectionUtils.js';
|
||||||
const logger = createLogger('Gitea');
|
const logger = createLogger('Gitea');
|
||||||
|
|
||||||
export const getGiteaReposFromConfig = async (config: GiteaConnectionConfig, orgId: number, db: PrismaClient) => {
|
export const getGiteaReposFromConfig = async (config: GiteaConnectionConfig, orgId: number, db: PrismaClient) => {
|
||||||
const token = config.token ? await getTokenFromConfig(config.token, orgId, db) : undefined;
|
const tokenResult = config.token ? await getTokenFromConfig(config.token, orgId, db) : undefined;
|
||||||
|
const token = tokenResult?.token ?? FALLBACK_GITEA_TOKEN;
|
||||||
|
|
||||||
const api = giteaApi(config.url ?? 'https://gitea.com', {
|
const api = giteaApi(config.url ?? 'https://gitea.com', {
|
||||||
token: token ?? FALLBACK_GITEA_TOKEN,
|
token: token,
|
||||||
customFetch: fetch,
|
customFetch: fetch,
|
||||||
});
|
});
|
||||||
|
|
||||||
let allRepos: GiteaRepository[] = [];
|
let allRepos: GiteaRepository[] = [];
|
||||||
|
let notFound: {
|
||||||
|
users: string[],
|
||||||
|
orgs: string[],
|
||||||
|
repos: string[],
|
||||||
|
} = {
|
||||||
|
users: [],
|
||||||
|
orgs: [],
|
||||||
|
repos: [],
|
||||||
|
};
|
||||||
|
|
||||||
if (config.orgs) {
|
if (config.orgs) {
|
||||||
const _repos = await fetchWithRetry(
|
const { validRepos, notFoundOrgs } = await getReposForOrgs(config.orgs, api);
|
||||||
() => getReposForOrgs(config.orgs!, api),
|
allRepos = allRepos.concat(validRepos);
|
||||||
`orgs ${config.orgs.join(', ')}`,
|
notFound.orgs = notFoundOrgs;
|
||||||
logger
|
|
||||||
);
|
|
||||||
allRepos = allRepos.concat(_repos);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.repos) {
|
if (config.repos) {
|
||||||
const _repos = await fetchWithRetry(
|
const { validRepos, notFoundRepos } = await getRepos(config.repos, api);
|
||||||
() => getRepos(config.repos!, api),
|
allRepos = allRepos.concat(validRepos);
|
||||||
`repos ${config.repos.join(', ')}`,
|
notFound.repos = notFoundRepos;
|
||||||
logger
|
|
||||||
);
|
|
||||||
allRepos = allRepos.concat(_repos);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.users) {
|
if (config.users) {
|
||||||
const _repos = await fetchWithRetry(
|
const { validRepos, notFoundUsers } = await getReposOwnedByUsers(config.users, api);
|
||||||
() => getReposOwnedByUsers(config.users!, api),
|
allRepos = allRepos.concat(validRepos);
|
||||||
`users ${config.users.join(', ')}`,
|
notFound.users = notFoundUsers;
|
||||||
logger
|
|
||||||
);
|
|
||||||
allRepos = allRepos.concat(_repos);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
allRepos = allRepos.filter(repo => repo.full_name !== undefined);
|
allRepos = allRepos.filter(repo => repo.full_name !== undefined);
|
||||||
|
|
@ -108,7 +110,10 @@ export const getGiteaReposFromConfig = async (config: GiteaConnectionConfig, org
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.debug(`Found ${repos.length} total repositories.`);
|
logger.debug(`Found ${repos.length} total repositories.`);
|
||||||
return repos;
|
return {
|
||||||
|
validRepos: repos,
|
||||||
|
notFound,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldExcludeRepo = ({
|
const shouldExcludeRepo = ({
|
||||||
|
|
@ -186,7 +191,7 @@ const getBranchesForRepo = async <T>(owner: string, repo: string, api: Api<T>) =
|
||||||
}
|
}
|
||||||
|
|
||||||
const getReposOwnedByUsers = async <T>(users: string[], api: Api<T>) => {
|
const getReposOwnedByUsers = async <T>(users: string[], api: Api<T>) => {
|
||||||
const repos = (await Promise.all(users.map(async (user) => {
|
const results = await Promise.allSettled(users.map(async (user) => {
|
||||||
try {
|
try {
|
||||||
logger.debug(`Fetching repos for user ${user}...`);
|
logger.debug(`Fetching repos for user ${user}...`);
|
||||||
|
|
||||||
|
|
@ -197,18 +202,33 @@ const getReposOwnedByUsers = async <T>(users: string[], api: Api<T>) => {
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.debug(`Found ${data.length} repos owned by user ${user} in ${durationMs}ms.`);
|
logger.debug(`Found ${data.length} repos owned by user ${user} in ${durationMs}ms.`);
|
||||||
return data;
|
return {
|
||||||
} catch (e) {
|
type: 'valid' as const,
|
||||||
logger.error(`Failed to fetch repos for user ${user}.`, e);
|
data
|
||||||
|
};
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.status === 404) {
|
||||||
|
logger.error(`User ${user} not found or no access`);
|
||||||
|
return {
|
||||||
|
type: 'notFound' as const,
|
||||||
|
value: user
|
||||||
|
};
|
||||||
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}))).flat();
|
}));
|
||||||
|
|
||||||
return repos;
|
throwIfAnyFailed(results);
|
||||||
|
const { validItems: validRepos, notFoundItems: notFoundUsers } = processPromiseResults<GiteaRepository>(results);
|
||||||
|
|
||||||
|
return {
|
||||||
|
validRepos,
|
||||||
|
notFoundUsers,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const getReposForOrgs = async <T>(orgs: string[], api: Api<T>) => {
|
const getReposForOrgs = async <T>(orgs: string[], api: Api<T>) => {
|
||||||
return (await Promise.all(orgs.map(async (org) => {
|
const results = await Promise.allSettled(orgs.map(async (org) => {
|
||||||
try {
|
try {
|
||||||
logger.debug(`Fetching repos for org ${org}...`);
|
logger.debug(`Fetching repos for org ${org}...`);
|
||||||
|
|
||||||
|
|
@ -220,16 +240,33 @@ const getReposForOrgs = async <T>(orgs: string[], api: Api<T>) => {
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.debug(`Found ${data.length} repos for org ${org} in ${durationMs}ms.`);
|
logger.debug(`Found ${data.length} repos for org ${org} in ${durationMs}ms.`);
|
||||||
return data;
|
return {
|
||||||
} catch (e) {
|
type: 'valid' as const,
|
||||||
logger.error(`Failed to fetch repos for org ${org}.`, e);
|
data
|
||||||
|
};
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.status === 404) {
|
||||||
|
logger.error(`Organization ${org} not found or no access`);
|
||||||
|
return {
|
||||||
|
type: 'notFound' as const,
|
||||||
|
value: org
|
||||||
|
};
|
||||||
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}))).flat();
|
}));
|
||||||
|
|
||||||
|
throwIfAnyFailed(results);
|
||||||
|
const { validItems: validRepos, notFoundItems: notFoundOrgs } = processPromiseResults<GiteaRepository>(results);
|
||||||
|
|
||||||
|
return {
|
||||||
|
validRepos,
|
||||||
|
notFoundOrgs,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const getRepos = async <T>(repos: string[], api: Api<T>) => {
|
const getRepos = async <T>(repos: string[], api: Api<T>) => {
|
||||||
return (await Promise.all(repos.map(async (repo) => {
|
const results = await Promise.allSettled(repos.map(async (repo) => {
|
||||||
try {
|
try {
|
||||||
logger.debug(`Fetching repository info for ${repo}...`);
|
logger.debug(`Fetching repository info for ${repo}...`);
|
||||||
|
|
||||||
|
|
@ -239,13 +276,29 @@ const getRepos = async <T>(repos: string[], api: Api<T>) => {
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.debug(`Found repo ${repo} in ${durationMs}ms.`);
|
logger.debug(`Found repo ${repo} in ${durationMs}ms.`);
|
||||||
|
return {
|
||||||
return [response.data];
|
type: 'valid' as const,
|
||||||
} catch (e) {
|
data: [response.data]
|
||||||
logger.error(`Failed to fetch repository info for ${repo}.`, e);
|
};
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.status === 404) {
|
||||||
|
logger.error(`Repository ${repo} not found or no access`);
|
||||||
|
return {
|
||||||
|
type: 'notFound' as const,
|
||||||
|
value: repo
|
||||||
|
};
|
||||||
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}))).flat();
|
}));
|
||||||
|
|
||||||
|
throwIfAnyFailed(results);
|
||||||
|
const { validItems: validRepos, notFoundItems: notFoundRepos } = processPromiseResults<GiteaRepository>(results);
|
||||||
|
|
||||||
|
return {
|
||||||
|
validRepos,
|
||||||
|
notFoundRepos,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// @see : https://docs.gitea.com/development/api-usage#pagination
|
// @see : https://docs.gitea.com/development/api-usage#pagination
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import { getTokenFromConfig, measure, fetchWithRetry } from "./utils.js";
|
||||||
import micromatch from "micromatch";
|
import micromatch from "micromatch";
|
||||||
import { PrismaClient } from "@sourcebot/db";
|
import { PrismaClient } from "@sourcebot/db";
|
||||||
import { FALLBACK_GITHUB_TOKEN } from "./environment.js";
|
import { FALLBACK_GITHUB_TOKEN } from "./environment.js";
|
||||||
|
import { BackendException, BackendError } from "@sourcebot/error";
|
||||||
|
import { processPromiseResults, throwIfAnyFailed } from "./connectionUtils.js";
|
||||||
const logger = createLogger("GitHub");
|
const logger = createLogger("GitHub");
|
||||||
|
|
||||||
export type OctokitRepository = {
|
export type OctokitRepository = {
|
||||||
|
|
@ -28,8 +30,17 @@ export type OctokitRepository = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isHttpError = (error: unknown, status: number): boolean => {
|
||||||
|
return error !== null
|
||||||
|
&& typeof error === 'object'
|
||||||
|
&& 'status' in error
|
||||||
|
&& error.status === status;
|
||||||
|
}
|
||||||
|
|
||||||
export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, orgId: number, db: PrismaClient, signal: AbortSignal) => {
|
export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, orgId: number, db: PrismaClient, signal: AbortSignal) => {
|
||||||
const token = config.token ? await getTokenFromConfig(config.token, orgId, db) : undefined;
|
const tokenResult = config.token ? await getTokenFromConfig(config.token, orgId, db) : undefined;
|
||||||
|
const token = tokenResult?.token;
|
||||||
|
const secretKey = tokenResult?.secretKey;
|
||||||
|
|
||||||
const octokit = new Octokit({
|
const octokit = new Octokit({
|
||||||
auth: token ?? FALLBACK_GITHUB_TOKEN,
|
auth: token ?? FALLBACK_GITHUB_TOKEN,
|
||||||
|
|
@ -38,25 +49,52 @@ export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, o
|
||||||
} : {}),
|
} : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
await octokit.rest.users.getAuthenticated();
|
||||||
|
} catch (error) {
|
||||||
|
if (isHttpError(error, 401)) {
|
||||||
|
throw new BackendException(BackendError.CONNECTION_SYNC_INVALID_TOKEN, {
|
||||||
|
secretKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BackendException(BackendError.CONNECTION_SYNC_SYSTEM_ERROR, {
|
||||||
|
message: `Failed to authenticate with GitHub`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let allRepos: OctokitRepository[] = [];
|
let allRepos: OctokitRepository[] = [];
|
||||||
|
let notFound: {
|
||||||
|
users: string[],
|
||||||
|
orgs: string[],
|
||||||
|
repos: string[],
|
||||||
|
} = {
|
||||||
|
users: [],
|
||||||
|
orgs: [],
|
||||||
|
repos: [],
|
||||||
|
};
|
||||||
|
|
||||||
if (config.orgs) {
|
if (config.orgs) {
|
||||||
const _repos = await getReposForOrgs(config.orgs, octokit, signal);
|
const { validRepos, notFoundOrgs } = await getReposForOrgs(config.orgs, octokit, signal);
|
||||||
allRepos = allRepos.concat(_repos);
|
allRepos = allRepos.concat(validRepos);
|
||||||
|
notFound.orgs = notFoundOrgs;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.repos) {
|
if (config.repos) {
|
||||||
const _repos = await getRepos(config.repos, octokit, signal);
|
const { validRepos, notFoundRepos } = await getRepos(config.repos, octokit, signal);
|
||||||
allRepos = allRepos.concat(_repos);
|
allRepos = allRepos.concat(validRepos);
|
||||||
|
notFound.repos = notFoundRepos;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.users) {
|
if (config.users) {
|
||||||
const isAuthenticated = config.token !== undefined;
|
const isAuthenticated = config.token !== undefined;
|
||||||
const _repos = await getReposOwnedByUsers(config.users, isAuthenticated, octokit, signal);
|
const { validRepos, notFoundUsers } = await getReposOwnedByUsers(config.users, isAuthenticated, octokit, signal);
|
||||||
allRepos = allRepos.concat(_repos);
|
allRepos = allRepos.concat(validRepos);
|
||||||
|
notFound.users = notFoundUsers;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Marshall results to our type
|
|
||||||
let repos = allRepos
|
let repos = allRepos
|
||||||
.filter((repo) => {
|
.filter((repo) => {
|
||||||
const isExcluded = shouldExcludeRepo({
|
const isExcluded = shouldExcludeRepo({
|
||||||
|
|
@ -72,21 +110,10 @@ export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, o
|
||||||
|
|
||||||
logger.debug(`Found ${repos.length} total repositories.`);
|
logger.debug(`Found ${repos.length} total repositories.`);
|
||||||
|
|
||||||
return repos;
|
return {
|
||||||
}
|
validRepos: repos,
|
||||||
|
notFound,
|
||||||
export const getGitHubRepoFromId = async (id: string, hostURL: string, token?: string) => {
|
};
|
||||||
const octokit = new Octokit({
|
|
||||||
auth: token ?? FALLBACK_GITHUB_TOKEN,
|
|
||||||
...(hostURL !== 'https://github.com' ? {
|
|
||||||
baseUrl: `${hostURL}/api/v3`
|
|
||||||
} : {})
|
|
||||||
});
|
|
||||||
|
|
||||||
const repo = await octokit.request('GET /repositories/:id', {
|
|
||||||
id,
|
|
||||||
});
|
|
||||||
return repo;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const shouldExcludeRepo = ({
|
export const shouldExcludeRepo = ({
|
||||||
|
|
@ -176,7 +203,7 @@ export const shouldExcludeRepo = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
const getReposOwnedByUsers = async (users: string[], isAuthenticated: boolean, octokit: Octokit, signal: AbortSignal) => {
|
const getReposOwnedByUsers = async (users: string[], isAuthenticated: boolean, octokit: Octokit, signal: AbortSignal) => {
|
||||||
const repos = (await Promise.all(users.map(async (user) => {
|
const results = await Promise.allSettled(users.map(async (user) => {
|
||||||
try {
|
try {
|
||||||
logger.debug(`Fetching repository info for user ${user}...`);
|
logger.debug(`Fetching repository info for user ${user}...`);
|
||||||
|
|
||||||
|
|
@ -207,18 +234,33 @@ const getReposOwnedByUsers = async (users: string[], isAuthenticated: boolean, o
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.debug(`Found ${data.length} owned by user ${user} in ${durationMs}ms.`);
|
logger.debug(`Found ${data.length} owned by user ${user} in ${durationMs}ms.`);
|
||||||
return data;
|
return {
|
||||||
} catch (e) {
|
type: 'valid' as const,
|
||||||
logger.error(`Failed to fetch repository info for user ${user}.`, e);
|
data
|
||||||
throw e;
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (isHttpError(error, 404)) {
|
||||||
|
logger.error(`User ${user} not found or no access`);
|
||||||
|
return {
|
||||||
|
type: 'notFound' as const,
|
||||||
|
value: user
|
||||||
|
};
|
||||||
|
}
|
||||||
|
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 getReposForOrgs = async (orgs: string[], octokit: Octokit, signal: AbortSignal) => {
|
||||||
const repos = (await Promise.all(orgs.map(async (org) => {
|
const results = await Promise.allSettled(orgs.map(async (org) => {
|
||||||
try {
|
try {
|
||||||
logger.info(`Fetching repository info for org ${org}...`);
|
logger.info(`Fetching repository info for org ${org}...`);
|
||||||
|
|
||||||
|
|
@ -235,18 +277,33 @@ const getReposForOrgs = async (orgs: string[], octokit: Octokit, signal: AbortSi
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`Found ${data.length} in org ${org} in ${durationMs}ms.`);
|
logger.info(`Found ${data.length} in org ${org} in ${durationMs}ms.`);
|
||||||
return data;
|
return {
|
||||||
} catch (e) {
|
type: 'valid' as const,
|
||||||
logger.error(`Failed to fetch repository info for org ${org}.`, e);
|
data
|
||||||
throw e;
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (isHttpError(error, 404)) {
|
||||||
|
logger.error(`Organization ${org} not found or no access`);
|
||||||
|
return {
|
||||||
|
type: 'notFound' as const,
|
||||||
|
value: org
|
||||||
|
};
|
||||||
|
}
|
||||||
|
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 getRepos = async (repoList: string[], octokit: Octokit, signal: AbortSignal) => {
|
||||||
const repos = (await Promise.all(repoList.map(async (repo) => {
|
const results = await Promise.allSettled(repoList.map(async (repo) => {
|
||||||
try {
|
try {
|
||||||
const [owner, repoName] = repo.split('/');
|
const [owner, repoName] = repo.split('/');
|
||||||
logger.info(`Fetching repository info for ${repo}...`);
|
logger.info(`Fetching repository info for ${repo}...`);
|
||||||
|
|
@ -264,13 +321,28 @@ const getRepos = async (repoList: string[], octokit: Octokit, signal: AbortSigna
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`Found info for repository ${repo} in ${durationMs}ms`);
|
logger.info(`Found info for repository ${repo} in ${durationMs}ms`);
|
||||||
|
return {
|
||||||
|
type: 'valid' as const,
|
||||||
|
data: [result.data]
|
||||||
|
};
|
||||||
|
|
||||||
return [result.data];
|
} catch (error) {
|
||||||
} catch (e) {
|
if (isHttpError(error, 404)) {
|
||||||
logger.error(`Failed to fetch repository info for ${repo}.`, e);
|
logger.error(`Repository ${repo} not found or no access`);
|
||||||
throw e;
|
return {
|
||||||
|
type: 'notFound' as const,
|
||||||
|
value: repo
|
||||||
|
};
|
||||||
|
}
|
||||||
|
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 { getTokenFromConfig, measure, fetchWithRetry } from "./utils.js";
|
||||||
import { PrismaClient } from "@sourcebot/db";
|
import { PrismaClient } from "@sourcebot/db";
|
||||||
import { FALLBACK_GITLAB_TOKEN } from "./environment.js";
|
import { FALLBACK_GITLAB_TOKEN } from "./environment.js";
|
||||||
|
import { processPromiseResults, throwIfAnyFailed } from "./connectionUtils.js";
|
||||||
const logger = createLogger("GitLab");
|
const logger = createLogger("GitLab");
|
||||||
export const GITLAB_CLOUD_HOSTNAME = "gitlab.com";
|
export const GITLAB_CLOUD_HOSTNAME = "gitlab.com";
|
||||||
|
|
||||||
export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, orgId: number, db: PrismaClient) => {
|
export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, orgId: number, db: PrismaClient) => {
|
||||||
const token = config.token ? await getTokenFromConfig(config.token, orgId, db) : undefined;
|
const tokenResult = config.token ? await getTokenFromConfig(config.token, orgId, db) : undefined;
|
||||||
|
const token = tokenResult?.token ?? FALLBACK_GITLAB_TOKEN;
|
||||||
|
const secretKey = tokenResult?.secretKey;
|
||||||
|
|
||||||
const api = new Gitlab({
|
const api = new Gitlab({
|
||||||
...(token ? {
|
...(token ? {
|
||||||
token,
|
token,
|
||||||
} : {
|
} : {}),
|
||||||
token: FALLBACK_GITLAB_TOKEN,
|
|
||||||
}),
|
|
||||||
...(config.url ? {
|
...(config.url ? {
|
||||||
host: config.url,
|
host: config.url,
|
||||||
} : {}),
|
} : {}),
|
||||||
});
|
});
|
||||||
const hostname = config.url ? new URL(config.url).hostname : GITLAB_CLOUD_HOSTNAME;
|
const hostname = config.url ? new URL(config.url).hostname : GITLAB_CLOUD_HOSTNAME;
|
||||||
|
|
||||||
let allProjects: ProjectSchema[] = [];
|
let allRepos: ProjectSchema[] = [];
|
||||||
|
let notFound: {
|
||||||
|
orgs: string[],
|
||||||
|
users: string[],
|
||||||
|
repos: string[],
|
||||||
|
} = {
|
||||||
|
orgs: [],
|
||||||
|
users: [],
|
||||||
|
repos: [],
|
||||||
|
};
|
||||||
|
|
||||||
if (config.all === true) {
|
if (config.all === true) {
|
||||||
if (hostname !== GITLAB_CLOUD_HOSTNAME) {
|
if (hostname !== GITLAB_CLOUD_HOSTNAME) {
|
||||||
|
|
@ -35,7 +46,7 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o
|
||||||
return fetchWithRetry(fetchFn, `all projects in ${config.url}`, logger);
|
return fetchWithRetry(fetchFn, `all projects in ${config.url}`, logger);
|
||||||
});
|
});
|
||||||
logger.debug(`Found ${_projects.length} projects in ${durationMs}ms.`);
|
logger.debug(`Found ${_projects.length} projects in ${durationMs}ms.`);
|
||||||
allProjects = allProjects.concat(_projects);
|
allRepos = allRepos.concat(_projects);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(`Failed to fetch all projects visible in ${config.url}.`, e);
|
logger.error(`Failed to fetch all projects visible in ${config.url}.`, e);
|
||||||
throw e;
|
throw e;
|
||||||
|
|
@ -46,7 +57,7 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.groups) {
|
if (config.groups) {
|
||||||
const _projects = (await Promise.all(config.groups.map(async (group) => {
|
const results = await Promise.allSettled(config.groups.map(async (group) => {
|
||||||
try {
|
try {
|
||||||
logger.debug(`Fetching project info for group ${group}...`);
|
logger.debug(`Fetching project info for group ${group}...`);
|
||||||
const { durationMs, data } = await measure(async () => {
|
const { durationMs, data } = await measure(async () => {
|
||||||
|
|
@ -57,18 +68,31 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o
|
||||||
return fetchWithRetry(fetchFn, `group ${group}`, logger);
|
return fetchWithRetry(fetchFn, `group ${group}`, logger);
|
||||||
});
|
});
|
||||||
logger.debug(`Found ${data.length} projects in group ${group} in ${durationMs}ms.`);
|
logger.debug(`Found ${data.length} projects in group ${group} in ${durationMs}ms.`);
|
||||||
return data;
|
return {
|
||||||
} catch (e) {
|
type: 'valid' as const,
|
||||||
logger.error(`Failed to fetch project info for group ${group}.`, e);
|
data
|
||||||
|
};
|
||||||
|
} catch (e: any) {
|
||||||
|
const status = e?.cause?.response?.status;
|
||||||
|
if (status === 404) {
|
||||||
|
logger.error(`Group ${group} not found or no access`);
|
||||||
|
return {
|
||||||
|
type: 'notFound' as const,
|
||||||
|
value: group
|
||||||
|
};
|
||||||
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}))).flat();
|
}));
|
||||||
|
|
||||||
allProjects = allProjects.concat(_projects);
|
throwIfAnyFailed(results);
|
||||||
|
const { validItems: validRepos, notFoundItems: notFoundOrgs } = processPromiseResults(results);
|
||||||
|
allRepos = allRepos.concat(validRepos);
|
||||||
|
notFound.orgs = notFoundOrgs;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.users) {
|
if (config.users) {
|
||||||
const _projects = (await Promise.all(config.users.map(async (user) => {
|
const results = await Promise.allSettled(config.users.map(async (user) => {
|
||||||
try {
|
try {
|
||||||
logger.debug(`Fetching project info for user ${user}...`);
|
logger.debug(`Fetching project info for user ${user}...`);
|
||||||
const { durationMs, data } = await measure(async () => {
|
const { durationMs, data } = await measure(async () => {
|
||||||
|
|
@ -78,18 +102,31 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o
|
||||||
return fetchWithRetry(fetchFn, `user ${user}`, logger);
|
return fetchWithRetry(fetchFn, `user ${user}`, logger);
|
||||||
});
|
});
|
||||||
logger.debug(`Found ${data.length} projects owned by user ${user} in ${durationMs}ms.`);
|
logger.debug(`Found ${data.length} projects owned by user ${user} in ${durationMs}ms.`);
|
||||||
return data;
|
return {
|
||||||
} catch (e) {
|
type: 'valid' as const,
|
||||||
logger.error(`Failed to fetch project info for user ${user}.`, e);
|
data
|
||||||
|
};
|
||||||
|
} catch (e: any) {
|
||||||
|
const status = e?.cause?.response?.status;
|
||||||
|
if (status === 404) {
|
||||||
|
logger.error(`User ${user} not found or no access`);
|
||||||
|
return {
|
||||||
|
type: 'notFound' as const,
|
||||||
|
value: user
|
||||||
|
};
|
||||||
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}))).flat();
|
}));
|
||||||
|
|
||||||
allProjects = allProjects.concat(_projects);
|
throwIfAnyFailed(results);
|
||||||
|
const { validItems: validRepos, notFoundItems: notFoundUsers } = processPromiseResults(results);
|
||||||
|
allRepos = allRepos.concat(validRepos);
|
||||||
|
notFound.users = notFoundUsers;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.projects) {
|
if (config.projects) {
|
||||||
const _projects = (await Promise.all(config.projects.map(async (project) => {
|
const results = await Promise.allSettled(config.projects.map(async (project) => {
|
||||||
try {
|
try {
|
||||||
logger.debug(`Fetching project info for project ${project}...`);
|
logger.debug(`Fetching project info for project ${project}...`);
|
||||||
const { durationMs, data } = await measure(async () => {
|
const { durationMs, data } = await measure(async () => {
|
||||||
|
|
@ -97,17 +134,31 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o
|
||||||
return fetchWithRetry(fetchFn, `project ${project}`, logger);
|
return fetchWithRetry(fetchFn, `project ${project}`, logger);
|
||||||
});
|
});
|
||||||
logger.debug(`Found project ${project} in ${durationMs}ms.`);
|
logger.debug(`Found project ${project} in ${durationMs}ms.`);
|
||||||
return [data];
|
return {
|
||||||
} catch (e) {
|
type: 'valid' as const,
|
||||||
logger.error(`Failed to fetch project info for project ${project}.`, e);
|
data: [data]
|
||||||
|
};
|
||||||
|
} catch (e: any) {
|
||||||
|
const status = e?.cause?.response?.status;
|
||||||
|
|
||||||
|
if (status === 404) {
|
||||||
|
logger.error(`Project ${project} not found or no access`);
|
||||||
|
return {
|
||||||
|
type: 'notFound' as const,
|
||||||
|
value: project
|
||||||
|
};
|
||||||
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}))).flat();
|
}));
|
||||||
|
|
||||||
allProjects = allProjects.concat(_projects);
|
throwIfAnyFailed(results);
|
||||||
|
const { validItems: validRepos, notFoundItems: notFoundRepos } = processPromiseResults(results);
|
||||||
|
allRepos = allRepos.concat(validRepos);
|
||||||
|
notFound.repos = notFoundRepos;
|
||||||
}
|
}
|
||||||
|
|
||||||
let repos = allProjects
|
let repos = allRepos
|
||||||
.filter((project) => {
|
.filter((project) => {
|
||||||
const isExcluded = shouldExcludeProject({
|
const isExcluded = shouldExcludeProject({
|
||||||
project,
|
project,
|
||||||
|
|
@ -122,7 +173,10 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o
|
||||||
|
|
||||||
logger.debug(`Found ${repos.length} total repositories.`);
|
logger.debug(`Found ${repos.length} total repositories.`);
|
||||||
|
|
||||||
return repos;
|
return {
|
||||||
|
validRepos: repos,
|
||||||
|
notFound,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const shouldExcludeProject = ({
|
export const shouldExcludeProject = ({
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,22 @@ export const compileGithubConfig = async (
|
||||||
connectionId: number,
|
connectionId: number,
|
||||||
orgId: number,
|
orgId: number,
|
||||||
db: PrismaClient,
|
db: PrismaClient,
|
||||||
abortController: AbortController): Promise<RepoData[]> => {
|
abortController: AbortController): Promise<{
|
||||||
const gitHubRepos = await getGitHubReposFromConfig(config, orgId, db, abortController.signal);
|
repoData: RepoData[],
|
||||||
|
notFound: {
|
||||||
|
users: string[],
|
||||||
|
orgs: string[],
|
||||||
|
repos: string[],
|
||||||
|
}
|
||||||
|
}> => {
|
||||||
|
const gitHubReposResult = await getGitHubReposFromConfig(config, orgId, db, abortController.signal);
|
||||||
|
const gitHubRepos = gitHubReposResult.validRepos;
|
||||||
|
const notFound = gitHubReposResult.notFound;
|
||||||
|
|
||||||
const hostUrl = config.url ?? 'https://github.com';
|
const hostUrl = config.url ?? 'https://github.com';
|
||||||
const hostname = config.url ? new URL(config.url).hostname : 'github.com';
|
const hostname = config.url ? new URL(config.url).hostname : 'github.com';
|
||||||
|
|
||||||
return gitHubRepos.map((repo) => {
|
const repos = gitHubRepos.map((repo) => {
|
||||||
const repoName = `${hostname}/${repo.full_name}`;
|
const repoName = `${hostname}/${repo.full_name}`;
|
||||||
const cloneUrl = new URL(repo.clone_url!);
|
const cloneUrl = new URL(repo.clone_url!);
|
||||||
|
|
||||||
|
|
@ -59,6 +69,11 @@ export const compileGithubConfig = async (
|
||||||
|
|
||||||
return record;
|
return record;
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
repoData: repos,
|
||||||
|
notFound,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const compileGitlabConfig = async (
|
export const compileGitlabConfig = async (
|
||||||
|
|
@ -67,10 +82,13 @@ export const compileGitlabConfig = async (
|
||||||
orgId: number,
|
orgId: number,
|
||||||
db: PrismaClient) => {
|
db: PrismaClient) => {
|
||||||
|
|
||||||
const gitlabRepos = await getGitLabReposFromConfig(config, orgId, db);
|
const gitlabReposResult = await getGitLabReposFromConfig(config, orgId, db);
|
||||||
|
const gitlabRepos = gitlabReposResult.validRepos;
|
||||||
|
const notFound = gitlabReposResult.notFound;
|
||||||
|
|
||||||
const hostUrl = config.url ?? 'https://gitlab.com';
|
const hostUrl = config.url ?? 'https://gitlab.com';
|
||||||
|
|
||||||
return gitlabRepos.map((project) => {
|
const repos = gitlabRepos.map((project) => {
|
||||||
const projectUrl = `${hostUrl}/${project.path_with_namespace}`;
|
const projectUrl = `${hostUrl}/${project.path_with_namespace}`;
|
||||||
const cloneUrl = new URL(project.http_url_to_repo);
|
const cloneUrl = new URL(project.http_url_to_repo);
|
||||||
const isFork = project.forked_from_project !== undefined;
|
const isFork = project.forked_from_project !== undefined;
|
||||||
|
|
@ -108,6 +126,11 @@ export const compileGitlabConfig = async (
|
||||||
|
|
||||||
return record;
|
return record;
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
repoData: repos,
|
||||||
|
notFound,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const compileGiteaConfig = async (
|
export const compileGiteaConfig = async (
|
||||||
|
|
@ -116,10 +139,13 @@ export const compileGiteaConfig = async (
|
||||||
orgId: number,
|
orgId: number,
|
||||||
db: PrismaClient) => {
|
db: PrismaClient) => {
|
||||||
|
|
||||||
const giteaRepos = await getGiteaReposFromConfig(config, orgId, db);
|
const giteaReposResult = await getGiteaReposFromConfig(config, orgId, db);
|
||||||
|
const giteaRepos = giteaReposResult.validRepos;
|
||||||
|
const notFound = giteaReposResult.notFound;
|
||||||
|
|
||||||
const hostUrl = config.url ?? 'https://gitea.com';
|
const hostUrl = config.url ?? 'https://gitea.com';
|
||||||
|
|
||||||
return giteaRepos.map((repo) => {
|
const repos = giteaRepos.map((repo) => {
|
||||||
const cloneUrl = new URL(repo.clone_url!);
|
const cloneUrl = new URL(repo.clone_url!);
|
||||||
|
|
||||||
const record: RepoData = {
|
const record: RepoData = {
|
||||||
|
|
@ -153,6 +179,11 @@ export const compileGiteaConfig = async (
|
||||||
|
|
||||||
return record;
|
return record;
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
repoData: repos,
|
||||||
|
notFound,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const compileGerritConfig = async (
|
export const compileGerritConfig = async (
|
||||||
|
|
@ -164,7 +195,7 @@ export const compileGerritConfig = async (
|
||||||
const hostUrl = config.url ?? 'https://gerritcodereview.com';
|
const hostUrl = config.url ?? 'https://gerritcodereview.com';
|
||||||
const hostname = new URL(hostUrl).hostname;
|
const hostname = new URL(hostUrl).hostname;
|
||||||
|
|
||||||
return gerritRepos.map((project) => {
|
const repos = gerritRepos.map((project) => {
|
||||||
const repoId = `${hostname}/${project.name}`;
|
const repoId = `${hostname}/${project.name}`;
|
||||||
const cloneUrl = new URL(`${config.url}/${encodeURIComponent(project.name)}`);
|
const cloneUrl = new URL(`${config.url}/${encodeURIComponent(project.name)}`);
|
||||||
|
|
||||||
|
|
@ -207,4 +238,13 @@ export const compileGerritConfig = async (
|
||||||
|
|
||||||
return record;
|
return record;
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
repoData: repos,
|
||||||
|
notFound: {
|
||||||
|
users: [],
|
||||||
|
orgs: [],
|
||||||
|
repos: [],
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -207,7 +207,8 @@ export class RepoManager implements IRepoManager {
|
||||||
|
|
||||||
const config = connection.config as unknown as GithubConnectionConfig | GitlabConnectionConfig | GiteaConnectionConfig;
|
const config = connection.config as unknown as GithubConnectionConfig | GitlabConnectionConfig | GiteaConnectionConfig;
|
||||||
if (config.token) {
|
if (config.token) {
|
||||||
token = await getTokenFromConfig(config.token, connection.orgId, db);
|
const tokenResult = await getTokenFromConfig(config.token, connection.orgId, db);
|
||||||
|
token = tokenResult?.token;
|
||||||
if (token) {
|
if (token) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import micromatch from "micromatch";
|
||||||
import { PrismaClient, Repo } from "@sourcebot/db";
|
import { PrismaClient, Repo } from "@sourcebot/db";
|
||||||
import { decrypt } from "@sourcebot/crypto";
|
import { decrypt } from "@sourcebot/crypto";
|
||||||
import { Token } from "@sourcebot/schemas/v3/shared.type";
|
import { Token } from "@sourcebot/schemas/v3/shared.type";
|
||||||
|
import { BackendException, BackendError } from "@sourcebot/error";
|
||||||
|
|
||||||
export const measure = async <T>(cb: () => Promise<T>) => {
|
export const measure = async <T>(cb: () => Promise<T>) => {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
|
@ -90,7 +91,9 @@ export const excludeReposByTopic = <T extends Repository>(repos: T[], excludedRe
|
||||||
|
|
||||||
export const getTokenFromConfig = async (token: Token, orgId: number, db?: PrismaClient) => {
|
export const getTokenFromConfig = async (token: Token, orgId: number, db?: PrismaClient) => {
|
||||||
if (!db) {
|
if (!db) {
|
||||||
throw new Error(`Database connection required to retrieve secret`);
|
throw new BackendException(BackendError.CONNECTION_SYNC_SYSTEM_ERROR, {
|
||||||
|
message: `No database connection provided.`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const secretKey = token.secret;
|
const secretKey = token.secret;
|
||||||
|
|
@ -104,11 +107,16 @@ export const getTokenFromConfig = async (token: Token, orgId: number, db?: Prism
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!secret) {
|
if (!secret) {
|
||||||
throw new Error(`Secret with key ${secretKey} not found for org ${orgId}`);
|
throw new BackendException(BackendError.CONNECTION_SYNC_SECRET_DNE, {
|
||||||
|
message: `Secret with key ${secretKey} not found for org ${orgId}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const decryptedSecret = decrypt(secret.iv, secret.encryptedValue);
|
const decryptedSecret = decrypt(secret.iv, secret.encryptedValue);
|
||||||
return decryptedSecret;
|
return {
|
||||||
|
token: decryptedSecret,
|
||||||
|
secretKey,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isRemotePath = (path: string) => {
|
export const isRemotePath = (path: string) => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Connection" ADD COLUMN "syncStatusMetadata" JSONB;
|
||||||
|
|
@ -59,21 +59,22 @@ model Repo {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Connection {
|
model Connection {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
config Json
|
config Json
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
syncedAt DateTime?
|
syncedAt DateTime?
|
||||||
repos RepoToConnection[]
|
repos RepoToConnection[]
|
||||||
syncStatus ConnectionSyncStatus @default(SYNC_NEEDED)
|
syncStatus ConnectionSyncStatus @default(SYNC_NEEDED)
|
||||||
|
syncStatusMetadata Json?
|
||||||
|
|
||||||
// The type of connection (e.g., github, gitlab, etc.)
|
// The type of connection (e.g., github, gitlab, etc.)
|
||||||
connectionType String
|
connectionType String
|
||||||
|
|
||||||
// The organization that owns this connection
|
// The organization that owns this connection
|
||||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
orgId Int
|
orgId Int
|
||||||
}
|
}
|
||||||
|
|
||||||
model RepoToConnection {
|
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": {
|
"aliases": {
|
||||||
"components": "@/components",
|
"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',
|
protocol: 'https',
|
||||||
hostname: 'avatars.githubusercontent.com',
|
hostname: 'avatars.githubusercontent.com',
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'gitlab.com',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@
|
||||||
"@shopify/lang-jsonc": "^1.0.0",
|
"@shopify/lang-jsonc": "^1.0.0",
|
||||||
"@sourcebot/crypto": "^0.1.0",
|
"@sourcebot/crypto": "^0.1.0",
|
||||||
"@sourcebot/db": "^0.1.0",
|
"@sourcebot/db": "^0.1.0",
|
||||||
|
"@sourcebot/error": "^0.1.0",
|
||||||
"@sourcebot/schemas": "^0.1.0",
|
"@sourcebot/schemas": "^0.1.0",
|
||||||
"@ssddanbrown/codemirror-lang-twig": "^1.0.0",
|
"@ssddanbrown/codemirror-lang-twig": "^1.0.0",
|
||||||
"@stripe/react-stripe-js": "^3.1.1",
|
"@stripe/react-stripe-js": "^3.1.1",
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema";
|
||||||
import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, GerritConnectionConfig, ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
|
import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, GerritConnectionConfig, ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
|
||||||
import { encrypt } from "@sourcebot/crypto"
|
import { encrypt } from "@sourcebot/crypto"
|
||||||
import { getConnection, getLinkedRepos } from "./data/connection";
|
import { getConnection, getLinkedRepos } from "./data/connection";
|
||||||
import { ConnectionSyncStatus, Prisma, Invite, OrgRole, Connection, Repo, Org } from "@sourcebot/db";
|
import { ConnectionSyncStatus, Prisma, Invite, OrgRole, Connection, Repo, Org, RepoIndexingStatus } from "@sourcebot/db";
|
||||||
import { headers } from "next/headers"
|
import { headers } from "next/headers"
|
||||||
import { getStripe } from "@/lib/stripe"
|
import { getStripe } from "@/lib/stripe"
|
||||||
import { getUser } from "@/data/user";
|
import { getUser } from "@/data/user";
|
||||||
|
|
@ -22,6 +22,7 @@ import { Session } from "next-auth";
|
||||||
import { STRIPE_PRODUCT_ID, CONFIG_MAX_REPOS_NO_TOKEN } from "@/lib/environment";
|
import { STRIPE_PRODUCT_ID, CONFIG_MAX_REPOS_NO_TOKEN } from "@/lib/environment";
|
||||||
import { StripeSubscriptionStatus } from "@sourcebot/db";
|
import { StripeSubscriptionStatus } from "@sourcebot/db";
|
||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
|
import { SyncStatusMetadataSchema, type NotFoundData } from "@/lib/syncStatusMetadataSchema";
|
||||||
const ajv = new Ajv({
|
const ajv = new Ajv({
|
||||||
validateFormats: false,
|
validateFormats: false,
|
||||||
});
|
});
|
||||||
|
|
@ -179,6 +180,7 @@ export const getConnections = async (domain: string): Promise<
|
||||||
id: number,
|
id: number,
|
||||||
name: string,
|
name: string,
|
||||||
syncStatus: ConnectionSyncStatus,
|
syncStatus: ConnectionSyncStatus,
|
||||||
|
syncStatusMetadata: Prisma.JsonValue,
|
||||||
connectionType: string,
|
connectionType: string,
|
||||||
updatedAt: Date,
|
updatedAt: Date,
|
||||||
syncedAt?: Date
|
syncedAt?: Date
|
||||||
|
|
@ -196,6 +198,7 @@ export const getConnections = async (domain: string): Promise<
|
||||||
id: connection.id,
|
id: connection.id,
|
||||||
name: connection.name,
|
name: connection.name,
|
||||||
syncStatus: connection.syncStatus,
|
syncStatus: connection.syncStatus,
|
||||||
|
syncStatusMetadata: connection.syncStatusMetadata,
|
||||||
connectionType: connection.connectionType,
|
connectionType: connection.connectionType,
|
||||||
updatedAt: connection.updatedAt,
|
updatedAt: connection.updatedAt,
|
||||||
syncedAt: connection.syncedAt ?? undefined,
|
syncedAt: connection.syncedAt ?? undefined,
|
||||||
|
|
@ -203,6 +206,40 @@ export const getConnections = async (domain: string): Promise<
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const getConnectionFailedRepos = async (connectionId: number, domain: string): Promise<{ repoId: number, repoName: string }[] | ServiceError> =>
|
||||||
|
withAuth((session) =>
|
||||||
|
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||||
|
const connection = await getConnection(connectionId, orgId);
|
||||||
|
if (!connection) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkedRepos = await getLinkedRepos(connectionId, orgId);
|
||||||
|
|
||||||
|
return linkedRepos.filter((repo) => repo.repo.repoIndexingStatus === RepoIndexingStatus.FAILED).map((repo) => ({
|
||||||
|
repoId: repo.repo.id,
|
||||||
|
repoName: repo.repo.name,
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getConnectionInProgressRepos = async (connectionId: number, domain: string): Promise<{ repoId: number, repoName: string }[] | ServiceError> =>
|
||||||
|
withAuth((session) =>
|
||||||
|
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||||
|
const connection = await getConnection(connectionId, orgId);
|
||||||
|
if (!connection) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkedRepos = await getLinkedRepos(connectionId, orgId);
|
||||||
|
|
||||||
|
return linkedRepos.filter((repo) => repo.repo.repoIndexingStatus === RepoIndexingStatus.IN_INDEX_QUEUE || repo.repo.repoIndexingStatus === RepoIndexingStatus.INDEXING).map((repo) => ({
|
||||||
|
repoId: repo.repo.id,
|
||||||
|
repoName: repo.repo.name,
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
export const createConnection = async (name: string, type: string, connectionConfig: string, domain: string): Promise<{ id: number } | ServiceError> =>
|
export const createConnection = async (name: string, type: string, connectionConfig: string, domain: string): Promise<{ id: number } | ServiceError> =>
|
||||||
withAuth((session) =>
|
withAuth((session) =>
|
||||||
|
|
@ -331,14 +368,6 @@ export const flagConnectionForSync = async (connectionId: number, domain: string
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (connection.syncStatus !== "FAILED") {
|
|
||||||
return {
|
|
||||||
statusCode: StatusCodes.BAD_REQUEST,
|
|
||||||
errorCode: ErrorCode.CONNECTION_NOT_FAILED,
|
|
||||||
message: "Connection is not in a failed state. Cannot flag for sync.",
|
|
||||||
} satisfies ServiceError;
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.connection.update({
|
await prisma.connection.update({
|
||||||
where: {
|
where: {
|
||||||
id: connection.id,
|
id: connection.id,
|
||||||
|
|
@ -353,6 +382,36 @@ export const flagConnectionForSync = async (connectionId: number, domain: string
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const flagRepoForIndex = async (repoId: number, domain: string): Promise<{ success: boolean } | ServiceError> =>
|
||||||
|
withAuth((session) =>
|
||||||
|
withOrgMembership(session, domain, async () => {
|
||||||
|
const repo = await prisma.repo.findUnique({
|
||||||
|
where: {
|
||||||
|
id: repoId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!repo) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.repo.update({
|
||||||
|
where: {
|
||||||
|
id: repoId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
repoIndexingStatus: RepoIndexingStatus.NEW,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const deleteConnection = async (connectionId: number, domain: string): Promise<{ success: boolean } | ServiceError> =>
|
export const deleteConnection = async (connectionId: number, domain: string): Promise<{ success: boolean } | ServiceError> =>
|
||||||
withAuth((session) =>
|
withAuth((session) =>
|
||||||
withOrgMembership(session, domain, async ({ orgId }) => {
|
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||||
|
|
|
||||||
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 { OrgSelector } from "./orgSelector";
|
||||||
import { getSubscriptionData } from "@/actions";
|
import { getSubscriptionData } from "@/actions";
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { isServiceError } from "@/lib/utils";
|
||||||
|
import { ErrorNavIndicator } from "./errorNavIndicator";
|
||||||
|
import { WarningNavIndicator } from "./warningNavIndicator";
|
||||||
|
import { ProgressNavIndicator } from "./progressNavIndicator";
|
||||||
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
||||||
|
|
||||||
const SOURCEBOT_DISCORD_URL = "https://discord.gg/6Fhp27x7Pb";
|
const SOURCEBOT_DISCORD_URL = "https://discord.gg/6Fhp27x7Pb";
|
||||||
|
|
@ -83,10 +86,13 @@ export const NavigationMenu = async ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
<ProgressNavIndicator />
|
||||||
|
<WarningNavIndicator />
|
||||||
|
<ErrorNavIndicator />
|
||||||
{!isServiceError(subscription) && subscription.status === "trialing" && (
|
{!isServiceError(subscription) && subscription.status === "trialing" && (
|
||||||
<Link href={`/${domain}/settings/billing`}>
|
<Link href={`/${domain}/settings/billing`}>
|
||||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-full text-yellow-700 dark:text-yellow-400 text-xs font-medium hover:bg-yellow-100 dark:hover:bg-yellow-900/30 transition-colors cursor-pointer">
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-full text-blue-700 dark:text-blue-400 text-xs font-medium hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors cursor-pointer">
|
||||||
<span className="inline-block w-2 h-2 bg-yellow-400 dark:bg-yellow-500 rounded-full"></span>
|
<span className="inline-block w-2 h-2 bg-blue-400 dark:bg-blue-500 rounded-full"></span>
|
||||||
<span>
|
<span>
|
||||||
{Math.ceil((subscription.nextBillingDate * 1000 - Date.now()) / (1000 * 60 * 60 * 24))} days left in
|
{Math.ceil((subscription.nextBillingDate * 1000 - Date.now()) / (1000 * 60 * 60 * 24))} days left in
|
||||||
trial
|
trial
|
||||||
|
|
|
||||||
|
|
@ -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 { StatusIcon } from "../../components/statusIcon";
|
||||||
import { RepoIndexingStatus } from "@sourcebot/db";
|
import { RepoIndexingStatus } from "@sourcebot/db";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { RetryRepoIndexButton } from "./repoRetryIndexButton";
|
||||||
|
|
||||||
|
|
||||||
interface RepoListItemProps {
|
interface RepoListItemProps {
|
||||||
|
|
@ -10,6 +11,8 @@ interface RepoListItemProps {
|
||||||
status: RepoIndexingStatus;
|
status: RepoIndexingStatus;
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
indexedAt?: Date;
|
indexedAt?: Date;
|
||||||
|
repoId: number;
|
||||||
|
domain: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RepoListItem = ({
|
export const RepoListItem = ({
|
||||||
|
|
@ -17,6 +20,8 @@ export const RepoListItem = ({
|
||||||
name,
|
name,
|
||||||
indexedAt,
|
indexedAt,
|
||||||
status,
|
status,
|
||||||
|
repoId,
|
||||||
|
domain,
|
||||||
}: RepoListItemProps) => {
|
}: RepoListItemProps) => {
|
||||||
const statusDisplayName = useMemo(() => {
|
const statusDisplayName = useMemo(() => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
|
|
@ -47,22 +52,27 @@ export const RepoListItem = ({
|
||||||
/>
|
/>
|
||||||
<p className="font-medium">{name}</p>
|
<p className="font-medium">{name}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row items-center">
|
<div className="flex flex-row items-center gap-4">
|
||||||
<StatusIcon
|
{status === RepoIndexingStatus.FAILED && (
|
||||||
status={convertIndexingStatus(status)}
|
<RetryRepoIndexButton repoId={repoId} domain={domain} />
|
||||||
className="w-4 h-4 mr-1"
|
)}
|
||||||
/>
|
<div className="flex flex-row items-center gap-0">
|
||||||
<p className="text-sm">
|
<StatusIcon
|
||||||
<span>{statusDisplayName}</span>
|
status={convertIndexingStatus(status)}
|
||||||
{
|
className="w-4 h-4 mr-1"
|
||||||
(
|
/>
|
||||||
status === RepoIndexingStatus.INDEXED ||
|
<p className="text-sm">
|
||||||
status === RepoIndexingStatus.FAILED
|
<span>{statusDisplayName}</span>
|
||||||
) && indexedAt && (
|
{
|
||||||
<span>{` ${getDisplayTime(indexedAt)}`}</span>
|
(
|
||||||
)
|
status === RepoIndexingStatus.INDEXED ||
|
||||||
}
|
status === RepoIndexingStatus.FAILED
|
||||||
</p>
|
) && indexedAt && (
|
||||||
|
<span>{` ${getDisplayTime(indexedAt)}`}</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</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 {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
BreadcrumbItem,
|
BreadcrumbItem,
|
||||||
|
|
@ -8,122 +8,125 @@ import {
|
||||||
BreadcrumbList,
|
BreadcrumbList,
|
||||||
BreadcrumbPage,
|
BreadcrumbPage,
|
||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator,
|
||||||
} from "@/components/ui/breadcrumb";
|
} from "@/components/ui/breadcrumb"
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import { TabSwitcher } from "@/components/ui/tab-switcher";
|
import { TabSwitcher } from "@/components/ui/tab-switcher"
|
||||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
import { Tabs, TabsContent } from "@/components/ui/tabs"
|
||||||
import { ConnectionIcon } from "../components/connectionIcon";
|
import { ConnectionIcon } from "../components/connectionIcon"
|
||||||
import { Header } from "../../components/header";
|
import { Header } from "../../components/header"
|
||||||
import { ConfigSetting } from "./components/configSetting";
|
import { ConfigSetting } from "./components/configSetting"
|
||||||
import { DeleteConnectionSetting } from "./components/deleteConnectionSetting";
|
import { DeleteConnectionSetting } from "./components/deleteConnectionSetting"
|
||||||
import { DisplayNameSetting } from "./components/displayNameSetting";
|
import { DisplayNameSetting } from "./components/displayNameSetting"
|
||||||
import { RepoListItem } from "./components/repoListItem";
|
import { RepoListItem } from "./components/repoListItem"
|
||||||
import { useParams, useSearchParams } from "next/navigation";
|
import { useParams, useSearchParams, useRouter } from "next/navigation"
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react"
|
||||||
import { Connection, Repo, Org } from "@sourcebot/db";
|
import type { Connection, Repo, Org } from "@sourcebot/db"
|
||||||
import { getConnectionInfoAction, getOrgFromDomainAction, flagConnectionForSync } from "@/actions";
|
import { getConnectionInfoAction, getOrgFromDomainAction } from "@/actions"
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { isServiceError } from "@/lib/utils"
|
||||||
import { Button } from "@/components/ui/button";
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
|
||||||
import { ReloadIcon } from "@radix-ui/react-icons";
|
import { DisplayConnectionError } from "./components/connectionError"
|
||||||
import { useToast } from "@/components/hooks/use-toast";
|
import { NotFoundWarning } from "./components/notFoundWarning"
|
||||||
|
import { RetrySyncButton } from "./components/retrySyncButton"
|
||||||
|
import { RetryAllFailedReposButton } from "./components/retryAllFailedReposButton"
|
||||||
|
|
||||||
export default function ConnectionManagementPage() {
|
export default function ConnectionManagementPage() {
|
||||||
const params = useParams();
|
const params = useParams()
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams()
|
||||||
const { toast } = useToast();
|
const router = useRouter()
|
||||||
const [org, setOrg] = useState<Org | null>(null);
|
const [org, setOrg] = useState<Org | null>(null)
|
||||||
const [connection, setConnection] = useState<Connection | null>(null);
|
const [connection, setConnection] = useState<Connection | null>(null)
|
||||||
const [linkedRepos, setLinkedRepos] = useState<Repo[]>([]);
|
const [linkedRepos, setLinkedRepos] = useState<Repo[]>([])
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleSecretsNavigation = () => {
|
||||||
|
router.push(`/${params.domain}/secrets`)
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
const orgResult = await getOrgFromDomainAction(params.domain as string);
|
const orgResult = await getOrgFromDomainAction(params.domain as string)
|
||||||
if (isServiceError(orgResult)) {
|
if (isServiceError(orgResult)) {
|
||||||
setError(orgResult.message);
|
setError(orgResult.message)
|
||||||
setLoading(false);
|
setLoading(false)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
setOrg(orgResult);
|
setOrg(orgResult)
|
||||||
|
|
||||||
const connectionId = Number(params.id);
|
const connectionId = Number(params.id)
|
||||||
if (isNaN(connectionId)) {
|
if (isNaN(connectionId)) {
|
||||||
setError("Invalid connection ID");
|
setError("Invalid connection ID")
|
||||||
setLoading(false);
|
setLoading(false)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const connectionInfoResult = await getConnectionInfoAction(connectionId, params.domain as string);
|
const connectionInfoResult = await getConnectionInfoAction(connectionId, params.domain as string)
|
||||||
if (isServiceError(connectionInfoResult)) {
|
if (isServiceError(connectionInfoResult)) {
|
||||||
setError(connectionInfoResult.message);
|
setError(connectionInfoResult.message)
|
||||||
setLoading(false);
|
setLoading(false)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
connectionInfoResult.linkedRepos.sort((a, b) => {
|
connectionInfoResult.linkedRepos.sort((a, b) => {
|
||||||
// Helper function to get priority of indexing status
|
// Helper function to get priority of indexing status
|
||||||
const getPriority = (status: string) => {
|
const getPriority = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'FAILED': return 0;
|
case "FAILED":
|
||||||
case 'IN_INDEX_QUEUE':
|
return 0
|
||||||
case 'INDEXING': return 1;
|
case "IN_INDEX_QUEUE":
|
||||||
case 'INDEXED': return 2;
|
case "INDEXING":
|
||||||
default: return 3;
|
return 1
|
||||||
|
case "INDEXED":
|
||||||
|
return 2
|
||||||
|
default:
|
||||||
|
return 3
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const priorityA = getPriority(a.repoIndexingStatus);
|
const priorityA = getPriority(a.repoIndexingStatus)
|
||||||
const priorityB = getPriority(b.repoIndexingStatus);
|
const priorityB = getPriority(b.repoIndexingStatus)
|
||||||
|
|
||||||
// First sort by priority
|
// First sort by priority
|
||||||
if (priorityA !== priorityB) {
|
if (priorityA !== priorityB) {
|
||||||
return priorityA - priorityB;
|
return priorityA - priorityB
|
||||||
}
|
}
|
||||||
|
|
||||||
// If same priority, sort by createdAt
|
// If same priority, sort by createdAt
|
||||||
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
return new Date(a.indexedAt ?? new Date()).getTime() - new Date(b.indexedAt ?? new Date()).getTime()
|
||||||
});
|
})
|
||||||
|
|
||||||
setConnection(connectionInfoResult.connection);
|
setConnection(connectionInfoResult.connection)
|
||||||
setLinkedRepos(connectionInfoResult.linkedRepos);
|
setLinkedRepos(connectionInfoResult.linkedRepos)
|
||||||
setLoading(false);
|
setLoading(false)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "An error occurred while loading the connection. If the problem persists, please contact us at team@sourcebot.dev");
|
setError(
|
||||||
setLoading(false);
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "An error occurred while loading the connection. If the problem persists, please contact us at team@sourcebot.dev",
|
||||||
|
)
|
||||||
|
setLoading(false)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
loadData();
|
loadData()
|
||||||
const intervalId = setInterval(loadData, 1000);
|
const intervalId = setInterval(loadData, 1000)
|
||||||
return () => clearInterval(intervalId);
|
return () => clearInterval(intervalId)
|
||||||
}, [params.domain, params.id]);
|
}, [params.domain, params.id])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div>Loading...</div>;
|
return <div>Loading...</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !org || !connection) {
|
if (error || !org || !connection) {
|
||||||
return (
|
return <NotFound className="flex w-full h-full items-center justify-center" message={error || "Not found"} />
|
||||||
<NotFound
|
|
||||||
className="flex w-full h-full items-center justify-center"
|
|
||||||
message={error || "Not found"}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentTab = searchParams.get("tab") || "overview";
|
const currentTab = searchParams.get("tab") || "overview"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs value={currentTab} className="w-full">
|
||||||
value={currentTab}
|
<Header className="mb-6" withTopMargin={false}>
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<Header
|
|
||||||
className="mb-6"
|
|
||||||
withTopMargin={false}
|
|
||||||
>
|
|
||||||
<Breadcrumb>
|
<Breadcrumb>
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
<BreadcrumbItem>
|
<BreadcrumbItem>
|
||||||
|
|
@ -135,10 +138,8 @@ export default function ConnectionManagementPage() {
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
<div className="mt-6 flex flex-row items-center gap-4">
|
<div className="mt-6 flex flex-row items-center gap-4 w-full">
|
||||||
<ConnectionIcon
|
<ConnectionIcon type={connection.connectionType} />
|
||||||
type={connection.connectionType}
|
|
||||||
/>
|
|
||||||
<h1 className="text-lg font-semibold">{connection.name}</h1>
|
<h1 className="text-lg font-semibold">{connection.name}</h1>
|
||||||
</div>
|
</div>
|
||||||
<TabSwitcher
|
<TabSwitcher
|
||||||
|
|
@ -160,7 +161,9 @@ export default function ConnectionManagementPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-border p-4 bg-background">
|
<div className="rounded-lg border border-border p-4 bg-background">
|
||||||
<h2 className="text-sm font-medium text-muted-foreground">Last Synced At</h2>
|
<h2 className="text-sm font-medium text-muted-foreground">Last Synced At</h2>
|
||||||
<p className="mt-2 text-sm">{connection.syncedAt ? new Date(connection.syncedAt).toLocaleDateString() : 'never'}</p>
|
<p className="mt-2 text-sm">
|
||||||
|
{connection.syncedAt ? new Date(connection.syncedAt).toLocaleDateString() : "never"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-border p-4 bg-background">
|
<div className="rounded-lg border border-border p-4 bg-background">
|
||||||
<h2 className="text-sm font-medium text-muted-foreground">Linked Repositories</h2>
|
<h2 className="text-sm font-medium text-muted-foreground">Linked Repositories</h2>
|
||||||
|
|
@ -168,74 +171,65 @@ export default function ConnectionManagementPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-border p-4 bg-background">
|
<div className="rounded-lg border border-border p-4 bg-background">
|
||||||
<h2 className="text-sm font-medium text-muted-foreground">Status</h2>
|
<h2 className="text-sm font-medium text-muted-foreground">Status</h2>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 mt-2">
|
||||||
<p className="mt-2 text-sm">{connection.syncStatus}</p>
|
{connection.syncStatus === "FAILED" ? (
|
||||||
|
<HoverCard openDelay={50}>
|
||||||
|
<HoverCardTrigger asChild>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="inline-flex items-center rounded-full bg-red-50 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-600/20 cursor-help hover:text-red-600 hover:bg-red-100 transition-colors duration-200">
|
||||||
|
{connection.syncStatus}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent className="w-80">
|
||||||
|
<DisplayConnectionError
|
||||||
|
syncStatusMetadata={connection.syncStatusMetadata}
|
||||||
|
onSecretsClick={handleSecretsNavigation}
|
||||||
|
/>
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">
|
||||||
|
{connection.syncStatus}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{connection.syncStatus === "FAILED" && (
|
{connection.syncStatus === "FAILED" && (
|
||||||
<Button
|
<RetrySyncButton connectionId={connection.id} domain={params.domain as string} />
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="mt-2 rounded-full"
|
|
||||||
onClick={async () => {
|
|
||||||
const result = await flagConnectionForSync(connection.id, params.domain as string);
|
|
||||||
if (isServiceError(result)) {
|
|
||||||
toast({
|
|
||||||
description: `❌ Failed to flag connection for sync. Reason: ${result.message}`,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
description: "✅ Connection flagged for sync.",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ReloadIcon className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<NotFoundWarning syncStatusMetadata={connection.syncStatusMetadata} onSecretsClick={handleSecretsNavigation} connectionId={connection.id} connectionType={connection.connectionType} domain={params.domain as string} />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="font-semibold text-lg mt-8">Linked Repositories</h1>
|
<div className="flex justify-between items-center mt-8">
|
||||||
<ScrollArea
|
<h1 className="font-semibold text-lg">Linked Repositories</h1>
|
||||||
className="mt-4 max-h-96 overflow-scroll"
|
<RetryAllFailedReposButton connectionId={connection.id} domain={params.domain as string} />
|
||||||
>
|
</div>
|
||||||
|
<ScrollArea className="mt-4 max-h-96 overflow-scroll">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{linkedRepos
|
{linkedRepos.map((repo) => (
|
||||||
.sort((a, b) => {
|
<RepoListItem
|
||||||
const aIndexedAt = a.indexedAt ?? new Date();
|
key={repo.id}
|
||||||
const bIndexedAt = b.indexedAt ?? new Date();
|
imageUrl={repo.imageUrl ?? undefined}
|
||||||
|
name={repo.name}
|
||||||
return bIndexedAt.getTime() - aIndexedAt.getTime();
|
indexedAt={repo.indexedAt ?? undefined}
|
||||||
})
|
status={repo.repoIndexingStatus}
|
||||||
.map((repo) => (
|
repoId={repo.id}
|
||||||
<RepoListItem
|
domain={params.domain as string}
|
||||||
key={repo.id}
|
/>
|
||||||
imageUrl={repo.imageUrl ?? undefined}
|
))}
|
||||||
name={repo.name}
|
|
||||||
indexedAt={repo.indexedAt ?? undefined}
|
|
||||||
status={repo.repoIndexingStatus}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent
|
<TabsContent value="settings" className="flex flex-col gap-6">
|
||||||
value="settings"
|
<DisplayNameSetting connectionId={connection.id} name={connection.name} />
|
||||||
className="flex flex-col gap-6"
|
|
||||||
>
|
|
||||||
<DisplayNameSetting
|
|
||||||
connectionId={connection.id}
|
|
||||||
name={connection.name}
|
|
||||||
/>
|
|
||||||
<ConfigSetting
|
<ConfigSetting
|
||||||
connectionId={connection.id}
|
connectionId={connection.id}
|
||||||
type={connection.connectionType}
|
type={connection.connectionType}
|
||||||
config={JSON.stringify(connection.config, null, 2)}
|
config={JSON.stringify(connection.config, null, 2)}
|
||||||
/>
|
/>
|
||||||
<DeleteConnectionSetting
|
<DeleteConnectionSetting connectionId={connection.id} />
|
||||||
connectionId={connection.id}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { getDisplayTime } from "@/lib/utils";
|
import { getDisplayTime } from "@/lib/utils";
|
||||||
import Link from "next/link";
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { ConnectionIcon } from "../connectionIcon";
|
import { ConnectionIcon } from "../connectionIcon";
|
||||||
import { ConnectionSyncStatus } from "@sourcebot/db";
|
import { ConnectionSyncStatus, Prisma } from "@sourcebot/db";
|
||||||
import { StatusIcon } from "../statusIcon";
|
import { StatusIcon } from "../statusIcon";
|
||||||
|
import { AlertTriangle, CircleX} from "lucide-react";
|
||||||
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
||||||
|
|
||||||
const convertSyncStatus = (status: ConnectionSyncStatus) => {
|
const convertSyncStatus = (status: ConnectionSyncStatus) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
|
|
@ -26,8 +26,10 @@ interface ConnectionListItemProps {
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
status: ConnectionSyncStatus;
|
status: ConnectionSyncStatus;
|
||||||
|
syncStatusMetadata: Prisma.JsonValue;
|
||||||
editedAt: Date;
|
editedAt: Date;
|
||||||
syncedAt?: Date;
|
syncedAt?: Date;
|
||||||
|
failedRepos?: { repoId: number, repoName: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ConnectionListItem = ({
|
export const ConnectionListItem = ({
|
||||||
|
|
@ -35,8 +37,10 @@ export const ConnectionListItem = ({
|
||||||
name,
|
name,
|
||||||
type,
|
type,
|
||||||
status,
|
status,
|
||||||
|
syncStatusMetadata,
|
||||||
editedAt,
|
editedAt,
|
||||||
syncedAt,
|
syncedAt,
|
||||||
|
failedRepos,
|
||||||
}: ConnectionListItemProps) => {
|
}: ConnectionListItemProps) => {
|
||||||
const statusDisplayName = useMemo(() => {
|
const statusDisplayName = useMemo(() => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
|
|
@ -52,46 +56,145 @@ export const ConnectionListItem = ({
|
||||||
}
|
}
|
||||||
}, [status]);
|
}, [status]);
|
||||||
|
|
||||||
|
const { notFoundData, displayNotFoundWarning } = useMemo(() => {
|
||||||
|
if (!syncStatusMetadata || typeof syncStatusMetadata !== 'object' || !('notFound' in syncStatusMetadata)) {
|
||||||
|
return { notFoundData: null, displayNotFoundWarning: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const notFoundData = syncStatusMetadata.notFound as {
|
||||||
|
users: string[],
|
||||||
|
orgs: string[],
|
||||||
|
repos: string[],
|
||||||
|
}
|
||||||
|
|
||||||
|
return { notFoundData, displayNotFoundWarning: notFoundData.users.length > 0 || notFoundData.orgs.length > 0 || notFoundData.repos.length > 0 };
|
||||||
|
}, [syncStatusMetadata]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`connections/${id}`}>
|
<div
|
||||||
<div
|
className="flex flex-row justify-between items-center border p-4 rounded-lg bg-background"
|
||||||
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">
|
||||||
<div className="flex flex-row items-center gap-3">
|
<ConnectionIcon
|
||||||
<ConnectionIcon
|
type={type}
|
||||||
type={type}
|
className="w-8 h-8"
|
||||||
className="w-8 h-8"
|
/>
|
||||||
/>
|
<div className="flex flex-col">
|
||||||
<div className="flex flex-col">
|
<p className="font-medium">{name}</p>
|
||||||
<p className="font-medium">{name}</p>
|
<span className="text-sm text-muted-foreground">{`Edited ${getDisplayTime(editedAt)}`}</span>
|
||||||
<span className="text-sm text-muted-foreground">{`Edited ${getDisplayTime(editedAt)}`}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<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>
|
</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>
|
</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 { useEffect } from "react";
|
||||||
import { InfoCircledIcon } from "@radix-ui/react-icons";
|
import { InfoCircledIcon } from "@radix-ui/react-icons";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ConnectionSyncStatus } from "@sourcebot/db";
|
import { ConnectionSyncStatus, Prisma } from "@sourcebot/db";
|
||||||
import { getConnections } from "@/actions";
|
import { getConnectionFailedRepos, getConnections } from "@/actions";
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { isServiceError } from "@/lib/utils";
|
||||||
|
|
||||||
interface ConnectionListProps {
|
interface ConnectionListProps {
|
||||||
|
|
@ -22,8 +22,10 @@ export const ConnectionList = ({
|
||||||
name: string;
|
name: string;
|
||||||
connectionType: string;
|
connectionType: string;
|
||||||
syncStatus: ConnectionSyncStatus;
|
syncStatus: ConnectionSyncStatus;
|
||||||
|
syncStatusMetadata: Prisma.JsonValue;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
syncedAt?: Date;
|
syncedAt?: Date;
|
||||||
|
failedRepos?: { repoId: number, repoName: string }[];
|
||||||
}[]>([]);
|
}[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -35,7 +37,19 @@ export const ConnectionList = ({
|
||||||
if (isServiceError(result)) {
|
if (isServiceError(result)) {
|
||||||
setError(result.message);
|
setError(result.message);
|
||||||
} else {
|
} else {
|
||||||
setConnections(result);
|
const connectionsWithFailedRepos = [];
|
||||||
|
for (const connection of result) {
|
||||||
|
const failedRepos = await getConnectionFailedRepos(connection.id, domain);
|
||||||
|
if (isServiceError(failedRepos)) {
|
||||||
|
setError(`An error occured while fetching the failed repositories for connection ${connection.name}. If the problem persists, please contact us at team@sourcebot.dev`);
|
||||||
|
} else {
|
||||||
|
connectionsWithFailedRepos.push({
|
||||||
|
...connection,
|
||||||
|
failedRepos,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setConnections(connectionsWithFailedRepos);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -69,8 +83,10 @@ export const ConnectionList = ({
|
||||||
name={connection.name}
|
name={connection.name}
|
||||||
type={connection.connectionType}
|
type={connection.connectionType}
|
||||||
status={connection.syncStatus}
|
status={connection.syncStatus}
|
||||||
|
syncStatusMetadata={connection.syncStatusMetadata}
|
||||||
editedAt={connection.updatedAt}
|
editedAt={connection.updatedAt}
|
||||||
syncedAt={connection.syncedAt ?? undefined}
|
syncedAt={connection.syncedAt ?? undefined}
|
||||||
|
failedRepos={connection.failedRepos}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Cross2Icon } from "@radix-ui/react-icons";
|
import { CircleCheckIcon, CircleXIcon } from "lucide-react";
|
||||||
import { CircleCheckIcon } from "lucide-react";
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { FiLoader } from "react-icons/fi";
|
import { FiLoader } from "react-icons/fi";
|
||||||
|
|
||||||
|
|
@ -18,7 +17,7 @@ export const StatusIcon = ({
|
||||||
case 'succeeded':
|
case 'succeeded':
|
||||||
return <CircleCheckIcon className={cn('text-green-600', className)} />;
|
return <CircleCheckIcon className={cn('text-green-600', className)} />;
|
||||||
case 'failed':
|
case 'failed':
|
||||||
return <Cross2Icon className={cn(className)} />;
|
return <CircleXIcon className={cn('text-destructive', className)} />;
|
||||||
|
|
||||||
}
|
}
|
||||||
}, [className, status]);
|
}, [className, status]);
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,16 @@ export const gitlabQuickActions: QuickAction<GitlabConnectionConfig>[] = [
|
||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
name: "Add a project",
|
name: "Add a project",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fn: (previous: GitlabConnectionConfig) => ({
|
||||||
|
...previous,
|
||||||
|
users: [
|
||||||
|
...previous.users ?? [],
|
||||||
|
""
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
name: "Add a user",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,14 +31,17 @@ export const SecretsTable = ({ initialSecrets }: SecretsTableProps) => {
|
||||||
const domain = useDomain();
|
const domain = useDomain();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getSecrets(domain).then((keys) => {
|
const fetchSecretKeys = async () => {
|
||||||
if ('keys' in keys) {
|
const keys = await getSecrets(domain);
|
||||||
setSecrets(keys);
|
if (isServiceError(keys)) {
|
||||||
} else {
|
console.error("Failed to fetch secrets:", keys);
|
||||||
console.error(keys);
|
return;
|
||||||
}
|
}
|
||||||
})
|
setSecrets(keys);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
|
fetchSecretKeys();
|
||||||
|
}, [domain]);
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
|
|
|
||||||
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