more improvements

This commit is contained in:
bkellam 2025-10-28 17:21:59 -07:00
parent 7db49f48c5
commit 28da73e292
17 changed files with 210 additions and 78 deletions

View file

@ -1,6 +1,6 @@
import { AzureDevOpsConnectionConfig } from "@sourcebot/schemas/v3/azuredevops.type"; import { AzureDevOpsConnectionConfig } from "@sourcebot/schemas/v3/azuredevops.type";
import { createLogger } from "@sourcebot/logger"; import { createLogger } from "@sourcebot/logger";
import { getTokenFromConfig, measure, fetchWithRetry } from "./utils.js"; import { measure, fetchWithRetry } from "./utils.js";
import micromatch from "micromatch"; import micromatch from "micromatch";
import { PrismaClient } from "@sourcebot/db"; import { PrismaClient } from "@sourcebot/db";
import { BackendException, BackendError } from "@sourcebot/error"; import { BackendException, BackendError } from "@sourcebot/error";
@ -8,6 +8,7 @@ import { processPromiseResults, throwIfAnyFailed } from "./connectionUtils.js";
import * as Sentry from "@sentry/node"; import * as Sentry from "@sentry/node";
import * as azdev from "azure-devops-node-api"; import * as azdev from "azure-devops-node-api";
import { GitRepository } from "azure-devops-node-api/interfaces/GitInterfaces.js"; import { GitRepository } from "azure-devops-node-api/interfaces/GitInterfaces.js";
import { getTokenFromConfig } from "@sourcebot/crypto";
const logger = createLogger('azuredevops'); const logger = createLogger('azuredevops');
const AZUREDEVOPS_CLOUD_HOSTNAME = "dev.azure.com"; const AZUREDEVOPS_CLOUD_HOSTNAME = "dev.azure.com";
@ -34,7 +35,7 @@ export const getAzureDevOpsReposFromConfig = async (
const baseUrl = config.url || `https://${AZUREDEVOPS_CLOUD_HOSTNAME}`; const baseUrl = config.url || `https://${AZUREDEVOPS_CLOUD_HOSTNAME}`;
const token = config.token ? const token = config.token ?
await getTokenFromConfig(config.token, orgId, db, logger) : await getTokenFromConfig(config.token, orgId, db) :
undefined; undefined;
if (!token) { if (!token) {

View file

@ -4,7 +4,7 @@ import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/bitbucket.type"
import type { ClientOptions, ClientPathsWithMethod } from "openapi-fetch"; import type { ClientOptions, ClientPathsWithMethod } from "openapi-fetch";
import { createLogger } from "@sourcebot/logger"; import { createLogger } from "@sourcebot/logger";
import { PrismaClient } from "@sourcebot/db"; import { PrismaClient } from "@sourcebot/db";
import { getTokenFromConfig, measure, fetchWithRetry } from "./utils.js"; import { measure, fetchWithRetry } from "./utils.js";
import * as Sentry from "@sentry/node"; import * as Sentry from "@sentry/node";
import { import {
SchemaRepository as CloudRepository, SchemaRepository as CloudRepository,
@ -12,6 +12,7 @@ import {
import { SchemaRestRepository as ServerRepository } from "@coderabbitai/bitbucket/server/openapi"; import { SchemaRestRepository as ServerRepository } from "@coderabbitai/bitbucket/server/openapi";
import { processPromiseResults } from "./connectionUtils.js"; import { processPromiseResults } from "./connectionUtils.js";
import { throwIfAnyFailed } from "./connectionUtils.js"; import { throwIfAnyFailed } from "./connectionUtils.js";
import { getTokenFromConfig } from "@sourcebot/crypto";
const logger = createLogger('bitbucket'); const logger = createLogger('bitbucket');
const BITBUCKET_CLOUD_GIT = 'https://bitbucket.org'; const BITBUCKET_CLOUD_GIT = 'https://bitbucket.org';
@ -59,7 +60,7 @@ type ServerPaginatedResponse<T> = {
export const getBitbucketReposFromConfig = async (config: BitbucketConnectionConfig, orgId: number, db: PrismaClient) => { export const getBitbucketReposFromConfig = async (config: BitbucketConnectionConfig, orgId: number, db: PrismaClient) => {
const token = config.token ? const token = config.token ?
await getTokenFromConfig(config.token, orgId, db, logger) : await getTokenFromConfig(config.token, orgId, db) :
undefined; undefined;
if (config.deploymentType === 'server' && !config.url) { if (config.deploymentType === 'server' && !config.url) {

View file

@ -1,8 +1,7 @@
import { GithubAppConfig, SourcebotConfig } from "@sourcebot/schemas/v3/index.type";
import { loadConfig } from "@sourcebot/shared"; import { loadConfig } from "@sourcebot/shared";
import { env } from "../env.js"; import { env } from "../env.js";
import { createLogger } from "@sourcebot/logger"; import { createLogger } from "@sourcebot/logger";
import { getTokenFromConfig } from "../utils.js"; import { getTokenFromConfig } from "@sourcebot/crypto";
import { PrismaClient } from "@sourcebot/db"; import { PrismaClient } from "@sourcebot/db";
import { App } from "@octokit/app"; import { App } from "@octokit/app";
import { GitHubAppConfig } from "@sourcebot/schemas/v3/index.type"; import { GitHubAppConfig } from "@sourcebot/schemas/v3/index.type";
@ -54,7 +53,7 @@ export class GithubAppManager {
return; return;
} }
const githubApps = config.apps.filter(app => app.type === 'githubApp') as GithubAppConfig[]; const githubApps = config.apps.filter(app => app.type === 'githubApp') as GitHubAppConfig[];
logger.info(`Found ${githubApps.length} GitHub apps in config`); logger.info(`Found ${githubApps.length} GitHub apps in config`);
for (const app of githubApps) { for (const app of githubApps) {
@ -63,7 +62,7 @@ export class GithubAppManager {
// @todo: we should move SINGLE_TENANT_ORG_ID to shared package or just remove the need to pass this in // @todo: we should move SINGLE_TENANT_ORG_ID to shared package or just remove the need to pass this in
// when resolving tokens // when resolving tokens
const SINGLE_TENANT_ORG_ID = 1; const SINGLE_TENANT_ORG_ID = 1;
const privateKey = await getTokenFromConfig(app.privateKey, SINGLE_TENANT_ORG_ID, this.db!); const privateKey = await getTokenFromConfig(app.privateKey, SINGLE_TENANT_ORG_ID, this.db);
const octokitApp = new App({ const octokitApp = new App({
appId: Number(app.id), appId: Number(app.id),

View file

@ -1,6 +1,6 @@
import { Api, giteaApi, HttpResponse, Repository as GiteaRepository } from 'gitea-js'; import { Api, giteaApi, HttpResponse, Repository as GiteaRepository } from 'gitea-js';
import { GiteaConnectionConfig } from '@sourcebot/schemas/v3/gitea.type'; import { GiteaConnectionConfig } from '@sourcebot/schemas/v3/gitea.type';
import { getTokenFromConfig, measure } from './utils.js'; import { measure } from './utils.js';
import fetch from 'cross-fetch'; import fetch from 'cross-fetch';
import { createLogger } from '@sourcebot/logger'; import { createLogger } from '@sourcebot/logger';
import micromatch from 'micromatch'; import micromatch from 'micromatch';
@ -8,6 +8,7 @@ import { PrismaClient } from '@sourcebot/db';
import { processPromiseResults, throwIfAnyFailed } from './connectionUtils.js'; import { processPromiseResults, throwIfAnyFailed } from './connectionUtils.js';
import * as Sentry from "@sentry/node"; import * as Sentry from "@sentry/node";
import { env } from './env.js'; import { env } from './env.js';
import { getTokenFromConfig } from "@sourcebot/crypto";
const logger = createLogger('gitea'); const logger = createLogger('gitea');
const GITEA_CLOUD_HOSTNAME = "gitea.com"; const GITEA_CLOUD_HOSTNAME = "gitea.com";
@ -18,7 +19,7 @@ export const getGiteaReposFromConfig = async (config: GiteaConnectionConfig, org
GITEA_CLOUD_HOSTNAME; GITEA_CLOUD_HOSTNAME;
const token = config.token ? const token = config.token ?
await getTokenFromConfig(config.token, orgId, db, logger) : await getTokenFromConfig(config.token, orgId, db) :
hostname === GITEA_CLOUD_HOSTNAME ? hostname === GITEA_CLOUD_HOSTNAME ?
env.FALLBACK_GITEA_CLOUD_TOKEN : env.FALLBACK_GITEA_CLOUD_TOKEN :
undefined; undefined;

View file

@ -8,7 +8,8 @@ import micromatch from "micromatch";
import { processPromiseResults, throwIfAnyFailed } from "./connectionUtils.js"; import { processPromiseResults, throwIfAnyFailed } from "./connectionUtils.js";
import { GithubAppManager } from "./ee/githubAppManager.js"; import { GithubAppManager } from "./ee/githubAppManager.js";
import { env } from "./env.js"; import { env } from "./env.js";
import { fetchWithRetry, getTokenFromConfig, measure } from "./utils.js"; import { fetchWithRetry, measure } from "./utils.js";
import { getTokenFromConfig } from "@sourcebot/crypto";
export const GITHUB_CLOUD_HOSTNAME = "github.com"; export const GITHUB_CLOUD_HOSTNAME = "github.com";
const logger = createLogger('github'); const logger = createLogger('github');
@ -97,7 +98,7 @@ export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, o
GITHUB_CLOUD_HOSTNAME; GITHUB_CLOUD_HOSTNAME;
const token = config.token ? const token = config.token ?
await getTokenFromConfig(config.token, orgId, db, logger) : await getTokenFromConfig(config.token, orgId, db) :
hostname === GITHUB_CLOUD_HOSTNAME ? hostname === GITHUB_CLOUD_HOSTNAME ?
env.FALLBACK_GITHUB_CLOUD_TOKEN : env.FALLBACK_GITHUB_CLOUD_TOKEN :
undefined; undefined;

View file

@ -2,11 +2,12 @@ import { Gitlab, ProjectSchema } from "@gitbeaker/rest";
import micromatch from "micromatch"; import micromatch from "micromatch";
import { createLogger } from "@sourcebot/logger"; import { createLogger } from "@sourcebot/logger";
import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type" import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type"
import { getTokenFromConfig, measure, fetchWithRetry } from "./utils.js"; import { measure, fetchWithRetry } from "./utils.js";
import { PrismaClient } from "@sourcebot/db"; import { PrismaClient } from "@sourcebot/db";
import { processPromiseResults, throwIfAnyFailed } from "./connectionUtils.js"; import { processPromiseResults, throwIfAnyFailed } from "./connectionUtils.js";
import * as Sentry from "@sentry/node"; import * as Sentry from "@sentry/node";
import { env } from "./env.js"; import { env } from "./env.js";
import { getTokenFromConfig } from "@sourcebot/crypto";
const logger = createLogger('gitlab'); const logger = createLogger('gitlab');
export const GITLAB_CLOUD_HOSTNAME = "gitlab.com"; export const GITLAB_CLOUD_HOSTNAME = "gitlab.com";
@ -17,7 +18,7 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o
GITLAB_CLOUD_HOSTNAME; GITLAB_CLOUD_HOSTNAME;
const token = config.token ? const token = config.token ?
await getTokenFromConfig(config.token, orgId, db, logger) : await getTokenFromConfig(config.token, orgId, db) :
hostname === GITLAB_CLOUD_HOSTNAME ? hostname === GITLAB_CLOUD_HOSTNAME ?
env.FALLBACK_GITLAB_CLOUD_TOKEN : env.FALLBACK_GITLAB_CLOUD_TOKEN :
undefined; undefined;

View file

@ -2,8 +2,7 @@ import { Logger } from "winston";
import { RepoAuthCredentials, RepoWithConnections } from "./types.js"; import { RepoAuthCredentials, RepoWithConnections } from "./types.js";
import path from 'path'; import path from 'path';
import { PrismaClient, Repo } from "@sourcebot/db"; import { PrismaClient, Repo } from "@sourcebot/db";
import { getTokenFromConfig as getTokenFromConfigBase } from "@sourcebot/crypto"; import { getTokenFromConfig } from "@sourcebot/crypto";
import { BackendException, BackendError } from "@sourcebot/error";
import * as Sentry from "@sentry/node"; import * as Sentry from "@sentry/node";
import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, BitbucketConnectionConfig, AzureDevOpsConnectionConfig } from '@sourcebot/schemas/v3/connection.type'; import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, BitbucketConnectionConfig, AzureDevOpsConnectionConfig } from '@sourcebot/schemas/v3/connection.type';
import { GithubAppManager } from "./ee/githubAppManager.js"; import { GithubAppManager } from "./ee/githubAppManager.js";
@ -24,22 +23,6 @@ export const marshalBool = (value?: boolean) => {
return !!value ? '1' : '0'; return !!value ? '1' : '0';
} }
export const getTokenFromConfig = async (token: any, orgId: number, db: PrismaClient, logger?: Logger) => {
try {
return await getTokenFromConfigBase(token, orgId, db);
} catch (error: unknown) {
if (error instanceof Error) {
const e = new BackendException(BackendError.CONNECTION_SYNC_SECRET_DNE, {
message: error.message,
});
Sentry.captureException(e);
logger?.error(error.message);
throw e;
}
throw error;
}
};
export const resolvePathRelativeToConfig = (localPath: string, configPath: string) => { export const resolvePathRelativeToConfig = (localPath: string, configPath: string) => {
let absolutePath = localPath; let absolutePath = localPath;
if (!path.isAbsolute(absolutePath)) { if (!path.isAbsolute(absolutePath)) {
@ -156,7 +139,7 @@ export const getAuthCredentialsForRepo = async (repo: RepoWithConnections, db: P
if (connection.connectionType === 'github') { if (connection.connectionType === 'github') {
const config = connection.config as unknown as GithubConnectionConfig; const config = connection.config as unknown as GithubConnectionConfig;
if (config.token) { if (config.token) {
const token = await getTokenFromConfig(config.token, connection.orgId, db, logger); const token = await getTokenFromConfig(config.token, connection.orgId, db);
return { return {
hostUrl: config.url, hostUrl: config.url,
token, token,
@ -171,7 +154,7 @@ export const getAuthCredentialsForRepo = async (repo: RepoWithConnections, db: P
} else if (connection.connectionType === 'gitlab') { } else if (connection.connectionType === 'gitlab') {
const config = connection.config as unknown as GitlabConnectionConfig; const config = connection.config as unknown as GitlabConnectionConfig;
if (config.token) { if (config.token) {
const token = await getTokenFromConfig(config.token, connection.orgId, db, logger); const token = await getTokenFromConfig(config.token, connection.orgId, db);
return { return {
hostUrl: config.url, hostUrl: config.url,
token, token,
@ -187,7 +170,7 @@ export const getAuthCredentialsForRepo = async (repo: RepoWithConnections, db: P
} else if (connection.connectionType === 'gitea') { } else if (connection.connectionType === 'gitea') {
const config = connection.config as unknown as GiteaConnectionConfig; const config = connection.config as unknown as GiteaConnectionConfig;
if (config.token) { if (config.token) {
const token = await getTokenFromConfig(config.token, connection.orgId, db, logger); const token = await getTokenFromConfig(config.token, connection.orgId, db);
return { return {
hostUrl: config.url, hostUrl: config.url,
token, token,
@ -202,7 +185,7 @@ export const getAuthCredentialsForRepo = async (repo: RepoWithConnections, db: P
} else if (connection.connectionType === 'bitbucket') { } else if (connection.connectionType === 'bitbucket') {
const config = connection.config as unknown as BitbucketConnectionConfig; const config = connection.config as unknown as BitbucketConnectionConfig;
if (config.token) { if (config.token) {
const token = await getTokenFromConfig(config.token, connection.orgId, db, logger); const token = await getTokenFromConfig(config.token, connection.orgId, db);
const username = config.user ?? 'x-token-auth'; const username = config.user ?? 'x-token-auth';
return { return {
hostUrl: config.url, hostUrl: config.url,
@ -219,7 +202,7 @@ export const getAuthCredentialsForRepo = async (repo: RepoWithConnections, db: P
} else if (connection.connectionType === 'azuredevops') { } else if (connection.connectionType === 'azuredevops') {
const config = connection.config as unknown as AzureDevOpsConnectionConfig; const config = connection.config as unknown as AzureDevOpsConnectionConfig;
if (config.token) { if (config.token) {
const token = await getTokenFromConfig(config.token, connection.orgId, db, logger); const token = await getTokenFromConfig(config.token, connection.orgId, db);
// For ADO server, multiple auth schemes may be supported. If the ADO deployment supports NTLM, the git clone will default // For ADO server, multiple auth schemes may be supported. If the ADO deployment supports NTLM, the git clone will default
// to this over basic auth. As a result, we cannot embed the token in the clone URL and must force basic auth by passing in the token // to this over basic auth. As a result, we cannot embed the token in the clone URL and must force basic auth by passing in the token

View file

@ -10,7 +10,7 @@ import { prisma } from "@/prisma";
import { render } from "@react-email/components"; import { render } from "@react-email/components";
import * as Sentry from '@sentry/nextjs'; import * as Sentry from '@sentry/nextjs';
import { encrypt, generateApiKey, getTokenFromConfig, hashSecret } from "@sourcebot/crypto"; import { encrypt, generateApiKey, getTokenFromConfig, hashSecret } from "@sourcebot/crypto";
import { ApiKey, Org, OrgRole, Prisma, RepoIndexingJobStatus, RepoIndexingJobType, StripeSubscriptionStatus } from "@sourcebot/db"; import { ApiKey, ConnectionSyncJobStatus, Org, OrgRole, Prisma, RepoIndexingJobStatus, RepoIndexingJobType, StripeSubscriptionStatus } from "@sourcebot/db";
import { createLogger } from "@sourcebot/logger"; import { createLogger } from "@sourcebot/logger";
import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type"; import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type";
import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"; import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type";
@ -30,7 +30,7 @@ import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail";
import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_ORG_DOMAIN, SOURCEBOT_GUEST_USER_ID, SOURCEBOT_SUPPORT_EMAIL } from "./lib/constants"; import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_ORG_DOMAIN, SOURCEBOT_GUEST_USER_ID, SOURCEBOT_SUPPORT_EMAIL } from "./lib/constants";
import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas"; import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas";
import { ApiKeyPayload, TenancyMode } from "./lib/types"; import { ApiKeyPayload, TenancyMode } from "./lib/types";
import { withOptionalAuthV2 } from "./withAuthV2"; import { withAuthV2, withOptionalAuthV2 } from "./withAuthV2";
const logger = createLogger('web-actions'); const logger = createLogger('web-actions');
const auditService = getAuditService(); const auditService = getAuditService();
@ -593,6 +593,7 @@ export const getReposStats = async () => sew(() =>
prisma.repo.count({ prisma.repo.count({
where: { where: {
orgId: org.id, orgId: org.id,
indexedAt: null,
jobs: { jobs: {
some: { some: {
type: RepoIndexingJobType.INDEX, type: RepoIndexingJobType.INDEX,
@ -604,7 +605,6 @@ export const getReposStats = async () => sew(() =>
} }
}, },
}, },
indexedAt: null,
} }
}), }),
prisma.repo.count({ prisma.repo.count({
@ -625,6 +625,42 @@ export const getReposStats = async () => sew(() =>
}) })
) )
export const getConnectionStats = async () => sew(() =>
withAuthV2(async ({ org, prisma }) => {
const [
numberOfConnections,
numberOfConnectionsWithFirstTimeSyncJobsInProgress,
] = await Promise.all([
prisma.connection.count({
where: {
orgId: org.id,
}
}),
prisma.connection.count({
where: {
orgId: org.id,
syncedAt: null,
syncJobs: {
some: {
status: {
in: [
ConnectionSyncJobStatus.PENDING,
ConnectionSyncJobStatus.IN_PROGRESS,
]
}
}
}
}
})
]);
return {
numberOfConnections,
numberOfConnectionsWithFirstTimeSyncJobsInProgress,
};
})
);
export const getRepoInfoByName = async (repoName: string) => sew(() => export const getRepoInfoByName = async (repoName: string) => sew(() =>
withOptionalAuthV2(async ({ org, prisma }) => { withOptionalAuthV2(async ({ org, prisma }) => {
// @note: repo names are represented by their remote url // @note: repo names are represented by their remote url

View file

@ -1,4 +1,4 @@
import { getRepos, getReposStats } from "@/actions"; import { getConnectionStats, getRepos, getReposStats } from "@/actions";
import { SourcebotLogo } from "@/app/components/sourcebotLogo"; import { SourcebotLogo } from "@/app/components/sourcebotLogo";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -39,6 +39,11 @@ export const NavigationMenu = async ({
throw new ServiceErrorException(repoStats); throw new ServiceErrorException(repoStats);
} }
const connectionStats = isAuthenticated ? await getConnectionStats() : null;
if (isServiceError(connectionStats)) {
throw new ServiceErrorException(connectionStats);
}
const sampleRepos = await getRepos({ const sampleRepos = await getRepos({
where: { where: {
jobs: { jobs: {
@ -93,7 +98,12 @@ export const NavigationMenu = async ({
<NavigationItems <NavigationItems
domain={domain} domain={domain}
numberOfRepos={numberOfRepos} numberOfRepos={numberOfRepos}
numberOfReposWithFirstTimeIndexingJobsInProgress={numberOfReposWithFirstTimeIndexingJobsInProgress} isReposButtonNotificationDotVisible={numberOfReposWithFirstTimeIndexingJobsInProgress > 0}
isSettingsButtonNotificationDotVisible={
connectionStats ?
connectionStats.numberOfConnectionsWithFirstTimeSyncJobsInProgress > 0 :
false
}
isAuthenticated={isAuthenticated} isAuthenticated={isAuthenticated}
/> />
</NavigationMenuBase> </NavigationMenuBase>

View file

@ -3,20 +3,23 @@
import { NavigationMenuItem, NavigationMenuLink, NavigationMenuList, navigationMenuTriggerStyle } from "@/components/ui/navigation-menu"; import { NavigationMenuItem, NavigationMenuLink, NavigationMenuList, navigationMenuTriggerStyle } from "@/components/ui/navigation-menu";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { cn, getShortenedNumberDisplayString } from "@/lib/utils"; import { cn, getShortenedNumberDisplayString } from "@/lib/utils";
import { SearchIcon, MessageCircleIcon, BookMarkedIcon, SettingsIcon, CircleIcon } from "lucide-react"; import { SearchIcon, MessageCircleIcon, BookMarkedIcon, SettingsIcon } from "lucide-react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { NotificationDot } from "../notificationDot";
interface NavigationItemsProps { interface NavigationItemsProps {
domain: string; domain: string;
numberOfRepos: number; numberOfRepos: number;
numberOfReposWithFirstTimeIndexingJobsInProgress: number; isReposButtonNotificationDotVisible: boolean;
isSettingsButtonNotificationDotVisible: boolean;
isAuthenticated: boolean; isAuthenticated: boolean;
} }
export const NavigationItems = ({ export const NavigationItems = ({
domain, domain,
numberOfRepos, numberOfRepos,
numberOfReposWithFirstTimeIndexingJobsInProgress, isReposButtonNotificationDotVisible,
isSettingsButtonNotificationDotVisible,
isAuthenticated, isAuthenticated,
}: NavigationItemsProps) => { }: NavigationItemsProps) => {
const pathname = usePathname(); const pathname = usePathname();
@ -59,9 +62,7 @@ export const NavigationItems = ({
<span className="mr-2">Repositories</span> <span className="mr-2">Repositories</span>
<Badge variant="secondary" className="px-1.5 relative"> <Badge variant="secondary" className="px-1.5 relative">
{getShortenedNumberDisplayString(numberOfRepos)} {getShortenedNumberDisplayString(numberOfRepos)}
{numberOfReposWithFirstTimeIndexingJobsInProgress > 0 && ( {isReposButtonNotificationDotVisible && <NotificationDot className="absolute -right-0.5 -top-0.5" />}
<CircleIcon className="absolute -right-0.5 -top-0.5 h-2 w-2 text-green-600" fill="currentColor" />
)}
</Badge> </Badge>
</NavigationMenuLink> </NavigationMenuLink>
{isActive(`/${domain}/repos`) && <ActiveIndicator />} {isActive(`/${domain}/repos`) && <ActiveIndicator />}
@ -74,6 +75,7 @@ export const NavigationItems = ({
> >
<SettingsIcon className="w-4 h-4 mr-1" /> <SettingsIcon className="w-4 h-4 mr-1" />
Settings Settings
{isSettingsButtonNotificationDotVisible && <NotificationDot className="absolute -right-0.5 -top-0.5" />}
</NavigationMenuLink> </NavigationMenuLink>
{isActive(`/${domain}/settings`) && <ActiveIndicator />} {isActive(`/${domain}/settings`) && <ActiveIndicator />}
</NavigationMenuItem> </NavigationMenuItem>

View file

@ -0,0 +1,9 @@
import { cn } from "@/lib/utils"
interface NotificationDotProps {
className?: string
}
export const NotificationDot = ({ className }: NotificationDotProps) => {
return <div className={cn("w-2 h-2 rounded-full bg-green-600", className)} />
}

View file

@ -37,6 +37,7 @@ import { useRouter } from "next/navigation"
import { useToast } from "@/components/hooks/use-toast"; import { useToast } from "@/components/hooks/use-toast";
import { DisplayDate } from "../../components/DisplayDate" import { DisplayDate } from "../../components/DisplayDate"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { NotificationDot } from "../../components/notificationDot"
// @see: https://v0.app/chat/repo-indexing-status-uhjdDim8OUS // @see: https://v0.app/chat/repo-indexing-status-uhjdDim8OUS
@ -53,6 +54,7 @@ export type Repo = {
imageUrl: string | null imageUrl: string | null
indexedCommitHash: string | null indexedCommitHash: string | null
latestJobStatus: "PENDING" | "IN_PROGRESS" | "COMPLETED" | "FAILED" | null latestJobStatus: "PENDING" | "IN_PROGRESS" | "COMPLETED" | "FAILED" | null
isFirstTimeIndex: boolean
} }
const statusBadgeVariants = cva("", { const statusBadgeVariants = cva("", {
@ -111,14 +113,32 @@ export const columns: ColumnDef<Repo>[] = [
{repo.displayName?.charAt(0) ?? repo.name.charAt(0)} {repo.displayName?.charAt(0) ?? repo.name.charAt(0)}
</div> </div>
)} )}
<Link href={getBrowsePath({
{/* Link to the details page (instead of browse) when the repo is indexing
as the code will not be available yet */}
<Link
href={repo.isFirstTimeIndex ? `/${SINGLE_TENANT_ORG_DOMAIN}/repos/${repo.id}` : getBrowsePath({
repoName: repo.name, repoName: repo.name,
path: '/', path: '/',
pathType: 'tree', pathType: 'tree',
domain: SINGLE_TENANT_ORG_DOMAIN, domain: SINGLE_TENANT_ORG_DOMAIN,
})} className="font-medium hover:underline"> })}
className="font-medium hover:underline"
>
{repo.displayName || repo.name} {repo.displayName || repo.name}
</Link> </Link>
{repo.isFirstTimeIndex && (
<Tooltip>
<TooltipTrigger asChild>
<span>
<NotificationDot className="ml-1.5" />
</span>
</TooltipTrigger>
<TooltipContent>
<span>This is the first time Sourcebot is indexing this repository. It may take a few minutes to complete.</span>
</TooltipContent>
</Tooltip>
)}
</div> </div>
) )
}, },
@ -150,7 +170,7 @@ export const columns: ColumnDef<Repo>[] = [
} }
return ( return (
<DisplayDate date={indexedAt} className="ml-3"/> <DisplayDate date={indexedAt} className="ml-3" />
) )
} }
}, },

View file

@ -3,14 +3,31 @@ import { ServiceErrorException } from "@/lib/serviceError";
import { isServiceError } from "@/lib/utils"; import { isServiceError } from "@/lib/utils";
import { withOptionalAuthV2 } from "@/withAuthV2"; import { withOptionalAuthV2 } from "@/withAuthV2";
import { ReposTable } from "./components/reposTable"; import { ReposTable } from "./components/reposTable";
import { RepoIndexingJobStatus } from "@sourcebot/db";
export default async function ReposPage() { export default async function ReposPage() {
const repos = await getReposWithLatestJob(); const _repos = await getReposWithLatestJob();
if (isServiceError(repos)) { if (isServiceError(_repos)) {
throw new ServiceErrorException(repos); throw new ServiceErrorException(_repos);
} }
const repos = _repos
.map((repo) => ({
...repo,
latestJobStatus: repo.jobs.length > 0 ? repo.jobs[0].status : null,
isFirstTimeIndex: repo.indexedAt === null && repo.jobs.filter((job) => job.status === RepoIndexingJobStatus.PENDING || job.status === RepoIndexingJobStatus.IN_PROGRESS).length > 0,
}))
.sort((a, b) => {
if (a.isFirstTimeIndex && !b.isFirstTimeIndex) {
return -1;
}
if (!a.isFirstTimeIndex && b.isFirstTimeIndex) {
return 1;
}
return a.name.localeCompare(b.name);
});
return ( return (
<> <>
<div className="mb-6"> <div className="mb-6">
@ -27,7 +44,8 @@ export default async function ReposPage() {
createdAt: repo.createdAt, createdAt: repo.createdAt,
webUrl: repo.webUrl, webUrl: repo.webUrl,
imageUrl: repo.imageUrl, imageUrl: repo.imageUrl,
latestJobStatus: repo.jobs.length > 0 ? repo.jobs[0].status : null, latestJobStatus: repo.latestJobStatus,
isFirstTimeIndex: repo.isFirstTimeIndex,
codeHostType: repo.external_codeHostType, codeHostType: repo.external_codeHostType,
indexedCommitHash: repo.indexedCommitHash, indexedCommitHash: repo.indexedCommitHash,
}))} /> }))} />

View file

@ -1,15 +1,17 @@
"use client" "use client"
import React from "react" import { buttonVariants } from "@/components/ui/button"
import { NotificationDot } from "@/app/[domain]/components/notificationDot"
import { cn } from "@/lib/utils"
import Link from "next/link" import Link from "next/link"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
import { cn } from "@/lib/utils" import React from "react"
import { buttonVariants } from "@/components/ui/button"
export type SidebarNavItem = { export type SidebarNavItem = {
href: string href: string
hrefRegex?: string hrefRegex?: string
title: React.ReactNode title: React.ReactNode
isNotificationDotVisible?: boolean
} }
interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> { interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
@ -43,6 +45,7 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
)} )}
> >
{item.title} {item.title}
{item.isNotificationDotVisible && <NotificationDot className="ml-1.5" />}
</Link> </Link>
) )
})} })}

View file

@ -1,12 +1,14 @@
"use client" "use client"
import { DisplayDate } from "@/app/[domain]/components/DisplayDate" import { DisplayDate } from "@/app/[domain]/components/DisplayDate"
import { NotificationDot } from "@/app/[domain]/components/notificationDot"
import { useToast } from "@/components/hooks/use-toast" import { useToast } from "@/components/hooks/use-toast"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants" import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"
import { CodeHostType, getCodeHostIcon } from "@/lib/utils" import { CodeHostType, getCodeHostIcon } from "@/lib/utils"
import { import {
@ -35,6 +37,7 @@ export type Connection = {
syncedAt: Date | null syncedAt: Date | null
codeHostType: CodeHostType codeHostType: CodeHostType
latestJobStatus: "PENDING" | "IN_PROGRESS" | "COMPLETED" | "FAILED" | null latestJobStatus: "PENDING" | "IN_PROGRESS" | "COMPLETED" | "FAILED" | null
isFirstTimeSync: boolean
} }
const statusBadgeVariants = cva("", { const statusBadgeVariants = cva("", {
@ -91,6 +94,18 @@ export const columns: ColumnDef<Connection>[] = [
<Link href={`/${SINGLE_TENANT_ORG_DOMAIN}/settings/connections/${connection.id}`} className="font-medium hover:underline"> <Link href={`/${SINGLE_TENANT_ORG_DOMAIN}/settings/connections/${connection.id}`} className="font-medium hover:underline">
{connection.name} {connection.name}
</Link> </Link>
{connection.isFirstTimeSync && (
<Tooltip>
<TooltipTrigger asChild>
<span>
<NotificationDot className="ml-1.5" />
</span>
</TooltipTrigger>
<TooltipContent>
<span>This is the first time Sourcebot is syncing this connection. It may take a few minutes to complete.</span>
</TooltipContent>
</Tooltip>
)}
</div> </div>
) )
}, },

View file

@ -4,15 +4,33 @@ import { CodeHostType, isServiceError } from "@/lib/utils";
import { withAuthV2 } from "@/withAuthV2"; import { withAuthV2 } from "@/withAuthV2";
import Link from "next/link"; import Link from "next/link";
import { ConnectionsTable } from "./components/connectionsTable"; import { ConnectionsTable } from "./components/connectionsTable";
import { ConnectionSyncJobStatus } from "@prisma/client";
const DOCS_URL = "https://docs.sourcebot.dev/docs/connections/overview"; const DOCS_URL = "https://docs.sourcebot.dev/docs/connections/overview";
export default async function ConnectionsPage() { export default async function ConnectionsPage() {
const connections = await getConnectionsWithLatestJob(); const _connections = await getConnectionsWithLatestJob();
if (isServiceError(connections)) { if (isServiceError(_connections)) {
throw new ServiceErrorException(connections); throw new ServiceErrorException(_connections);
} }
// Sort connections so that first time syncs are at the top.
const connections = _connections
.map((connection) => ({
...connection,
isFirstTimeSync: connection.syncedAt === null && connection.syncJobs.filter((job) => job.status === ConnectionSyncJobStatus.PENDING || job.status === ConnectionSyncJobStatus.IN_PROGRESS).length > 0,
latestJobStatus: connection.syncJobs.length > 0 ? connection.syncJobs[0].status : null,
}))
.sort((a, b) => {
if (a.isFirstTimeSync && !b.isFirstTimeSync) {
return -1;
}
if (!a.isFirstTimeSync && b.isFirstTimeSync) {
return 1;
}
return a.name.localeCompare(b.name);
});
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div> <div>
@ -24,7 +42,8 @@ export default async function ConnectionsPage() {
name: connection.name, name: connection.name,
codeHostType: connection.connectionType as CodeHostType, codeHostType: connection.connectionType as CodeHostType,
syncedAt: connection.syncedAt, syncedAt: connection.syncedAt,
latestJobStatus: connection.syncJobs.length > 0 ? connection.syncJobs[0].status : null, latestJobStatus: connection.latestJobStatus,
isFirstTimeSync: connection.isFirstTimeSync,
}))} /> }))} />
</div> </div>
) )
@ -34,16 +53,22 @@ const getConnectionsWithLatestJob = async () => sew(() =>
withAuthV2(async ({ prisma }) => { withAuthV2(async ({ prisma }) => {
const connections = await prisma.connection.findMany({ const connections = await prisma.connection.findMany({
include: { include: {
_count: {
select: {
syncJobs: true,
}
},
syncJobs: { syncJobs: {
orderBy: { orderBy: {
createdAt: 'desc' createdAt: 'desc'
}, },
take: 1 take: 1
} },
}, },
orderBy: { orderBy: {
name: 'asc' name: 'asc'
} },
}); });
return connections; return connections;
})); }));

View file

@ -6,7 +6,7 @@ import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { isServiceError } from "@/lib/utils"; import { isServiceError } from "@/lib/utils";
import { getMe, getOrgAccountRequests } from "@/actions"; import { getConnectionStats, getMe, getOrgAccountRequests } from "@/actions";
import { ServiceErrorException } from "@/lib/serviceError"; import { ServiceErrorException } from "@/lib/serviceError";
import { getOrgFromDomain } from "@/data/org"; import { getOrgFromDomain } from "@/data/org";
import { OrgRole } from "@prisma/client"; import { OrgRole } from "@prisma/client";
@ -63,6 +63,11 @@ export default async function SettingsLayout(
numJoinRequests = requests.length; numJoinRequests = requests.length;
} }
const connectionStats = await getConnectionStats();
if (isServiceError(connectionStats)) {
throw new ServiceErrorException(connectionStats);
}
const sidebarNavItems: SidebarNavItem[] = [ const sidebarNavItems: SidebarNavItem[] = [
{ {
title: "General", title: "General",
@ -98,6 +103,7 @@ export default async function SettingsLayout(
title: "Connections", title: "Connections",
href: `/${domain}/settings/connections`, href: `/${domain}/settings/connections`,
hrefRegex: `/${domain}/settings/connections(\/[^/]+)?$`, hrefRegex: `/${domain}/settings/connections(\/[^/]+)?$`,
isNotificationDotVisible: connectionStats.numberOfConnectionsWithFirstTimeSyncJobsInProgress > 0,
} }
] : []), ] : []),
{ {
@ -141,3 +147,4 @@ export default async function SettingsLayout(
</div> </div>
) )
} }