diff --git a/CHANGELOG.md b/CHANGELOG.md index 94dfcdc91..d6358d9b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved homepage performance by removing client side polling. [#563](https://github.com/sourcebot-dev/sourcebot/pull/563) - Changed navbar indexing indicator to only report progress for first time indexing jobs. [#563](https://github.com/sourcebot-dev/sourcebot/pull/563) - Improved repo indexing job stability and robustness. [#563](https://github.com/sourcebot-dev/sourcebot/pull/563) +- Improved repositories table. [#572](https://github.com/sourcebot-dev/sourcebot/pull/572) ### Removed - Removed spam "login page loaded" log. [#552](https://github.com/sourcebot-dev/sourcebot/pull/552) diff --git a/packages/backend/src/constants.ts b/packages/backend/src/constants.ts index 3ede6d4ee..3150187e2 100644 --- a/packages/backend/src/constants.ts +++ b/packages/backend/src/constants.ts @@ -1,27 +1,6 @@ import { env } from "./env.js"; -import { Settings } from "./types.js"; import path from "path"; -/** - * Default settings. - */ -export const DEFAULT_SETTINGS: Settings = { - maxFileSize: 2 * 1024 * 1024, // 2MB in bytes - maxTrigramCount: 20000, - reindexIntervalMs: 1000 * 60 * 60, // 1 hour - resyncConnectionIntervalMs: 1000 * 60 * 60 * 24, // 24 hours - resyncConnectionPollingIntervalMs: 1000 * 1, // 1 second - reindexRepoPollingIntervalMs: 1000 * 1, // 1 second - maxConnectionSyncJobConcurrency: 8, - maxRepoIndexingJobConcurrency: 8, - maxRepoGarbageCollectionJobConcurrency: 8, - repoGarbageCollectionGracePeriodMs: 10 * 1000, // 10 seconds - repoIndexTimeoutMs: 1000 * 60 * 60 * 2, // 2 hours - enablePublicAccess: false, // deprected, use FORCE_ENABLE_ANONYMOUS_ACCESS instead - experiment_repoDrivenPermissionSyncIntervalMs: 1000 * 60 * 60 * 24, // 24 hours - experiment_userDrivenPermissionSyncIntervalMs: 1000 * 60 * 60 * 24, // 24 hours -} - export const PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES = [ 'github', ]; diff --git a/packages/backend/src/git.ts b/packages/backend/src/git.ts index 308f3f4a6..80d34cefb 100644 --- a/packages/backend/src/git.ts +++ b/packages/backend/src/git.ts @@ -268,4 +268,26 @@ export const getTags = async (path: string) => { const git = createGitClientForPath(path); const tags = await git.tags(); return tags.all; +} + +export const getCommitHashForRefName = async ({ + path, + refName, +}: { + path: string, + refName: string, +}) => { + const git = createGitClientForPath(path); + + try { + // The `^{commit}` suffix is used to fully dereference the ref to a commit hash. + const rev = await git.revparse(`${refName}^{commit}`); + return rev; + + // @note: Was hitting errors when the repository is empty, + // so we're catching the error and returning undefined. + } catch (error: unknown) { + console.error(error); + return undefined; + } } \ No newline at end of file diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index d4b0f5db6..2e2ec569c 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -2,12 +2,12 @@ import "./instrument.js"; import { PrismaClient } from "@sourcebot/db"; import { createLogger } from "@sourcebot/logger"; -import { hasEntitlement, loadConfig } from '@sourcebot/shared'; +import { getConfigSettings, hasEntitlement } from '@sourcebot/shared'; import { existsSync } from 'fs'; import { mkdir } from 'fs/promises'; import { Redis } from 'ioredis'; import { ConnectionManager } from './connectionManager.js'; -import { DEFAULT_SETTINGS, INDEX_CACHE_DIR, REPOS_CACHE_DIR } from './constants.js'; +import { INDEX_CACHE_DIR, REPOS_CACHE_DIR } from './constants.js'; import { RepoPermissionSyncer } from './ee/repoPermissionSyncer.js'; import { UserPermissionSyncer } from "./ee/userPermissionSyncer.js"; import { GithubAppManager } from "./ee/githubAppManager.js"; @@ -18,20 +18,6 @@ import { PromClient } from './promClient.js'; const logger = createLogger('backend-entrypoint'); -const getSettings = async (configPath?: string) => { - if (!configPath) { - return DEFAULT_SETTINGS; - } - - const config = await loadConfig(configPath); - - return { - ...DEFAULT_SETTINGS, - ...config.settings, - } -} - - const reposPath = REPOS_CACHE_DIR; const indexPath = INDEX_CACHE_DIR; @@ -57,8 +43,7 @@ redis.ping().then(() => { const promClient = new PromClient(); -const settings = await getSettings(env.CONFIG_PATH); - +const settings = await getConfigSettings(env.CONFIG_PATH); if (hasEntitlement('github-app')) { await GithubAppManager.getInstance().init(prisma); diff --git a/packages/backend/src/repoCompileUtils.ts b/packages/backend/src/repoCompileUtils.ts index 80d80ebe5..f5edb4f6c 100644 --- a/packages/backend/src/repoCompileUtils.ts +++ b/packages/backend/src/repoCompileUtils.ts @@ -13,12 +13,12 @@ import { marshalBool } from "./utils.js"; import { createLogger } from '@sourcebot/logger'; import { BitbucketConnectionConfig, GerritConnectionConfig, GiteaConnectionConfig, GitlabConnectionConfig, GenericGitHostConnectionConfig, AzureDevOpsConnectionConfig } from '@sourcebot/schemas/v3/connection.type'; import { ProjectVisibility } from "azure-devops-node-api/interfaces/CoreInterfaces.js"; -import { RepoMetadata } from './types.js'; import path from 'path'; import { glob } from 'glob'; import { getOriginUrl, isPathAValidGitRepoRoot, isUrlAValidGitRepo } from './git.js'; import assert from 'assert'; import GitUrlParse from 'git-url-parse'; +import { RepoMetadata } from '@sourcebot/shared'; export type RepoData = WithRequired; diff --git a/packages/backend/src/repoIndexManager.ts b/packages/backend/src/repoIndexManager.ts index ae013788d..98258b0b8 100644 --- a/packages/backend/src/repoIndexManager.ts +++ b/packages/backend/src/repoIndexManager.ts @@ -1,15 +1,18 @@ import * as Sentry from '@sentry/node'; import { PrismaClient, Repo, RepoIndexingJobStatus, RepoIndexingJobType } from "@sourcebot/db"; import { createLogger, Logger } from "@sourcebot/logger"; +import { repoMetadataSchema, RepoIndexingJobMetadata, repoIndexingJobMetadataSchema, RepoMetadata } from '@sourcebot/shared'; import { existsSync } from 'fs'; import { readdir, rm } from 'fs/promises'; import { Job, Queue, ReservedJob, Worker } from "groupmq"; import { Redis } from 'ioredis'; +import micromatch from 'micromatch'; import { INDEX_CACHE_DIR } from './constants.js'; import { env } from './env.js'; -import { cloneRepository, fetchRepository, isPathAValidGitRepoRoot, unsetGitConfig, upsertGitConfig } from './git.js'; +import { cloneRepository, fetchRepository, getBranches, getCommitHashForRefName, getTags, isPathAValidGitRepoRoot, unsetGitConfig, upsertGitConfig } from './git.js'; +import { captureEvent } from './posthog.js'; import { PromClient } from './promClient.js'; -import { repoMetadataSchema, RepoWithConnections, Settings } from "./types.js"; +import { RepoWithConnections, Settings } from "./types.js"; import { getAuthCredentialsForRepo, getRepoPath, getShardPrefix, groupmqLifecycleExceptionWrapper, measure } from './utils.js'; import { indexGitRepository } from './zoekt.js'; @@ -61,7 +64,7 @@ export class RepoIndexManager { concurrency: this.settings.maxRepoIndexingJobConcurrency, ...(env.DEBUG_ENABLE_GROUPMQ_LOGGING === 'true' ? { logger: true, - }: {}), + } : {}), }); this.worker.on('completed', this.onJobCompleted.bind(this)); @@ -126,7 +129,7 @@ export class RepoIndexManager { { AND: [ { status: RepoIndexingJobStatus.FAILED }, - { completedAt: { gt: timeoutDate } }, + { completedAt: { gt: thresholdDate } }, ] } ] @@ -263,7 +266,16 @@ export class RepoIndexManager { try { if (jobType === RepoIndexingJobType.INDEX) { - await this.indexRepository(repo, logger, abortController.signal); + const revisions = await this.indexRepository(repo, logger, abortController.signal); + + await this.db.repoIndexingJob.update({ + where: { id }, + data: { + metadata: { + indexedRevisions: revisions, + } satisfies RepoIndexingJobMetadata, + }, + }); } else if (jobType === RepoIndexingJobType.CLEANUP) { await this.cleanupRepository(repo, logger); } @@ -285,7 +297,7 @@ export class RepoIndexManager { // If the repo path exists but it is not a valid git repository root, this indicates // that the repository is in a bad state. To fix, we remove the directory and perform // a fresh clone. - if (existsSync(repoPath) && !(await isPathAValidGitRepoRoot( { path: repoPath } ))) { + if (existsSync(repoPath) && !(await isPathAValidGitRepoRoot({ path: repoPath }))) { const isValidGitRepo = await isPathAValidGitRepoRoot({ path: repoPath, signal, @@ -354,10 +366,54 @@ export class RepoIndexManager { }); } + let revisions = [ + 'HEAD' + ]; + + if (metadata.branches) { + const branchGlobs = metadata.branches + const allBranches = await getBranches(repoPath); + const matchingBranches = + allBranches + .filter((branch) => micromatch.isMatch(branch, branchGlobs)) + .map((branch) => `refs/heads/${branch}`); + + revisions = [ + ...revisions, + ...matchingBranches + ]; + } + + if (metadata.tags) { + const tagGlobs = metadata.tags; + const allTags = await getTags(repoPath); + const matchingTags = + allTags + .filter((tag) => micromatch.isMatch(tag, tagGlobs)) + .map((tag) => `refs/tags/${tag}`); + + revisions = [ + ...revisions, + ...matchingTags + ]; + } + + // zoekt has a limit of 64 branches/tags to index. + if (revisions.length > 64) { + logger.warn(`Too many revisions (${revisions.length}) for repo ${repo.id}, truncating to 64`); + captureEvent('backend_revisions_truncated', { + repoId: repo.id, + revisionCount: revisions.length, + }); + revisions = revisions.slice(0, 64); + } + logger.info(`Indexing ${repo.name} (id: ${repo.id})...`); - const { durationMs } = await measure(() => indexGitRepository(repo, this.settings, signal)); + const { durationMs } = await measure(() => indexGitRepository(repo, this.settings, revisions, signal)); const indexDuration_s = durationMs / 1000; logger.info(`Indexed ${repo.name} (id: ${repo.id}) in ${indexDuration_s}s`); + + return revisions; } private async cleanupRepository(repo: Repo, logger: Logger) { @@ -384,16 +440,32 @@ export class RepoIndexManager { data: { status: RepoIndexingJobStatus.COMPLETED, completedAt: new Date(), + }, + include: { + repo: true, } }); const jobTypeLabel = getJobTypePrometheusLabel(jobData.type); if (jobData.type === RepoIndexingJobType.INDEX) { + const { path: repoPath } = getRepoPath(jobData.repo); + const commitHash = await getCommitHashForRefName({ + path: repoPath, + refName: 'HEAD', + }); + + const jobMetadata = repoIndexingJobMetadataSchema.parse(jobData.metadata); + const repo = await this.db.repo.update({ where: { id: jobData.repoId }, data: { indexedAt: new Date(), + indexedCommitHash: commitHash, + metadata: { + ...(jobData.repo.metadata as RepoMetadata), + indexedRevisions: jobMetadata.indexedRevisions, + } satisfies RepoMetadata, } }); diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 8e27867de..70e16c05d 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -1,36 +1,8 @@ import { Connection, Repo, RepoToConnection } from "@sourcebot/db"; import { Settings as SettingsSchema } from "@sourcebot/schemas/v3/index.type"; -import { z } from "zod"; export type Settings = Required; -// Structure of the `metadata` field in the `Repo` table. -// -// @WARNING: If you modify this schema, please make sure it is backwards -// compatible with any prior versions of the schema!! -// @NOTE: If you move this schema, please update the comment in schema.prisma -// to point to the new location. -export const repoMetadataSchema = z.object({ - /** - * A set of key-value pairs that will be used as git config - * variables when cloning the repo. - * @see: https://git-scm.com/docs/git-clone#Documentation/git-clone.txt-code--configcodecodeltkeygtltvaluegtcode - */ - gitConfig: z.record(z.string(), z.string()).optional(), - - /** - * A list of branches to index. Glob patterns are supported. - */ - branches: z.array(z.string()).optional(), - - /** - * A list of tags to index. Glob patterns are supported. - */ - tags: z.array(z.string()).optional(), -}); - -export type RepoMetadata = z.infer; - // @see : https://stackoverflow.com/a/61132308 export type DeepPartial = T extends object ? { [P in keyof T]?: DeepPartial; diff --git a/packages/backend/src/zoekt.ts b/packages/backend/src/zoekt.ts index ad75927a1..54ae615eb 100644 --- a/packages/backend/src/zoekt.ts +++ b/packages/backend/src/zoekt.ts @@ -1,62 +1,16 @@ import { Repo } from "@sourcebot/db"; import { createLogger } from "@sourcebot/logger"; import { exec } from "child_process"; -import micromatch from "micromatch"; import { INDEX_CACHE_DIR } from "./constants.js"; -import { getBranches, getTags } from "./git.js"; -import { captureEvent } from "./posthog.js"; -import { repoMetadataSchema, Settings } from "./types.js"; +import { Settings } from "./types.js"; import { getRepoPath, getShardPrefix } from "./utils.js"; const logger = createLogger('zoekt'); -export const indexGitRepository = async (repo: Repo, settings: Settings, signal?: AbortSignal) => { - let revisions = [ - 'HEAD' - ]; - +export const indexGitRepository = async (repo: Repo, settings: Settings, revisions: string[], signal?: AbortSignal) => { const { path: repoPath } = getRepoPath(repo); const shardPrefix = getShardPrefix(repo.orgId, repo.id); - const metadata = repoMetadataSchema.parse(repo.metadata); - - if (metadata.branches) { - const branchGlobs = metadata.branches - const allBranches = await getBranches(repoPath); - const matchingBranches = - allBranches - .filter((branch) => micromatch.isMatch(branch, branchGlobs)) - .map((branch) => `refs/heads/${branch}`); - - revisions = [ - ...revisions, - ...matchingBranches - ]; - } - - if (metadata.tags) { - const tagGlobs = metadata.tags; - const allTags = await getTags(repoPath); - const matchingTags = - allTags - .filter((tag) => micromatch.isMatch(tag, tagGlobs)) - .map((tag) => `refs/tags/${tag}`); - revisions = [ - ...revisions, - ...matchingTags - ]; - } - - // zoekt has a limit of 64 branches/tags to index. - if (revisions.length > 64) { - logger.warn(`Too many revisions (${revisions.length}) for repo ${repo.id}, truncating to 64`); - captureEvent('backend_revisions_truncated', { - repoId: repo.id, - revisionCount: revisions.length, - }); - revisions = revisions.slice(0, 64); - } - const command = [ 'zoekt-git-index', '-allow_missing_branches', @@ -76,7 +30,7 @@ export const indexGitRepository = async (repo: Repo, settings: Settings, signal? reject(error); return; } - + if (stdout) { stdout.split('\n').filter(line => line.trim()).forEach(line => { logger.info(line); @@ -89,7 +43,7 @@ export const indexGitRepository = async (repo: Repo, settings: Settings, signal? logger.info(line); }); } - + resolve({ stdout, stderr diff --git a/packages/db/prisma/migrations/20251024000926_add_indexed_commit_hash_to_repo_table/migration.sql b/packages/db/prisma/migrations/20251024000926_add_indexed_commit_hash_to_repo_table/migration.sql new file mode 100644 index 000000000..d27b87212 --- /dev/null +++ b/packages/db/prisma/migrations/20251024000926_add_indexed_commit_hash_to_repo_table/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Repo" ADD COLUMN "indexedCommitHash" TEXT; diff --git a/packages/db/prisma/migrations/20251024214005_add_metadata_field_to_repo_job_table/migration.sql b/packages/db/prisma/migrations/20251024214005_add_metadata_field_to_repo_job_table/migration.sql new file mode 100644 index 000000000..3b05f19dd --- /dev/null +++ b/packages/db/prisma/migrations/20251024214005_add_metadata_field_to_repo_job_table/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "RepoIndexingJob" ADD COLUMN "metadata" JSONB; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 8952d0fcb..f04d293c6 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -38,7 +38,7 @@ model Repo { isFork Boolean isArchived Boolean isPublic Boolean @default(false) - metadata Json /// For schema see repoMetadataSchema in packages/backend/src/types.ts + metadata Json /// For schema see repoMetadataSchema in packages/shared/src/types.ts cloneUrl String webUrl String? connections RepoToConnection[] @@ -50,6 +50,7 @@ model Repo { jobs RepoIndexingJob[] indexedAt DateTime? /// When the repo was last indexed successfully. + indexedCommitHash String? /// The commit hash of the last indexed commit (on HEAD). external_id String /// The id of the repo in the external service external_codeHostType String /// The type of the external service (e.g., github, gitlab, etc.) @@ -83,6 +84,7 @@ model RepoIndexingJob { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt completedAt DateTime? + metadata Json? /// For schema see repoIndexingJobMetadataSchema in packages/shared/src/types.ts errorMessage String? diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index e0bbd29b8..42e1aa694 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1,3 +1,4 @@ +import { ConfigSettings } from "./types.js"; export const SOURCEBOT_SUPPORT_EMAIL = 'team@sourcebot.dev'; @@ -8,4 +9,24 @@ export const SOURCEBOT_CLOUD_ENVIRONMENT = [ "prod", ] as const; -export const SOURCEBOT_UNLIMITED_SEATS = -1; \ No newline at end of file +export const SOURCEBOT_UNLIMITED_SEATS = -1; + +/** + * Default settings. + */ +export const DEFAULT_CONFIG_SETTINGS: ConfigSettings = { + maxFileSize: 2 * 1024 * 1024, // 2MB in bytes + maxTrigramCount: 20000, + reindexIntervalMs: 1000 * 60 * 60, // 1 hour + resyncConnectionIntervalMs: 1000 * 60 * 60 * 24, // 24 hours + resyncConnectionPollingIntervalMs: 1000 * 1, // 1 second + reindexRepoPollingIntervalMs: 1000 * 1, // 1 second + maxConnectionSyncJobConcurrency: 8, + maxRepoIndexingJobConcurrency: 8, + maxRepoGarbageCollectionJobConcurrency: 8, + repoGarbageCollectionGracePeriodMs: 10 * 1000, // 10 seconds + repoIndexTimeoutMs: 1000 * 60 * 60 * 2, // 2 hours + enablePublicAccess: false, // deprected, use FORCE_ENABLE_ANONYMOUS_ACCESS instead + experiment_repoDrivenPermissionSyncIntervalMs: 1000 * 60 * 60 * 24, // 24 hours + experiment_userDrivenPermissionSyncIntervalMs: 1000 * 60 * 60 * 24, // 24 hours +} diff --git a/packages/shared/src/index.server.ts b/packages/shared/src/index.server.ts index ce00642e6..bdaea067a 100644 --- a/packages/shared/src/index.server.ts +++ b/packages/shared/src/index.server.ts @@ -9,11 +9,20 @@ export type { Plan, Entitlement, } from "./entitlements.js"; +export type { + RepoMetadata, + RepoIndexingJobMetadata, +} from "./types.js"; +export { + repoMetadataSchema, + repoIndexingJobMetadataSchema, +} from "./types.js"; export { base64Decode, loadConfig, loadJsonFile, isRemotePath, + getConfigSettings, } from "./utils.js"; export { syncSearchContexts, diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts new file mode 100644 index 000000000..a03b2e9d2 --- /dev/null +++ b/packages/shared/src/types.ts @@ -0,0 +1,45 @@ +import { Settings as SettingsSchema } from "@sourcebot/schemas/v3/index.type"; +import { z } from "zod"; + +export type ConfigSettings = Required; + +// Structure of the `metadata` field in the `Repo` table. +// +// @WARNING: If you modify this schema, please make sure it is backwards +// compatible with any prior versions of the schema!! +// @NOTE: If you move this schema, please update the comment in schema.prisma +// to point to the new location. +export const repoMetadataSchema = z.object({ + /** + * A set of key-value pairs that will be used as git config + * variables when cloning the repo. + * @see: https://git-scm.com/docs/git-clone#Documentation/git-clone.txt-code--configcodecodeltkeygtltvaluegtcode + */ + gitConfig: z.record(z.string(), z.string()).optional(), + + /** + * A list of branches to index. Glob patterns are supported. + */ + branches: z.array(z.string()).optional(), + + /** + * A list of tags to index. Glob patterns are supported. + */ + tags: z.array(z.string()).optional(), + + /** + * A list of revisions that were indexed for the repo. + */ + indexedRevisions: z.array(z.string()).optional(), +}); + +export type RepoMetadata = z.infer; + +export const repoIndexingJobMetadataSchema = z.object({ + /** + * A list of revisions that were indexed for the repo. + */ + indexedRevisions: z.array(z.string()).optional(), +}); + +export type RepoIndexingJobMetadata = z.infer; diff --git a/packages/shared/src/utils.ts b/packages/shared/src/utils.ts index 69cbc52e8..f7c1b56e0 100644 --- a/packages/shared/src/utils.ts +++ b/packages/shared/src/utils.ts @@ -4,6 +4,8 @@ import { readFile } from 'fs/promises'; import stripJsonComments from 'strip-json-comments'; import { Ajv } from "ajv"; import { z } from "zod"; +import { DEFAULT_CONFIG_SETTINGS } from "./constants.js"; +import { ConfigSettings } from "./types.js"; const ajv = new Ajv({ validateFormats: false, @@ -130,3 +132,16 @@ export const loadConfig = async (configPath: string): Promise = } return config; } + +export const getConfigSettings = async (configPath?: string): Promise => { + if (!configPath) { + return DEFAULT_CONFIG_SETTINGS; + } + + const config = await loadConfig(configPath); + + return { + ...DEFAULT_CONFIG_SETTINGS, + ...config.settings, + } +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/DisplayDate.tsx b/packages/web/src/app/[domain]/components/DisplayDate.tsx new file mode 100644 index 000000000..da108b959 --- /dev/null +++ b/packages/web/src/app/[domain]/components/DisplayDate.tsx @@ -0,0 +1,36 @@ +import { getFormattedDate } from "@/lib/utils" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" + +const formatFullDate = (date: Date) => { + return new Intl.DateTimeFormat("en-US", { + month: "long", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + second: "2-digit", + timeZoneName: "short", + }).format(date) +} + +interface DisplayDateProps { + date: Date + className?: string +} + +export const DisplayDate = ({ date, className }: DisplayDateProps) => { + return ( + + + + + {getFormattedDate(date)} + + + +

{formatFullDate(date)}

+
+
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/header.tsx b/packages/web/src/app/[domain]/components/header.tsx deleted file mode 100644 index 79a24ee4c..000000000 --- a/packages/web/src/app/[domain]/components/header.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Separator } from "@/components/ui/separator"; -import { cn } from "@/lib/utils"; -import clsx from "clsx"; - -interface HeaderProps { - children: React.ReactNode; - withTopMargin?: boolean; - className?: string; -} - -export const Header = ({ - children, - withTopMargin = true, - className, -}: HeaderProps) => { - return ( -
- {children} - -
- ) -} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/searchBar/useSuggestionsData.ts b/packages/web/src/app/[domain]/components/searchBar/useSuggestionsData.ts index a1f9c4530..8c48e9d14 100644 --- a/packages/web/src/app/[domain]/components/searchBar/useSuggestionsData.ts +++ b/packages/web/src/app/[domain]/components/searchBar/useSuggestionsData.ts @@ -19,7 +19,7 @@ import { VscSymbolVariable } from "react-icons/vsc"; import { useSearchHistory } from "@/hooks/useSearchHistory"; -import { getDisplayTime, isServiceError, unwrapServiceError } from "@/lib/utils"; +import { getFormattedDate, isServiceError, unwrapServiceError } from "@/lib/utils"; import { useDomain } from "@/hooks/useDomain"; @@ -139,7 +139,7 @@ export const useSuggestionsData = ({ const searchHistorySuggestions = useMemo(() => { return searchHistory.map(search => ({ value: search.query, - description: getDisplayTime(new Date(search.date)), + description: getFormattedDate(new Date(search.date)), } satisfies Suggestion)); }, [searchHistory]); diff --git a/packages/web/src/app/[domain]/components/searchModeSelector.tsx b/packages/web/src/app/[domain]/components/searchModeSelector.tsx index e615ac8cf..cf63f7341 100644 --- a/packages/web/src/app/[domain]/components/searchModeSelector.tsx +++ b/packages/web/src/app/[domain]/components/searchModeSelector.tsx @@ -2,7 +2,7 @@ import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint"; import { useDomain } from "@/hooks/useDomain"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Select, SelectContent, SelectItemNoItemText, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; @@ -87,7 +87,7 @@ export const SearchModeSelector = ({ onMouseEnter={() => setFocusedSearchMode("precise")} onFocus={() => setFocusedSearchMode("precise")} > - @@ -99,7 +99,7 @@ export const SearchModeSelector = ({ - + setFocusedSearchMode("agentic")} onFocus={() => setFocusedSearchMode("agentic")} > - @@ -138,7 +138,7 @@ export const SearchModeSelector = ({ - + @@ -167,5 +167,3 @@ export const SearchModeSelector = ({ ) } - - diff --git a/packages/web/src/app/[domain]/repos/[id]/page.tsx b/packages/web/src/app/[domain]/repos/[id]/page.tsx new file mode 100644 index 000000000..715afa5cc --- /dev/null +++ b/packages/web/src/app/[domain]/repos/[id]/page.tsx @@ -0,0 +1,206 @@ +import { sew } from "@/actions" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Skeleton } from "@/components/ui/skeleton" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants" +import { ServiceErrorException } from "@/lib/serviceError" +import { cn, getCodeHostInfoForRepo, isServiceError } from "@/lib/utils" +import { withOptionalAuthV2 } from "@/withAuthV2" +import { ChevronLeft, ExternalLink, Info } from "lucide-react" +import Image from "next/image" +import Link from "next/link" +import { notFound } from "next/navigation" +import { Suspense } from "react" +import { RepoJobsTable } from "../components/repoJobsTable" +import { getConfigSettings } from "@sourcebot/shared" +import { env } from "@/env.mjs" +import { DisplayDate } from "../../components/DisplayDate" +import { RepoBranchesTable } from "../components/repoBranchesTable" +import { repoMetadataSchema } from "@sourcebot/shared" + +export default async function RepoDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const repo = await getRepoWithJobs(Number.parseInt(id)) + if (isServiceError(repo)) { + throw new ServiceErrorException(repo); + } + + const codeHostInfo = getCodeHostInfoForRepo({ + codeHostType: repo.external_codeHostType, + name: repo.name, + displayName: repo.displayName ?? undefined, + webUrl: repo.webUrl ?? undefined, + }); + + const configSettings = await getConfigSettings(env.CONFIG_PATH); + + const nextIndexAttempt = (() => { + const latestJob = repo.jobs.length > 0 ? repo.jobs[0] : null; + if (!latestJob) { + return undefined; + } + + if (latestJob.completedAt) { + return new Date(latestJob.completedAt.getTime() + configSettings.reindexIntervalMs); + } + + return undefined; + })(); + + const repoMetadata = repoMetadataSchema.parse(repo.metadata); + + return ( +
+
+ + +
+
+

{repo.displayName || repo.name}

+

{repo.name}

+
+ {(codeHostInfo && codeHostInfo.repoLink) && ( + + )} +
+ +
+ {repo.isArchived && Archived} + {repo.isPublic && Public} +
+
+ +
+ + + + Created + + + + + +

When this repository was first added to Sourcebot

+
+
+
+
+ + + +
+ + + + + Last indexed + + + + + +

The last time this repository was successfully indexed

+
+
+
+
+ + {repo.indexedAt ? : "Never"} + +
+ + + + + Scheduled + + + + + +

When the next indexing job is scheduled to run

+
+
+
+
+ + {nextIndexAttempt ? : "-"} + +
+
+ + {repoMetadata.indexedRevisions && ( + + +
+ Indexed Branches +
+ Branches that have been indexed for this repository. Docs +
+ + }> + + + +
+ )} + + + + Indexing Jobs + History of all indexing and cleanup jobs for this repository. + + + }> + + + + +
+ ) +} + +const getRepoWithJobs = async (repoId: number) => sew(() => + withOptionalAuthV2(async ({ prisma }) => { + + const repo = await prisma.repo.findUnique({ + where: { + id: repoId, + }, + include: { + jobs: { + orderBy: { + createdAt: 'desc' + }, + } + }, + }); + + if (!repo) { + return notFound(); + } + + return repo; + }) +); \ No newline at end of file diff --git a/packages/web/src/app/[domain]/repos/columns.tsx b/packages/web/src/app/[domain]/repos/columns.tsx deleted file mode 100644 index 7d617cda2..000000000 --- a/packages/web/src/app/[domain]/repos/columns.tsx +++ /dev/null @@ -1,208 +0,0 @@ -"use client" - -import { Button } from "@/components/ui/button" -import type { ColumnDef } from "@tanstack/react-table" -import { ArrowUpDown, Clock, Loader2, CheckCircle2, Check, ListFilter } from "lucide-react" -import Image from "next/image" -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" -import { cn, getRepoImageSrc } from "@/lib/utils" -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" -import Link from "next/link" -import { getBrowsePath } from "../browse/hooks/utils" - -export type RepoStatus = 'syncing' | 'indexed' | 'not-indexed'; - -export type RepositoryColumnInfo = { - repoId: number - repoName: string; - repoDisplayName: string - imageUrl?: string - status: RepoStatus - lastIndexed: string -} - -const statusLabels: Record = { - 'syncing': "Syncing", - 'indexed': "Indexed", - 'not-indexed': "Pending", -}; - -const StatusIndicator = ({ status }: { status: RepoStatus }) => { - let icon = null - let description = "" - let className = "" - - switch (status) { - case 'syncing': - icon = - description = "Repository is currently syncing" - className = "text-blue-600 bg-blue-50 dark:bg-blue-900/20 dark:text-blue-400" - break - case 'indexed': - icon = - description = "Repository has been successfully indexed and is up to date" - className = "text-green-600 bg-green-50 dark:bg-green-900/20 dark:text-green-400" - break - case 'not-indexed': - icon = - description = "Repository is pending initial sync" - className = "text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 dark:text-yellow-400" - break - } - - return ( - - - -
- {icon} - {statusLabels[status]} -
-
- -

{description}

-
-
-
- ) -} - -export const columns = (domain: string): ColumnDef[] => [ - { - accessorKey: "repoDisplayName", - header: 'Repository', - size: 500, - cell: ({ row: { original: { repoId, repoName, repoDisplayName, imageUrl } } }) => { - return ( -
-
- {imageUrl ? ( - {`${repoDisplayName} - ) : ( -
- {repoDisplayName.charAt(0)} -
- )} -
-
- - {repoDisplayName.length > 40 ? `${repoDisplayName.slice(0, 40)}...` : repoDisplayName} - -
-
- ) - }, - }, - { - accessorKey: "status", - size: 150, - header: ({ column }) => { - const uniqueLabels = Object.values(statusLabels); - const currentFilter = column.getFilterValue() as string | undefined; - - return ( -
- - - - - - column.setFilterValue(undefined)}> - - All - - {uniqueLabels.map((label) => ( - column.setFilterValue(label)}> - - {label} - - ))} - - -
- ) - }, - cell: ({ row }) => { - return - }, - filterFn: (row, id, value) => { - if (value === undefined) return true; - - const status = row.getValue(id) as RepoStatus; - return statusLabels[status] === value; - }, - }, - { - accessorKey: "lastIndexed", - size: 150, - header: ({ column }) => ( -
- -
- ), - cell: ({ row }) => { - if (!row.original.lastIndexed) { - return
Never
; - } - const date = new Date(row.original.lastIndexed) - return ( -
-
- {date.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - })} -
-
- {date - .toLocaleTimeString("en-US", { - hour: "2-digit", - minute: "2-digit", - hour12: false, - }) - .toLowerCase()} -
-
- ) - }, - }, -] diff --git a/packages/web/src/app/[domain]/repos/components/repoBranchesTable.tsx b/packages/web/src/app/[domain]/repos/components/repoBranchesTable.tsx new file mode 100644 index 000000000..bc186acd9 --- /dev/null +++ b/packages/web/src/app/[domain]/repos/components/repoBranchesTable.tsx @@ -0,0 +1,141 @@ +"use client" + +import * as React from "react" +import { + type ColumnDef, + type ColumnFiltersState, + type SortingState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { CodeHostType, getCodeHostBrowseAtBranchUrl } from "@/lib/utils" +import Link from "next/link" + +type RepoBranchesTableProps = { + indexRevisions: string[]; + repoWebUrl: string | null; + repoCodeHostType: string; +} + +export const RepoBranchesTable = ({ indexRevisions, repoWebUrl, repoCodeHostType }: RepoBranchesTableProps) => { + const [sorting, setSorting] = React.useState([]) + const [columnFilters, setColumnFilters] = React.useState([]) + + const columns = React.useMemo[]>(() => { + return [ + { + accessorKey: "refName", + header: "Revision", + cell: ({ row }) => { + const refName = row.original; + const shortRefName = refName.replace(/^refs\/(heads|tags)\//, ""); + + const branchUrl = getCodeHostBrowseAtBranchUrl({ + webUrl: repoWebUrl, + codeHostType: repoCodeHostType as CodeHostType, + branchName: refName, + }); + + return branchUrl ? ( + + {shortRefName} + + ) : ( + + {shortRefName} + + ) + }, + } + ] + }, [repoCodeHostType, repoWebUrl]); + + const table = useReactTable({ + data: indexRevisions, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + state: { + sorting, + columnFilters, + }, + initialState: { + pagination: { + pageSize: 5, + }, + }, + }) + + return ( +
+
+ table.getColumn("refName")?.setFilterValue(event.target.value)} + className="max-w-sm" + /> +
+ +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + {flexRender(cell.column.columnDef.cell, cell.getContext())} + ))} + + )) + ) : ( + + + No branches found. + + + )} + +
+
+ +
+ + +
+
+ ) +} diff --git a/packages/web/src/app/[domain]/repos/components/repoJobsTable.tsx b/packages/web/src/app/[domain]/repos/components/repoJobsTable.tsx new file mode 100644 index 000000000..1f8e290e5 --- /dev/null +++ b/packages/web/src/app/[domain]/repos/components/repoJobsTable.tsx @@ -0,0 +1,320 @@ +"use client" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" +import { + type ColumnDef, + type ColumnFiltersState, + type SortingState, + type VisibilityState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table" +import { cva } from "class-variance-authority" +import { AlertCircle, ArrowUpDown, RefreshCwIcon } from "lucide-react" +import * as React from "react" +import { CopyIconButton } from "../../components/copyIconButton" +import { useMemo } from "react" +import { LightweightCodeHighlighter } from "../../components/lightweightCodeHighlighter" +import { useRouter } from "next/navigation" +import { useToast } from "@/components/hooks/use-toast" +import { DisplayDate } from "../../components/DisplayDate" + +// @see: https://v0.app/chat/repo-indexing-status-uhjdDim8OUS + +export type RepoIndexingJob = { + id: string + type: "INDEX" | "CLEANUP" + status: "PENDING" | "IN_PROGRESS" | "COMPLETED" | "FAILED" + createdAt: Date + updatedAt: Date + completedAt: Date | null + errorMessage: string | null +} + +const statusBadgeVariants = cva("", { + variants: { + status: { + PENDING: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + IN_PROGRESS: "bg-primary text-primary-foreground hover:bg-primary/90", + COMPLETED: "bg-green-600 text-white hover:bg-green-700", + FAILED: "bg-destructive text-destructive-foreground hover:bg-destructive/90", + }, + }, +}) + +const getStatusBadge = (status: RepoIndexingJob["status"]) => { + const labels = { + PENDING: "Pending", + IN_PROGRESS: "In Progress", + COMPLETED: "Completed", + FAILED: "Failed", + } + + return {labels[status]} +} + +const getTypeBadge = (type: RepoIndexingJob["type"]) => { + return ( + + {type} + + ) +} + +const getDuration = (start: Date, end: Date | null) => { + if (!end) return "-" + const diff = end.getTime() - start.getTime() + const minutes = Math.floor(diff / 60000) + const seconds = Math.floor((diff % 60000) / 1000) + return `${minutes}m ${seconds}s` +} + +export const columns: ColumnDef[] = [ + { + accessorKey: "type", + header: "Type", + cell: ({ row }) => getTypeBadge(row.getValue("type")), + filterFn: (row, id, value) => { + return value.includes(row.getValue(id)) + }, + }, + { + accessorKey: "status", + header: "Status", + cell: ({ row }) => { + const job = row.original + return ( +
+ {getStatusBadge(row.getValue("status"))} + {job.errorMessage && ( + + + + + + + + {job.errorMessage} + + + + + )} +
+ ) + }, + filterFn: (row, id, value) => { + return value.includes(row.getValue(id)) + }, + }, + { + accessorKey: "createdAt", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => , + }, + { + accessorKey: "completedAt", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const completedAt = row.getValue("completedAt") as Date | null; + if (!completedAt) { + return "-"; + } + + return + }, + }, + { + id: "duration", + header: "Duration", + cell: ({ row }) => { + const job = row.original + return getDuration(job.createdAt, job.completedAt) + }, + }, + { + accessorKey: "id", + header: "Job ID", + cell: ({ row }) => { + const id = row.getValue("id") as string + return ( +
+ {id} + { + navigator.clipboard.writeText(id); + return true; + }} /> +
+ ) + }, + }, +] + +export const RepoJobsTable = ({ data }: { data: RepoIndexingJob[] }) => { + const [sorting, setSorting] = React.useState([{ id: "createdAt", desc: true }]) + const [columnFilters, setColumnFilters] = React.useState([]) + const [columnVisibility, setColumnVisibility] = React.useState({}) + const router = useRouter(); + const { toast } = useToast(); + + const table = useReactTable({ + data, + columns, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onColumnVisibilityChange: setColumnVisibility, + state: { + sorting, + columnFilters, + columnVisibility, + }, + }) + + const { + numCompleted, + numInProgress, + numPending, + numFailed, + } = useMemo(() => { + return { + numCompleted: data.filter((job) => job.status === "COMPLETED").length, + numInProgress: data.filter((job) => job.status === "IN_PROGRESS").length, + numPending: data.filter((job) => job.status === "PENDING").length, + numFailed: data.filter((job) => job.status === "FAILED").length, + }; + }, [data]); + + return ( +
+
+ + + + + +
+ +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + {flexRender(cell.column.columnDef.cell, cell.getContext())} + ))} + + )) + ) : ( + + + No indexing jobs found. + + + )} + +
+
+ +
+
+ {table.getFilteredRowModel().rows.length} job(s) total +
+
+ + +
+
+
+ ) +} diff --git a/packages/web/src/app/[domain]/repos/components/reposTable.tsx b/packages/web/src/app/[domain]/repos/components/reposTable.tsx new file mode 100644 index 000000000..755d638a1 --- /dev/null +++ b/packages/web/src/app/[domain]/repos/components/reposTable.tsx @@ -0,0 +1,395 @@ +"use client" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Input } from "@/components/ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants" +import { CodeHostType, getCodeHostCommitUrl, getCodeHostInfoForRepo, getRepoImageSrc } from "@/lib/utils" +import { + type ColumnDef, + type ColumnFiltersState, + type SortingState, + type VisibilityState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table" +import { cva } from "class-variance-authority" +import { ArrowUpDown, ExternalLink, MoreHorizontal, RefreshCwIcon } from "lucide-react" +import Image from "next/image" +import Link from "next/link" +import { useMemo, useState } from "react" +import { getBrowsePath } from "../../browse/hooks/utils" +import { useRouter } from "next/navigation" +import { useToast } from "@/components/hooks/use-toast"; +import { DisplayDate } from "../../components/DisplayDate" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" + +// @see: https://v0.app/chat/repo-indexing-status-uhjdDim8OUS + +export type Repo = { + id: number + name: string + displayName: string | null + isArchived: boolean + isPublic: boolean + indexedAt: Date | null + createdAt: Date + webUrl: string | null + codeHostType: string + imageUrl: string | null + indexedCommitHash: string | null + latestJobStatus: "PENDING" | "IN_PROGRESS" | "COMPLETED" | "FAILED" | null +} + +const statusBadgeVariants = cva("", { + variants: { + status: { + PENDING: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + IN_PROGRESS: "bg-primary text-primary-foreground hover:bg-primary/90", + COMPLETED: "bg-green-600 text-white hover:bg-green-700", + FAILED: "bg-destructive text-destructive-foreground hover:bg-destructive/90", + NO_JOBS: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + }, + }, +}) + +const getStatusBadge = (status: Repo["latestJobStatus"]) => { + if (!status) { + return No Jobs + } + + const labels = { + PENDING: "Pending", + IN_PROGRESS: "In Progress", + COMPLETED: "Completed", + FAILED: "Failed", + } + + return {labels[status]} +} + +export const columns: ColumnDef[] = [ + { + accessorKey: "displayName", + size: 400, + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const repo = row.original + return ( +
+ {repo.imageUrl ? ( + {`${repo.displayName} + ) : ( +
+ {repo.displayName?.charAt(0) ?? repo.name.charAt(0)} +
+ )} + + {repo.displayName || repo.name} + +
+ ) + }, + }, + { + accessorKey: "latestJobStatus", + size: 150, + header: "Lastest status", + cell: ({ row }) => getStatusBadge(row.getValue("latestJobStatus")), + }, + { + accessorKey: "indexedAt", + size: 200, + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const indexedAt = row.getValue("indexedAt") as Date | null; + if (!indexedAt) { + return "-"; + } + + return ( + + ) + } + }, + { + accessorKey: "indexedCommitHash", + size: 150, + header: "Synced commit", + cell: ({ row }) => { + const hash = row.getValue("indexedCommitHash") as string | null; + if (!hash) { + return "-"; + } + + const smallHash = hash.slice(0, 7); + const repo = row.original; + const codeHostType = repo.codeHostType as CodeHostType; + const webUrl = repo.webUrl; + + const commitUrl = getCodeHostCommitUrl({ + webUrl, + codeHostType, + commitHash: hash, + }); + + const HashComponent = commitUrl ? ( + + {smallHash} + + ) : ( + + {smallHash} + + ) + + return ( + + + {HashComponent} + + + {hash} + + + ); + }, + }, + { + id: "actions", + size: 80, + enableHiding: false, + cell: ({ row }) => { + const repo = row.original + const codeHostInfo = getCodeHostInfoForRepo({ + codeHostType: repo.codeHostType, + name: repo.name, + displayName: repo.displayName ?? undefined, + webUrl: repo.webUrl ?? undefined, + }); + + return ( + + + + + + Actions + + View details + + {(repo.webUrl && codeHostInfo) && ( + <> + + + + Open in {codeHostInfo.codeHostName} + + + + + )} + + + ) + }, + }, +] + +export const ReposTable = ({ data }: { data: Repo[] }) => { + const [sorting, setSorting] = useState([]) + const [columnFilters, setColumnFilters] = useState([]) + const [columnVisibility, setColumnVisibility] = useState({}) + const [rowSelection, setRowSelection] = useState({}) + const router = useRouter(); + const { toast } = useToast(); + + const { + numCompleted, + numInProgress, + numPending, + numFailed, + numNoJobs, + } = useMemo(() => { + return { + numCompleted: data.filter((repo) => repo.latestJobStatus === "COMPLETED").length, + numInProgress: data.filter((repo) => repo.latestJobStatus === "IN_PROGRESS").length, + numPending: data.filter((repo) => repo.latestJobStatus === "PENDING").length, + numFailed: data.filter((repo) => repo.latestJobStatus === "FAILED").length, + numNoJobs: data.filter((repo) => repo.latestJobStatus === null).length, + } + }, [data]); + + const table = useReactTable({ + data, + columns, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + columnResizeMode: 'onChange', + enableColumnResizing: false, + state: { + sorting, + columnFilters, + columnVisibility, + rowSelection, + }, + }) + + return ( +
+
+ table.getColumn("displayName")?.setFilterValue(event.target.value)} + className="max-w-sm" + /> + + +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+
+
+ {table.getFilteredRowModel().rows.length} {data.length > 1 ? 'repositories' : 'repository'} total +
+
+ + +
+
+
+ ) +} diff --git a/packages/web/src/app/[domain]/repos/page.tsx b/packages/web/src/app/[domain]/repos/page.tsx index e9dd4e43c..3e6120819 100644 --- a/packages/web/src/app/[domain]/repos/page.tsx +++ b/packages/web/src/app/[domain]/repos/page.tsx @@ -1,65 +1,54 @@ -import { env } from "@/env.mjs"; -import { RepoIndexingJob } from "@sourcebot/db"; -import { Header } from "../components/header"; -import { RepoStatus } from "./columns"; -import { RepositoryTable } from "./repositoryTable"; import { sew } from "@/actions"; -import { withOptionalAuthV2 } from "@/withAuthV2"; -import { isServiceError } from "@/lib/utils"; import { ServiceErrorException } from "@/lib/serviceError"; +import { isServiceError } from "@/lib/utils"; +import { withOptionalAuthV2 } from "@/withAuthV2"; +import { ReposTable } from "./components/reposTable"; -function getRepoStatus(repo: { indexedAt: Date | null, jobs: RepoIndexingJob[] }): RepoStatus { - const latestJob = repo.jobs[0]; - - if (latestJob?.status === 'PENDING' || latestJob?.status === 'IN_PROGRESS') { - return 'syncing'; - } - - return repo.indexedAt ? 'indexed' : 'not-indexed'; -} - -export default async function ReposPage(props: { params: Promise<{ domain: string }> }) { - const params = await props.params; - - const { - domain - } = params; +export default async function ReposPage() { - const repos = await getReposWithJobs(); + const repos = await getReposWithLatestJob(); if (isServiceError(repos)) { throw new ServiceErrorException(repos); } return ( -
-
-

Repositories

-
-
- ({ - repoId: repo.id, - repoName: repo.name, - repoDisplayName: repo.displayName ?? repo.name, - imageUrl: repo.imageUrl ?? undefined, - indexedAt: repo.indexedAt ?? undefined, - status: getRepoStatus(repo), - }))} - domain={domain} - isAddReposButtonVisible={env.EXPERIMENT_SELF_SERVE_REPO_INDEXING_ENABLED === 'true'} - /> +
+
+

Repositories

+

View and manage your code repositories and their indexing status.

+ ({ + id: repo.id, + name: repo.name, + displayName: repo.displayName ?? repo.name, + isArchived: repo.isArchived, + isPublic: repo.isPublic, + indexedAt: repo.indexedAt, + createdAt: repo.createdAt, + webUrl: repo.webUrl, + imageUrl: repo.imageUrl, + latestJobStatus: repo.jobs.length > 0 ? repo.jobs[0].status : null, + codeHostType: repo.external_codeHostType, + indexedCommitHash: repo.indexedCommitHash, + }))} />
) } -const getReposWithJobs = async () => sew(() => +const getReposWithLatestJob = async () => sew(() => withOptionalAuthV2(async ({ prisma }) => { const repos = await prisma.repo.findMany({ include: { - jobs: true, + jobs: { + orderBy: { + createdAt: 'desc' + }, + take: 1 + } + }, + orderBy: { + name: 'asc' } }); - return repos; })); \ No newline at end of file diff --git a/packages/web/src/app/[domain]/repos/repositoryTable.tsx b/packages/web/src/app/[domain]/repos/repositoryTable.tsx deleted file mode 100644 index 9b67cca7c..000000000 --- a/packages/web/src/app/[domain]/repos/repositoryTable.tsx +++ /dev/null @@ -1,122 +0,0 @@ -"use client"; - -import { useToast } from "@/components/hooks/use-toast"; -import { Button } from "@/components/ui/button"; -import { DataTable } from "@/components/ui/data-table"; -import { PlusIcon, RefreshCwIcon } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useMemo, useState } from "react"; -import { columns, RepositoryColumnInfo, RepoStatus } from "./columns"; -import { AddRepositoryDialog } from "./components/addRepositoryDialog"; - -interface RepositoryTableProps { - repos: { - repoId: number; - repoName: string; - repoDisplayName: string; - imageUrl?: string; - indexedAt?: Date; - status: RepoStatus; - }[]; - domain: string; - isAddReposButtonVisible: boolean; -} - -export const RepositoryTable = ({ - repos, - domain, - isAddReposButtonVisible, -}: RepositoryTableProps) => { - const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); - const router = useRouter(); - const { toast } = useToast(); - - const tableRepos = useMemo(() => { - return repos.map((repo): RepositoryColumnInfo => ({ - repoId: repo.repoId, - repoName: repo.repoName, - repoDisplayName: repo.repoDisplayName ?? repo.repoName, - imageUrl: repo.imageUrl, - status: repo.status, - lastIndexed: repo.indexedAt?.toISOString() ?? "", - })).sort((a, b) => { - const getPriorityFromStatus = (status: RepoStatus) => { - switch (status) { - case 'syncing': - return 0 // Highest priority - currently syncing - case 'not-indexed': - return 1 // Second priority - not yet indexed - case 'indexed': - return 2 // Third priority - successfully indexed - default: - return 3 - } - } - - // Sort by priority first - const aPriority = getPriorityFromStatus(a.status); - const bPriority = getPriorityFromStatus(b.status); - - if (aPriority !== bPriority) { - return aPriority - bPriority; - } - - // If same priority, sort by last indexed date (most recent first) - if (a.lastIndexed && b.lastIndexed) { - return new Date(b.lastIndexed).getTime() - new Date(a.lastIndexed).getTime(); - } - - // Put items without dates at the end - if (!a.lastIndexed) return 1; - if (!b.lastIndexed) return -1; - return 0; - }); - }, [repos]); - - const tableColumns = useMemo(() => { - return columns(domain); - }, [domain]); - - return ( - <> - - - {isAddReposButtonVisible && ( - - )} -
- )} - /> - - - - ); -} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/settings/secrets/components/secretsList.tsx b/packages/web/src/app/[domain]/settings/secrets/components/secretsList.tsx index e28efe752..92ed4df7e 100644 --- a/packages/web/src/app/[domain]/settings/secrets/components/secretsList.tsx +++ b/packages/web/src/app/[domain]/settings/secrets/components/secretsList.tsx @@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input"; import { LucideKeyRound, MoreVertical, Search, LucideTrash } from "lucide-react"; import { useState, useMemo, useCallback } from "react"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { getDisplayTime, isServiceError } from "@/lib/utils"; +import { getFormattedDate, isServiceError } from "@/lib/utils"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { Button } from "@/components/ui/button"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; @@ -104,7 +104,7 @@ export const SecretsList = ({ secrets }: SecretsListProps) => {

- Created {getDisplayTime(secret.createdAt)} + Created {getFormattedDate(secret.createdAt)}

diff --git a/packages/web/src/components/ui/select.tsx b/packages/web/src/components/ui/select.tsx index f28b0b862..9a6cd13f7 100644 --- a/packages/web/src/components/ui/select.tsx +++ b/packages/web/src/components/ui/select.tsx @@ -129,11 +129,34 @@ const SelectItem = React.forwardRef< - {children} + {children} )) SelectItem.displayName = SelectPrimitive.Item.displayName +const SelectItemNoItemText = React.forwardRef< +React.ElementRef, +React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)) +SelectItemNoItemText.displayName = SelectPrimitive.Item.displayName + const SelectSeparator = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef @@ -154,6 +177,7 @@ export { SelectContent, SelectLabel, SelectItem, + SelectItemNoItemText, SelectSeparator, SelectScrollUpButton, SelectScrollDownButton, diff --git a/packages/web/src/lib/utils.ts b/packages/web/src/lib/utils.ts index b35de40b4..f25206a52 100644 --- a/packages/web/src/lib/utils.ts +++ b/packages/web/src/lib/utils.ts @@ -17,6 +17,7 @@ import { ErrorCode } from "./errorCodes"; import { NextRequest } from "next/server"; import { Org } from "@sourcebot/db"; import { OrgMetadata, orgMetadataSchema } from "@/types"; +import { SINGLE_TENANT_ORG_DOMAIN } from "./constants"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) @@ -319,6 +320,70 @@ export const getCodeHostIcon = (codeHostType: string): { src: string, className? } } +export const getCodeHostCommitUrl = ({ + webUrl, + codeHostType, + commitHash, +}: { + webUrl?: string | null, + codeHostType: CodeHostType, + commitHash: string, +}) => { + if (!webUrl) { + return undefined; + } + + switch (codeHostType) { + case 'github': + return `${webUrl}/commit/${commitHash}`; + case 'gitlab': + return `${webUrl}/-/commit/${commitHash}`; + case 'gitea': + return `${webUrl}/commit/${commitHash}`; + case 'azuredevops': + return `${webUrl}/commit/${commitHash}`; + case 'bitbucket-cloud': + return `${webUrl}/commits/${commitHash}`; + case 'bitbucket-server': + return `${webUrl}/commits/${commitHash}`; + case 'gerrit': + case 'generic-git-host': + return undefined; + } +} + +export const getCodeHostBrowseAtBranchUrl = ({ + webUrl, + codeHostType, + branchName, +}: { + webUrl?: string | null, + codeHostType: CodeHostType, + branchName: string, +}) => { + if (!webUrl) { + return undefined; + } + + switch (codeHostType) { + case 'github': + return `${webUrl}/tree/${branchName}`; + case 'gitlab': + return `${webUrl}/-/tree/${branchName}`; + case 'gitea': + return `${webUrl}/src/branch/${branchName}`; + case 'azuredevops': + return `${webUrl}?branch=${branchName}`; + case 'bitbucket-cloud': + return `${webUrl}?at=${branchName}`; + case 'bitbucket-server': + return `${webUrl}?at=${branchName}`; + case 'gerrit': + case 'generic-git-host': + return undefined; + } +} + export const isAuthSupportedForCodeHost = (codeHostType: CodeHostType): boolean => { switch (codeHostType) { case "github": @@ -347,32 +412,38 @@ export const isDefined = (arg: T | null | undefined): arg is T extends null | return arg !== null && arg !== undefined; } -export const getDisplayTime = (date: Date) => { +export const getFormattedDate = (date: Date) => { const now = new Date(); - const minutes = (now.getTime() - date.getTime()) / (1000 * 60); + const diffMinutes = (now.getTime() - date.getTime()) / (1000 * 60); + const isFuture = diffMinutes < 0; + + // Use absolute values for calculations + const minutes = Math.abs(diffMinutes); const hours = minutes / 60; const days = hours / 24; const months = days / 30; - const formatTime = (value: number, unit: 'minute' | 'hour' | 'day' | 'month') => { + const formatTime = (value: number, unit: 'minute' | 'hour' | 'day' | 'month', isFuture: boolean) => { const roundedValue = Math.floor(value); - if (roundedValue < 2) { - return `${roundedValue} ${unit} ago`; + const pluralUnit = roundedValue === 1 ? unit : `${unit}s`; + + if (isFuture) { + return `In ${roundedValue} ${pluralUnit}`; } else { - return `${roundedValue} ${unit}s ago`; + return `${roundedValue} ${pluralUnit} ago`; } } if (minutes < 1) { return 'just now'; } else if (minutes < 60) { - return formatTime(minutes, 'minute'); + return formatTime(minutes, 'minute', isFuture); } else if (hours < 24) { - return formatTime(hours, 'hour'); + return formatTime(hours, 'hour', isFuture); } else if (days < 30) { - return formatTime(days, 'day'); + return formatTime(days, 'day', isFuture); } else { - return formatTime(months, 'month'); + return formatTime(months, 'month', isFuture); } } @@ -458,7 +529,7 @@ export const requiredQueryParamGuard = (request: NextRequest, param: string): Se return value; } -export const getRepoImageSrc = (imageUrl: string | undefined, repoId: number, domain: string): string | undefined => { +export const getRepoImageSrc = (imageUrl: string | undefined, repoId: number): string | undefined => { if (!imageUrl) return undefined; try { @@ -478,7 +549,7 @@ export const getRepoImageSrc = (imageUrl: string | undefined, repoId: number, do return imageUrl; } else { // Use the proxied route for self-hosted instances - return `/api/${domain}/repos/${repoId}/image`; + return `/api/${SINGLE_TENANT_ORG_DOMAIN}/repos/${repoId}/image`; } } catch { // If URL parsing fails, use the original URL