From 8168ec2c1f3219242e36615f41eefa0165efac9c Mon Sep 17 00:00:00 2001 From: bkellam Date: Thu, 23 Oct 2025 17:11:22 -0700 Subject: [PATCH] Store HEAD commit hash in DB. Also improve how we display dates --- packages/backend/src/git.ts | 12 ++++ packages/backend/src/repoIndexManager.ts | 12 +++- .../migration.sql | 2 + packages/db/prisma/schema.prisma | 1 + .../app/[domain]/components/DisplayDate.tsx | 36 +++++++++++ .../searchBar/useSuggestionsData.ts | 4 +- .../web/src/app/[domain]/repos/[id]/page.tsx | 18 ++---- .../repos/components/repoJobsTable.tsx | 23 +++---- .../[domain]/repos/components/reposTable.tsx | 64 +++++++++++++++---- packages/web/src/app/[domain]/repos/page.tsx | 4 ++ .../secrets/components/secretsList.tsx | 4 +- packages/web/src/lib/utils.ts | 58 ++++++++++++++--- 12 files changed, 184 insertions(+), 54 deletions(-) create mode 100644 packages/db/prisma/migrations/20251024000926_add_indexed_commit_hash_to_repo_table/migration.sql create mode 100644 packages/web/src/app/[domain]/components/DisplayDate.tsx diff --git a/packages/backend/src/git.ts b/packages/backend/src/git.ts index 308f3f4a..d19e1572 100644 --- a/packages/backend/src/git.ts +++ b/packages/backend/src/git.ts @@ -268,4 +268,16 @@ 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); + const rev = await git.revparse(refName); + return rev; } \ No newline at end of file diff --git a/packages/backend/src/repoIndexManager.ts b/packages/backend/src/repoIndexManager.ts index 5c1a3679..6db3e280 100644 --- a/packages/backend/src/repoIndexManager.ts +++ b/packages/backend/src/repoIndexManager.ts @@ -7,7 +7,7 @@ import { Job, Queue, ReservedJob, Worker } from "groupmq"; import { Redis } from 'ioredis'; import { INDEX_CACHE_DIR } from './constants.js'; import { env } from './env.js'; -import { cloneRepository, fetchRepository, isPathAValidGitRepoRoot, unsetGitConfig, upsertGitConfig } from './git.js'; +import { cloneRepository, fetchRepository, getCommitHashForRefName, isPathAValidGitRepoRoot, unsetGitConfig, upsertGitConfig } from './git.js'; import { PromClient } from './promClient.js'; import { repoMetadataSchema, RepoWithConnections, Settings } from "./types.js"; import { getAuthCredentialsForRepo, getRepoPath, getShardPrefix, groupmqLifecycleExceptionWrapper, measure } from './utils.js'; @@ -384,16 +384,26 @@ 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 repo = await this.db.repo.update({ where: { id: jobData.repoId }, data: { indexedAt: new Date(), + indexedCommitHash: commitHash, } }); 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 00000000..d27b8721 --- /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/schema.prisma b/packages/db/prisma/schema.prisma index 8952d0fc..c2a7c32a 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -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.) 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 00000000..da108b95 --- /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/searchBar/useSuggestionsData.ts b/packages/web/src/app/[domain]/components/searchBar/useSuggestionsData.ts index a1f9c453..8c48e9d1 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]/repos/[id]/page.tsx b/packages/web/src/app/[domain]/repos/[id]/page.tsx index e6b8dced..0fb4a864 100644 --- a/packages/web/src/app/[domain]/repos/[id]/page.tsx +++ b/packages/web/src/app/[domain]/repos/[id]/page.tsx @@ -16,17 +16,7 @@ import { Suspense } from "react" import { RepoJobsTable } from "../components/repoJobsTable" import { getConfigSettings } from "@sourcebot/shared" import { env } from "@/env.mjs" - -function formatDate(date: Date | null) { - if (!date) return "Never" - return new Intl.DateTimeFormat("en-US", { - month: "short", - day: "numeric", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - }).format(date) -} +import { DisplayDate } from "../../components/DisplayDate" export default async function RepoDetailPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params @@ -109,7 +99,7 @@ export default async function RepoDetailPage({ params }: { params: Promise<{ id: -
{formatDate(repo.createdAt)}
+
@@ -128,7 +118,7 @@ export default async function RepoDetailPage({ params }: { params: Promise<{ id: -
{repo.indexedAt ? formatDate(repo.indexedAt) : "Never"}
+ {repo.indexedAt ? : "Never" }
@@ -147,7 +137,7 @@ export default async function RepoDetailPage({ params }: { params: Promise<{ id: -
{nextIndexAttempt ? formatDate(nextIndexAttempt) : "-"}
+ {nextIndexAttempt ? : "-" }
diff --git a/packages/web/src/app/[domain]/repos/components/repoJobsTable.tsx b/packages/web/src/app/[domain]/repos/components/repoJobsTable.tsx index a1bc23d6..1f8e290e 100644 --- a/packages/web/src/app/[domain]/repos/components/repoJobsTable.tsx +++ b/packages/web/src/app/[domain]/repos/components/repoJobsTable.tsx @@ -25,6 +25,7 @@ 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 @@ -68,17 +69,6 @@ const getTypeBadge = (type: RepoIndexingJob["type"]) => { ) } -const formatDate = (date: Date | null) => { - if (!date) return "-" - return new Intl.DateTimeFormat("en-US", { - month: "short", - day: "numeric", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - }).format(date) -} - const getDuration = (start: Date, end: Date | null) => { if (!end) return "-" const diff = end.getTime() - start.getTime() @@ -139,7 +129,7 @@ export const columns: ColumnDef[] = [ ) }, - cell: ({ row }) => formatDate(row.getValue("createdAt")), + cell: ({ row }) => , }, { accessorKey: "completedAt", @@ -151,7 +141,14 @@ export const columns: ColumnDef[] = [ ) }, - cell: ({ row }) => formatDate(row.getValue("completedAt")), + cell: ({ row }) => { + const completedAt = row.getValue("completedAt") as Date | null; + if (!completedAt) { + return "-"; + } + + return + }, }, { id: "duration", diff --git a/packages/web/src/app/[domain]/repos/components/reposTable.tsx b/packages/web/src/app/[domain]/repos/components/reposTable.tsx index 2731eae2..bc7c5a60 100644 --- a/packages/web/src/app/[domain]/repos/components/reposTable.tsx +++ b/packages/web/src/app/[domain]/repos/components/reposTable.tsx @@ -14,7 +14,7 @@ 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 { getCodeHostInfoForRepo, getRepoImageSrc } from "@/lib/utils" +import { CodeHostType, getCodeHostCommitUrl, getCodeHostInfoForRepo, getFormattedDate, getRepoImageSrc } from "@/lib/utils" import { type ColumnDef, type ColumnFiltersState, @@ -35,6 +35,7 @@ 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" // @see: https://v0.app/chat/repo-indexing-status-uhjdDim8OUS @@ -49,6 +50,7 @@ export type Repo = { webUrl: string | null codeHostType: string imageUrl: string | null + indexedCommitHash: string | null latestJobStatus: "PENDING" | "IN_PROGRESS" | "COMPLETED" | "FAILED" | null } @@ -81,13 +83,7 @@ const getStatusBadge = (status: Repo["latestJobStatus"]) => { const formatDate = (date: Date | null) => { if (!date) return "Never" - return new Intl.DateTimeFormat("en-US", { - month: "short", - day: "numeric", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - }).format(date) + return getFormattedDate(date); } export const columns: ColumnDef[] = [ @@ -132,20 +128,64 @@ export const columns: ColumnDef[] = [ }, { accessorKey: "latestJobStatus", - header: "Status", + header: "Lastest status", cell: ({ row }) => getStatusBadge(row.getValue("latestJobStatus")), }, { accessorKey: "indexedAt", header: ({ column }) => { return ( - ) }, - cell: ({ row }) => formatDate(row.getValue("indexedAt")), + cell: ({ row }) => { + const indexedAt = row.getValue("indexedAt") as Date | null; + if (!indexedAt) { + return "-"; + } + + return ( + + ) + } + }, + { + accessorKey: "indexedCommitHash", + header: "Last 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, + }); + + if (!commitUrl) { + return {smallHash} + } + + return + {smallHash} + + }, }, { id: "actions", diff --git a/packages/web/src/app/[domain]/repos/page.tsx b/packages/web/src/app/[domain]/repos/page.tsx index 64ddc8d0..3e612081 100644 --- a/packages/web/src/app/[domain]/repos/page.tsx +++ b/packages/web/src/app/[domain]/repos/page.tsx @@ -29,6 +29,7 @@ export default async function ReposPage() { imageUrl: repo.imageUrl, latestJobStatus: repo.jobs.length > 0 ? repo.jobs[0].status : null, codeHostType: repo.external_codeHostType, + indexedCommitHash: repo.indexedCommitHash, }))} /> ) @@ -44,6 +45,9 @@ const getReposWithLatestJob = async () => sew(() => }, take: 1 } + }, + orderBy: { + name: 'asc' } }); return repos; 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 e28efe75..92ed4df7 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/lib/utils.ts b/packages/web/src/lib/utils.ts index c6038227..270a291d 100644 --- a/packages/web/src/lib/utils.ts +++ b/packages/web/src/lib/utils.ts @@ -320,6 +320,38 @@ 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 isAuthSupportedForCodeHost = (codeHostType: CodeHostType): boolean => { switch (codeHostType) { case "github": @@ -348,32 +380,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); } }