From 62c6ce8665ad74a0ccbf267c42261a0cb42d0678 Mon Sep 17 00:00:00 2001 From: bkellam Date: Fri, 24 Jan 2025 17:08:32 -0800 Subject: [PATCH 01/12] wip --- .../migration.sql | 8 + .../migration.sql | 8 + packages/db/prisma/schema.prisma | 39 ++-- packages/web/src/actions.ts | 10 +- .../searchBar/useSuggestionsData.ts | 30 +-- .../web/src/app/connections/[id]/page.tsx | 8 + packages/web/src/app/connections/new/page.tsx | 11 +- packages/web/src/app/connections/page.tsx | 180 ++++++++++++++++++ packages/web/src/lib/utils.ts | 82 +++++++- packages/web/tailwind.config.ts | 1 + 10 files changed, 314 insertions(+), 63 deletions(-) create mode 100644 packages/db/prisma/migrations/20250124230248_add_name_to_connection/migration.sql create mode 100644 packages/db/prisma/migrations/20250124231224_add_connection_type/migration.sql create mode 100644 packages/web/src/app/connections/[id]/page.tsx create mode 100644 packages/web/src/app/connections/page.tsx diff --git a/packages/db/prisma/migrations/20250124230248_add_name_to_connection/migration.sql b/packages/db/prisma/migrations/20250124230248_add_name_to_connection/migration.sql new file mode 100644 index 00000000..6ab30f01 --- /dev/null +++ b/packages/db/prisma/migrations/20250124230248_add_name_to_connection/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `name` to the `Connection` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Connection" ADD COLUMN "name" TEXT NOT NULL; diff --git a/packages/db/prisma/migrations/20250124231224_add_connection_type/migration.sql b/packages/db/prisma/migrations/20250124231224_add_connection_type/migration.sql new file mode 100644 index 00000000..9df4c179 --- /dev/null +++ b/packages/db/prisma/migrations/20250124231224_add_connection_type/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `connectionType` to the `Connection` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Connection" ADD COLUMN "connectionType" TEXT NOT NULL; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 4f071a1b..38cf5b0b 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -27,15 +27,15 @@ enum ConnectionSyncStatus { } model Repo { - id Int @id @default(autoincrement()) - name String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - indexedAt DateTime? - isFork Boolean - isArchived Boolean - metadata Json - cloneUrl String + id Int @id @default(autoincrement()) + name String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + indexedAt DateTime? + isFork Boolean + isArchived Boolean + metadata Json + cloneUrl String connections RepoToConnection[] repoIndexingStatus RepoIndexingStatus @default(NEW) @@ -54,15 +54,18 @@ model Repo { } model Connection { - id Int @id @default(autoincrement()) - config Json - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - syncedAt DateTime? - repos RepoToConnection[] - + id Int @id @default(autoincrement()) + name String + config Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + syncedAt DateTime? + repos RepoToConnection[] syncStatus ConnectionSyncStatus @default(SYNC_NEEDED) + // The type of connection (e.g., github, gitlab, etc.) + connectionType String + // The organization that owns this connection org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) orgId Int @@ -71,10 +74,10 @@ model Connection { model RepoToConnection { addedAt DateTime @default(now()) - connection Connection @relation(fields: [connectionId], references: [id], onDelete: Cascade) + connection Connection @relation(fields: [connectionId], references: [id], onDelete: Cascade) connectionId Int - repo Repo @relation(fields: [repoId], references: [id], onDelete: Cascade) + repo Repo @relation(fields: [repoId], references: [id], onDelete: Cascade) repoId Int @@id([connectionId, repoId]) diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index b0f5f468..b2474a5c 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -141,7 +141,7 @@ export const switchActiveOrg = async (orgId: number): Promise<{ id: number } | S } } -export const createConnection = async (config: string): Promise<{ id: number } | ServiceError> => { +export const createConnection = async (name: string, type: string, connectionConfig: string): Promise<{ id: number } | ServiceError> => { const orgId = await getCurrentUserOrg(); if (isServiceError(orgId)) { return orgId; @@ -149,8 +149,8 @@ export const createConnection = async (config: string): Promise<{ id: number } | let parsedConfig; try { - parsedConfig = JSON.parse(config); - } catch { + parsedConfig = JSON.parse(connectionConfig); + } catch (e) { return { statusCode: StatusCodes.BAD_REQUEST, errorCode: ErrorCode.INVALID_REQUEST_BODY, @@ -170,8 +170,10 @@ export const createConnection = async (config: string): Promise<{ id: number } | const connection = await prisma.connection.create({ data: { - orgId: orgId, + orgId, + name, config: parsedConfig, + connectionType: type, } }); diff --git a/packages/web/src/app/components/searchBar/useSuggestionsData.ts b/packages/web/src/app/components/searchBar/useSuggestionsData.ts index 668207f7..3548fe4d 100644 --- a/packages/web/src/app/components/searchBar/useSuggestionsData.ts +++ b/packages/web/src/app/components/searchBar/useSuggestionsData.ts @@ -18,6 +18,7 @@ import { VscSymbolVariable } from "react-icons/vsc"; import { useSearchHistory } from "@/hooks/useSearchHistory"; +import { getDisplayTime } from "@/lib/utils"; interface Props { @@ -155,32 +156,3 @@ const getSymbolIcon = (symbol: Symbol) => { return VscSymbolEnum; } } - -const getDisplayTime = (createdAt: Date) => { - const now = new Date(); - const minutes = (now.getTime() - createdAt.getTime()) / (1000 * 60); - const hours = minutes / 60; - const days = hours / 24; - const months = days / 30; - - const formatTime = (value: number, unit: 'minute' | 'hour' | 'day' | 'month') => { - const roundedValue = Math.floor(value); - if (roundedValue < 2) { - return `${roundedValue} ${unit} ago`; - } else { - return `${roundedValue} ${unit}s ago`; - } - } - - if (minutes < 1) { - return 'just now'; - } else if (minutes < 60) { - return formatTime(minutes, 'minute'); - } else if (hours < 24) { - return formatTime(hours, 'hour'); - } else if (days < 30) { - return formatTime(days, 'day'); - } else { - return formatTime(months, 'month'); - } -} diff --git a/packages/web/src/app/connections/[id]/page.tsx b/packages/web/src/app/connections/[id]/page.tsx new file mode 100644 index 00000000..18449b64 --- /dev/null +++ b/packages/web/src/app/connections/[id]/page.tsx @@ -0,0 +1,8 @@ + +export default async function ConnectionManagementPage({ params }: { params: { id: string } }) { + return ( +
+

Connection Management

+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/connections/new/page.tsx b/packages/web/src/app/connections/new/page.tsx index e5239658..316bd524 100644 --- a/packages/web/src/app/connections/new/page.tsx +++ b/packages/web/src/app/connections/new/page.tsx @@ -91,7 +91,9 @@ export default function NewConnectionPage() { const router = useRouter(); const onSubmit = useCallback((data: z.infer) => { - createConnection(data.config) + // @todo: we will need to key into the type of connection here + const connectionType = 'github'; + createConnection(data.name, connectionType, data.config) .then((response) => { if (isServiceError(response)) { toast({ @@ -101,7 +103,7 @@ export default function NewConnectionPage() { toast({ description: `✅ Connection created successfully!` }); - router.push('/'); + router.push('/connections'); } }); }, [router, toast]); @@ -119,7 +121,10 @@ export default function NewConnectionPage() { Display Name - + diff --git a/packages/web/src/app/connections/page.tsx b/packages/web/src/app/connections/page.tsx new file mode 100644 index 00000000..d6403d41 --- /dev/null +++ b/packages/web/src/app/connections/page.tsx @@ -0,0 +1,180 @@ +import { auth } from "@/auth"; +import { Button } from "@/components/ui/button"; +import { getUser } from "@/data/user"; +import { cn, getCodeHostIcon, getDisplayTime } from "@/lib/utils"; +import { prisma } from "@/prisma"; +import placeholderLogo from "@/public/placeholder_avatar.png"; +import { Cross2Icon } from "@radix-ui/react-icons"; +import { ConnectionSyncStatus } from "@sourcebot/db"; +import { CircleCheckIcon } from "lucide-react"; +import Image from "next/image"; +import Link from "next/link"; +import { useMemo } from "react"; +import { FiLoader } from "react-icons/fi"; + +const convertSyncStatus = (status: ConnectionSyncStatus): SyncStatus => { + switch (status) { + case ConnectionSyncStatus.SYNC_NEEDED: + return 'waiting'; + case ConnectionSyncStatus.IN_SYNC_QUEUE: + case ConnectionSyncStatus.SYNCING: + return 'syncing'; + case ConnectionSyncStatus.SYNCED: + return 'synced'; + case ConnectionSyncStatus.FAILED: + return 'failed'; + } +} + +export default async function ConnectionsPage() { + const session = await auth(); + if (!session) { + return null; + } + + const user = await getUser(session.user.id); + if (!user || !user.activeOrgId) { + return null; + } + + const connections = await prisma.connection.findMany({ + where: { + orgId: user.activeOrgId, + } + }); + + return ( +
+
+ {connections + .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()) + .map((connection) => ( + + ))} +
+
+ ); +} + +type SyncStatus = 'waiting' | 'syncing' | 'synced' | 'failed'; + +interface ConnectionProps { + id: string; + name: string; + type: string; + status: SyncStatus; + editedAt: Date; + syncedAt?: Date; +} + +const Connection = ({ + id, + name, + type, + status, + editedAt, + syncedAt, +}: ConnectionProps) => { + + const Icon = useMemo(() => { + const iconInfo = getCodeHostIcon(type); + if (iconInfo) { + const { src, className } = iconInfo; + return ( + {`${type} + ) + } + + return {''} + + }, [type]); + + const statusDisplayName = useMemo(() => { + switch (status) { + case 'waiting': + return 'Waiting...'; + case 'syncing': + return 'Syncing...'; + case 'synced': + return 'Synced'; + case 'failed': + return 'Sync failed'; + } + }, [status]); + + return ( + +
+
+ {Icon} +
+

{name}

+
+ {`Edited ${getDisplayTime(editedAt)}`} + {''} +
+
+
+
+ +

+ {statusDisplayName} + { + (status === 'synced' || status === 'failed') && syncedAt && ( + {` ${getDisplayTime(syncedAt)}`} + ) + } +

+ +
+
+ + ) +} + +const StatusIcon = ({ + status, + className, +}: { status: SyncStatus, className?: string }) => { + const Icon = useMemo(() => { + switch (status) { + case 'waiting': + case 'syncing': + return ; + case 'synced': + return ; + case 'failed': + return ; + } + }, [className, status]); + + return Icon; +} \ No newline at end of file diff --git a/packages/web/src/lib/utils.ts b/packages/web/src/lib/utils.ts index b63b784a..b002ed62 100644 --- a/packages/web/src/lib/utils.ts +++ b/packages/web/src/lib/utils.ts @@ -53,39 +53,74 @@ export const getRepoCodeHostInfo = (repo?: Repository): CodeHostInfo | undefined const url = new URL(repo.URL); const displayName = url.pathname.slice(1); switch (webUrlType) { - case 'github': + case 'github': { + const { src, className } = getCodeHostIcon('github')!; return { type: "github", displayName: displayName, costHostName: "GitHub", repoLink: repo.URL, - icon: githubLogo, - iconClassName: "dark:invert", + icon: src, + iconClassName: className, } - case 'gitlab': + } + case 'gitlab': { + const { src, className } = getCodeHostIcon('gitlab')!; return { type: "gitlab", displayName: displayName, costHostName: "GitLab", repoLink: repo.URL, - icon: gitlabLogo, + icon: src, + iconClassName: className, } - case 'gitea': + } + case 'gitea': { + const { src, className } = getCodeHostIcon('gitea')!; return { type: "gitea", displayName: displayName, costHostName: "Gitea", repoLink: repo.URL, - icon: giteaLogo, + icon: src, + iconClassName: className, } - case 'gitiles': + } + case 'gitiles': { + const { src, className } = getCodeHostIcon('gerrit')!; return { type: "gerrit", displayName: displayName, costHostName: "Gerrit", repoLink: repo.URL, - icon: gerritLogo, + icon: src, + iconClassName: className, } + } + } +} + +export const getCodeHostIcon = (codeHostType: string): { src: string, className?: string } | null => { + switch (codeHostType) { + case "github": + return { + src: githubLogo, + className: "dark:invert", + }; + case "gitlab": + return { + src: gitlabLogo, + }; + case "gitea": + return { + src: giteaLogo, + } + case "gerrit": + return { + src: gerritLogo, + } + default: + return null; } } @@ -122,3 +157,32 @@ export const base64Decode = (base64: string): string => { export const isDefined = (arg: T | null | undefined): arg is T extends null | undefined ? never : T => { return arg !== null && arg !== undefined; } + +export const getDisplayTime = (date: Date) => { + const now = new Date(); + const minutes = (now.getTime() - date.getTime()) / (1000 * 60); + const hours = minutes / 60; + const days = hours / 24; + const months = days / 30; + + const formatTime = (value: number, unit: 'minute' | 'hour' | 'day' | 'month') => { + const roundedValue = Math.floor(value); + if (roundedValue < 2) { + return `${roundedValue} ${unit} ago`; + } else { + return `${roundedValue} ${unit}s ago`; + } + } + + if (minutes < 1) { + return 'just now'; + } else if (minutes < 60) { + return formatTime(minutes, 'minute'); + } else if (hours < 24) { + return formatTime(hours, 'hour'); + } else if (days < 30) { + return formatTime(days, 'day'); + } else { + return formatTime(months, 'month'); + } +} diff --git a/packages/web/tailwind.config.ts b/packages/web/tailwind.config.ts index 9ef8846a..73f5c2a4 100644 --- a/packages/web/tailwind.config.ts +++ b/packages/web/tailwind.config.ts @@ -72,6 +72,7 @@ const config = { animation: { "accordion-down": "accordion-down 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out", + "spin-slow": "spin 1.5s linear infinite", }, }, }, From 831f2deb9515e105525e220929637d4c3919aa75 Mon Sep 17 00:00:00 2001 From: bkellam Date: Mon, 27 Jan 2025 12:12:41 -0800 Subject: [PATCH 02/12] UI improvements --- .../connectionList/connectionListItem.tsx | 123 +++++++++++++ .../components/connectionList/index.tsx | 46 +++++ .../src/app/connections/components/header.tsx | 16 ++ .../components/newConnectionCard.tsx | 92 ++++++++++ packages/web/src/app/connections/layout.tsx | 4 +- packages/web/src/app/connections/new/page.tsx | 5 +- packages/web/src/app/connections/page.tsx | 167 ++---------------- packages/web/src/lib/utils.ts | 6 +- 8 files changed, 301 insertions(+), 158 deletions(-) create mode 100644 packages/web/src/app/connections/components/connectionList/connectionListItem.tsx create mode 100644 packages/web/src/app/connections/components/connectionList/index.tsx create mode 100644 packages/web/src/app/connections/components/header.tsx create mode 100644 packages/web/src/app/connections/components/newConnectionCard.tsx diff --git a/packages/web/src/app/connections/components/connectionList/connectionListItem.tsx b/packages/web/src/app/connections/components/connectionList/connectionListItem.tsx new file mode 100644 index 00000000..1f4ebb19 --- /dev/null +++ b/packages/web/src/app/connections/components/connectionList/connectionListItem.tsx @@ -0,0 +1,123 @@ +import { Button } from "@/components/ui/button"; +import { cn, CodeHostType, getCodeHostIcon, getDisplayTime } from "@/lib/utils"; +import placeholderLogo from "@/public/placeholder_avatar.png"; +import { Cross2Icon } from "@radix-ui/react-icons"; +import { CircleCheckIcon } from "lucide-react"; +import Image from "next/image"; +import Link from "next/link"; +import { useMemo } from "react"; +import { FiLoader } from "react-icons/fi"; + +export type SyncStatus = 'waiting' | 'syncing' | 'synced' | 'failed'; + +interface ConnectionListItemProps { + id: string; + name: string; + type: string; + status: SyncStatus; + editedAt: Date; + syncedAt?: Date; +} + +export const ConnectionListItem = ({ + id, + name, + type, + status, + editedAt, + syncedAt, +}: ConnectionListItemProps) => { + const Icon = useMemo(() => { + const iconInfo = getCodeHostIcon(type as CodeHostType); + if (iconInfo) { + const { src, className } = iconInfo; + return ( + {`${type} + ) + } + + return {''} + + }, [type]); + + const statusDisplayName = useMemo(() => { + switch (status) { + case 'waiting': + return 'Waiting...'; + case 'syncing': + return 'Syncing...'; + case 'synced': + return 'Synced'; + case 'failed': + return 'Sync failed'; + } + }, [status]); + + return ( + +
+
+ {Icon} +
+

{name}

+
+ {`Edited ${getDisplayTime(editedAt)}`} + {''} +
+
+
+
+ +

+ {statusDisplayName} + { + (status === 'synced' || status === 'failed') && syncedAt && ( + {` ${getDisplayTime(syncedAt)}`} + ) + } +

+ +
+
+ + ) +} + +const StatusIcon = ({ + status, + className, +}: { status: SyncStatus, className?: string }) => { + const Icon = useMemo(() => { + switch (status) { + case 'waiting': + case 'syncing': + return ; + case 'synced': + return ; + case 'failed': + return ; + } + }, [className, status]); + + return Icon; +} \ No newline at end of file diff --git a/packages/web/src/app/connections/components/connectionList/index.tsx b/packages/web/src/app/connections/components/connectionList/index.tsx new file mode 100644 index 00000000..861792dc --- /dev/null +++ b/packages/web/src/app/connections/components/connectionList/index.tsx @@ -0,0 +1,46 @@ +import { Connection, ConnectionSyncStatus } from "@sourcebot/db" +import { ConnectionListItem, SyncStatus } from "./connectionListItem"; +import { cn } from "@/lib/utils"; + +const convertSyncStatus = (status: ConnectionSyncStatus): SyncStatus => { + switch (status) { + case ConnectionSyncStatus.SYNC_NEEDED: + return 'waiting'; + case ConnectionSyncStatus.IN_SYNC_QUEUE: + case ConnectionSyncStatus.SYNCING: + return 'syncing'; + case ConnectionSyncStatus.SYNCED: + return 'synced'; + case ConnectionSyncStatus.FAILED: + return 'failed'; + } +} + +interface ConnectionListProps { + connections: Connection[]; + className?: string; +} + +export const ConnectionList = ({ + connections, + className, +}: ConnectionListProps) => { + + return ( +
+ {connections + .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()) + .map((connection) => ( + + ))} +
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/connections/components/header.tsx b/packages/web/src/app/connections/components/header.tsx new file mode 100644 index 00000000..43028b61 --- /dev/null +++ b/packages/web/src/app/connections/components/header.tsx @@ -0,0 +1,16 @@ +import { Separator } from "@/components/ui/separator"; + +interface HeaderProps { + children: React.ReactNode; +} + +export const Header = ({ + children, +}: HeaderProps) => { + return ( +
+ {children} + +
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/connections/components/newConnectionCard.tsx b/packages/web/src/app/connections/components/newConnectionCard.tsx new file mode 100644 index 00000000..1e9551fa --- /dev/null +++ b/packages/web/src/app/connections/components/newConnectionCard.tsx @@ -0,0 +1,92 @@ +import { cn, CodeHostType, getCodeHostIcon } from "@/lib/utils"; +import placeholderLogo from "@/public/placeholder_avatar.png"; +import { BlocksIcon } from "lucide-react"; +import Image from "next/image"; +import Link from "next/link"; +import { useMemo } from "react"; + +interface NewConnectionCardProps { + className?: string; +} + +export const NewConnectionCard = ({ + className, +}: NewConnectionCardProps) => { + return ( +
+ +

Connect to a Code Host

+

Create a connection to import repos from a code host.

+
+ + + + +
+
+ ) +} + +interface CardProps { + type: string; + title: string; + subtitle: string; +} + +const Card = ({ + type, + title, + subtitle, +}: CardProps) => { + const Icon = useMemo(() => { + const iconInfo = getCodeHostIcon(type as CodeHostType); + if (iconInfo) { + const { src, className } = iconInfo; + return ( + {`${type} + ) + } + + return {`${type} + + }, [type]); + + return ( + +
+ {Icon} +
+

{title}

+

{subtitle}

+
+
+ + ) +} \ No newline at end of file diff --git a/packages/web/src/app/connections/layout.tsx b/packages/web/src/app/connections/layout.tsx index 3c9edb6a..d18aedfc 100644 --- a/packages/web/src/app/connections/layout.tsx +++ b/packages/web/src/app/connections/layout.tsx @@ -9,8 +9,8 @@ export default function Layout({ return (
-
-
{children}
+
+
{children}
) diff --git a/packages/web/src/app/connections/new/page.tsx b/packages/web/src/app/connections/new/page.tsx index 316bd524..9a6081a9 100644 --- a/packages/web/src/app/connections/new/page.tsx +++ b/packages/web/src/app/connections/new/page.tsx @@ -27,6 +27,7 @@ import { useToast } from "@/components/hooks/use-toast"; import { isServiceError } from "@/lib/utils"; import { useRouter } from "next/navigation"; import { githubSchema } from "@sourcebot/schemas/v3/github.schema"; +import { Header } from "../components/header"; const ajv = new Ajv({ validateFormats: false, @@ -110,7 +111,9 @@ export default function NewConnectionPage() { return (
-

Create a connection

+
+

Create a GitHub connection

+
diff --git a/packages/web/src/app/connections/page.tsx b/packages/web/src/app/connections/page.tsx index d6403d41..f023c216 100644 --- a/packages/web/src/app/connections/page.tsx +++ b/packages/web/src/app/connections/page.tsx @@ -1,30 +1,9 @@ import { auth } from "@/auth"; -import { Button } from "@/components/ui/button"; import { getUser } from "@/data/user"; -import { cn, getCodeHostIcon, getDisplayTime } from "@/lib/utils"; import { prisma } from "@/prisma"; -import placeholderLogo from "@/public/placeholder_avatar.png"; -import { Cross2Icon } from "@radix-ui/react-icons"; -import { ConnectionSyncStatus } from "@sourcebot/db"; -import { CircleCheckIcon } from "lucide-react"; -import Image from "next/image"; -import Link from "next/link"; -import { useMemo } from "react"; -import { FiLoader } from "react-icons/fi"; - -const convertSyncStatus = (status: ConnectionSyncStatus): SyncStatus => { - switch (status) { - case ConnectionSyncStatus.SYNC_NEEDED: - return 'waiting'; - case ConnectionSyncStatus.IN_SYNC_QUEUE: - case ConnectionSyncStatus.SYNCING: - return 'syncing'; - case ConnectionSyncStatus.SYNCED: - return 'synced'; - case ConnectionSyncStatus.FAILED: - return 'failed'; - } -} +import { ConnectionList } from "./components/connectionList"; +import { Header } from "./components/header"; +import { NewConnectionCard } from "./components/newConnectionCard"; export default async function ConnectionsPage() { const session = await auth(); @@ -45,136 +24,18 @@ export default async function ConnectionsPage() { return (
-
- {connections - .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()) - .map((connection) => ( - - ))} +
+

Connections

+
+
+ +
); } - -type SyncStatus = 'waiting' | 'syncing' | 'synced' | 'failed'; - -interface ConnectionProps { - id: string; - name: string; - type: string; - status: SyncStatus; - editedAt: Date; - syncedAt?: Date; -} - -const Connection = ({ - id, - name, - type, - status, - editedAt, - syncedAt, -}: ConnectionProps) => { - - const Icon = useMemo(() => { - const iconInfo = getCodeHostIcon(type); - if (iconInfo) { - const { src, className } = iconInfo; - return ( - {`${type} - ) - } - - return {''} - - }, [type]); - - const statusDisplayName = useMemo(() => { - switch (status) { - case 'waiting': - return 'Waiting...'; - case 'syncing': - return 'Syncing...'; - case 'synced': - return 'Synced'; - case 'failed': - return 'Sync failed'; - } - }, [status]); - - return ( - -
-
- {Icon} -
-

{name}

-
- {`Edited ${getDisplayTime(editedAt)}`} - {''} -
-
-
-
- -

- {statusDisplayName} - { - (status === 'synced' || status === 'failed') && syncedAt && ( - {` ${getDisplayTime(syncedAt)}`} - ) - } -

- -
-
- - ) -} - -const StatusIcon = ({ - status, - className, -}: { status: SyncStatus, className?: string }) => { - const Icon = useMemo(() => { - switch (status) { - case 'waiting': - case 'syncing': - return ; - case 'synced': - return ; - case 'failed': - return ; - } - }, [className, status]); - - return Icon; -} \ No newline at end of file diff --git a/packages/web/src/lib/utils.ts b/packages/web/src/lib/utils.ts index b002ed62..27ecbe52 100644 --- a/packages/web/src/lib/utils.ts +++ b/packages/web/src/lib/utils.ts @@ -31,8 +31,10 @@ export const createPathWithQueryParams = (path: string, ...queryParams: [string, return `${path}?${queryString}`; } +export type CodeHostType = "github" | "gitlab" | "gitea" | "gerrit"; + type CodeHostInfo = { - type: "github" | "gitlab" | "gitea" | "gerrit"; + type: CodeHostType; displayName: string; costHostName: string; repoLink: string; @@ -100,7 +102,7 @@ export const getRepoCodeHostInfo = (repo?: Repository): CodeHostInfo | undefined } } -export const getCodeHostIcon = (codeHostType: string): { src: string, className?: string } | null => { +export const getCodeHostIcon = (codeHostType: CodeHostType): { src: string, className?: string } | null => { switch (codeHostType) { case "github": return { From 586457b2ef052b4f1abef23727e6c490348c8ef1 Mon Sep 17 00:00:00 2001 From: bkellam Date: Mon, 27 Jan 2025 13:42:34 -0800 Subject: [PATCH 03/12] wip on 'quick actions' --- packages/web/src/app/connections/layout.tsx | 2 +- packages/web/src/app/connections/new/page.tsx | 150 +++++++++++++----- 2 files changed, 110 insertions(+), 42 deletions(-) diff --git a/packages/web/src/app/connections/layout.tsx b/packages/web/src/app/connections/layout.tsx index d18aedfc..2877c918 100644 --- a/packages/web/src/app/connections/layout.tsx +++ b/packages/web/src/app/connections/layout.tsx @@ -10,7 +10,7 @@ export default function Layout({
-
{children}
+
{children}
) diff --git a/packages/web/src/app/connections/new/page.tsx b/packages/web/src/app/connections/new/page.tsx index 9a6081a9..07f21423 100644 --- a/packages/web/src/app/connections/new/page.tsx +++ b/packages/web/src/app/connections/new/page.tsx @@ -2,7 +2,7 @@ 'use client'; import { Button } from "@/components/ui/button"; -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { useKeymapExtension } from "@/hooks/useKeymapExtension"; import { useThemeNormalized } from "@/hooks/useThemeNormalized"; import { json, jsonLanguage, jsonParseLinter } from "@codemirror/lang-json"; @@ -24,10 +24,13 @@ import { z } from "zod"; import { Input } from "@/components/ui/input"; import { createConnection } from "@/actions"; import { useToast } from "@/components/hooks/use-toast"; -import { isServiceError } from "@/lib/utils"; +import { getCodeHostIcon, isServiceError } from "@/lib/utils"; import { useRouter } from "next/navigation"; import { githubSchema } from "@sourcebot/schemas/v3/github.schema"; -import { Header } from "../components/header"; +import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"; +import Image from "next/image"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; const ajv = new Ajv({ validateFormats: false, @@ -78,10 +81,16 @@ const customAutocompleteStyle = EditorView.baseTheme({ }) export default function NewConnectionPage() { + + const defaultConfig: GithubConnectionConfig = { + type: 'github' + } + const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { - config: JSON.stringify({ type: "github" }, null, 2), + config: JSON.stringify(defaultConfig, null, 2), + name: "my-github-connection" }, }); @@ -110,11 +119,18 @@ export default function NewConnectionPage() { }, [router, toast]); return ( -
-
+
+
+ {''}

Create a GitHub connection

-
- +
+
( Display Name + This is the connection's display name within Sourcebot. Examples: public-github, self-hosted-gitlab, gerrit-other, etc. @@ -136,39 +154,89 @@ export default function NewConnectionPage() { ( - - Configuration - - - - - - - )} + render={({ field: { value, onChange } }) => { + + const onQuickAction = (e: any, action: (config: GithubConnectionConfig) => GithubConnectionConfig) => { + e.preventDefault(); + let parsedConfig: GithubConnectionConfig; + try { + parsedConfig = JSON.parse(value) as GithubConnectionConfig; + } catch { + return; + } + + onChange(JSON.stringify( + action(parsedConfig), + null, + 2 + )); + + // todo: need to figure out how we can move the codemirror cursor + // into the correct location. + } + + return ( + + Configuration + Code hosts are configured via a.... +
+ + + +
+ + + + + + +
+ ) + }} />
From 28efed372b14cba71f286a4dfbbe0f0bc471986e Mon Sep 17 00:00:00 2001 From: bkellam Date: Tue, 28 Jan 2025 12:18:43 -0500 Subject: [PATCH 04/12] Refactor editor parts into configEditor --- .../connections/components/configEditor.tsx | 130 ++++++++++++++++ packages/web/src/app/connections/new/page.tsx | 140 ++++-------------- 2 files changed, 160 insertions(+), 110 deletions(-) create mode 100644 packages/web/src/app/connections/components/configEditor.tsx diff --git a/packages/web/src/app/connections/components/configEditor.tsx b/packages/web/src/app/connections/components/configEditor.tsx new file mode 100644 index 00000000..7a172497 --- /dev/null +++ b/packages/web/src/app/connections/components/configEditor.tsx @@ -0,0 +1,130 @@ +'use client'; + +import { ScrollArea } from "@/components/ui/scroll-area"; +import { useKeymapExtension } from "@/hooks/useKeymapExtension"; +import { useThemeNormalized } from "@/hooks/useThemeNormalized"; +import { json, jsonLanguage, jsonParseLinter } from "@codemirror/lang-json"; +import { linter } from "@codemirror/lint"; +import { EditorView, hoverTooltip } from "@codemirror/view"; +import { githubSchema } from "@sourcebot/schemas/v3/github.schema"; +import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; +import CodeMirror, { ReactCodeMirrorRef } from "@uiw/react-codemirror"; +import { + handleRefresh, + jsonCompletion, + jsonSchemaHover, + jsonSchemaLinter, + stateExtensions +} from "codemirror-json-schema"; +import { useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; + + +interface ConfigEditorProps { + value: string; + onChange: (...event: any[]) => void; + actions: { + name: string; + fn: QuickActionFn; + }[], +} + +const customAutocompleteStyle = EditorView.baseTheme({ + ".cm-tooltip.cm-completionInfo": { + padding: "8px", + fontSize: "12px", + fontFamily: "monospace", + }, + ".cm-tooltip-hover.cm-tooltip": { + padding: "8px", + fontSize: "12px", + fontFamily: "monospace", + } +}); + +type QuickActionFn = (previous: ConnectionConfig) => ConnectionConfig; + +export const ConfigEditor = ({ + value, + onChange, + actions, +}: ConfigEditorProps) => { + const editorRef = useRef(null); + const keymapExtension = useKeymapExtension(editorRef.current?.view); + const { theme } = useThemeNormalized(); + + const onQuickAction = (e: any, action: QuickActionFn) => { + e.preventDefault(); + let previousConfig: ConnectionConfig; + try { + previousConfig = JSON.parse(value) as ConnectionConfig; + } catch { + return; + } + + const nextConfig = action(previousConfig); + const next = JSON.stringify(nextConfig, null, 2); + + const cursorPos = next.lastIndexOf(`""`) + 1; + + editorRef.current?.view?.focus(); + editorRef.current?.view?.dispatch({ + changes: { + from: 0, + to: value.length, + insert: next, + } + }); + editorRef.current?.view?.dispatch({ + selection: { anchor: cursorPos, head: cursorPos } + }); + } + + return ( + <> +
+ {actions.map(({ name, fn }, index) => ( + <> + + {index !== actions.length - 1 && ( + + )} + + ))} +
+ + + + + ) +} \ No newline at end of file diff --git a/packages/web/src/app/connections/new/page.tsx b/packages/web/src/app/connections/new/page.tsx index 07f21423..6b6cf157 100644 --- a/packages/web/src/app/connections/new/page.tsx +++ b/packages/web/src/app/connections/new/page.tsx @@ -1,36 +1,22 @@ 'use client'; +import { createConnection } from "@/actions"; +import { useToast } from "@/components/hooks/use-toast"; import { Button } from "@/components/ui/button"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; -import { useKeymapExtension } from "@/hooks/useKeymapExtension"; -import { useThemeNormalized } from "@/hooks/useThemeNormalized"; -import { json, jsonLanguage, jsonParseLinter } from "@codemirror/lang-json"; -import { linter } from "@codemirror/lint"; -import { EditorView, hoverTooltip } from "@codemirror/view"; -import { zodResolver } from "@hookform/resolvers/zod"; -import CodeMirror, { ReactCodeMirrorRef } from "@uiw/react-codemirror"; -import Ajv from "ajv"; -import { - handleRefresh, - jsonCompletion, - jsonSchemaHover, - jsonSchemaLinter, - stateExtensions -} from "codemirror-json-schema"; -import { useCallback, useRef } from "react"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; import { Input } from "@/components/ui/input"; -import { createConnection } from "@/actions"; -import { useToast } from "@/components/hooks/use-toast"; import { getCodeHostIcon, isServiceError } from "@/lib/utils"; -import { useRouter } from "next/navigation"; +import { zodResolver } from "@hookform/resolvers/zod"; import { githubSchema } from "@sourcebot/schemas/v3/github.schema"; import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"; +import Ajv from "ajv"; import Image from "next/image"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Separator } from "@/components/ui/separator"; +import { useRouter } from "next/navigation"; +import { useCallback } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { ConfigEditor } from "../components/configEditor"; const ajv = new Ajv({ validateFormats: false, @@ -66,20 +52,6 @@ const formSchema = z.object({ }), }); -// Add this theme extension to your extensions array -const customAutocompleteStyle = EditorView.baseTheme({ - ".cm-tooltip.cm-completionInfo": { - padding: "8px", - fontSize: "12px", - fontFamily: "monospace", - }, - ".cm-tooltip-hover.cm-tooltip": { - padding: "8px", - fontSize: "12px", - fontFamily: "monospace", - } -}) - export default function NewConnectionPage() { const defaultConfig: GithubConnectionConfig = { @@ -94,9 +66,6 @@ export default function NewConnectionPage() { }, }); - const editorRef = useRef(null); - const keymapExtension = useKeymapExtension(editorRef.current?.view); - const { theme } = useThemeNormalized(); const { toast } = useToast(); const router = useRouter(); @@ -155,83 +124,34 @@ export default function NewConnectionPage() { control={form.control} name="config" render={({ field: { value, onChange } }) => { - - const onQuickAction = (e: any, action: (config: GithubConnectionConfig) => GithubConnectionConfig) => { - e.preventDefault(); - let parsedConfig: GithubConnectionConfig; - try { - parsedConfig = JSON.parse(value) as GithubConnectionConfig; - } catch { - return; - } - - onChange(JSON.stringify( - action(parsedConfig), - null, - 2 - )); - - // todo: need to figure out how we can move the codemirror cursor - // into the correct location. - } - return ( Configuration Code hosts are configured via a.... -
- - - -
- - ({ + ...previous, + orgs: [ + ...(previous.orgs ?? []), + "" + ] }), - jsonLanguage.data.of({ - autocomplete: jsonCompletion(), + name: "Add an organization", + }, + { + fn: (previous) => ({ + ...previous, + url: previous.url ?? "", }), - hoverTooltip(jsonSchemaHover()), - // @todo: we will need to validate the config against different schemas based on the type of connection. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - stateExtensions(githubSchema as any), - customAutocompleteStyle, - ]} - theme={theme === "dark" ? "dark" : "light"} - /> - + name: "Set a custom url", + } + ]} + />
From 09bfae900a34cf4254577c55034fd93d40690b64 Mon Sep 17 00:00:00 2001 From: bkellam Date: Tue, 28 Jan 2025 13:01:13 -0500 Subject: [PATCH 05/12] wip --- .../connections/components/configEditor.tsx | 5 +++- packages/web/src/app/connections/new/page.tsx | 23 +++++++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/web/src/app/connections/components/configEditor.tsx b/packages/web/src/app/connections/components/configEditor.tsx index 7a172497..1f4227ff 100644 --- a/packages/web/src/app/connections/components/configEditor.tsx +++ b/packages/web/src/app/connections/components/configEditor.tsx @@ -94,7 +94,10 @@ export const ConfigEditor = ({ {name} {index !== actions.length - 1 && ( - + )} ))} diff --git a/packages/web/src/app/connections/new/page.tsx b/packages/web/src/app/connections/new/page.tsx index 6b6cf157..4b2c9fb9 100644 --- a/packages/web/src/app/connections/new/page.tsx +++ b/packages/web/src/app/connections/new/page.tsx @@ -134,7 +134,7 @@ export default function NewConnectionPage() { onChange={onChange} actions={[ { - fn: (previous) => ({ + fn: (previous: GithubConnectionConfig) => ({ ...previous, orgs: [ ...(previous.orgs ?? []), @@ -144,11 +144,30 @@ export default function NewConnectionPage() { name: "Add an organization", }, { - fn: (previous) => ({ + fn: (previous: GithubConnectionConfig) => ({ ...previous, url: previous.url ?? "", }), name: "Set a custom url", + }, + { + fn: (previous: GithubConnectionConfig) => ({ + ...previous, + repos: [ + ...(previous.orgs ?? []), + "" + ] + }), + name: "Add a repo", + }, + { + fn: (previous: GithubConnectionConfig) => ({ + ...previous, + token: previous.token ?? { + env: "", + }, + }), + name: "Add a secret", } ]} /> From e4697d095228fa6e3672a2a6ddc16349b59b3bcb Mon Sep 17 00:00:00 2001 From: bkellam Date: Tue, 28 Jan 2025 15:17:45 -0500 Subject: [PATCH 06/12] support for multiple connection types --- packages/backend/src/connectionManager.ts | 4 + packages/schemas/src/v3/connection.schema.ts | 138 ++++++++++++ packages/schemas/src/v3/connection.type.ts | 70 +++++- packages/schemas/src/v3/gitlab.schema.ts | 208 ++++++++++++++++++ packages/schemas/src/v3/gitlab.type.ts | 83 +++++++ .../connections/components/configEditor.tsx | 57 +++-- .../components/newConnectionCard.tsx | 2 +- .../connectionCreationForm/creationForm.tsx | 162 ++++++++++++++ .../connectionCreationForm/github.tsx | 63 ++++++ .../connectionCreationForm/gitlab.tsx | 63 ++++++ .../connectionCreationForm/index.tsx | 3 + .../src/app/connections/new/[type]/page.tsx | 18 ++ packages/web/src/app/connections/new/page.tsx | 186 ---------------- schemas/v3/connection.json | 3 + schemas/v3/gitlab.json | 138 ++++++++++++ 15 files changed, 989 insertions(+), 209 deletions(-) create mode 100644 packages/schemas/src/v3/gitlab.schema.ts create mode 100644 packages/schemas/src/v3/gitlab.type.ts create mode 100644 packages/web/src/app/connections/new/[type]/components/connectionCreationForm/creationForm.tsx create mode 100644 packages/web/src/app/connections/new/[type]/components/connectionCreationForm/github.tsx create mode 100644 packages/web/src/app/connections/new/[type]/components/connectionCreationForm/gitlab.tsx create mode 100644 packages/web/src/app/connections/new/[type]/components/connectionCreationForm/index.tsx create mode 100644 packages/web/src/app/connections/new/[type]/page.tsx delete mode 100644 packages/web/src/app/connections/new/page.tsx create mode 100644 schemas/v3/gitlab.json diff --git a/packages/backend/src/connectionManager.ts b/packages/backend/src/connectionManager.ts index 4fcc4113..15d6e73c 100644 --- a/packages/backend/src/connectionManager.ts +++ b/packages/backend/src/connectionManager.ts @@ -126,6 +126,10 @@ export class ConnectionManager implements IConnectionManager { return record; }) } + case 'gitlab': { + // @todo + return []; + } } })(); diff --git a/packages/schemas/src/v3/connection.schema.ts b/packages/schemas/src/v3/connection.schema.ts index 77a85423..dbef6edf 100644 --- a/packages/schemas/src/v3/connection.schema.ts +++ b/packages/schemas/src/v3/connection.schema.ts @@ -214,6 +214,144 @@ const schema = { "type" ], "additionalProperties": false + }, + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "GitLabConnectionConfig", + "properties": { + "type": { + "const": "gitlab", + "description": "GitLab Configuration" + }, + "token": { + "$ref": "#/oneOf/0/properties/token", + "description": "An authentication token.", + "examples": [ + "secret-token", + { + "env": "ENV_VAR_CONTAINING_TOKEN" + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://gitlab.com", + "description": "The URL of the GitLab host. Defaults to https://gitlab.com", + "examples": [ + "https://gitlab.com", + "https://gitlab.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "all": { + "type": "boolean", + "default": false, + "description": "Sync all projects visible to the provided `token` (if any) in the GitLab instance. This option is ignored if `url` is either unset or set to https://gitlab.com ." + }, + "users": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of users to sync with. All projects owned by the user and visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property." + }, + "groups": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-group" + ], + [ + "my-group/sub-group-a", + "my-group/sub-group-b" + ] + ], + "description": "List of groups to sync with. All projects in the group (and recursive subgroups) visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property. Subgroups can be specified by providing the path to the subgroup (e.g. `my-group/sub-group-a`)." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-group/my-project" + ], + [ + "my-group/my-sub-group/my-project" + ] + ], + "description": "List of individual projects to sync with. The project's namespace must be specified. See: https://docs.gitlab.com/ee/user/namespace/" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "description": "List of project topics to include when syncing. Only projects that match at least one of the provided `topics` will be synced. If not specified, all projects will be synced, unless explicitly defined in the `exclude` property. Glob patterns are supported.", + "examples": [ + [ + "docs", + "core" + ] + ] + }, + "exclude": { + "type": "object", + "properties": { + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked projects from syncing." + }, + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived projects from syncing." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "examples": [ + [ + "my-group/my-project" + ] + ], + "description": "List of projects to exclude from syncing. Glob patterns are supported. The project's namespace must be specified, see: https://docs.gitlab.com/ee/user/namespace/" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of project topics to exclude when syncing. Projects that match one of the provided `topics` will be excluded from syncing. Glob patterns are supported.", + "examples": [ + [ + "tests", + "ci" + ] + ] + } + }, + "additionalProperties": false + }, + "revisions": { + "$ref": "#/oneOf/0/properties/revisions" + } + }, + "required": [ + "type" + ], + "additionalProperties": false } ] } as const; diff --git a/packages/schemas/src/v3/connection.type.ts b/packages/schemas/src/v3/connection.type.ts index 3d07a7c2..4ef6d4b7 100644 --- a/packages/schemas/src/v3/connection.type.ts +++ b/packages/schemas/src/v3/connection.type.ts @@ -1,6 +1,6 @@ // THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! -export type ConnectionConfig = GithubConnectionConfig; +export type ConnectionConfig = GithubConnectionConfig | GitLabConnectionConfig; export interface GithubConnectionConfig { /** @@ -92,3 +92,71 @@ export interface GitRevisions { */ tags?: string[]; } +export interface GitLabConnectionConfig { + /** + * GitLab Configuration + */ + type: "gitlab"; + /** + * An authentication token. + */ + token?: + | string + | { + /** + * The name of the environment variable that contains the token. + */ + env: string; + } + | { + /** + * The name of the secret that contains the token. + */ + secret: string; + }; + /** + * The URL of the GitLab host. Defaults to https://gitlab.com + */ + url?: string; + /** + * Sync all projects visible to the provided `token` (if any) in the GitLab instance. This option is ignored if `url` is either unset or set to https://gitlab.com . + */ + all?: boolean; + /** + * List of users to sync with. All projects owned by the user and visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property. + */ + users?: string[]; + /** + * List of groups to sync with. All projects in the group (and recursive subgroups) visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property. Subgroups can be specified by providing the path to the subgroup (e.g. `my-group/sub-group-a`). + */ + groups?: string[]; + /** + * List of individual projects to sync with. The project's namespace must be specified. See: https://docs.gitlab.com/ee/user/namespace/ + */ + projects?: string[]; + /** + * List of project topics to include when syncing. Only projects that match at least one of the provided `topics` will be synced. If not specified, all projects will be synced, unless explicitly defined in the `exclude` property. Glob patterns are supported. + * + * @minItems 1 + */ + topics?: string[]; + exclude?: { + /** + * Exclude forked projects from syncing. + */ + forks?: boolean; + /** + * Exclude archived projects from syncing. + */ + archived?: boolean; + /** + * List of projects to exclude from syncing. Glob patterns are supported. The project's namespace must be specified, see: https://docs.gitlab.com/ee/user/namespace/ + */ + projects?: string[]; + /** + * List of project topics to exclude when syncing. Projects that match one of the provided `topics` will be excluded from syncing. Glob patterns are supported. + */ + topics?: string[]; + }; + revisions?: GitRevisions; +} diff --git a/packages/schemas/src/v3/gitlab.schema.ts b/packages/schemas/src/v3/gitlab.schema.ts new file mode 100644 index 00000000..8ed238ad --- /dev/null +++ b/packages/schemas/src/v3/gitlab.schema.ts @@ -0,0 +1,208 @@ +// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! +const schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "GitLabConnectionConfig", + "properties": { + "type": { + "const": "gitlab", + "description": "GitLab Configuration" + }, + "token": { + "description": "An authentication token.", + "examples": [ + "secret-token", + { + "env": "ENV_VAR_CONTAINING_TOKEN" + } + ], + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://gitlab.com", + "description": "The URL of the GitLab host. Defaults to https://gitlab.com", + "examples": [ + "https://gitlab.com", + "https://gitlab.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "all": { + "type": "boolean", + "default": false, + "description": "Sync all projects visible to the provided `token` (if any) in the GitLab instance. This option is ignored if `url` is either unset or set to https://gitlab.com ." + }, + "users": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of users to sync with. All projects owned by the user and visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property." + }, + "groups": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-group" + ], + [ + "my-group/sub-group-a", + "my-group/sub-group-b" + ] + ], + "description": "List of groups to sync with. All projects in the group (and recursive subgroups) visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property. Subgroups can be specified by providing the path to the subgroup (e.g. `my-group/sub-group-a`)." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-group/my-project" + ], + [ + "my-group/my-sub-group/my-project" + ] + ], + "description": "List of individual projects to sync with. The project's namespace must be specified. See: https://docs.gitlab.com/ee/user/namespace/" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "description": "List of project topics to include when syncing. Only projects that match at least one of the provided `topics` will be synced. If not specified, all projects will be synced, unless explicitly defined in the `exclude` property. Glob patterns are supported.", + "examples": [ + [ + "docs", + "core" + ] + ] + }, + "exclude": { + "type": "object", + "properties": { + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked projects from syncing." + }, + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived projects from syncing." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "examples": [ + [ + "my-group/my-project" + ] + ], + "description": "List of projects to exclude from syncing. Glob patterns are supported. The project's namespace must be specified, see: https://docs.gitlab.com/ee/user/namespace/" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of project topics to exclude when syncing. Projects that match one of the provided `topics` will be excluded from syncing. Glob patterns are supported.", + "examples": [ + [ + "tests", + "ci" + ] + ] + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type" + ], + "additionalProperties": false +} as const; +export { schema as gitlabSchema }; \ No newline at end of file diff --git a/packages/schemas/src/v3/gitlab.type.ts b/packages/schemas/src/v3/gitlab.type.ts new file mode 100644 index 00000000..aa30c206 --- /dev/null +++ b/packages/schemas/src/v3/gitlab.type.ts @@ -0,0 +1,83 @@ +// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! + +export interface GitLabConnectionConfig { + /** + * GitLab Configuration + */ + type: "gitlab"; + /** + * An authentication token. + */ + token?: + | string + | { + /** + * The name of the environment variable that contains the token. + */ + env: string; + } + | { + /** + * The name of the secret that contains the token. + */ + secret: string; + }; + /** + * The URL of the GitLab host. Defaults to https://gitlab.com + */ + url?: string; + /** + * Sync all projects visible to the provided `token` (if any) in the GitLab instance. This option is ignored if `url` is either unset or set to https://gitlab.com . + */ + all?: boolean; + /** + * List of users to sync with. All projects owned by the user and visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property. + */ + users?: string[]; + /** + * List of groups to sync with. All projects in the group (and recursive subgroups) visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property. Subgroups can be specified by providing the path to the subgroup (e.g. `my-group/sub-group-a`). + */ + groups?: string[]; + /** + * List of individual projects to sync with. The project's namespace must be specified. See: https://docs.gitlab.com/ee/user/namespace/ + */ + projects?: string[]; + /** + * List of project topics to include when syncing. Only projects that match at least one of the provided `topics` will be synced. If not specified, all projects will be synced, unless explicitly defined in the `exclude` property. Glob patterns are supported. + * + * @minItems 1 + */ + topics?: string[]; + exclude?: { + /** + * Exclude forked projects from syncing. + */ + forks?: boolean; + /** + * Exclude archived projects from syncing. + */ + archived?: boolean; + /** + * List of projects to exclude from syncing. Glob patterns are supported. The project's namespace must be specified, see: https://docs.gitlab.com/ee/user/namespace/ + */ + projects?: string[]; + /** + * List of project topics to exclude when syncing. Projects that match one of the provided `topics` will be excluded from syncing. Glob patterns are supported. + */ + topics?: string[]; + }; + revisions?: GitRevisions; +} +/** + * The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. + */ +export interface GitRevisions { + /** + * List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. + */ + branches?: string[]; + /** + * List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. + */ + tags?: string[]; +} diff --git a/packages/web/src/app/connections/components/configEditor.tsx b/packages/web/src/app/connections/components/configEditor.tsx index 1f4227ff..6fc32749 100644 --- a/packages/web/src/app/connections/components/configEditor.tsx +++ b/packages/web/src/app/connections/components/configEditor.tsx @@ -6,8 +6,6 @@ import { useThemeNormalized } from "@/hooks/useThemeNormalized"; import { json, jsonLanguage, jsonParseLinter } from "@codemirror/lang-json"; import { linter } from "@codemirror/lint"; import { EditorView, hoverTooltip } from "@codemirror/view"; -import { githubSchema } from "@sourcebot/schemas/v3/github.schema"; -import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; import CodeMirror, { ReactCodeMirrorRef } from "@uiw/react-codemirror"; import { handleRefresh, @@ -16,18 +14,22 @@ import { jsonSchemaLinter, stateExtensions } from "codemirror-json-schema"; -import { useRef } from "react"; +import { useMemo, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; +import { Schema } from "ajv"; +export type QuickActionFn = (previous: T) => T; -interface ConfigEditorProps { +interface ConfigEditorProps { value: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any onChange: (...event: any[]) => void; actions: { name: string; - fn: QuickActionFn; + fn: QuickActionFn; }[], + schema: Schema; } const customAutocompleteStyle = EditorView.baseTheme({ @@ -43,22 +45,30 @@ const customAutocompleteStyle = EditorView.baseTheme({ } }); -type QuickActionFn = (previous: ConnectionConfig) => ConnectionConfig; -export const ConfigEditor = ({ +export function ConfigEditor({ value, onChange, actions, -}: ConfigEditorProps) => { + schema, +}: ConfigEditorProps) { const editorRef = useRef(null); const keymapExtension = useKeymapExtension(editorRef.current?.view); const { theme } = useThemeNormalized(); - const onQuickAction = (e: any, action: QuickActionFn) => { - e.preventDefault(); - let previousConfig: ConnectionConfig; + const isQuickActionsDisabled = useMemo(() => { try { - previousConfig = JSON.parse(value) as ConnectionConfig; + JSON.parse(value); + return false; + } catch { + return true; + } + }, [value]); + + const onQuickAction = (action: QuickActionFn) => { + let previousConfig: T; + try { + previousConfig = JSON.parse(value) as T; } catch { return; } @@ -83,23 +93,29 @@ export const ConfigEditor = ({ return ( <> -
+
{actions.map(({ name, fn }, index) => ( - <> +
{index !== actions.length - 1 && ( )} - +
))}
@@ -120,9 +136,8 @@ export const ConfigEditor = ({ autocomplete: jsonCompletion(), }), hoverTooltip(jsonSchemaHover()), - // @todo: we will need to validate the config against different schemas based on the type of connection. // eslint-disable-next-line @typescript-eslint/no-explicit-any - stateExtensions(githubSchema as any), + stateExtensions(schema as any), customAutocompleteStyle, ]} theme={theme === "dark" ? "dark" : "light"} diff --git a/packages/web/src/app/connections/components/newConnectionCard.tsx b/packages/web/src/app/connections/components/newConnectionCard.tsx index 1e9551fa..871fc0e0 100644 --- a/packages/web/src/app/connections/components/newConnectionCard.tsx +++ b/packages/web/src/app/connections/components/newConnectionCard.tsx @@ -78,7 +78,7 @@ const Card = ({ return (
{Icon} diff --git a/packages/web/src/app/connections/new/[type]/components/connectionCreationForm/creationForm.tsx b/packages/web/src/app/connections/new/[type]/components/connectionCreationForm/creationForm.tsx new file mode 100644 index 00000000..154777ae --- /dev/null +++ b/packages/web/src/app/connections/new/[type]/components/connectionCreationForm/creationForm.tsx @@ -0,0 +1,162 @@ + +'use client'; + +import { createConnection } from "@/actions"; +import { useToast } from "@/components/hooks/use-toast"; +import { Button } from "@/components/ui/button"; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { isServiceError } from "@/lib/utils"; +import { zodResolver } from "@hookform/resolvers/zod"; +import Ajv, { Schema } from "ajv"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; +import { useCallback, useMemo } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { ConfigEditor, QuickActionFn } from "../../../../components/configEditor"; + +const ajv = new Ajv({ + validateFormats: false, +}); + +interface CreationFormProps { + type: 'github' | 'gitlab'; + defaultValues: { + name: string; + config: string; + }; + icon: string; + title: string; + schema: Schema; + quickActions?: { + name: string; + fn: QuickActionFn; + }[], +} + +export default function CreationForm({ + type, + defaultValues, + icon, + title, + schema, + quickActions, +}: CreationFormProps) { + + const { toast } = useToast(); + const router = useRouter(); + + const formSchema = useMemo(() => { + const validate = ajv.compile(schema); + + return z.object({ + name: z.string().min(1), + config: z + .string() + .superRefine((data, ctx) => { + const addIssue = (message: string) => { + return ctx.addIssue({ + code: "custom", + message: `Schema validation error: ${message}` + }); + } + + let parsed; + try { + parsed = JSON.parse(data); + } catch { + addIssue("Invalid JSON"); + return; + } + + const valid = validate(parsed); + if (!valid) { + addIssue(ajv.errorsText(validate.errors)); + } + }), + }); + }, [schema]); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: defaultValues, + }); + + const onSubmit = useCallback((data: z.infer) => { + createConnection(data.name, type, data.config) + .then((response) => { + if (isServiceError(response)) { + toast({ + description: `❌ Failed to create connection. Reason: ${response.message}` + }); + } else { + toast({ + description: `✅ Connection created successfully!` + }); + router.push('/connections'); + } + }); + }, [router, toast, type]); + + return ( +
+
+ {`${type} +

{title}

+
+ + +
+ ( + + Display Name + This is the {`connection's`} display name within Sourcebot. Examples: public-github, self-hosted-gitlab, gerrit-other, etc. + + + + + + )} + /> + { + return ( + + Configuration + Code hosts are configured via a.... + + + value={value} + onChange={onChange} + actions={quickActions ?? []} + schema={schema} + /> + + + + ) + }} + /> +
+ + + +
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/connections/new/[type]/components/connectionCreationForm/github.tsx b/packages/web/src/app/connections/new/[type]/components/connectionCreationForm/github.tsx new file mode 100644 index 00000000..ad3a8c94 --- /dev/null +++ b/packages/web/src/app/connections/new/[type]/components/connectionCreationForm/github.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"; +import CreationForm from "./creationForm"; +import githubLogo from "@/public/github.svg"; +import { githubSchema } from "@sourcebot/schemas/v3/github.schema"; + +const defaultConfig: GithubConnectionConfig = { + type: 'github', +} + +export const GitHubCreationForm = () => { + return ( + + type="github" + icon={githubLogo} + title="Create a GitHub connection" + defaultValues={{ + config: JSON.stringify(defaultConfig, null, 2), + name: 'my-github-connection', + }} + schema={githubSchema} + quickActions={[ + { + fn: (previous: GithubConnectionConfig) => ({ + ...previous, + orgs: [ + ...(previous.orgs ?? []), + "" + ] + }), + name: "Add an organization", + }, + { + fn: (previous: GithubConnectionConfig) => ({ + ...previous, + url: previous.url ?? "", + }), + name: "Set a custom url", + }, + { + fn: (previous: GithubConnectionConfig) => ({ + ...previous, + repos: [ + ...(previous.orgs ?? []), + "" + ] + }), + name: "Add a repo", + }, + { + fn: (previous: GithubConnectionConfig) => ({ + ...previous, + token: previous.token ?? { + secret: "", + }, + }), + name: "Add a secret", + } + ]} + /> + ) +} \ No newline at end of file diff --git a/packages/web/src/app/connections/new/[type]/components/connectionCreationForm/gitlab.tsx b/packages/web/src/app/connections/new/[type]/components/connectionCreationForm/gitlab.tsx new file mode 100644 index 00000000..c680e5da --- /dev/null +++ b/packages/web/src/app/connections/new/[type]/components/connectionCreationForm/gitlab.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { GitLabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type"; +import CreationForm from "./creationForm"; +import gitlabLogo from "@/public/gitlab.svg"; +import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema"; + +const defaultConfig: GitLabConnectionConfig = { + type: 'gitlab', +} + +export const GitLabCreationForm = () => { + return ( + + type="gitlab" + icon={gitlabLogo} + title="Create a GitLab connection" + defaultValues={{ + config: JSON.stringify(defaultConfig, null, 2), + name: 'my-gitlab-connection', + }} + schema={gitlabSchema} + quickActions={[ + { + fn: (previous: GitLabConnectionConfig) => ({ + ...previous, + groups: [ + ...previous.groups ?? [], + "" + ] + }), + name: "Add a group", + }, + { + fn: (previous: GitLabConnectionConfig) => ({ + ...previous, + url: previous.url ?? "", + }), + name: "Set a custom url", + }, + { + fn: (previous: GitLabConnectionConfig) => ({ + ...previous, + token: previous.token ?? { + secret: "", + }, + }), + name: "Add a secret", + }, + { + fn: (previous: GitLabConnectionConfig) => ({ + ...previous, + projects: [ + ...previous.projects ?? [], + "" + ] + }), + name: "Add a project", + } + ]} + /> + ) +} \ No newline at end of file diff --git a/packages/web/src/app/connections/new/[type]/components/connectionCreationForm/index.tsx b/packages/web/src/app/connections/new/[type]/components/connectionCreationForm/index.tsx new file mode 100644 index 00000000..c5b5e08d --- /dev/null +++ b/packages/web/src/app/connections/new/[type]/components/connectionCreationForm/index.tsx @@ -0,0 +1,3 @@ + +export { GitHubCreationForm } from './github'; +export { GitLabCreationForm } from './gitlab'; \ No newline at end of file diff --git a/packages/web/src/app/connections/new/[type]/page.tsx b/packages/web/src/app/connections/new/[type]/page.tsx new file mode 100644 index 00000000..572dc529 --- /dev/null +++ b/packages/web/src/app/connections/new/[type]/page.tsx @@ -0,0 +1,18 @@ +import { redirect } from "next/navigation"; +import { GitHubCreationForm, GitLabCreationForm } from "./components/connectionCreationForm"; + +export default async function NewConnectionPage({ + params +}: { params: { type: string } }) { + const { type } = params; + + if (type === 'github') { + return ; + } + + if (type === 'gitlab') { + return ; + } + + redirect('/connections'); +} \ No newline at end of file diff --git a/packages/web/src/app/connections/new/page.tsx b/packages/web/src/app/connections/new/page.tsx deleted file mode 100644 index 4b2c9fb9..00000000 --- a/packages/web/src/app/connections/new/page.tsx +++ /dev/null @@ -1,186 +0,0 @@ - -'use client'; - -import { createConnection } from "@/actions"; -import { useToast } from "@/components/hooks/use-toast"; -import { Button } from "@/components/ui/button"; -import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { getCodeHostIcon, isServiceError } from "@/lib/utils"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { githubSchema } from "@sourcebot/schemas/v3/github.schema"; -import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"; -import Ajv from "ajv"; -import Image from "next/image"; -import { useRouter } from "next/navigation"; -import { useCallback } from "react"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { ConfigEditor } from "../components/configEditor"; - -const ajv = new Ajv({ - validateFormats: false, -}); - -// @todo: we will need to validate the config against different schemas based on the type of connection. -const validate = ajv.compile(githubSchema); - -const formSchema = z.object({ - name: z.string().min(1), - config: z - .string() - .superRefine((data, ctx) => { - const addIssue = (message: string) => { - return ctx.addIssue({ - code: "custom", - message: `Schema validation error: ${message}` - }); - } - - let parsed; - try { - parsed = JSON.parse(data); - } catch { - addIssue("Invalid JSON"); - return; - } - - const valid = validate(parsed); - if (!valid) { - addIssue(ajv.errorsText(validate.errors)); - } - }), -}); - -export default function NewConnectionPage() { - - const defaultConfig: GithubConnectionConfig = { - type: 'github' - } - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - config: JSON.stringify(defaultConfig, null, 2), - name: "my-github-connection" - }, - }); - - const { toast } = useToast(); - const router = useRouter(); - - const onSubmit = useCallback((data: z.infer) => { - // @todo: we will need to key into the type of connection here - const connectionType = 'github'; - createConnection(data.name, connectionType, data.config) - .then((response) => { - if (isServiceError(response)) { - toast({ - description: `❌ Failed to create connection. Reason: ${response.message}` - }); - } else { - toast({ - description: `✅ Connection created successfully!` - }); - router.push('/connections'); - } - }); - }, [router, toast]); - - return ( -
-
- {''} -

Create a GitHub connection

-
-
- -
- ( - - Display Name - This is the connection's display name within Sourcebot. Examples: public-github, self-hosted-gitlab, gerrit-other, etc. - - - - - - )} - /> - { - return ( - - Configuration - Code hosts are configured via a.... - - ({ - ...previous, - orgs: [ - ...(previous.orgs ?? []), - "" - ] - }), - name: "Add an organization", - }, - { - fn: (previous: GithubConnectionConfig) => ({ - ...previous, - url: previous.url ?? "", - }), - name: "Set a custom url", - }, - { - fn: (previous: GithubConnectionConfig) => ({ - ...previous, - repos: [ - ...(previous.orgs ?? []), - "" - ] - }), - name: "Add a repo", - }, - { - fn: (previous: GithubConnectionConfig) => ({ - ...previous, - token: previous.token ?? { - env: "", - }, - }), - name: "Add a secret", - } - ]} - /> - - - - ) - }} - /> -
- -
- -
- ) -} \ No newline at end of file diff --git a/schemas/v3/connection.json b/schemas/v3/connection.json index a40c76e1..0e720d29 100644 --- a/schemas/v3/connection.json +++ b/schemas/v3/connection.json @@ -4,6 +4,9 @@ "oneOf": [ { "$ref": "./github.json" + }, + { + "$ref": "./gitlab.json" } ] } \ No newline at end of file diff --git a/schemas/v3/gitlab.json b/schemas/v3/gitlab.json new file mode 100644 index 00000000..1690929c --- /dev/null +++ b/schemas/v3/gitlab.json @@ -0,0 +1,138 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "GitLabConnectionConfig", + "properties": { + "type": { + "const": "gitlab", + "description": "GitLab Configuration" + }, + "token": { + "$ref": "./shared.json#/definitions/Token", + "description": "An authentication token.", + "examples": [ + "secret-token", + { + "env": "ENV_VAR_CONTAINING_TOKEN" + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://gitlab.com", + "description": "The URL of the GitLab host. Defaults to https://gitlab.com", + "examples": [ + "https://gitlab.com", + "https://gitlab.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "all": { + "type": "boolean", + "default": false, + "description": "Sync all projects visible to the provided `token` (if any) in the GitLab instance. This option is ignored if `url` is either unset or set to https://gitlab.com ." + }, + "users": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of users to sync with. All projects owned by the user and visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property." + }, + "groups": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-group" + ], + [ + "my-group/sub-group-a", + "my-group/sub-group-b" + ] + ], + "description": "List of groups to sync with. All projects in the group (and recursive subgroups) visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property. Subgroups can be specified by providing the path to the subgroup (e.g. `my-group/sub-group-a`)." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-group/my-project" + ], + [ + "my-group/my-sub-group/my-project" + ] + ], + "description": "List of individual projects to sync with. The project's namespace must be specified. See: https://docs.gitlab.com/ee/user/namespace/" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "description": "List of project topics to include when syncing. Only projects that match at least one of the provided `topics` will be synced. If not specified, all projects will be synced, unless explicitly defined in the `exclude` property. Glob patterns are supported.", + "examples": [ + [ + "docs", + "core" + ] + ] + }, + "exclude": { + "type": "object", + "properties": { + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked projects from syncing." + }, + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived projects from syncing." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "examples": [ + [ + "my-group/my-project" + ] + ], + "description": "List of projects to exclude from syncing. Glob patterns are supported. The project's namespace must be specified, see: https://docs.gitlab.com/ee/user/namespace/" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of project topics to exclude when syncing. Projects that match one of the provided `topics` will be excluded from syncing. Glob patterns are supported.", + "examples": [ + [ + "tests", + "ci" + ] + ] + } + }, + "additionalProperties": false + }, + "revisions": { + "$ref": "./shared.json#/definitions/GitRevisions" + } + }, + "required": [ + "type" + ], + "additionalProperties": false +} \ No newline at end of file From 76bbea739b4f32ea0a9e5cc5b20ceaebc1c9f3b9 Mon Sep 17 00:00:00 2001 From: bkellam Date: Tue, 28 Jan 2025 15:26:44 -0500 Subject: [PATCH 07/12] gitlab schema in action --- packages/web/src/actions.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index b2474a5c..be90c35b 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -8,6 +8,7 @@ import { StatusCodes } from "http-status-codes"; import { ErrorCode } from "@/lib/errorCodes"; import { isServiceError } from "@/lib/utils"; import { githubSchema } from "@sourcebot/schemas/v3/github.schema"; +import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema"; import { encrypt } from "@sourcebot/crypto" const ajv = new Ajv({ @@ -158,8 +159,25 @@ export const createConnection = async (name: string, type: string, connectionCon } satisfies ServiceError; } + const schema = (() => { + switch (type) { + case "github": + return githubSchema; + case "gitlab": + return gitlabSchema; + } + })(); + + if (!schema) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: "invalid connection type", + } satisfies ServiceError; + } + // @todo: we will need to validate the config against different schemas based on the type of connection. - const isValidConfig = ajv.validate(githubSchema, parsedConfig); + const isValidConfig = ajv.validate(schema, parsedConfig); if (!isValidConfig) { return { statusCode: StatusCodes.BAD_REQUEST, From f65e4f2ee0e11b2c1bbacdeaacdcd311170e4607 Mon Sep 17 00:00:00 2001 From: bkellam Date: Tue, 28 Jan 2025 15:45:18 -0500 Subject: [PATCH 08/12] Add breadcrumb --- packages/web/package.json | 4 +- .../web/src/app/connections/[id]/page.tsx | 23 +++- packages/web/src/components/ui/breadcrumb.tsx | 115 ++++++++++++++++++ yarn.lock | 4 +- 4 files changed, 141 insertions(+), 5 deletions(-) create mode 100644 packages/web/src/components/ui/breadcrumb.tsx diff --git a/packages/web/package.json b/packages/web/package.json index d269aa94..fd6508ed 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -48,7 +48,7 @@ "@radix-ui/react-navigation-menu": "^1.2.0", "@radix-ui/react-scroll-area": "^1.1.0", "@radix-ui/react-separator": "^1.1.0", - "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.4", @@ -59,8 +59,8 @@ "@replit/codemirror-vim": "^6.2.1", "@shopify/lang-jsonc": "^1.0.0", "@sourcebot/crypto": "^0.1.0", - "@sourcebot/schemas": "^0.1.0", "@sourcebot/db": "^0.1.0", + "@sourcebot/schemas": "^0.1.0", "@ssddanbrown/codemirror-lang-twig": "^1.0.0", "@tanstack/react-query": "^5.53.3", "@tanstack/react-table": "^8.20.5", diff --git a/packages/web/src/app/connections/[id]/page.tsx b/packages/web/src/app/connections/[id]/page.tsx index 18449b64..de5e8ace 100644 --- a/packages/web/src/app/connections/[id]/page.tsx +++ b/packages/web/src/app/connections/[id]/page.tsx @@ -1,8 +1,29 @@ +import { Header } from "../components/header" +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, + } from "@/components/ui/breadcrumb" export default async function ConnectionManagementPage({ params }: { params: { id: string } }) { return (
-

Connection Management

+
+ + + + Connections + + + + Connection + + + +
) } \ No newline at end of file diff --git a/packages/web/src/components/ui/breadcrumb.tsx b/packages/web/src/components/ui/breadcrumb.tsx new file mode 100644 index 00000000..60e6c96f --- /dev/null +++ b/packages/web/src/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode + } +>(({ ...props }, ref) =>
diff --git a/packages/web/src/app/components/notFound.tsx b/packages/web/src/app/components/notFound.tsx new file mode 100644 index 00000000..6d9dc36f --- /dev/null +++ b/packages/web/src/app/components/notFound.tsx @@ -0,0 +1,25 @@ +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; + +interface NotFoundProps { + message: string; + className?: string; +} + +export const NotFound = ({ + message, + className, +}: NotFoundProps) => { + return ( +
+
+

404

+ +

{message}

+
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/components/pageNotFound.tsx b/packages/web/src/app/components/pageNotFound.tsx index 8878f85b..aa1e6e6d 100644 --- a/packages/web/src/app/components/pageNotFound.tsx +++ b/packages/web/src/app/components/pageNotFound.tsx @@ -1,18 +1,9 @@ -import { Separator } from "@/components/ui/separator" +import { NotFound } from "./notFound" export const PageNotFound = () => { return (
-
-
-

404

- -

Page not found

-
-
+
) } \ No newline at end of file diff --git a/packages/web/src/app/connections/[id]/page.tsx b/packages/web/src/app/connections/[id]/page.tsx index de5e8ace..1c742e63 100644 --- a/packages/web/src/app/connections/[id]/page.tsx +++ b/packages/web/src/app/connections/[id]/page.tsx @@ -1,3 +1,4 @@ +import { getCurrentUserOrg } from "@/auth"; import { Header } from "../components/header" import { Breadcrumb, @@ -7,8 +8,41 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from "@/components/ui/breadcrumb" +import { isServiceError } from "@/lib/utils"; +import { getConnection } from "@/data/connection"; +import { NotFound } from "@/app/components/notFound"; +import { ConnectionIcon } from "../components/connectionIcon"; export default async function ConnectionManagementPage({ params }: { params: { id: string } }) { + const orgId = await getCurrentUserOrg(); + if (isServiceError(orgId)) { + return ( + <> + Error: {orgId.message} + + ) + } + + const connectionId = Number(params.id); + if (isNaN(connectionId)) { + return ( + + ) + } + + const connection = await getConnection(Number(params.id), orgId); + if (!connection) { + return ( + + ) + } + return (
@@ -19,10 +53,16 @@ export default async function ConnectionManagementPage({ params }: { params: { i - Connection + {connection.name} +
+ +

{connection.name}

+
) diff --git a/packages/web/src/app/connections/components/connectionIcon.tsx b/packages/web/src/app/connections/components/connectionIcon.tsx new file mode 100644 index 00000000..01afb849 --- /dev/null +++ b/packages/web/src/app/connections/components/connectionIcon.tsx @@ -0,0 +1,36 @@ +import { cn, CodeHostType, getCodeHostIcon } from "@/lib/utils"; +import { useMemo } from "react"; +import Image from "next/image"; +import placeholderLogo from "@/public/placeholder_avatar.png"; + +interface ConnectionIconProps { + type: string; + className?: string; +} + +export const ConnectionIcon = ({ + type, + className, +}: ConnectionIconProps) => { + const Icon = useMemo(() => { + const iconInfo = getCodeHostIcon(type as CodeHostType); + if (iconInfo) { + return ( + {`${type} + ) + } + + return {''} + + }, [type]); + + return Icon; +} \ No newline at end of file diff --git a/packages/web/src/app/connections/components/connectionList/connectionListItem.tsx b/packages/web/src/app/connections/components/connectionList/connectionListItem.tsx index 1f4ebb19..68aeb3b9 100644 --- a/packages/web/src/app/connections/components/connectionList/connectionListItem.tsx +++ b/packages/web/src/app/connections/components/connectionList/connectionListItem.tsx @@ -1,5 +1,5 @@ import { Button } from "@/components/ui/button"; -import { cn, CodeHostType, getCodeHostIcon, getDisplayTime } from "@/lib/utils"; +import { cn, getDisplayTime } from "@/lib/utils"; import placeholderLogo from "@/public/placeholder_avatar.png"; import { Cross2Icon } from "@radix-ui/react-icons"; import { CircleCheckIcon } from "lucide-react"; @@ -7,6 +7,7 @@ import Image from "next/image"; import Link from "next/link"; import { useMemo } from "react"; import { FiLoader } from "react-icons/fi"; +import { ConnectionIcon } from "../connectionIcon"; export type SyncStatus = 'waiting' | 'syncing' | 'synced' | 'failed'; @@ -27,27 +28,6 @@ export const ConnectionListItem = ({ editedAt, syncedAt, }: ConnectionListItemProps) => { - const Icon = useMemo(() => { - const iconInfo = getCodeHostIcon(type as CodeHostType); - if (iconInfo) { - const { src, className } = iconInfo; - return ( - {`${type} - ) - } - - return {''} - - }, [type]); - const statusDisplayName = useMemo(() => { switch (status) { case 'waiting': @@ -67,7 +47,10 @@ export const ConnectionListItem = ({ className="flex flex-row justify-between items-center border p-4 rounded-lg cursor-pointer bg-background dark:bg-background" >
- {Icon} +

{name}

diff --git a/packages/web/src/app/connections/new/[type]/components/connectionCreationForm/creationForm.tsx b/packages/web/src/app/connections/new/[type]/components/connectionCreationForm/creationForm.tsx index 154777ae..706ecda0 100644 --- a/packages/web/src/app/connections/new/[type]/components/connectionCreationForm/creationForm.tsx +++ b/packages/web/src/app/connections/new/[type]/components/connectionCreationForm/creationForm.tsx @@ -9,12 +9,12 @@ import { Input } from "@/components/ui/input"; import { isServiceError } from "@/lib/utils"; import { zodResolver } from "@hookform/resolvers/zod"; import Ajv, { Schema } from "ajv"; -import Image from "next/image"; import { useRouter } from "next/navigation"; import { useCallback, useMemo } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { ConfigEditor, QuickActionFn } from "../../../../components/configEditor"; +import { ConnectionIcon } from "@/app/connections/components/connectionIcon"; const ajv = new Ajv({ validateFormats: false, @@ -26,7 +26,6 @@ interface CreationFormProps { name: string; config: string; }; - icon: string; title: string; schema: Schema; quickActions?: { @@ -38,7 +37,6 @@ interface CreationFormProps { export default function CreationForm({ type, defaultValues, - icon, title, schema, quickActions, @@ -102,9 +100,8 @@ export default function CreationForm({ return (
- {`${type}

{title}

diff --git a/packages/web/src/app/connections/new/[type]/components/connectionCreationForm/github.tsx b/packages/web/src/app/connections/new/[type]/components/connectionCreationForm/github.tsx index ad3a8c94..efdfbb2d 100644 --- a/packages/web/src/app/connections/new/[type]/components/connectionCreationForm/github.tsx +++ b/packages/web/src/app/connections/new/[type]/components/connectionCreationForm/github.tsx @@ -2,7 +2,6 @@ import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"; import CreationForm from "./creationForm"; -import githubLogo from "@/public/github.svg"; import { githubSchema } from "@sourcebot/schemas/v3/github.schema"; const defaultConfig: GithubConnectionConfig = { @@ -13,7 +12,6 @@ export const GitHubCreationForm = () => { return ( type="github" - icon={githubLogo} title="Create a GitHub connection" defaultValues={{ config: JSON.stringify(defaultConfig, null, 2), diff --git a/packages/web/src/app/connections/new/[type]/components/connectionCreationForm/gitlab.tsx b/packages/web/src/app/connections/new/[type]/components/connectionCreationForm/gitlab.tsx index c680e5da..08781354 100644 --- a/packages/web/src/app/connections/new/[type]/components/connectionCreationForm/gitlab.tsx +++ b/packages/web/src/app/connections/new/[type]/components/connectionCreationForm/gitlab.tsx @@ -2,7 +2,6 @@ import { GitLabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type"; import CreationForm from "./creationForm"; -import gitlabLogo from "@/public/gitlab.svg"; import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema"; const defaultConfig: GitLabConnectionConfig = { @@ -13,7 +12,6 @@ export const GitLabCreationForm = () => { return ( type="gitlab" - icon={gitlabLogo} title="Create a GitLab connection" defaultValues={{ config: JSON.stringify(defaultConfig, null, 2), diff --git a/packages/web/src/app/not-found.tsx b/packages/web/src/app/not-found.tsx index b777ee1b..4e9f5e34 100644 --- a/packages/web/src/app/not-found.tsx +++ b/packages/web/src/app/not-found.tsx @@ -1,6 +1,6 @@ import { PageNotFound } from "./components/pageNotFound"; -export default function NotFound() { +export default function NotFoundPage() { return ( ) diff --git a/packages/web/src/data/connection.ts b/packages/web/src/data/connection.ts new file mode 100644 index 00000000..eef98425 --- /dev/null +++ b/packages/web/src/data/connection.ts @@ -0,0 +1,13 @@ +import { prisma } from '@/prisma'; +import 'server-only'; + +export const getConnection = async (connectionId: number, orgId: number) => { + const connection = await prisma.connection.findUnique({ + where: { + id: connectionId, + orgId: orgId, + }, + }); + + return connection; +} \ No newline at end of file From 28f13170b0ddb33da2ec3ae595e37a72add3c1cd Mon Sep 17 00:00:00 2001 From: bkellam Date: Mon, 3 Feb 2025 16:04:04 -0500 Subject: [PATCH 10/12] wip --- packages/web/package.json | 2 + packages/web/src/actions.ts | 136 ++++++++++-- .../[id]/components/configSetting.tsx | 141 ++++++++++++ .../components/deleteConnectionSetting.tsx | 90 ++++++++ .../[id]/components/displayNameSetting.tsx | 97 +++++++++ .../web/src/app/connections/[id]/page.tsx | 74 ++++++- .../connections/components/configEditor.tsx | 9 +- .../components/connectionList/index.tsx | 12 +- .../src/app/connections/components/header.tsx | 10 +- .../components/newConnectionCard.tsx | 2 +- ...ionForm.tsx => connectionCreationForm.tsx} | 49 +---- .../connectionCreationForm/github.tsx | 61 ------ .../connectionCreationForm/gitlab.tsx | 61 ------ .../connectionCreationForm/index.tsx | 3 - .../src/app/connections/new/[type]/page.tsx | 54 ++++- .../web/src/app/connections/quickActions.ts | 82 +++++++ packages/web/src/app/connections/utils.ts | 33 +++ .../web/src/components/ui/alert-dialog.tsx | 141 ++++++++++++ .../web/src/components/ui/tab-switcher.tsx | 53 +++++ packages/web/src/components/ui/tabs.tsx | 55 +++++ packages/web/src/lib/errorCodes.ts | 1 + packages/web/src/schemas/github.schema.ts | 205 ------------------ yarn.lock | 105 ++++++++- 23 files changed, 1067 insertions(+), 409 deletions(-) create mode 100644 packages/web/src/app/connections/[id]/components/configSetting.tsx create mode 100644 packages/web/src/app/connections/[id]/components/deleteConnectionSetting.tsx create mode 100644 packages/web/src/app/connections/[id]/components/displayNameSetting.tsx rename packages/web/src/app/connections/new/[type]/components/{connectionCreationForm/creationForm.tsx => connectionCreationForm.tsx} (80%) delete mode 100644 packages/web/src/app/connections/new/[type]/components/connectionCreationForm/github.tsx delete mode 100644 packages/web/src/app/connections/new/[type]/components/connectionCreationForm/gitlab.tsx delete mode 100644 packages/web/src/app/connections/new/[type]/components/connectionCreationForm/index.tsx create mode 100644 packages/web/src/app/connections/quickActions.ts create mode 100644 packages/web/src/app/connections/utils.ts create mode 100644 packages/web/src/components/ui/alert-dialog.tsx create mode 100644 packages/web/src/components/ui/tab-switcher.tsx create mode 100644 packages/web/src/components/ui/tabs.tsx delete mode 100644 packages/web/src/schemas/github.schema.ts diff --git a/packages/web/package.json b/packages/web/package.json index fd6508ed..106d29f7 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -40,6 +40,7 @@ "@hookform/resolvers": "^3.9.0", "@iconify/react": "^5.1.0", "@iizukak/codemirror-lang-wgsl": "^0.3.0", + "@radix-ui/react-alert-dialog": "^1.1.5", "@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.1", @@ -49,6 +50,7 @@ "@radix-ui/react-scroll-area": "^1.1.0", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.4", diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index be90c35b..bea36b00 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -9,7 +9,10 @@ import { ErrorCode } from "@/lib/errorCodes"; import { isServiceError } from "@/lib/utils"; import { githubSchema } from "@sourcebot/schemas/v3/github.schema"; import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema"; +import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; import { encrypt } from "@sourcebot/crypto" +import { getConnection } from "./data/connection"; +import { Prisma } from "@sourcebot/db"; const ajv = new Ajv({ validateFormats: false, @@ -148,10 +151,121 @@ export const createConnection = async (name: string, type: string, connectionCon return orgId; } - let parsedConfig; + const parsedConfig = parseConnectionConfig(type, connectionConfig); + if (isServiceError(parsedConfig)) { + return parsedConfig; + } + + const connection = await prisma.connection.create({ + data: { + orgId, + name, + config: parsedConfig as unknown as Prisma.InputJsonValue, + connectionType: type, + } + }); + + return { + id: connection.id, + } +} + +export const updateConnectionDisplayName = async (connectionId: number, name: string): Promise<{ success: boolean } | ServiceError> => { + const orgId = await getCurrentUserOrg(); + if (isServiceError(orgId)) { + return orgId; + } + + const connection = await getConnection(connectionId, orgId); + if (!connection) { + return notFound(); + } + + await prisma.connection.update({ + where: { + id: connectionId, + orgId, + }, + data: { + name, + } + }); + + return { + success: true, + } +} + +export const updateConnectionConfigAndScheduleSync = async (connectionId: number, config: string): Promise<{ success: boolean } | ServiceError> => { + const orgId = await getCurrentUserOrg(); + if (isServiceError(orgId)) { + return orgId; + } + + const connection = await getConnection(connectionId, orgId); + if (!connection) { + return notFound(); + } + + const parsedConfig = parseConnectionConfig(connection.connectionType, config); + if (isServiceError(parsedConfig)) { + return parsedConfig; + } + + if (connection.syncStatus === "SYNC_NEEDED" || + connection.syncStatus === "IN_SYNC_QUEUE" || + connection.syncStatus === "SYNCING") { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.CONNECTION_SYNC_ALREADY_SCHEDULED, + message: "Connection is already syncing. Please wait for the sync to complete before updating the connection.", + } satisfies ServiceError; + } + + await prisma.connection.update({ + where: { + id: connectionId, + orgId, + }, + data: { + config: parsedConfig as unknown as Prisma.InputJsonValue, + syncStatus: "SYNC_NEEDED", + } + }); + + return { + success: true, + } +} + +export const deleteConnection = async (connectionId: number): Promise<{ success: boolean } | ServiceError> => { + const orgId = await getCurrentUserOrg(); + if (isServiceError(orgId)) { + return orgId; + } + + const connection = await getConnection(connectionId, orgId); + if (!connection) { + return notFound(); + } + + await prisma.connection.delete({ + where: { + id: connectionId, + orgId, + } + }); + + return { + success: true, + } +} + +const parseConnectionConfig = (connectionType: string, config: string) => { + let parsedConfig: ConnectionConfig; try { - parsedConfig = JSON.parse(connectionConfig); - } catch (e) { + parsedConfig = JSON.parse(config); + } catch (_e) { return { statusCode: StatusCodes.BAD_REQUEST, errorCode: ErrorCode.INVALID_REQUEST_BODY, @@ -160,7 +274,7 @@ export const createConnection = async (name: string, type: string, connectionCon } const schema = (() => { - switch (type) { + switch (connectionType) { case "github": return githubSchema; case "gitlab": @@ -176,7 +290,6 @@ export const createConnection = async (name: string, type: string, connectionCon } satisfies ServiceError; } - // @todo: we will need to validate the config against different schemas based on the type of connection. const isValidConfig = ajv.validate(schema, parsedConfig); if (!isValidConfig) { return { @@ -186,16 +299,5 @@ export const createConnection = async (name: string, type: string, connectionCon } satisfies ServiceError; } - const connection = await prisma.connection.create({ - data: { - orgId, - name, - config: parsedConfig, - connectionType: type, - } - }); - - return { - id: connection.id, - } + return parsedConfig; } diff --git a/packages/web/src/app/connections/[id]/components/configSetting.tsx b/packages/web/src/app/connections/[id]/components/configSetting.tsx new file mode 100644 index 00000000..278b7775 --- /dev/null +++ b/packages/web/src/app/connections/[id]/components/configSetting.tsx @@ -0,0 +1,141 @@ +'use client'; + +import { Button } from "@/components/ui/button"; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { githubSchema } from "@sourcebot/schemas/v3/github.schema"; +import { Loader2 } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { ConfigEditor, QuickAction } from "../../components/configEditor"; +import { createZodConnectionConfigValidator } from "../../utils"; +import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"; +import { githubQuickActions, gitlabQuickActions } from "../../quickActions"; +import { Schema } from "ajv"; +import { GitLabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type"; +import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema"; +import { updateConnectionConfigAndScheduleSync } from "@/actions"; +import { useToast } from "@/components/hooks/use-toast"; +import { isServiceError } from "@/lib/utils"; +import { useRouter } from "next/navigation"; + + +interface ConfigSettingProps { + connectionId: number; + config: string; + type: string; +} + +export const ConfigSetting = (props: ConfigSettingProps) => { + const { type } = props; + + if (type === 'github') { + return + {...props} + quickActions={githubQuickActions} + schema={githubSchema} + />; + } + + if (type === 'gitlab') { + return + {...props} + quickActions={gitlabQuickActions} + schema={gitlabSchema} + />; + } + + return null; +} + + +function ConfigSettingInternal({ + connectionId, + config, + quickActions, + schema, +}: ConfigSettingProps & { + quickActions?: QuickAction[], + schema: Schema, +}) { + const { toast } = useToast(); + const router = useRouter(); + const formSchema = useMemo(() => { + return z.object({ + config: createZodConnectionConfigValidator(schema), + }); + }, [schema]); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + config, + }, + }); + + const [isLoading, setIsLoading] = useState(false); + const onSubmit = useCallback((data: z.infer) => { + setIsLoading(true); + updateConnectionConfigAndScheduleSync(connectionId, data.config) + .then((response) => { + if (isServiceError(response)) { + toast({ + description: `❌ Failed to update connection. Reason: ${response.message}` + }); + } else { + toast({ + description: `✅ Connection config updated successfully.` + }); + router.refresh(); + } + }) + .finally(() => { + setIsLoading(false); + }) + }, [connectionId, router, toast]); + + return ( +
+
+ + ( + + + Configuration + {/* @todo : refactor this description into a shared file */} + Code hosts are configured via a....TODO + + + value={value} + onChange={onChange} + schema={schema} + actions={quickActions ?? []} + /> + + + + + + )} + /> +
+ +
+ + +
+ ); +} \ No newline at end of file diff --git a/packages/web/src/app/connections/[id]/components/deleteConnectionSetting.tsx b/packages/web/src/app/connections/[id]/components/deleteConnectionSetting.tsx new file mode 100644 index 00000000..cce8f399 --- /dev/null +++ b/packages/web/src/app/connections/[id]/components/deleteConnectionSetting.tsx @@ -0,0 +1,90 @@ +'use client'; + +import { Button } from "@/components/ui/button"; +import { useCallback, useState } from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, + } from "@/components/ui/alert-dialog"; +import { deleteConnection } from "@/actions"; +import { Loader2 } from "lucide-react"; +import { isServiceError } from "@/lib/utils"; +import { useToast } from "@/components/hooks/use-toast"; +import { useRouter } from "next/navigation"; + +interface DeleteConnectionSettingProps { + connectionId: number; +} + +export const DeleteConnectionSetting = ({ + connectionId, +}: DeleteConnectionSettingProps) => { + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const { toast } = useToast(); + const router = useRouter(); + + const handleDelete = useCallback(() => { + setIsDialogOpen(false); + setIsLoading(true); + deleteConnection(connectionId) + .then((response) => { + if (isServiceError(response)) { + toast({ + description: `❌ Failed to delete connection. Reason: ${response.message}` + }); + } else { + toast({ + description: `✅ Connection deleted successfully.` + }); + router.replace("/connections"); + router.refresh(); + } + }) + .finally(() => { + setIsLoading(false); + }); + }, [connectionId]); + + return ( +
+

Delete Connection

+

+ Permanently delete this connection from Sourcebot. All linked repositories that are not linked to any other connection will also be deleted. +

+
+ + + + + + + Are you sure? + + This action cannot be undone. + + + + Cancel + Yes, delete connection + + + +
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/connections/[id]/components/displayNameSetting.tsx b/packages/web/src/app/connections/[id]/components/displayNameSetting.tsx new file mode 100644 index 00000000..91613d91 --- /dev/null +++ b/packages/web/src/app/connections/[id]/components/displayNameSetting.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { updateConnectionDisplayName } from "@/actions"; +import { useToast } from "@/components/hooks/use-toast"; +import { Button } from "@/components/ui/button"; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { isServiceError } from "@/lib/utils"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Loader2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useCallback, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +const formSchema = z.object({ + name: z.string().min(1), +}); + +interface DisplayNameSettingProps { + connectionId: number; + name: string; +} + +export const DisplayNameSetting = ({ + connectionId, + name, +}: DisplayNameSettingProps) => { + const { toast } = useToast(); + const router = useRouter(); + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + name, + }, + }); + + const [isLoading, setIsLoading] = useState(false); + const onSubmit = useCallback((data: z.infer) => { + setIsLoading(true); + updateConnectionDisplayName(connectionId, data.name) + .then((response) => { + if (isServiceError(response)) { + toast({ + description: `❌ Failed to rename connection. Reason: ${response.message}` + }); + } else { + toast({ + description: `✅ Connection renamed successfully.` + }); + router.refresh(); + } + }).finally(() => { + setIsLoading(false); + }); + }, [connectionId, router, toast]); + + return ( +
+
+ + ( + + Display Name + {/* @todo : refactor this description into a shared file */} + This is the {`connection's`} display name within Sourcebot. Examples: public-github, self-hosted-gitlab, gerrit-other, etc. + + + + + + )} + /> +
+ +
+ + +
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/connections/[id]/page.tsx b/packages/web/src/app/connections/[id]/page.tsx index 1c742e63..ef9d5432 100644 --- a/packages/web/src/app/connections/[id]/page.tsx +++ b/packages/web/src/app/connections/[id]/page.tsx @@ -1,5 +1,5 @@ +import { NotFound } from "@/app/components/notFound"; import { getCurrentUserOrg } from "@/auth"; -import { Header } from "../components/header" import { Breadcrumb, BreadcrumbItem, @@ -7,13 +7,30 @@ import { BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, - } from "@/components/ui/breadcrumb" -import { isServiceError } from "@/lib/utils"; +} from "@/components/ui/breadcrumb"; +import { Tabs, TabsContent } from "@/components/ui/tabs"; import { getConnection } from "@/data/connection"; -import { NotFound } from "@/app/components/notFound"; +import { isServiceError } from "@/lib/utils"; import { ConnectionIcon } from "../components/connectionIcon"; +import { Header } from "../components/header"; +import { DisplayNameSetting } from "./components/displayNameSetting"; +import { ConfigSetting } from "./components/configSetting"; +import { DeleteConnectionSetting } from "./components/deleteConnectionSetting"; +import { TabSwitcher } from "@/components/ui/tab-switcher"; -export default async function ConnectionManagementPage({ params }: { params: { id: string } }) { +interface ConnectionManagementPageProps { + params: { + id: string; + }, + searchParams: { + tab?: string; + } +} + +export default async function ConnectionManagementPage({ + params, + searchParams, +}: ConnectionManagementPageProps) { const orgId = await getCurrentUserOrg(); if (isServiceError(orgId)) { return ( @@ -32,7 +49,7 @@ export default async function ConnectionManagementPage({ params }: { params: { i /> ) } - + const connection = await getConnection(Number(params.id), orgId); if (!connection) { return ( @@ -43,9 +60,17 @@ export default async function ConnectionManagementPage({ params }: { params: { i ) } + const currentTab = searchParams.tab || "overview"; + return ( -
-
+ +
@@ -63,7 +88,36 @@ export default async function ConnectionManagementPage({ params }: { params: { i />

{connection.name}

+ -
+ +

Todo

+
+ + + + + + + ) -} \ No newline at end of file +} diff --git a/packages/web/src/app/connections/components/configEditor.tsx b/packages/web/src/app/connections/components/configEditor.tsx index 6fc32749..28f47f58 100644 --- a/packages/web/src/app/connections/components/configEditor.tsx +++ b/packages/web/src/app/connections/components/configEditor.tsx @@ -20,15 +20,16 @@ import { Separator } from "@/components/ui/separator"; import { Schema } from "ajv"; export type QuickActionFn = (previous: T) => T; +export type QuickAction = { + name: string; + fn: QuickActionFn; +}; interface ConfigEditorProps { value: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any onChange: (...event: any[]) => void; - actions: { - name: string; - fn: QuickActionFn; - }[], + actions: QuickAction[], schema: Schema; } diff --git a/packages/web/src/app/connections/components/connectionList/index.tsx b/packages/web/src/app/connections/components/connectionList/index.tsx index 861792dc..4c8131ae 100644 --- a/packages/web/src/app/connections/components/connectionList/index.tsx +++ b/packages/web/src/app/connections/components/connectionList/index.tsx @@ -1,6 +1,7 @@ import { Connection, ConnectionSyncStatus } from "@sourcebot/db" import { ConnectionListItem, SyncStatus } from "./connectionListItem"; import { cn } from "@/lib/utils"; +import { InfoCircledIcon } from "@radix-ui/react-icons"; const convertSyncStatus = (status: ConnectionSyncStatus): SyncStatus => { switch (status) { @@ -28,7 +29,8 @@ export const ConnectionList = ({ return (
- {connections + + {connections.length > 0 ? connections .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()) .map((connection) => ( - ))} + )) + : ( +
+ +

No connections

+
+ )}
) } \ No newline at end of file diff --git a/packages/web/src/app/connections/components/header.tsx b/packages/web/src/app/connections/components/header.tsx index 43028b61..79a24ee4 100644 --- a/packages/web/src/app/connections/components/header.tsx +++ b/packages/web/src/app/connections/components/header.tsx @@ -1,16 +1,22 @@ 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/connections/components/newConnectionCard.tsx b/packages/web/src/app/connections/components/newConnectionCard.tsx index 871fc0e0..fd6351fd 100644 --- a/packages/web/src/app/connections/components/newConnectionCard.tsx +++ b/packages/web/src/app/connections/components/newConnectionCard.tsx @@ -13,7 +13,7 @@ export const NewConnectionCard = ({ className, }: NewConnectionCardProps) => { return ( -
+

Connect to a Code Host

Create a connection to import repos from a code host.

diff --git a/packages/web/src/app/connections/new/[type]/components/connectionCreationForm/creationForm.tsx b/packages/web/src/app/connections/new/[type]/components/connectionCreationForm.tsx similarity index 80% rename from packages/web/src/app/connections/new/[type]/components/connectionCreationForm/creationForm.tsx rename to packages/web/src/app/connections/new/[type]/components/connectionCreationForm.tsx index 706ecda0..9f9f2fe4 100644 --- a/packages/web/src/app/connections/new/[type]/components/connectionCreationForm/creationForm.tsx +++ b/packages/web/src/app/connections/new/[type]/components/connectionCreationForm.tsx @@ -2,25 +2,22 @@ 'use client'; import { createConnection } from "@/actions"; +import { ConnectionIcon } from "@/app/connections/components/connectionIcon"; +import { createZodConnectionConfigValidator } from "@/app/connections/utils"; import { useToast } from "@/components/hooks/use-toast"; import { Button } from "@/components/ui/button"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { isServiceError } from "@/lib/utils"; import { zodResolver } from "@hookform/resolvers/zod"; -import Ajv, { Schema } from "ajv"; +import { Schema } from "ajv"; import { useRouter } from "next/navigation"; import { useCallback, useMemo } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import { ConfigEditor, QuickActionFn } from "../../../../components/configEditor"; -import { ConnectionIcon } from "@/app/connections/components/connectionIcon"; - -const ajv = new Ajv({ - validateFormats: false, -}); +import { ConfigEditor, QuickActionFn } from "../../../components/configEditor"; -interface CreationFormProps { +interface ConnectionCreationForm { type: 'github' | 'gitlab'; defaultValues: { name: string; @@ -34,45 +31,21 @@ interface CreationFormProps { }[], } -export default function CreationForm({ +export default function ConnectionCreationForm({ type, defaultValues, title, schema, quickActions, -}: CreationFormProps) { +}: ConnectionCreationForm) { const { toast } = useToast(); const router = useRouter(); const formSchema = useMemo(() => { - const validate = ajv.compile(schema); - return z.object({ name: z.string().min(1), - config: z - .string() - .superRefine((data, ctx) => { - const addIssue = (message: string) => { - return ctx.addIssue({ - code: "custom", - message: `Schema validation error: ${message}` - }); - } - - let parsed; - try { - parsed = JSON.parse(data); - } catch { - addIssue("Invalid JSON"); - return; - } - - const valid = validate(parsed); - if (!valid) { - addIssue(ajv.errorsText(validate.errors)); - } - }), + config: createZodConnectionConfigValidator(schema), }); }, [schema]); @@ -90,9 +63,10 @@ export default function CreationForm({ }); } else { toast({ - description: `✅ Connection created successfully!` + description: `✅ Connection created successfully.` }); router.push('/connections'); + router.refresh(); } }); }, [router, toast, type]); @@ -136,7 +110,8 @@ export default function CreationForm({ return ( Configuration - Code hosts are configured via a.... + {/* @todo : refactor this description into a shared file */} + Code hosts are configured via a....TODO value={value} diff --git a/packages/web/src/app/connections/new/[type]/components/connectionCreationForm/github.tsx b/packages/web/src/app/connections/new/[type]/components/connectionCreationForm/github.tsx deleted file mode 100644 index efdfbb2d..00000000 --- a/packages/web/src/app/connections/new/[type]/components/connectionCreationForm/github.tsx +++ /dev/null @@ -1,61 +0,0 @@ -'use client'; - -import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"; -import CreationForm from "./creationForm"; -import { githubSchema } from "@sourcebot/schemas/v3/github.schema"; - -const defaultConfig: GithubConnectionConfig = { - type: 'github', -} - -export const GitHubCreationForm = () => { - return ( - - type="github" - title="Create a GitHub connection" - defaultValues={{ - config: JSON.stringify(defaultConfig, null, 2), - name: 'my-github-connection', - }} - schema={githubSchema} - quickActions={[ - { - fn: (previous: GithubConnectionConfig) => ({ - ...previous, - orgs: [ - ...(previous.orgs ?? []), - "" - ] - }), - name: "Add an organization", - }, - { - fn: (previous: GithubConnectionConfig) => ({ - ...previous, - url: previous.url ?? "", - }), - name: "Set a custom url", - }, - { - fn: (previous: GithubConnectionConfig) => ({ - ...previous, - repos: [ - ...(previous.orgs ?? []), - "" - ] - }), - name: "Add a repo", - }, - { - fn: (previous: GithubConnectionConfig) => ({ - ...previous, - token: previous.token ?? { - secret: "", - }, - }), - name: "Add a secret", - } - ]} - /> - ) -} \ No newline at end of file diff --git a/packages/web/src/app/connections/new/[type]/components/connectionCreationForm/gitlab.tsx b/packages/web/src/app/connections/new/[type]/components/connectionCreationForm/gitlab.tsx deleted file mode 100644 index 08781354..00000000 --- a/packages/web/src/app/connections/new/[type]/components/connectionCreationForm/gitlab.tsx +++ /dev/null @@ -1,61 +0,0 @@ -'use client'; - -import { GitLabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type"; -import CreationForm from "./creationForm"; -import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema"; - -const defaultConfig: GitLabConnectionConfig = { - type: 'gitlab', -} - -export const GitLabCreationForm = () => { - return ( - - type="gitlab" - title="Create a GitLab connection" - defaultValues={{ - config: JSON.stringify(defaultConfig, null, 2), - name: 'my-gitlab-connection', - }} - schema={gitlabSchema} - quickActions={[ - { - fn: (previous: GitLabConnectionConfig) => ({ - ...previous, - groups: [ - ...previous.groups ?? [], - "" - ] - }), - name: "Add a group", - }, - { - fn: (previous: GitLabConnectionConfig) => ({ - ...previous, - url: previous.url ?? "", - }), - name: "Set a custom url", - }, - { - fn: (previous: GitLabConnectionConfig) => ({ - ...previous, - token: previous.token ?? { - secret: "", - }, - }), - name: "Add a secret", - }, - { - fn: (previous: GitLabConnectionConfig) => ({ - ...previous, - projects: [ - ...previous.projects ?? [], - "" - ] - }), - name: "Add a project", - } - ]} - /> - ) -} \ No newline at end of file diff --git a/packages/web/src/app/connections/new/[type]/components/connectionCreationForm/index.tsx b/packages/web/src/app/connections/new/[type]/components/connectionCreationForm/index.tsx deleted file mode 100644 index c5b5e08d..00000000 --- a/packages/web/src/app/connections/new/[type]/components/connectionCreationForm/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ - -export { GitHubCreationForm } from './github'; -export { GitLabCreationForm } from './gitlab'; \ No newline at end of file diff --git a/packages/web/src/app/connections/new/[type]/page.tsx b/packages/web/src/app/connections/new/[type]/page.tsx index 572dc529..6c16d113 100644 --- a/packages/web/src/app/connections/new/[type]/page.tsx +++ b/packages/web/src/app/connections/new/[type]/page.tsx @@ -1,10 +1,18 @@ -import { redirect } from "next/navigation"; -import { GitHubCreationForm, GitLabCreationForm } from "./components/connectionCreationForm"; +'use client'; -export default async function NewConnectionPage({ +import { githubQuickActions, gitlabQuickActions } from "../../quickActions"; +import ConnectionCreationForm from "./components/connectionCreationForm"; +import { GitLabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type"; +import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema"; +import { githubSchema } from "@sourcebot/schemas/v3/github.schema"; +import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"; +import { useRouter } from "next/navigation"; + +export default function NewConnectionPage({ params }: { params: { type: string } }) { const { type } = params; + const router = useRouter(); if (type === 'github') { return ; @@ -14,5 +22,43 @@ export default async function NewConnectionPage({ return ; } - redirect('/connections'); + router.push('/connections'); +} + +const GitLabCreationForm = () => { + const defaultConfig: GitLabConnectionConfig = { + type: 'gitlab', + } + + return ( + + type="gitlab" + title="Create a GitLab connection" + defaultValues={{ + config: JSON.stringify(defaultConfig, null, 2), + name: 'my-gitlab-connection', + }} + schema={gitlabSchema} + quickActions={gitlabQuickActions} + /> + ) +} + +const GitHubCreationForm = () => { + const defaultConfig: GithubConnectionConfig = { + type: 'github', + } + + return ( + + type="github" + title="Create a GitHub connection" + defaultValues={{ + config: JSON.stringify(defaultConfig, null, 2), + name: 'my-github-connection', + }} + schema={githubSchema} + quickActions={githubQuickActions} + /> + ) } \ No newline at end of file diff --git a/packages/web/src/app/connections/quickActions.ts b/packages/web/src/app/connections/quickActions.ts new file mode 100644 index 00000000..5051dd07 --- /dev/null +++ b/packages/web/src/app/connections/quickActions.ts @@ -0,0 +1,82 @@ +import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type" +import { GitLabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type"; +import { QuickAction } from "./components/configEditor"; + +export const githubQuickActions: QuickAction[] = [ + { + fn: (previous: GithubConnectionConfig) => ({ + ...previous, + orgs: [ + ...(previous.orgs ?? []), + "" + ] + }), + name: "Add an organization", + }, + { + fn: (previous: GithubConnectionConfig) => ({ + ...previous, + url: previous.url ?? "", + }), + name: "Set a custom url", + }, + { + fn: (previous: GithubConnectionConfig) => ({ + ...previous, + repos: [ + ...(previous.repos ?? []), + "" + ] + }), + name: "Add a repo", + }, + { + fn: (previous: GithubConnectionConfig) => ({ + ...previous, + token: previous.token ?? { + secret: "", + }, + }), + name: "Add a secret", + } +]; + +export const gitlabQuickActions: QuickAction[] = [ + { + fn: (previous: GitLabConnectionConfig) => ({ + ...previous, + groups: [ + ...previous.groups ?? [], + "" + ] + }), + name: "Add a group", + }, + { + fn: (previous: GitLabConnectionConfig) => ({ + ...previous, + url: previous.url ?? "", + }), + name: "Set a custom url", + }, + { + fn: (previous: GitLabConnectionConfig) => ({ + ...previous, + token: previous.token ?? { + secret: "", + }, + }), + name: "Add a secret", + }, + { + fn: (previous: GitLabConnectionConfig) => ({ + ...previous, + projects: [ + ...previous.projects ?? [], + "" + ] + }), + name: "Add a project", + } +] + diff --git a/packages/web/src/app/connections/utils.ts b/packages/web/src/app/connections/utils.ts new file mode 100644 index 00000000..2fbe552d --- /dev/null +++ b/packages/web/src/app/connections/utils.ts @@ -0,0 +1,33 @@ +import Ajv, { Schema } from "ajv"; +import { z } from "zod"; + +export const createZodConnectionConfigValidator = (jsonSchema: Schema) => { + const ajv = new Ajv({ + validateFormats: false, + }); + const validate = ajv.compile(jsonSchema); + + return z + .string() + .superRefine((data, ctx) => { + const addIssue = (message: string) => { + return ctx.addIssue({ + code: "custom", + message: `Schema validation error: ${message}` + }); + } + + let parsed; + try { + parsed = JSON.parse(data); + } catch { + addIssue("Invalid JSON"); + return; + } + + const valid = validate(parsed); + if (!valid) { + addIssue(ajv.errorsText(validate.errors)); + } + }); +} \ No newline at end of file diff --git a/packages/web/src/components/ui/alert-dialog.tsx b/packages/web/src/components/ui/alert-dialog.tsx new file mode 100644 index 00000000..25e7b474 --- /dev/null +++ b/packages/web/src/components/ui/alert-dialog.tsx @@ -0,0 +1,141 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/packages/web/src/components/ui/tab-switcher.tsx b/packages/web/src/components/ui/tab-switcher.tsx new file mode 100644 index 00000000..0749e77f --- /dev/null +++ b/packages/web/src/components/ui/tab-switcher.tsx @@ -0,0 +1,53 @@ +"use client" + +import { useRouter } from "next/navigation" +import { TabsList, TabsTrigger } from "@/components/ui/tabs" + +interface TabSwitcherProps { + tabs: { value: string; label: string }[] + currentTab: string + className?: string +} + +export function TabSwitcher({ tabs, currentTab, className }: TabSwitcherProps) { + const router = useRouter() + + const handleTabChange = (value: string) => { + router.push(`?tab=${value}`, { scroll: false }) + } + + return ( + + {tabs.map((tab) => ( + handleTabChange(tab.value)} + data-state={currentTab === tab.value ? "active" : ""} + > + {tab.label} + + ))} + + ) +} + +interface LowProfileTabsTrigger { + value: string + children: React.ReactNode + onClick?: () => void + } + + export function LowProfileTabsTrigger({ value, children, onClick }: LowProfileTabsTrigger) { + return ( + + {children} + + ) + } + + \ No newline at end of file diff --git a/packages/web/src/components/ui/tabs.tsx b/packages/web/src/components/ui/tabs.tsx new file mode 100644 index 00000000..26eb1091 --- /dev/null +++ b/packages/web/src/components/ui/tabs.tsx @@ -0,0 +1,55 @@ +"use client" + +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/lib/utils" + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/packages/web/src/lib/errorCodes.ts b/packages/web/src/lib/errorCodes.ts index ba89333b..0e4e1c25 100644 --- a/packages/web/src/lib/errorCodes.ts +++ b/packages/web/src/lib/errorCodes.ts @@ -7,4 +7,5 @@ export enum ErrorCode { INVALID_REQUEST_BODY = 'INVALID_REQUEST_BODY', NOT_AUTHENTICATED = 'NOT_AUTHENTICATED', NOT_FOUND = 'NOT_FOUND', + CONNECTION_SYNC_ALREADY_SCHEDULED = 'CONNECTION_SYNC_ALREADY_SCHEDULED', } diff --git a/packages/web/src/schemas/github.schema.ts b/packages/web/src/schemas/github.schema.ts deleted file mode 100644 index 0c88f81b..00000000 --- a/packages/web/src/schemas/github.schema.ts +++ /dev/null @@ -1,205 +0,0 @@ -// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! -const schema = { - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "title": "GitHubConfig", - "properties": { - "type": { - "const": "github", - "description": "GitHub Configuration" - }, - "token": { - "description": "A Personal Access Token (PAT).", - "examples": [ - "secret-token", - { - "env": "ENV_VAR_CONTAINING_TOKEN" - } - ], - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "env": { - "type": "string", - "description": "The name of the environment variable that contains the token." - } - }, - "required": [ - "env" - ], - "additionalProperties": false - } - ] - }, - "url": { - "type": "string", - "format": "url", - "default": "https://github.com", - "description": "The URL of the GitHub host. Defaults to https://github.com", - "examples": [ - "https://github.com", - "https://github.example.com" - ], - "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" - }, - "users": { - "type": "array", - "items": { - "type": "string", - "pattern": "^[\\w.-]+$" - }, - "examples": [ - [ - "torvalds", - "DHH" - ] - ], - "description": "List of users to sync with. All repositories that the user owns will be synced, unless explicitly defined in the `exclude` property." - }, - "orgs": { - "type": "array", - "items": { - "type": "string", - "pattern": "^[\\w.-]+$" - }, - "examples": [ - [ - "my-org-name" - ], - [ - "sourcebot-dev", - "commaai" - ] - ], - "description": "List of organizations to sync with. All repositories in the organization visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property." - }, - "repos": { - "type": "array", - "items": { - "type": "string", - "pattern": "^[\\w.-]+\\/[\\w.-]+$" - }, - "description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{repoName}' or '{userName}/{repoName}'." - }, - "topics": { - "type": "array", - "items": { - "type": "string" - }, - "minItems": 1, - "description": "List of repository topics to include when syncing. Only repositories that match at least one of the provided `topics` will be synced. If not specified, all repositories will be synced, unless explicitly defined in the `exclude` property. Glob patterns are supported.", - "examples": [ - [ - "docs", - "core" - ] - ] - }, - "tenantId": { - "type": "number", - "description": "@nocheckin" - }, - "exclude": { - "type": "object", - "properties": { - "forks": { - "type": "boolean", - "default": false, - "description": "Exclude forked repositories from syncing." - }, - "archived": { - "type": "boolean", - "default": false, - "description": "Exclude archived repositories from syncing." - }, - "repos": { - "type": "array", - "items": { - "type": "string" - }, - "default": [], - "description": "List of individual repositories to exclude from syncing. Glob patterns are supported." - }, - "topics": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of repository topics to exclude when syncing. Repositories that match one of the provided `topics` will be excluded from syncing. Glob patterns are supported.", - "examples": [ - [ - "tests", - "ci" - ] - ] - }, - "size": { - "type": "object", - "description": "Exclude repositories based on their disk usage. Note: the disk usage is calculated by GitHub and may not reflect the actual disk usage when cloned.", - "properties": { - "min": { - "type": "integer", - "description": "Minimum repository size (in bytes) to sync (inclusive). Repositories less than this size will be excluded from syncing." - }, - "max": { - "type": "integer", - "description": "Maximum repository size (in bytes) to sync (inclusive). Repositories greater than this size will be excluded from syncing." - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "revisions": { - "type": "object", - "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed.", - "properties": { - "branches": { - "type": "array", - "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported.", - "items": { - "type": "string" - }, - "examples": [ - [ - "main", - "release/*" - ], - [ - "**" - ] - ], - "default": [] - }, - "tags": { - "type": "array", - "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported.", - "items": { - "type": "string" - }, - "examples": [ - [ - "latest", - "v2.*.*" - ], - [ - "**" - ] - ], - "default": [] - } - }, - "additionalProperties": false - } - }, - "required": [ - "type" - ], - "additionalProperties": false -} as const; -export { schema as githubSchema }; \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 3138bb19..b49e7b30 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1400,6 +1400,18 @@ resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.1.tgz#fc169732d755c7fbad33ba8d0cd7fd10c90dc8e3" integrity sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA== +"@radix-ui/react-alert-dialog@^1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.5.tgz#d937512a727d8b7afa8959d43dbd7e557d52a1eb" + integrity sha512-1Y2sI17QzSZP58RjGtrklfSGIf3AF7U/HkD3aAcAnhOUJrm7+7GG1wRDFaUlSe0nW5B/t4mYd/+7RNbP2Wexug== + dependencies: + "@radix-ui/primitive" "1.1.1" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-dialog" "1.1.5" + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-slot" "1.1.1" + "@radix-ui/react-arrow@1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz#744f388182d360b86285217e43b6c63633f39e7a" @@ -1427,6 +1439,16 @@ "@radix-ui/react-primitive" "2.0.0" "@radix-ui/react-slot" "1.1.0" +"@radix-ui/react-collection@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.1.1.tgz#be2c7e01d3508e6d4b6d838f492e7d182f17d3b0" + integrity sha512-LwT3pSho9Dljg+wY2KN2mrrh6y3qELfftINERIzBUO9e0N+t0oMTyn3k9iv+ZqgrwGkRnLpNJrsMv9BZlt2yuA== + dependencies: + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-slot" "1.1.1" + "@radix-ui/react-compose-refs@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz#7ed868b66946aa6030e580b1ffca386dd4d21989" @@ -1482,6 +1504,26 @@ aria-hidden "^1.1.1" react-remove-scroll "2.5.5" +"@radix-ui/react-dialog@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.1.5.tgz#1bb2880e6b0ef9d9d0d9f440e1414c94bbacb55b" + integrity sha512-LaO3e5h/NOEL4OfXjxD43k9Dx+vn+8n+PCFt6uhX/BADFflllyv3WJG6rgvvSVBxpTch938Qq/LGc2MMxipXPw== + dependencies: + "@radix-ui/primitive" "1.1.1" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-dismissable-layer" "1.1.4" + "@radix-ui/react-focus-guards" "1.1.1" + "@radix-ui/react-focus-scope" "1.1.1" + "@radix-ui/react-id" "1.1.0" + "@radix-ui/react-portal" "1.1.3" + "@radix-ui/react-presence" "1.1.2" + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-slot" "1.1.1" + "@radix-ui/react-use-controllable-state" "1.1.0" + aria-hidden "^1.2.4" + react-remove-scroll "^2.6.2" + "@radix-ui/react-dialog@^1.1.4": version "1.1.4" resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.1.4.tgz#d68e977acfcc0d044b9dab47b6dd2c179d2b3191" @@ -1541,6 +1583,17 @@ "@radix-ui/react-use-callback-ref" "1.1.0" "@radix-ui/react-use-escape-keydown" "1.1.0" +"@radix-ui/react-dismissable-layer@1.1.4": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.4.tgz#6e31ad92e7d9e77548001fd8c04f8561300c02a9" + integrity sha512-XDUI0IVYVSwjMXxM6P4Dfti7AH+Y4oS/TB+sglZ/EXc7cqLwGAmp1NlMrcUjj7ks6R5WTZuWKv44FBbLpwU3sA== + dependencies: + "@radix-ui/primitive" "1.1.1" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-use-callback-ref" "1.1.0" + "@radix-ui/react-use-escape-keydown" "1.1.0" + "@radix-ui/react-dropdown-menu@^2.1.1": version "2.1.2" resolved "https://registry.yarnpkg.com/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.2.tgz#acc49577130e3c875ef0133bd1e271ea3392d924" @@ -1767,6 +1820,21 @@ "@radix-ui/react-use-callback-ref" "1.1.0" "@radix-ui/react-use-controllable-state" "1.1.0" +"@radix-ui/react-roving-focus@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.1.tgz#3b3abb1e03646937f28d9ab25e96343667ca6520" + integrity sha512-QE1RoxPGJ/Nm8Qmk0PxP8ojmoaS67i0s7hVssS7KuI2FQoc/uzVlZsqKfQvxPE6D8hICCPHJ4D88zNhT3OOmkw== + dependencies: + "@radix-ui/primitive" "1.1.1" + "@radix-ui/react-collection" "1.1.1" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-direction" "1.1.0" + "@radix-ui/react-id" "1.1.0" + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-use-callback-ref" "1.1.0" + "@radix-ui/react-use-controllable-state" "1.1.0" + "@radix-ui/react-scroll-area@^1.1.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.0.tgz#d09fd693728b09c50145935bec6f91efc2661729" @@ -1811,6 +1879,20 @@ dependencies: "@radix-ui/react-compose-refs" "1.1.1" +"@radix-ui/react-tabs@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.1.2.tgz#a72da059593cba30fccb30a226d63af686b32854" + integrity sha512-9u/tQJMcC2aGq7KXpGivMm1mgq7oRJKXphDwdypPd/j21j/2znamPU8WkXgnhUaTrSFNIt8XhOyCAupg8/GbwQ== + dependencies: + "@radix-ui/primitive" "1.1.1" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-direction" "1.1.0" + "@radix-ui/react-id" "1.1.0" + "@radix-ui/react-presence" "1.1.2" + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-roving-focus" "1.1.1" + "@radix-ui/react-use-controllable-state" "1.1.0" + "@radix-ui/react-toast@^1.2.2": version "1.2.2" resolved "https://registry.yarnpkg.com/@radix-ui/react-toast/-/react-toast-1.2.2.tgz#fdd8ed0b80f47d6631dfd90278fee6debc06bf33" @@ -2718,7 +2800,7 @@ argparse@^2.0.1: resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== -aria-hidden@^1.1.1: +aria-hidden@^1.1.1, aria-hidden@^1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/aria-hidden/-/aria-hidden-1.2.4.tgz#b78e383fdbc04d05762c78b4a25a501e736c4522" integrity sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A== @@ -6101,6 +6183,17 @@ react-remove-scroll@^2.6.1: use-callback-ref "^1.3.3" use-sidecar "^1.1.2" +react-remove-scroll@^2.6.2: + version "2.6.3" + resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz#df02cde56d5f2731e058531f8ffd7f9adec91ac2" + integrity sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ== + dependencies: + react-remove-scroll-bar "^2.3.7" + react-style-singleton "^2.2.3" + tslib "^2.1.0" + use-callback-ref "^1.3.3" + use-sidecar "^1.1.3" + react-resizable-panels@^2.1.1: version "2.1.4" resolved "https://registry.yarnpkg.com/react-resizable-panels/-/react-resizable-panels-2.1.4.tgz#ae1803a916ba759e483336c7bd49830f1b0cd16f" @@ -6115,7 +6208,7 @@ react-style-singleton@^2.2.1: invariant "^2.2.4" tslib "^2.0.0" -react-style-singleton@^2.2.2: +react-style-singleton@^2.2.2, react-style-singleton@^2.2.3: version "2.2.3" resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz#4265608be69a4d70cfe3047f2c6c88b2c3ace388" integrity sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ== @@ -7194,6 +7287,14 @@ use-sidecar@^1.1.2: detect-node-es "^1.1.0" tslib "^2.0.0" +use-sidecar@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.1.3.tgz#10e7fd897d130b896e2c546c63a5e8233d00efdb" + integrity sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ== + dependencies: + detect-node-es "^1.1.0" + tslib "^2.0.0" + usehooks-ts@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/usehooks-ts/-/usehooks-ts-3.1.0.tgz#156119f36efc85f1b1952616c02580f140950eca" From a7a0da69cd311f0937cbf5c3568bc188f2ba2f22 Mon Sep 17 00:00:00 2001 From: bkellam Date: Mon, 3 Feb 2025 17:01:08 -0500 Subject: [PATCH 11/12] Add repository image url to schema --- packages/backend/src/connectionManager.ts | 1 + packages/backend/src/github.ts | 3 ++ .../migration.sql | 2 ++ packages/db/prisma/schema.prisma | 32 +++++++++---------- packages/web/next.config.mjs | 9 ++++++ .../[id]/components/configSetting.tsx | 1 + .../web/src/app/connections/[id]/page.tsx | 19 +++++++++-- .../components/connectionList/index.tsx | 1 - packages/web/src/data/connection.ts | 16 ++++++++++ 9 files changed, 65 insertions(+), 19 deletions(-) create mode 100644 packages/db/prisma/migrations/20250203215003_add_repo_image/migration.sql diff --git a/packages/backend/src/connectionManager.ts b/packages/backend/src/connectionManager.ts index 15d6e73c..77755fb9 100644 --- a/packages/backend/src/connectionManager.ts +++ b/packages/backend/src/connectionManager.ts @@ -96,6 +96,7 @@ export class ConnectionManager implements IConnectionManager { external_codeHostType: 'github', external_codeHostUrl: hostUrl, cloneUrl: cloneUrl.toString(), + imageUrl: repo.owner.avatar_url, name: repoName, isFork: repo.fork, isArchived: !!repo.archived, diff --git a/packages/backend/src/github.ts b/packages/backend/src/github.ts index bef5e6bc..10fe6f18 100644 --- a/packages/backend/src/github.ts +++ b/packages/backend/src/github.ts @@ -24,6 +24,9 @@ export type OctokitRepository = { topics?: string[], // @note: this is expressed in kilobytes. size?: number, + owner: { + avatar_url: string, + } } export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, orgId: number, db: PrismaClient, signal: AbortSignal) => { diff --git a/packages/db/prisma/migrations/20250203215003_add_repo_image/migration.sql b/packages/db/prisma/migrations/20250203215003_add_repo_image/migration.sql new file mode 100644 index 00000000..c75047a4 --- /dev/null +++ b/packages/db/prisma/migrations/20250203215003_add_repo_image/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Repo" ADD COLUMN "imageUrl" TEXT; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 38cf5b0b..156adccc 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -27,17 +27,17 @@ enum ConnectionSyncStatus { } model Repo { - id Int @id @default(autoincrement()) - name String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - indexedAt DateTime? - isFork Boolean - isArchived Boolean - metadata Json - cloneUrl String - connections RepoToConnection[] - + id Int @id @default(autoincrement()) + name String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + indexedAt DateTime? + isFork Boolean + isArchived Boolean + metadata Json + cloneUrl String + connections RepoToConnection[] + imageUrl String? repoIndexingStatus RepoIndexingStatus @default(NEW) // The id of the repo in the external service @@ -116,12 +116,12 @@ model UserToOrg { } model Secret { - orgId Int - key String - encryptedValue String - iv String + orgId Int + key String + encryptedValue String + iv String - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) diff --git a/packages/web/next.config.mjs b/packages/web/next.config.mjs index 585bb245..a560a21f 100644 --- a/packages/web/next.config.mjs +++ b/packages/web/next.config.mjs @@ -22,6 +22,15 @@ const nextConfig = { // This is required to support PostHog trailing slash API requests skipTrailingSlashRedirect: true, + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'avatars.githubusercontent.com', + } + ] + } + // @nocheckin: This was interfering with the the `matcher` regex in middleware.ts, // causing regular expressions parsing errors when making a request. It's unclear // why exactly this was happening, but it's likely due to a bad replacement happening diff --git a/packages/web/src/app/connections/[id]/components/configSetting.tsx b/packages/web/src/app/connections/[id]/components/configSetting.tsx index 278b7775..7b245130 100644 --- a/packages/web/src/app/connections/[id]/components/configSetting.tsx +++ b/packages/web/src/app/connections/[id]/components/configSetting.tsx @@ -87,6 +87,7 @@ function ConfigSettingInternal({ toast({ description: `✅ Connection config updated successfully.` }); + router.push(`?tab=overview`); router.refresh(); } }) diff --git a/packages/web/src/app/connections/[id]/page.tsx b/packages/web/src/app/connections/[id]/page.tsx index ef9d5432..4a54f524 100644 --- a/packages/web/src/app/connections/[id]/page.tsx +++ b/packages/web/src/app/connections/[id]/page.tsx @@ -9,7 +9,7 @@ import { BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; import { Tabs, TabsContent } from "@/components/ui/tabs"; -import { getConnection } from "@/data/connection"; +import { getConnection, getLinkedRepos } from "@/data/connection"; import { isServiceError } from "@/lib/utils"; import { ConnectionIcon } from "../components/connectionIcon"; import { Header } from "../components/header"; @@ -17,6 +17,7 @@ import { DisplayNameSetting } from "./components/displayNameSetting"; import { ConfigSetting } from "./components/configSetting"; import { DeleteConnectionSetting } from "./components/deleteConnectionSetting"; import { TabSwitcher } from "@/components/ui/tab-switcher"; +import Image from "next/image"; interface ConnectionManagementPageProps { params: { @@ -60,6 +61,8 @@ export default async function ConnectionManagementPage({ ) } + const linkedRepos = await getLinkedRepos(connectionId, orgId); + const currentTab = searchParams.tab || "overview"; return ( @@ -98,7 +101,19 @@ export default async function ConnectionManagementPage({ /> -

Todo

+

Linked Repositories

+ {linkedRepos.map(({ repo }, index) => ( +
+ {repo.name} +

{repo.name} | {repo.repoIndexingStatus}

+
+ ))}
- {connections.length > 0 ? connections .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()) .map((connection) => ( diff --git a/packages/web/src/data/connection.ts b/packages/web/src/data/connection.ts index eef98425..b49d8e09 100644 --- a/packages/web/src/data/connection.ts +++ b/packages/web/src/data/connection.ts @@ -10,4 +10,20 @@ export const getConnection = async (connectionId: number, orgId: number) => { }); return connection; +} + +export const getLinkedRepos = async (connectionId: number, orgId: number) => { + const linkedRepos = await prisma.repoToConnection.findMany({ + where: { + connection: { + id: connectionId, + orgId: orgId, + } + }, + include: { + repo: true, + } + }); + + return linkedRepos; } \ No newline at end of file From 3e33c97edc7a0a24587ef337606a1d9ceaeb8d01 Mon Sep 17 00:00:00 2001 From: bkellam Date: Tue, 4 Feb 2025 11:53:19 -0500 Subject: [PATCH 12/12] Further wip --- packages/backend/src/connectionManager.ts | 111 ++++++++++-------- .../[id]/components/displayNameSetting.tsx | 1 - .../[id]/components/repoListItem.tsx | 82 +++++++++++++ .../web/src/app/connections/[id]/page.tsx | 62 +++++++--- .../connectionList/connectionListItem.tsx | 75 ++++++------ .../components/connectionList/index.tsx | 19 +-- .../app/connections/components/statusIcon.tsx | 27 +++++ 7 files changed, 252 insertions(+), 125 deletions(-) create mode 100644 packages/web/src/app/connections/[id]/components/repoListItem.tsx create mode 100644 packages/web/src/app/connections/components/statusIcon.tsx diff --git a/packages/backend/src/connectionManager.ts b/packages/backend/src/connectionManager.ts index 77755fb9..3fe5c0a7 100644 --- a/packages/backend/src/connectionManager.ts +++ b/packages/backend/src/connectionManager.ts @@ -80,59 +80,68 @@ export class ConnectionManager implements IConnectionManager { const abortController = new AbortController(); type RepoData = WithRequired; - const repoData: RepoData[] = await (async () => { - switch (config.type) { - case 'github': { - const gitHubRepos = await getGitHubReposFromConfig(config, orgId, this.db, abortController.signal); - const hostUrl = config.url ?? 'https://github.com'; - const hostname = config.url ? new URL(config.url).hostname : 'github.com'; - - return gitHubRepos.map((repo) => { - const repoName = `${hostname}/${repo.full_name}`; - const cloneUrl = new URL(repo.clone_url!); - - const record: RepoData = { - external_id: repo.id.toString(), - external_codeHostType: 'github', - external_codeHostUrl: hostUrl, - cloneUrl: cloneUrl.toString(), - imageUrl: repo.owner.avatar_url, - name: repoName, - isFork: repo.fork, - isArchived: !!repo.archived, - org: { - connect: { - id: orgId, + const repoData: RepoData[] = ( + await (async () => { + switch (config.type) { + case 'github': { + const gitHubRepos = await getGitHubReposFromConfig(config, orgId, this.db, abortController.signal); + const hostUrl = config.url ?? 'https://github.com'; + const hostname = config.url ? new URL(config.url).hostname : 'github.com'; + + return gitHubRepos.map((repo) => { + const repoName = `${hostname}/${repo.full_name}`; + const cloneUrl = new URL(repo.clone_url!); + + const record: RepoData = { + external_id: repo.id.toString(), + external_codeHostType: 'github', + external_codeHostUrl: hostUrl, + cloneUrl: cloneUrl.toString(), + imageUrl: repo.owner.avatar_url, + name: repoName, + isFork: repo.fork, + isArchived: !!repo.archived, + org: { + connect: { + id: orgId, + }, }, - }, - connections: { - create: { - connectionId: job.data.connectionId, - } - }, - metadata: { - 'zoekt.web-url-type': 'github', - 'zoekt.web-url': repo.html_url, - 'zoekt.name': repoName, - 'zoekt.github-stars': (repo.stargazers_count ?? 0).toString(), - 'zoekt.github-watchers': (repo.watchers_count ?? 0).toString(), - 'zoekt.github-subscribers': (repo.subscribers_count ?? 0).toString(), - 'zoekt.github-forks': (repo.forks_count ?? 0).toString(), - 'zoekt.archived': marshalBool(repo.archived), - 'zoekt.fork': marshalBool(repo.fork), - 'zoekt.public': marshalBool(repo.private === false) - }, - }; - - return record; - }) - } - case 'gitlab': { - // @todo - return []; + connections: { + create: { + connectionId: job.data.connectionId, + } + }, + metadata: { + 'zoekt.web-url-type': 'github', + 'zoekt.web-url': repo.html_url, + 'zoekt.name': repoName, + 'zoekt.github-stars': (repo.stargazers_count ?? 0).toString(), + 'zoekt.github-watchers': (repo.watchers_count ?? 0).toString(), + 'zoekt.github-subscribers': (repo.subscribers_count ?? 0).toString(), + 'zoekt.github-forks': (repo.forks_count ?? 0).toString(), + 'zoekt.archived': marshalBool(repo.archived), + 'zoekt.fork': marshalBool(repo.fork), + 'zoekt.public': marshalBool(repo.private === false) + }, + }; + + return record; + }) + } + case 'gitlab': { + // @todo + return []; + } } - } - })(); + })() + ) + // Filter out any duplicates by external_id and external_codeHostUrl. + .filter((repo, index, self) => { + return index === self.findIndex(r => + r.external_id === repo.external_id && + r.external_codeHostUrl === repo.external_codeHostUrl + ); + }) // @note: to handle orphaned Repos we delete all RepoToConnection records for this connection, // and then recreate them when we upsert the repos. For example, if a repo is no-longer diff --git a/packages/web/src/app/connections/[id]/components/displayNameSetting.tsx b/packages/web/src/app/connections/[id]/components/displayNameSetting.tsx index 91613d91..26918d42 100644 --- a/packages/web/src/app/connections/[id]/components/displayNameSetting.tsx +++ b/packages/web/src/app/connections/[id]/components/displayNameSetting.tsx @@ -73,7 +73,6 @@ export const DisplayNameSetting = ({ diff --git a/packages/web/src/app/connections/[id]/components/repoListItem.tsx b/packages/web/src/app/connections/[id]/components/repoListItem.tsx new file mode 100644 index 00000000..731454c7 --- /dev/null +++ b/packages/web/src/app/connections/[id]/components/repoListItem.tsx @@ -0,0 +1,82 @@ +import { getDisplayTime } from "@/lib/utils"; +import Image from "next/image"; +import { StatusIcon } from "../../components/statusIcon"; +import { RepoIndexingStatus } from "@sourcebot/db"; +import { useMemo } from "react"; + + +interface RepoListItemProps { + name: string; + status: RepoIndexingStatus; + imageUrl?: string; + indexedAt?: Date; +} + +export const RepoListItem = ({ + imageUrl, + name, + indexedAt, + status, +}: RepoListItemProps) => { + const statusDisplayName = useMemo(() => { + switch (status) { + case RepoIndexingStatus.NEW: + return 'Waiting...'; + case RepoIndexingStatus.IN_INDEX_QUEUE: + case RepoIndexingStatus.INDEXING: + return 'Indexing...'; + case RepoIndexingStatus.INDEXED: + return 'Indexed'; + case RepoIndexingStatus.FAILED: + return 'Index failed'; + } + }, [status]); + + return ( +
+
+ {name} +

{name}

+
+
+ +

+ {statusDisplayName} + { + ( + status === RepoIndexingStatus.INDEXED || + status === RepoIndexingStatus.FAILED + ) && indexedAt && ( + {` ${getDisplayTime(indexedAt)}`} + ) + } +

+
+
+ ) +} + +const convertIndexingStatus = (status: RepoIndexingStatus) => { + switch (status) { + case RepoIndexingStatus.NEW: + return 'waiting'; + case RepoIndexingStatus.IN_INDEX_QUEUE: + case RepoIndexingStatus.INDEXING: + return 'running'; + case RepoIndexingStatus.INDEXED: + return 'succeeded'; + case RepoIndexingStatus.FAILED: + return 'failed'; + } +} \ No newline at end of file diff --git a/packages/web/src/app/connections/[id]/page.tsx b/packages/web/src/app/connections/[id]/page.tsx index 4a54f524..74213a51 100644 --- a/packages/web/src/app/connections/[id]/page.tsx +++ b/packages/web/src/app/connections/[id]/page.tsx @@ -8,16 +8,17 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { TabSwitcher } from "@/components/ui/tab-switcher"; import { Tabs, TabsContent } from "@/components/ui/tabs"; import { getConnection, getLinkedRepos } from "@/data/connection"; import { isServiceError } from "@/lib/utils"; import { ConnectionIcon } from "../components/connectionIcon"; import { Header } from "../components/header"; -import { DisplayNameSetting } from "./components/displayNameSetting"; import { ConfigSetting } from "./components/configSetting"; import { DeleteConnectionSetting } from "./components/deleteConnectionSetting"; -import { TabSwitcher } from "@/components/ui/tab-switcher"; -import Image from "next/image"; +import { DisplayNameSetting } from "./components/displayNameSetting"; +import { RepoListItem } from "./components/repoListItem"; interface ConnectionManagementPageProps { params: { @@ -101,19 +102,50 @@ export default async function ConnectionManagementPage({ /> -

Linked Repositories

- {linkedRepos.map(({ repo }, index) => ( -
- {repo.name} -

{repo.name} | {repo.repoIndexingStatus}

+

Overview

+
+
+
+

Connection Type

+

{connection.connectionType}

+
+
+

Last Synced At

+

{connection.syncedAt ? new Date(connection.syncedAt).toLocaleDateString() : 'never'}

+
+
+

Linked Repositories

+

{linkedRepos.length}

+
+
+

Status

+

{connection.syncStatus}

+
+
+
+

Linked Repositories

+ +
+ {linkedRepos + .sort((a, b) => { + const aIndexedAt = a.repo.indexedAt ?? new Date(); + const bIndexedAt = b.repo.indexedAt ?? new Date(); + + return bIndexedAt.getTime() - aIndexedAt.getTime(); + }) + .map(({ repo }) => ( + + ))}
- ))} +
{ + switch (status) { + case ConnectionSyncStatus.SYNC_NEEDED: + return 'waiting'; + case ConnectionSyncStatus.IN_SYNC_QUEUE: + case ConnectionSyncStatus.SYNCING: + return 'running'; + case ConnectionSyncStatus.SYNCED: + return 'succeeded'; + case ConnectionSyncStatus.FAILED: + return 'failed'; + } +} interface ConnectionListItemProps { id: string; name: string; type: string; - status: SyncStatus; + status: ConnectionSyncStatus; editedAt: Date; syncedAt?: Date; } @@ -30,13 +40,14 @@ export const ConnectionListItem = ({ }: ConnectionListItemProps) => { const statusDisplayName = useMemo(() => { switch (status) { - case 'waiting': + case ConnectionSyncStatus.SYNC_NEEDED: return 'Waiting...'; - case 'syncing': + case ConnectionSyncStatus.IN_SYNC_QUEUE: + case ConnectionSyncStatus.SYNCING: return 'Syncing...'; - case 'synced': + case ConnectionSyncStatus.SYNCED: return 'Synced'; - case 'failed': + case ConnectionSyncStatus.FAILED: return 'Sync failed'; } }, [status]); @@ -44,7 +55,7 @@ export const ConnectionListItem = ({ return (

{name}

-
- {`Edited ${getDisplayTime(editedAt)}`} - {''} -
+ {`Edited ${getDisplayTime(editedAt)}`}
- +

{statusDisplayName} { - (status === 'synced' || status === 'failed') && syncedAt && ( + ( + status === ConnectionSyncStatus.SYNCED || + status === ConnectionSyncStatus.FAILED + ) && syncedAt && ( {` ${getDisplayTime(syncedAt)}`} ) } @@ -85,22 +95,3 @@ export const ConnectionListItem = ({ ) } - -const StatusIcon = ({ - status, - className, -}: { status: SyncStatus, className?: string }) => { - const Icon = useMemo(() => { - switch (status) { - case 'waiting': - case 'syncing': - return ; - case 'synced': - return ; - case 'failed': - return ; - } - }, [className, status]); - - return Icon; -} \ No newline at end of file diff --git a/packages/web/src/app/connections/components/connectionList/index.tsx b/packages/web/src/app/connections/components/connectionList/index.tsx index adb671eb..841c833d 100644 --- a/packages/web/src/app/connections/components/connectionList/index.tsx +++ b/packages/web/src/app/connections/components/connectionList/index.tsx @@ -1,21 +1,8 @@ -import { Connection, ConnectionSyncStatus } from "@sourcebot/db" -import { ConnectionListItem, SyncStatus } from "./connectionListItem"; +import { Connection } from "@sourcebot/db" +import { ConnectionListItem } from "./connectionListItem"; import { cn } from "@/lib/utils"; import { InfoCircledIcon } from "@radix-ui/react-icons"; -const convertSyncStatus = (status: ConnectionSyncStatus): SyncStatus => { - switch (status) { - case ConnectionSyncStatus.SYNC_NEEDED: - return 'waiting'; - case ConnectionSyncStatus.IN_SYNC_QUEUE: - case ConnectionSyncStatus.SYNCING: - return 'syncing'; - case ConnectionSyncStatus.SYNCED: - return 'synced'; - case ConnectionSyncStatus.FAILED: - return 'failed'; - } -} interface ConnectionListProps { connections: Connection[]; @@ -37,7 +24,7 @@ export const ConnectionList = ({ id={connection.id.toString()} name={connection.name} type={connection.connectionType} - status={convertSyncStatus(connection.syncStatus)} + status={connection.syncStatus} editedAt={connection.updatedAt} syncedAt={connection.syncedAt ?? undefined} /> diff --git a/packages/web/src/app/connections/components/statusIcon.tsx b/packages/web/src/app/connections/components/statusIcon.tsx new file mode 100644 index 00000000..b7b6b2bf --- /dev/null +++ b/packages/web/src/app/connections/components/statusIcon.tsx @@ -0,0 +1,27 @@ +import { cn } from "@/lib/utils"; +import { Cross2Icon } from "@radix-ui/react-icons"; +import { CircleCheckIcon } from "lucide-react"; +import { useMemo } from "react"; +import { FiLoader } from "react-icons/fi"; + +export type Status = 'waiting' | 'running' | 'succeeded' | 'failed'; + +export const StatusIcon = ({ + status, + className, +}: { status: Status, className?: string }) => { + const Icon = useMemo(() => { + switch (status) { + case 'waiting': + case 'running': + return ; + case 'succeeded': + return ; + case 'failed': + return ; + + } + }, [className, status]); + + return Icon; +} \ No newline at end of file