From de6a84754f725394115c8f8bfc9a7da7f3dbb036 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Mon, 29 Sep 2025 17:53:13 +0300 Subject: [PATCH 01/10] refactor: use actual git info --- .../table/components/domain_list.tsx | 19 +- .../components/table/deployments-list.tsx | 9 +- .../active-deployment-card/git-avatar.tsx | 12 +- .../details/active-deployment-card/index.tsx | 2 +- .../project-details-expandables/index.tsx | 42 ++-- .../project-details-expandables/sections.tsx | 17 +- .../create-project/create-project-dialog.tsx | 1 + .../projects/_components/list/index.tsx | 1 + .../_components/list/projects-card.tsx | 9 +- .../_components/list/repo-display.tsx | 13 +- .../lib/collections/deploy/deployments.ts | 14 +- .../lib/collections/deploy/projects.ts | 1 + .../trpc/routers/deploy/deployment/list.ts | 12 +- .../lib/trpc/routers/deploy/project/list.ts | 11 +- .../services/deployment/create_deployment.go | 21 +- .../create_deployment_simple_test.go | 42 ++-- .../services/deployment/get_deployment.go | 8 +- go/cmd/deploy/control_plane.go | 30 ++- go/gen/proto/ctrl/v1/deployment.pb.go | 70 +++--- .../bulk_deployment_insert.sql_generated.go | 7 +- .../db/deployment_find_by_id.sql_generated.go | 12 +- go/pkg/db/deployment_insert.sql_generated.go | 14 +- go/pkg/db/models_generated.go | 4 +- go/pkg/db/querier_generated.go | 7 +- go/pkg/db/queries/deployment_find_by_id.sql | 3 +- go/pkg/db/queries/deployment_insert.sql | 6 +- go/pkg/db/schema.sql | 5 +- go/pkg/git/git.go | 212 +++++++++++++++--- go/proto/ctrl/v1/deployment.proto | 16 +- internal/db/src/schema/deployments.ts | 4 +- .../proto/generated/ctrl/v1/deployment_pb.ts | 30 +-- 31 files changed, 383 insertions(+), 271 deletions(-) diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/deployments/components/table/components/domain_list.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/deployments/components/table/components/domain_list.tsx index 3e27dc6451..fbf59a4ce6 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/deployments/components/table/components/domain_list.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/deployments/components/table/components/domain_list.tsx @@ -3,22 +3,15 @@ import { useProjectLayout } from "../../../../layout-provider"; type Props = { deploymentId: string; - // I couldn't figure out how to make the domains revalidate on a rollback - // From my understanding it should already work, because we're using the - // .util.refetch() in the trpc mutation, but it doesn't. - // We need to investigate this later - hackyRevalidateDependency?: unknown; }; -export const DomainList = ({ deploymentId, hackyRevalidateDependency }: Props) => { +export const DomainList = ({ deploymentId }: Props) => { const { collections } = useProjectLayout(); - const domains = useLiveQuery( - (q) => - q - .from({ domain: collections.domains }) - .where(({ domain }) => eq(domain.deploymentId, deploymentId)) - .orderBy(({ domain }) => domain.domain, "asc"), - [hackyRevalidateDependency], + const domains = useLiveQuery((q) => + q + .from({ domain: collections.domains }) + .where(({ domain }) => eq(domain.deploymentId, deploymentId)) + .orderBy(({ domain }) => domain.domain, "asc"), ); return ( diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/deployments/components/table/deployments-list.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/deployments/components/table/deployments-list.tsx index f4534cad6a..af573dc270 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/deployments/components/table/deployments-list.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/deployments/components/table/deployments-list.tsx @@ -127,7 +127,6 @@ export const DeploymentsList = () => { ), }, @@ -237,13 +236,13 @@ export const DeploymentsList = () => {
- {deployment.gitCommitAuthorUsername} + {deployment.gitCommitAuthorHandle}
@@ -283,10 +282,10 @@ export const DeploymentsList = () => {
- {deployment.gitCommitAuthorName} + {deployment.gitCommitAuthorHandle}
); diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/active-deployment-card/git-avatar.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/active-deployment-card/git-avatar.tsx index 72c9d0ed49..b7b9ca5c3d 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/active-deployment-card/git-avatar.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/active-deployment-card/git-avatar.tsx @@ -1,3 +1,4 @@ +import { cn } from "@/lib/utils"; import { User } from "@unkey/icons"; import { useState } from "react"; @@ -7,12 +8,17 @@ type AvatarProps = { className?: string; }; -export function Avatar({ src, alt, className = "size-5" }: AvatarProps) { +export function Avatar({ src, alt, className }: AvatarProps) { const [hasError, setHasError] = useState(false); if (!src || hasError) { return ( -
+
); @@ -22,7 +28,7 @@ export function Avatar({ src, alt, className = "size-5" }: AvatarProps) { {alt} setHasError(true)} /> ); diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/active-deployment-card/index.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/active-deployment-card/index.tsx index f82e7f4dec..d471fdf012 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/active-deployment-card/index.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/active-deployment-card/index.tsx @@ -135,7 +135,7 @@ export const ActiveDeploymentCard = ({ deploymentId }: Props) => { Created by - {deployment.gitCommitAuthorName} + {deployment.gitCommitAuthorHandle}
diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/index.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/index.tsx index 972ef07cb6..9fd2bba847 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/index.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/index.tsx @@ -33,12 +33,24 @@ export const ProjectDetailsExpandable = ({ ); const data = query.data.at(0); + const { data: domainsData } = useLiveQuery( + (q) => + q + .from({ domain: collections.domains }) + .where(({ domain }) => eq(domain.deploymentId, data?.project.liveDeploymentId)) + .select(({ domain }) => ({ domain: domain.domain })) + .orderBy(({ domain }) => domain.id, "asc"), + [data?.project.liveDeploymentId], + ); if (!data?.deployment) { return null; } - const detailSections = createDetailSections(data.deployment); + const detailSections = createDetailSections({ + ...data.deployment, + repository: data.project.gitRepositoryUrl, + }); return (
@@ -101,35 +113,37 @@ export const ProjectDetailsExpandable = ({
- dashboard -
+ + {data.project.name} + +
- api.gateway.com + {data.project.domain} - {["staging.gateway.com", "dev.gateway.com"].map((region) => ( +
+ {domainsData.slice(1).map((d) => (
-
+ ))} @@ -137,7 +151,7 @@ export const ProjectDetailsExpandable = ({ } >
- +2 + +{domainsData.slice(1).length}
diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/sections.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/sections.tsx index 8a497e61d7..8148319503 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/sections.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/sections.tsx @@ -16,6 +16,7 @@ import { } from "@unkey/icons"; import { Badge, TimestampInfo } from "@unkey/ui"; import type { ReactNode } from "react"; +import { RepoDisplay } from "../../../_components/list/repo-display"; import { Avatar } from "../active-deployment-card/git-avatar"; export type DetailItem = { @@ -30,7 +31,9 @@ export type DetailSection = { items: DetailItem[]; }; -export const createDetailSections = (details: Deployment): DetailSection[] => [ +export const createDetailSections = ( + details: Deployment & { repository: string | null }, +): DetailSection[] => [ { title: "Active deployment", items: [ @@ -38,9 +41,11 @@ export const createDetailSections = (details: Deployment): DetailSection[] => [ icon: , label: "Repository", content: ( -
- TODO/ TODO -
+ ), }, { @@ -75,9 +80,9 @@ export const createDetailSections = (details: Deployment): DetailSection[] => [
- {details.gitCommitAuthorUsername} + {details.gitCommitAuthorHandle}
), }, diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/_components/create-project/create-project-dialog.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/_components/create-project/create-project-dialog.tsx index 4323f0ea1e..949976e6cc 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/projects/_components/create-project/create-project-dialog.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/projects/_components/create-project/create-project-dialog.tsx @@ -44,6 +44,7 @@ export const CreateProjectDialog = () => { updatedAt: null, id: "will-be-replace-by-server", author: "will-be-replace-by-server", + authorAvatar: "will-be-replace-by-server", branch: "will-be-replace-by-server", commitTimestamp: Date.now(), commitTitle: "will-be-replace-by-server", diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/_components/list/index.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/_components/list/index.tsx index b4ee4ef028..5d72463ba3 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/projects/_components/list/index.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/projects/_components/list/index.tsx @@ -85,6 +85,7 @@ export const ProjectsList = () => { commitTimestamp={project.commitTimestamp} branch={project.branch} author={project.author} + authorAvatar={project.authorAvatar} regions={project.regions} repository={project.gitRepositoryUrl || undefined} actions={ diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/_components/list/projects-card.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/_components/list/projects-card.tsx index b19979c8a7..96a62bd253 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/projects/_components/list/projects-card.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/projects/_components/list/projects-card.tsx @@ -1,9 +1,10 @@ import { useWorkspaceNavigation } from "@/hooks/use-workspace-navigation"; -import { CodeBranch, Cube, User } from "@unkey/icons"; +import { CodeBranch, Cube } from "@unkey/icons"; import { InfoTooltip, Loading, TimestampInfo } from "@unkey/ui"; import Link from "next/link"; import type { ReactNode } from "react"; import { useCallback, useState } from "react"; +import { Avatar } from "../../[projectId]/details/active-deployment-card/git-avatar"; import { RegionBadges } from "./region-badges"; type ProjectCardProps = { @@ -13,6 +14,7 @@ type ProjectCardProps = { commitTimestamp?: number | null; branch: string; author: string; + authorAvatar: string | null; regions: string[]; repository?: string; actions: ReactNode; @@ -26,6 +28,7 @@ export const ProjectCard = ({ commitTimestamp, branch, author, + authorAvatar, regions, repository, actions, @@ -104,9 +107,7 @@ export const ProjectCard = ({ {branch} by -
- -
+ {author} diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/_components/list/repo-display.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/_components/list/repo-display.tsx index 81cbbc1c06..87c9ae22d5 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/projects/_components/list/repo-display.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/projects/_components/list/repo-display.tsx @@ -1,5 +1,6 @@ import { Github } from "@unkey/icons"; import { InfoTooltip } from "@unkey/ui"; +import { cn } from "@unkey/ui/src/lib/utils"; import type { ReactNode } from "react"; type RepositoryDisplayProps = { @@ -19,12 +20,20 @@ export const RepoDisplay = ({ const safeHref = isSafeHttpUrl(url) ? url : undefined; return ( - + {showIcon && } {children || {repoName}} diff --git a/apps/dashboard/lib/collections/deploy/deployments.ts b/apps/dashboard/lib/collections/deploy/deployments.ts index 5bc7498674..3fa9c178c1 100644 --- a/apps/dashboard/lib/collections/deploy/deployments.ts +++ b/apps/dashboard/lib/collections/deploy/deployments.ts @@ -8,18 +8,12 @@ const schema = z.object({ id: z.string(), projectId: z.string(), environmentId: z.string(), - // Git information - // TEMP: Git fields as non-nullable for UI development with mock data - // TODO: Convert to nullable (.nullable()) when real git integration is added - // In production, deployments may not have git metadata if triggered manually - gitCommitSha: z.string(), + gitCommitSha: z.string().nullable(), gitBranch: z.string(), - gitCommitMessage: z.string(), - gitCommitAuthorName: z.string(), - gitCommitAuthorEmail: z.string(), - gitCommitAuthorUsername: z.string(), + gitCommitMessage: z.string().nullable(), + gitCommitAuthorHandle: z.string().nullable(), gitCommitAuthorAvatarUrl: z.string(), - gitCommitTimestamp: z.number().int(), + gitCommitTimestamp: z.number().int().nullable(), // Immutable configuration snapshot runtimeConfig: z.object({ regions: z.array( diff --git a/apps/dashboard/lib/collections/deploy/projects.ts b/apps/dashboard/lib/collections/deploy/projects.ts index 49bb31fd6b..1ee603f24b 100644 --- a/apps/dashboard/lib/collections/deploy/projects.ts +++ b/apps/dashboard/lib/collections/deploy/projects.ts @@ -16,6 +16,7 @@ const schema = z.object({ commitTitle: z.string(), branch: z.string(), author: z.string(), + authorAvatar: z.string().nullable(), commitTimestamp: z.number().int().nullable(), regions: z.array(z.string()), // Domain field diff --git a/apps/dashboard/lib/trpc/routers/deploy/deployment/list.ts b/apps/dashboard/lib/trpc/routers/deploy/deployment/list.ts index 12ffe8657e..df2c6b237b 100644 --- a/apps/dashboard/lib/trpc/routers/deploy/deployment/list.ts +++ b/apps/dashboard/lib/trpc/routers/deploy/deployment/list.ts @@ -20,9 +20,7 @@ export const listDeployments = t.procedure gitCommitSha: true, gitBranch: true, gitCommitMessage: true, - gitCommitAuthorName: true, - gitCommitAuthorEmail: true, - gitCommitAuthorUsername: true, + gitCommitAuthorHandle: true, gitCommitAuthorAvatarUrl: true, gitCommitTimestamp: true, runtimeConfig: true, @@ -34,16 +32,10 @@ export const listDeployments = t.procedure return deployments.map((deployment) => ({ ...deployment, - // Replace NULL git fields with dummy data that clearly indicates it's fake - gitCommitSha: deployment.gitCommitSha ?? "abc123ef456789012345678901234567890abcdef", gitBranch: deployment.gitBranch ?? "main", - gitCommitMessage: deployment.gitCommitMessage ?? "[DUMMY] Initial commit", - gitCommitAuthorName: deployment.gitCommitAuthorName ?? "[DUMMY] Unknown Author", - gitCommitAuthorEmail: deployment.gitCommitAuthorEmail ?? "dummy@example.com", - gitCommitAuthorUsername: deployment.gitCommitAuthorUsername ?? "dummy-user", gitCommitAuthorAvatarUrl: deployment.gitCommitAuthorAvatarUrl ?? "https://github.com/identicons/dummy-user.png", - gitCommitTimestamp: deployment.gitCommitTimestamp ?? Date.now() - 86400000, + gitCommitTimestamp: deployment.gitCommitTimestamp, })); } catch (_error) { throw new TRPCError({ diff --git a/apps/dashboard/lib/trpc/routers/deploy/project/list.ts b/apps/dashboard/lib/trpc/routers/deploy/project/list.ts index 61c56f8e88..582bd3dfae 100644 --- a/apps/dashboard/lib/trpc/routers/deploy/project/list.ts +++ b/apps/dashboard/lib/trpc/routers/deploy/project/list.ts @@ -14,7 +14,8 @@ type ProjectRow = { is_rolled_back: boolean; git_commit_message: string | null; git_branch: string | null; - git_commit_author_name: string | null; + git_commit_author_handle: string | null; + git_commit_author_avatar_url: string | null; git_commit_timestamp: number | null; runtime_config: Deployment["runtimeConfig"] | null; domain: string | null; @@ -36,7 +37,8 @@ export const listProjects = t.procedure ${projects.isRolledBack}, ${deployments.gitCommitMessage}, ${deployments.gitBranch}, - ${deployments.gitCommitAuthorName}, + ${deployments.gitCommitAuthorHandle}, + ${deployments.gitCommitAuthorAvatarUrl}, ${deployments.gitCommitTimestamp}, ${deployments.runtimeConfig}, ${domains.domain} @@ -62,8 +64,9 @@ export const listProjects = t.procedure isRolledBack: row.is_rolled_back, commitTitle: row.git_commit_message ?? "[DUMMY] Initial commit", branch: row.git_branch ?? "main", - author: row.git_commit_author_name ?? "[DUMMY] Unknown Author", - commitTimestamp: row.git_commit_timestamp ?? Date.now() - 86400000, + author: row.git_commit_author_handle ?? "[DUMMY] Unknown Author", + commitTimestamp: Number(row.git_commit_timestamp), + authorAvatar: row.git_commit_author_avatar_url, regions: row.runtime_config?.regions?.map((r) => r.region) ?? ["us-east-1"], domain: row.domain ?? "project-temp.unkey.app", }), diff --git a/go/apps/ctrl/services/deployment/create_deployment.go b/go/apps/ctrl/services/deployment/create_deployment.go index 83902e344e..60c1bb524b 100644 --- a/go/apps/ctrl/services/deployment/create_deployment.go +++ b/go/apps/ctrl/services/deployment/create_deployment.go @@ -105,8 +105,7 @@ func (s *Service) CreateDeployment( // Sanitize input values before persisting gitCommitSha := req.Msg.GetGitCommitSha() gitCommitMessage := trimLength(req.Msg.GetGitCommitMessage(), 10240) - gitCommitAuthorName := trimLength(strings.TrimSpace(req.Msg.GetGitCommitAuthorName()), 256) - gitCommitAuthorUsername := trimLength(strings.TrimSpace(req.Msg.GetGitCommitAuthorUsername()), 256) + gitCommitAuthorHandle := trimLength(strings.TrimSpace(req.Msg.GetGitCommitAuthorHandle()), 256) gitCommitAuthorAvatarUrl := trimLength(strings.TrimSpace(req.Msg.GetGitCommitAuthorAvatarUrl()), 512) // Insert deployment into database @@ -120,16 +119,14 @@ func (s *Service) CreateDeployment( "cpus": 2, "memory": 2048 }`), - OpenapiSpec: sql.NullString{String: "", Valid: false}, - Status: db.DeploymentsStatusPending, - CreatedAt: now, - UpdatedAt: sql.NullInt64{Int64: now, Valid: true}, - GitCommitSha: sql.NullString{String: gitCommitSha, Valid: gitCommitSha != ""}, - GitBranch: sql.NullString{String: gitBranch, Valid: true}, - GitCommitMessage: sql.NullString{String: gitCommitMessage, Valid: req.Msg.GetGitCommitMessage() != ""}, - GitCommitAuthorName: sql.NullString{String: gitCommitAuthorName, Valid: req.Msg.GetGitCommitAuthorName() != ""}, - // TODO: Use email to lookup GitHub username/avatar via GitHub API instead of persisting PII - GitCommitAuthorUsername: sql.NullString{String: gitCommitAuthorUsername, Valid: req.Msg.GetGitCommitAuthorUsername() != ""}, + OpenapiSpec: sql.NullString{String: "", Valid: false}, + Status: db.DeploymentsStatusPending, + CreatedAt: now, + UpdatedAt: sql.NullInt64{Int64: now, Valid: true}, + GitCommitSha: sql.NullString{String: gitCommitSha, Valid: gitCommitSha != ""}, + GitBranch: sql.NullString{String: gitBranch, Valid: true}, + GitCommitMessage: sql.NullString{String: gitCommitMessage, Valid: req.Msg.GetGitCommitMessage() != ""}, + GitCommitAuthorHandle: sql.NullString{String: gitCommitAuthorHandle, Valid: req.Msg.GetGitCommitAuthorHandle() != ""}, GitCommitAuthorAvatarUrl: sql.NullString{String: gitCommitAuthorAvatarUrl, Valid: req.Msg.GetGitCommitAuthorAvatarUrl() != ""}, GitCommitTimestamp: sql.NullInt64{Int64: req.Msg.GetGitCommitTimestamp(), Valid: req.Msg.GetGitCommitTimestamp() != 0}, }) diff --git a/go/apps/ctrl/services/deployment/create_deployment_simple_test.go b/go/apps/ctrl/services/deployment/create_deployment_simple_test.go index 0619420862..18b1cec9b4 100644 --- a/go/apps/ctrl/services/deployment/create_deployment_simple_test.go +++ b/go/apps/ctrl/services/deployment/create_deployment_simple_test.go @@ -70,27 +70,23 @@ func TestGitFieldValidation_SpecialCharacters(t *testing.T) { // Test that special characters are preserved in protobuf req := &ctrlv1.CreateDeploymentRequest{ GitCommitMessage: tt.input, - GitCommitAuthorName: tt.input, - GitCommitAuthorUsername: tt.input, + GitCommitAuthorHandle: tt.input, GitCommitAuthorAvatarUrl: tt.input, } require.Equal(t, tt.expected, req.GetGitCommitMessage()) - require.Equal(t, tt.expected, req.GetGitCommitAuthorName()) - require.Equal(t, tt.expected, req.GetGitCommitAuthorUsername()) + require.Equal(t, tt.expected, req.GetGitCommitAuthorHandle()) require.Equal(t, tt.expected, req.GetGitCommitAuthorAvatarUrl()) // Test that special characters are preserved in database model deployment := db.Deployment{ GitCommitMessage: sql.NullString{String: tt.input, Valid: true}, - GitCommitAuthorName: sql.NullString{String: tt.input, Valid: true}, - GitCommitAuthorUsername: sql.NullString{String: tt.input, Valid: true}, + GitCommitAuthorHandle: sql.NullString{String: tt.input, Valid: true}, GitCommitAuthorAvatarUrl: sql.NullString{String: tt.input, Valid: true}, } require.Equal(t, tt.expected, deployment.GitCommitMessage.String) - require.Equal(t, tt.expected, deployment.GitCommitAuthorName.String) - require.Equal(t, tt.expected, deployment.GitCommitAuthorUsername.String) + require.Equal(t, tt.expected, deployment.GitCommitAuthorHandle.String) require.Equal(t, tt.expected, deployment.GitCommitAuthorAvatarUrl.String) }) } @@ -105,32 +101,27 @@ func TestGitFieldValidation_NullHandling(t *testing.T) { WorkspaceId: "ws_test", ProjectId: "proj_test", GitCommitMessage: "", - GitCommitAuthorName: "", - GitCommitAuthorUsername: "", GitCommitAuthorAvatarUrl: "", GitCommitTimestamp: 0, } // Empty strings should be returned as-is require.Equal(t, "", req.GetGitCommitMessage()) - require.Equal(t, "", req.GetGitCommitAuthorName()) - require.Equal(t, "", req.GetGitCommitAuthorUsername()) + require.Equal(t, "", req.GetGitCommitAuthorHandle()) require.Equal(t, "", req.GetGitCommitAuthorAvatarUrl()) require.Equal(t, int64(0), req.GetGitCommitTimestamp()) // Test NULL database fields deployment := db.Deployment{ GitCommitMessage: sql.NullString{Valid: false}, - GitCommitAuthorName: sql.NullString{Valid: false}, - GitCommitAuthorUsername: sql.NullString{Valid: false}, + GitCommitAuthorHandle: sql.NullString{Valid: false}, GitCommitAuthorAvatarUrl: sql.NullString{Valid: false}, GitCommitTimestamp: sql.NullInt64{Valid: false}, } // NULL fields should be invalid require.False(t, deployment.GitCommitMessage.Valid) - require.False(t, deployment.GitCommitAuthorName.Valid) - require.False(t, deployment.GitCommitAuthorUsername.Valid) + require.False(t, deployment.GitCommitAuthorHandle.Valid) require.False(t, deployment.GitCommitAuthorAvatarUrl.Valid) require.False(t, deployment.GitCommitTimestamp.Valid) } @@ -290,8 +281,7 @@ func TestCreateVersionFieldMapping(t *testing.T) { SourceType: ctrlv1.SourceType_SOURCE_TYPE_GIT, GitCommitSha: "abc123def456789", GitCommitMessage: "feat: implement new feature", - GitCommitAuthorName: "Jane Doe", - GitCommitAuthorUsername: "janedoe", + GitCommitAuthorHandle: "janedoe", GitCommitAuthorAvatarUrl: "https://github.com/janedoe.png", GitCommitTimestamp: 1724251845123, // Fixed millisecond timestamp }, @@ -336,8 +326,7 @@ func TestCreateVersionFieldMapping(t *testing.T) { SourceType: ctrlv1.SourceType_SOURCE_TYPE_GIT, GitCommitSha: "", GitCommitMessage: "", - GitCommitAuthorName: "", - GitCommitAuthorUsername: "", + GitCommitAuthorHandle: "", GitCommitAuthorAvatarUrl: "", GitCommitTimestamp: 0, }, @@ -382,8 +371,7 @@ func TestCreateVersionFieldMapping(t *testing.T) { SourceType: ctrlv1.SourceType_SOURCE_TYPE_GIT, GitCommitSha: "xyz789abc123", GitCommitMessage: "fix: critical security issue", - GitCommitAuthorName: "", // Empty - GitCommitAuthorUsername: "", // Empty + GitCommitAuthorHandle: "", // Empty GitCommitAuthorAvatarUrl: "", // Empty GitCommitTimestamp: 1724251845999, }, @@ -436,8 +424,7 @@ func TestCreateVersionFieldMapping(t *testing.T) { GitCommitSha: sql.NullString{String: tt.request.GetGitCommitSha(), Valid: tt.request.GetGitCommitSha() != ""}, GitBranch: sql.NullString{String: tt.request.GetBranch(), Valid: true}, GitCommitMessage: sql.NullString{String: tt.request.GetGitCommitMessage(), Valid: tt.request.GetGitCommitMessage() != ""}, - GitCommitAuthorName: sql.NullString{String: tt.request.GetGitCommitAuthorName(), Valid: tt.request.GetGitCommitAuthorName() != ""}, - GitCommitAuthorUsername: sql.NullString{String: tt.request.GetGitCommitAuthorUsername(), Valid: tt.request.GetGitCommitAuthorUsername() != ""}, + GitCommitAuthorHandle: sql.NullString{String: tt.request.GetGitCommitAuthorHandle(), Valid: tt.request.GetGitCommitAuthorHandle() != ""}, GitCommitAuthorAvatarUrl: sql.NullString{String: tt.request.GetGitCommitAuthorAvatarUrl(), Valid: tt.request.GetGitCommitAuthorAvatarUrl() != ""}, GitCommitTimestamp: sql.NullInt64{Int64: tt.request.GetGitCommitTimestamp(), Valid: tt.request.GetGitCommitTimestamp() != 0}, RuntimeConfig: []byte("{}"), @@ -457,11 +444,8 @@ func TestCreateVersionFieldMapping(t *testing.T) { require.Equal(t, tt.expected.gitCommitMessage, params.GitCommitMessage.String, "GitCommitMessage string mismatch") require.Equal(t, tt.expected.gitCommitMessageValid, params.GitCommitMessage.Valid, "GitCommitMessage valid flag mismatch") - require.Equal(t, tt.expected.gitCommitAuthorName, params.GitCommitAuthorName.String, "GitCommitAuthorName string mismatch") - require.Equal(t, tt.expected.gitCommitAuthorNameValid, params.GitCommitAuthorName.Valid, "GitCommitAuthorName valid flag mismatch") - - require.Equal(t, tt.expected.gitCommitAuthorUsername, params.GitCommitAuthorUsername.String, "GitCommitAuthorUsername string mismatch") - require.Equal(t, tt.expected.gitCommitAuthorUsernameValid, params.GitCommitAuthorUsername.Valid, "GitCommitAuthorUsername valid flag mismatch") + require.Equal(t, tt.expected.gitCommitAuthorUsername, params.GitCommitAuthorHandle.String, "GitCommitAuthorUsername string mismatch") + require.Equal(t, tt.expected.gitCommitAuthorUsernameValid, params.GitCommitAuthorHandle.Valid, "GitCommitAuthorUsername valid flag mismatch") require.Equal(t, tt.expected.gitCommitAuthorAvatarUrl, params.GitCommitAuthorAvatarUrl.String, "GitCommitAuthorAvatarUrl string mismatch") require.Equal(t, tt.expected.gitCommitAuthorAvatarUrlValid, params.GitCommitAuthorAvatarUrl.Valid, "GitCommitAuthorAvatarUrl valid flag mismatch") diff --git a/go/apps/ctrl/services/deployment/get_deployment.go b/go/apps/ctrl/services/deployment/get_deployment.go index d34e0ffb29..03229fc9b0 100644 --- a/go/apps/ctrl/services/deployment/get_deployment.go +++ b/go/apps/ctrl/services/deployment/get_deployment.go @@ -53,12 +53,10 @@ func (s *Service) GetDeployment( if deployment.GitCommitMessage.Valid { protoDeployment.GitCommitMessage = deployment.GitCommitMessage.String } - if deployment.GitCommitAuthorName.Valid { - protoDeployment.GitCommitAuthorName = deployment.GitCommitAuthorName.String - } + // Email removed to avoid storing PII - TODO: implement GitHub API lookup - if deployment.GitCommitAuthorUsername.Valid { - protoDeployment.GitCommitAuthorUsername = deployment.GitCommitAuthorUsername.String + if deployment.GitCommitAuthorHandle.Valid { + protoDeployment.GitCommitAuthorHandle = deployment.GitCommitAuthorHandle.String } if deployment.GitCommitAuthorAvatarUrl.Valid { protoDeployment.GitCommitAuthorAvatarUrl = deployment.GitCommitAuthorAvatarUrl.String diff --git a/go/cmd/deploy/control_plane.go b/go/cmd/deploy/control_plane.go index 78f29a75eb..f7b30c4d58 100644 --- a/go/cmd/deploy/control_plane.go +++ b/go/cmd/deploy/control_plane.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log" "net/http" "strings" "time" @@ -13,6 +14,7 @@ import ( "github.com/unkeyed/unkey/go/gen/proto/ctrl/v1/ctrlv1connect" "github.com/unkeyed/unkey/go/pkg/codes" "github.com/unkeyed/unkey/go/pkg/fault" + "github.com/unkeyed/unkey/go/pkg/git" "github.com/unkeyed/unkey/go/pkg/otel/logging" ) @@ -50,15 +52,27 @@ func NewControlPlaneClient(opts DeployOptions) *ControlPlaneClient { // CreateDeployment creates a new deployment in the control plane func (c *ControlPlaneClient) CreateDeployment(ctx context.Context, dockerImage string) (string, error) { + // Get git commit information + commitInfo, err := git.GetCommitInfo() + if err != nil { + // Log warning but don't fail the deployment if git info is unavailable + log.Printf("Warning: failed to get git commit info: %v", err) + commitInfo = &git.CommitInfo{} // Use empty struct + } + createReq := connect.NewRequest(&ctrlv1.CreateDeploymentRequest{ - WorkspaceId: c.opts.WorkspaceID, - ProjectId: c.opts.ProjectID, - KeyspaceId: &c.opts.KeyspaceID, - Branch: c.opts.Branch, - SourceType: ctrlv1.SourceType_SOURCE_TYPE_CLI_UPLOAD, - GitCommitSha: c.opts.Commit, - EnvironmentSlug: c.opts.Environment, - DockerImage: dockerImage, + WorkspaceId: c.opts.WorkspaceID, + ProjectId: c.opts.ProjectID, + KeyspaceId: &c.opts.KeyspaceID, + Branch: c.opts.Branch, + SourceType: ctrlv1.SourceType_SOURCE_TYPE_CLI_UPLOAD, + EnvironmentSlug: c.opts.Environment, + DockerImage: dockerImage, + GitCommitSha: commitInfo.SHA, + GitCommitMessage: commitInfo.Message, + GitCommitAuthorHandle: commitInfo.AuthorHandle, + GitCommitAuthorAvatarUrl: commitInfo.AuthorAvatarURL, + GitCommitTimestamp: commitInfo.CommitTimestamp, }) // Use API key for authentication if provided, fallback to auth token diff --git a/go/gen/proto/ctrl/v1/deployment.pb.go b/go/gen/proto/ctrl/v1/deployment.pb.go index 77c7766bdd..6640f96c5d 100644 --- a/go/gen/proto/ctrl/v1/deployment.pb.go +++ b/go/gen/proto/ctrl/v1/deployment.pb.go @@ -143,15 +143,14 @@ type CreateDeploymentRequest struct { SourceType SourceType `protobuf:"varint,5,opt,name=source_type,json=sourceType,proto3,enum=ctrl.v1.SourceType" json:"source_type,omitempty"` DockerImage string `protobuf:"bytes,6,opt,name=docker_image,json=dockerImage,proto3" json:"docker_image,omitempty"` // Extended git information - GitCommitSha string `protobuf:"bytes,7,opt,name=git_commit_sha,json=gitCommitSha,proto3" json:"git_commit_sha,omitempty"` // For git sources - GitCommitMessage string `protobuf:"bytes,8,opt,name=git_commit_message,json=gitCommitMessage,proto3" json:"git_commit_message,omitempty"` - GitCommitAuthorName string `protobuf:"bytes,9,opt,name=git_commit_author_name,json=gitCommitAuthorName,proto3" json:"git_commit_author_name,omitempty"` + GitCommitSha string `protobuf:"bytes,7,opt,name=git_commit_sha,json=gitCommitSha,proto3" json:"git_commit_sha,omitempty"` // For git sources + GitCommitMessage string `protobuf:"bytes,8,opt,name=git_commit_message,json=gitCommitMessage,proto3" json:"git_commit_message,omitempty"` // TODO: Add GitHub API integration to lookup username/avatar from email - GitCommitAuthorUsername string `protobuf:"bytes,10,opt,name=git_commit_author_username,json=gitCommitAuthorUsername,proto3" json:"git_commit_author_username,omitempty"` - GitCommitAuthorAvatarUrl string `protobuf:"bytes,11,opt,name=git_commit_author_avatar_url,json=gitCommitAuthorAvatarUrl,proto3" json:"git_commit_author_avatar_url,omitempty"` - GitCommitTimestamp int64 `protobuf:"varint,12,opt,name=git_commit_timestamp,json=gitCommitTimestamp,proto3" json:"git_commit_timestamp,omitempty"` // Unix epoch milliseconds + GitCommitAuthorHandle string `protobuf:"bytes,9,opt,name=git_commit_author_handle,json=gitCommitAuthorHandle,proto3" json:"git_commit_author_handle,omitempty"` + GitCommitAuthorAvatarUrl string `protobuf:"bytes,10,opt,name=git_commit_author_avatar_url,json=gitCommitAuthorAvatarUrl,proto3" json:"git_commit_author_avatar_url,omitempty"` + GitCommitTimestamp int64 `protobuf:"varint,11,opt,name=git_commit_timestamp,json=gitCommitTimestamp,proto3" json:"git_commit_timestamp,omitempty"` // Unix epoch milliseconds // Keyspace ID for authentication - KeyspaceId *string `protobuf:"bytes,13,opt,name=keyspace_id,json=keyspaceId,proto3,oneof" json:"keyspace_id,omitempty"` + KeyspaceId *string `protobuf:"bytes,12,opt,name=keyspace_id,json=keyspaceId,proto3,oneof" json:"keyspace_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -242,16 +241,9 @@ func (x *CreateDeploymentRequest) GetGitCommitMessage() string { return "" } -func (x *CreateDeploymentRequest) GetGitCommitAuthorName() string { +func (x *CreateDeploymentRequest) GetGitCommitAuthorHandle() string { if x != nil { - return x.GitCommitAuthorName - } - return "" -} - -func (x *CreateDeploymentRequest) GetGitCommitAuthorUsername() string { - if x != nil { - return x.GitCommitAuthorUsername + return x.GitCommitAuthorHandle } return "" } @@ -444,12 +436,11 @@ type Deployment struct { // Deployment steps Steps []*DeploymentStep `protobuf:"bytes,16,rep,name=steps,proto3" json:"steps,omitempty"` // Extended git information - GitCommitMessage string `protobuf:"bytes,17,opt,name=git_commit_message,json=gitCommitMessage,proto3" json:"git_commit_message,omitempty"` - GitCommitAuthorName string `protobuf:"bytes,18,opt,name=git_commit_author_name,json=gitCommitAuthorName,proto3" json:"git_commit_author_name,omitempty"` + GitCommitMessage string `protobuf:"bytes,17,opt,name=git_commit_message,json=gitCommitMessage,proto3" json:"git_commit_message,omitempty"` // Removed: email is PII and not stored - GitCommitAuthorUsername string `protobuf:"bytes,20,opt,name=git_commit_author_username,json=gitCommitAuthorUsername,proto3" json:"git_commit_author_username,omitempty"` - GitCommitAuthorAvatarUrl string `protobuf:"bytes,21,opt,name=git_commit_author_avatar_url,json=gitCommitAuthorAvatarUrl,proto3" json:"git_commit_author_avatar_url,omitempty"` - GitCommitTimestamp int64 `protobuf:"varint,22,opt,name=git_commit_timestamp,json=gitCommitTimestamp,proto3" json:"git_commit_timestamp,omitempty"` // Unix epoch milliseconds + GitCommitAuthorHandle string `protobuf:"bytes,18,opt,name=git_commit_author_handle,json=gitCommitAuthorHandle,proto3" json:"git_commit_author_handle,omitempty"` + GitCommitAuthorAvatarUrl string `protobuf:"bytes,19,opt,name=git_commit_author_avatar_url,json=gitCommitAuthorAvatarUrl,proto3" json:"git_commit_author_avatar_url,omitempty"` + GitCommitTimestamp int64 `protobuf:"varint,20,opt,name=git_commit_timestamp,json=gitCommitTimestamp,proto3" json:"git_commit_timestamp,omitempty"` // Unix epoch milliseconds unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -603,16 +594,9 @@ func (x *Deployment) GetGitCommitMessage() string { return "" } -func (x *Deployment) GetGitCommitAuthorName() string { - if x != nil { - return x.GitCommitAuthorName - } - return "" -} - -func (x *Deployment) GetGitCommitAuthorUsername() string { +func (x *Deployment) GetGitCommitAuthorHandle() string { if x != nil { - return x.GitCommitAuthorUsername + return x.GitCommitAuthorHandle } return "" } @@ -1017,7 +1001,7 @@ var File_ctrl_v1_deployment_proto protoreflect.FileDescriptor const file_ctrl_v1_deployment_proto_rawDesc = "" + "\n" + - "\x18ctrl/v1/deployment.proto\x12\actrl.v1\"\xe5\x04\n" + + "\x18ctrl/v1/deployment.proto\x12\actrl.v1\"\xac\x04\n" + "\x17CreateDeploymentRequest\x12!\n" + "\fworkspace_id\x18\x01 \x01(\tR\vworkspaceId\x12\x1d\n" + "\n" + @@ -1028,13 +1012,12 @@ const file_ctrl_v1_deployment_proto_rawDesc = "" + "sourceType\x12!\n" + "\fdocker_image\x18\x06 \x01(\tR\vdockerImage\x12$\n" + "\x0egit_commit_sha\x18\a \x01(\tR\fgitCommitSha\x12,\n" + - "\x12git_commit_message\x18\b \x01(\tR\x10gitCommitMessage\x123\n" + - "\x16git_commit_author_name\x18\t \x01(\tR\x13gitCommitAuthorName\x12;\n" + - "\x1agit_commit_author_username\x18\n" + - " \x01(\tR\x17gitCommitAuthorUsername\x12>\n" + - "\x1cgit_commit_author_avatar_url\x18\v \x01(\tR\x18gitCommitAuthorAvatarUrl\x120\n" + - "\x14git_commit_timestamp\x18\f \x01(\x03R\x12gitCommitTimestamp\x12$\n" + - "\vkeyspace_id\x18\r \x01(\tH\x00R\n" + + "\x12git_commit_message\x18\b \x01(\tR\x10gitCommitMessage\x127\n" + + "\x18git_commit_author_handle\x18\t \x01(\tR\x15gitCommitAuthorHandle\x12>\n" + + "\x1cgit_commit_author_avatar_url\x18\n" + + " \x01(\tR\x18gitCommitAuthorAvatarUrl\x120\n" + + "\x14git_commit_timestamp\x18\v \x01(\x03R\x12gitCommitTimestamp\x12$\n" + + "\vkeyspace_id\x18\f \x01(\tH\x00R\n" + "keyspaceId\x88\x01\x01B\x0e\n" + "\f_keyspace_id\"r\n" + "\x18CreateDeploymentResponse\x12#\n" + @@ -1045,7 +1028,7 @@ const file_ctrl_v1_deployment_proto_rawDesc = "" + "\x15GetDeploymentResponse\x123\n" + "\n" + "deployment\x18\x01 \x01(\v2\x13.ctrl.v1.DeploymentR\n" + - "deployment\"\xde\a\n" + + "deployment\"\xa5\a\n" + "\n" + "Deployment\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12!\n" + @@ -1069,11 +1052,10 @@ const file_ctrl_v1_deployment_proto_rawDesc = "" + "\x0frootfs_image_id\x18\x0e \x01(\tR\rrootfsImageId\x12\x19\n" + "\bbuild_id\x18\x0f \x01(\tR\abuildId\x12-\n" + "\x05steps\x18\x10 \x03(\v2\x17.ctrl.v1.DeploymentStepR\x05steps\x12,\n" + - "\x12git_commit_message\x18\x11 \x01(\tR\x10gitCommitMessage\x123\n" + - "\x16git_commit_author_name\x18\x12 \x01(\tR\x13gitCommitAuthorName\x12;\n" + - "\x1agit_commit_author_username\x18\x14 \x01(\tR\x17gitCommitAuthorUsername\x12>\n" + - "\x1cgit_commit_author_avatar_url\x18\x15 \x01(\tR\x18gitCommitAuthorAvatarUrl\x120\n" + - "\x14git_commit_timestamp\x18\x16 \x01(\x03R\x12gitCommitTimestamp\x1aG\n" + + "\x12git_commit_message\x18\x11 \x01(\tR\x10gitCommitMessage\x127\n" + + "\x18git_commit_author_handle\x18\x12 \x01(\tR\x15gitCommitAuthorHandle\x12>\n" + + "\x1cgit_commit_author_avatar_url\x18\x13 \x01(\tR\x18gitCommitAuthorAvatarUrl\x120\n" + + "\x14git_commit_timestamp\x18\x14 \x01(\x03R\x12gitCommitTimestamp\x1aG\n" + "\x19EnvironmentVariablesEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x86\x01\n" + diff --git a/go/pkg/db/bulk_deployment_insert.sql_generated.go b/go/pkg/db/bulk_deployment_insert.sql_generated.go index fb8b1f66fb..465a6610ea 100644 --- a/go/pkg/db/bulk_deployment_insert.sql_generated.go +++ b/go/pkg/db/bulk_deployment_insert.sql_generated.go @@ -9,7 +9,7 @@ import ( ) // bulkInsertDeployment is the base query for bulk insert -const bulkInsertDeployment = `INSERT INTO ` + "`" + `deployments` + "`" + ` ( id, workspace_id, project_id, environment_id, git_commit_sha, git_branch, runtime_config, git_commit_message, git_commit_author_name, git_commit_author_username, git_commit_author_avatar_url, git_commit_timestamp, openapi_spec, status, created_at, updated_at ) VALUES %s` +const bulkInsertDeployment = `INSERT INTO ` + "`" + `deployments` + "`" + ` ( id, workspace_id, project_id, environment_id, git_commit_sha, git_branch, runtime_config, git_commit_message, git_commit_author_handle, git_commit_author_avatar_url, git_commit_timestamp, openapi_spec, status, created_at, updated_at ) VALUES %s` // InsertDeployments performs bulk insert in a single query func (q *BulkQueries) InsertDeployments(ctx context.Context, db DBTX, args []InsertDeploymentParams) error { @@ -21,7 +21,7 @@ func (q *BulkQueries) InsertDeployments(ctx context.Context, db DBTX, args []Ins // Build the bulk insert query valueClauses := make([]string, len(args)) for i := range args { - valueClauses[i] = "( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? )" + valueClauses[i] = "( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? )" } bulkQuery := fmt.Sprintf(bulkInsertDeployment, strings.Join(valueClauses, ", ")) @@ -37,8 +37,7 @@ func (q *BulkQueries) InsertDeployments(ctx context.Context, db DBTX, args []Ins allArgs = append(allArgs, arg.GitBranch) allArgs = append(allArgs, arg.RuntimeConfig) allArgs = append(allArgs, arg.GitCommitMessage) - allArgs = append(allArgs, arg.GitCommitAuthorName) - allArgs = append(allArgs, arg.GitCommitAuthorUsername) + allArgs = append(allArgs, arg.GitCommitAuthorHandle) allArgs = append(allArgs, arg.GitCommitAuthorAvatarUrl) allArgs = append(allArgs, arg.GitCommitTimestamp) allArgs = append(allArgs, arg.OpenapiSpec) diff --git a/go/pkg/db/deployment_find_by_id.sql_generated.go b/go/pkg/db/deployment_find_by_id.sql_generated.go index c3f70b786f..5ec188df08 100644 --- a/go/pkg/db/deployment_find_by_id.sql_generated.go +++ b/go/pkg/db/deployment_find_by_id.sql_generated.go @@ -21,8 +21,7 @@ SELECT git_branch, runtime_config, git_commit_message, - git_commit_author_name, - git_commit_author_username, + git_commit_author_handle, git_commit_author_avatar_url, git_commit_timestamp, openapi_spec, @@ -42,8 +41,7 @@ type FindDeploymentByIdRow struct { GitBranch sql.NullString `db:"git_branch"` RuntimeConfig json.RawMessage `db:"runtime_config"` GitCommitMessage sql.NullString `db:"git_commit_message"` - GitCommitAuthorName sql.NullString `db:"git_commit_author_name"` - GitCommitAuthorUsername sql.NullString `db:"git_commit_author_username"` + GitCommitAuthorHandle sql.NullString `db:"git_commit_author_handle"` GitCommitAuthorAvatarUrl sql.NullString `db:"git_commit_author_avatar_url"` GitCommitTimestamp sql.NullInt64 `db:"git_commit_timestamp"` OpenapiSpec sql.NullString `db:"openapi_spec"` @@ -63,8 +61,7 @@ type FindDeploymentByIdRow struct { // git_branch, // runtime_config, // git_commit_message, -// git_commit_author_name, -// git_commit_author_username, +// git_commit_author_handle, // git_commit_author_avatar_url, // git_commit_timestamp, // openapi_spec, @@ -85,8 +82,7 @@ func (q *Queries) FindDeploymentById(ctx context.Context, db DBTX, id string) (F &i.GitBranch, &i.RuntimeConfig, &i.GitCommitMessage, - &i.GitCommitAuthorName, - &i.GitCommitAuthorUsername, + &i.GitCommitAuthorHandle, &i.GitCommitAuthorAvatarUrl, &i.GitCommitTimestamp, &i.OpenapiSpec, diff --git a/go/pkg/db/deployment_insert.sql_generated.go b/go/pkg/db/deployment_insert.sql_generated.go index 7fb05a9714..2e5d78412a 100644 --- a/go/pkg/db/deployment_insert.sql_generated.go +++ b/go/pkg/db/deployment_insert.sql_generated.go @@ -21,8 +21,7 @@ INSERT INTO ` + "`" + `deployments` + "`" + ` ( git_branch, runtime_config, git_commit_message, - git_commit_author_name, - git_commit_author_username, + git_commit_author_handle, git_commit_author_avatar_url, git_commit_timestamp, -- Unix epoch milliseconds openapi_spec, @@ -45,7 +44,6 @@ VALUES ( ?, ?, ?, - ?, ? ) ` @@ -59,8 +57,7 @@ type InsertDeploymentParams struct { GitBranch sql.NullString `db:"git_branch"` RuntimeConfig json.RawMessage `db:"runtime_config"` GitCommitMessage sql.NullString `db:"git_commit_message"` - GitCommitAuthorName sql.NullString `db:"git_commit_author_name"` - GitCommitAuthorUsername sql.NullString `db:"git_commit_author_username"` + GitCommitAuthorHandle sql.NullString `db:"git_commit_author_handle"` GitCommitAuthorAvatarUrl sql.NullString `db:"git_commit_author_avatar_url"` GitCommitTimestamp sql.NullInt64 `db:"git_commit_timestamp"` OpenapiSpec sql.NullString `db:"openapi_spec"` @@ -80,8 +77,7 @@ type InsertDeploymentParams struct { // git_branch, // runtime_config, // git_commit_message, -// git_commit_author_name, -// git_commit_author_username, +// git_commit_author_handle, // git_commit_author_avatar_url, // git_commit_timestamp, -- Unix epoch milliseconds // openapi_spec, @@ -104,7 +100,6 @@ type InsertDeploymentParams struct { // ?, // ?, // ?, -// ?, // ? // ) func (q *Queries) InsertDeployment(ctx context.Context, db DBTX, arg InsertDeploymentParams) error { @@ -117,8 +112,7 @@ func (q *Queries) InsertDeployment(ctx context.Context, db DBTX, arg InsertDeplo arg.GitBranch, arg.RuntimeConfig, arg.GitCommitMessage, - arg.GitCommitAuthorName, - arg.GitCommitAuthorUsername, + arg.GitCommitAuthorHandle, arg.GitCommitAuthorAvatarUrl, arg.GitCommitTimestamp, arg.OpenapiSpec, diff --git a/go/pkg/db/models_generated.go b/go/pkg/db/models_generated.go index d961d08d53..ad35be5799 100644 --- a/go/pkg/db/models_generated.go +++ b/go/pkg/db/models_generated.go @@ -574,9 +574,7 @@ type Deployment struct { GitCommitSha sql.NullString `db:"git_commit_sha"` GitBranch sql.NullString `db:"git_branch"` GitCommitMessage sql.NullString `db:"git_commit_message"` - GitCommitAuthorName sql.NullString `db:"git_commit_author_name"` - GitCommitAuthorEmail sql.NullString `db:"git_commit_author_email"` - GitCommitAuthorUsername sql.NullString `db:"git_commit_author_username"` + GitCommitAuthorHandle sql.NullString `db:"git_commit_author_handle"` GitCommitAuthorAvatarUrl sql.NullString `db:"git_commit_author_avatar_url"` GitCommitTimestamp sql.NullInt64 `db:"git_commit_timestamp"` RuntimeConfig json.RawMessage `db:"runtime_config"` diff --git a/go/pkg/db/querier_generated.go b/go/pkg/db/querier_generated.go index 9ab7a23a09..f166bc877b 100644 --- a/go/pkg/db/querier_generated.go +++ b/go/pkg/db/querier_generated.go @@ -149,8 +149,7 @@ type Querier interface { // git_branch, // runtime_config, // git_commit_message, - // git_commit_author_name, - // git_commit_author_username, + // git_commit_author_handle, // git_commit_author_avatar_url, // git_commit_timestamp, // openapi_spec, @@ -884,8 +883,7 @@ type Querier interface { // git_branch, // runtime_config, // git_commit_message, - // git_commit_author_name, - // git_commit_author_username, + // git_commit_author_handle, // git_commit_author_avatar_url, // git_commit_timestamp, -- Unix epoch milliseconds // openapi_spec, @@ -908,7 +906,6 @@ type Querier interface { // ?, // ?, // ?, - // ?, // ? // ) InsertDeployment(ctx context.Context, db DBTX, arg InsertDeploymentParams) error diff --git a/go/pkg/db/queries/deployment_find_by_id.sql b/go/pkg/db/queries/deployment_find_by_id.sql index b617fcd757..ee2b438b83 100644 --- a/go/pkg/db/queries/deployment_find_by_id.sql +++ b/go/pkg/db/queries/deployment_find_by_id.sql @@ -8,8 +8,7 @@ SELECT git_branch, runtime_config, git_commit_message, - git_commit_author_name, - git_commit_author_username, + git_commit_author_handle, git_commit_author_avatar_url, git_commit_timestamp, openapi_spec, diff --git a/go/pkg/db/queries/deployment_insert.sql b/go/pkg/db/queries/deployment_insert.sql index 7415304ad7..5d4659f329 100644 --- a/go/pkg/db/queries/deployment_insert.sql +++ b/go/pkg/db/queries/deployment_insert.sql @@ -8,8 +8,7 @@ INSERT INTO `deployments` ( git_branch, runtime_config, git_commit_message, - git_commit_author_name, - git_commit_author_username, + git_commit_author_handle, git_commit_author_avatar_url, git_commit_timestamp, -- Unix epoch milliseconds openapi_spec, @@ -26,8 +25,7 @@ VALUES ( sqlc.arg(git_branch), sqlc.arg(runtime_config), sqlc.arg(git_commit_message), - sqlc.arg(git_commit_author_name), - sqlc.arg(git_commit_author_username), + sqlc.arg(git_commit_author_handle), sqlc.arg(git_commit_author_avatar_url), sqlc.arg(git_commit_timestamp), sqlc.arg(openapi_spec), diff --git a/go/pkg/db/schema.sql b/go/pkg/db/schema.sql index 3b7177fd95..a1f23ad252 100644 --- a/go/pkg/db/schema.sql +++ b/go/pkg/db/schema.sql @@ -331,9 +331,7 @@ CREATE TABLE `deployments` ( `git_commit_sha` varchar(40), `git_branch` varchar(256), `git_commit_message` text, - `git_commit_author_name` varchar(256), - `git_commit_author_email` varchar(256), - `git_commit_author_username` varchar(256), + `git_commit_author_handle` varchar(256), `git_commit_author_avatar_url` varchar(512), `git_commit_timestamp` bigint, `runtime_config` json NOT NULL, @@ -420,4 +418,3 @@ CREATE INDEX `project_idx` ON `domains` (`project_id`); CREATE INDEX `deployment_idx` ON `domains` (`deployment_id`); CREATE INDEX `workspace_idx` ON `acme_challenges` (`workspace_id`); CREATE INDEX `status_idx` ON `acme_challenges` (`status`); - diff --git a/go/pkg/git/git.go b/go/pkg/git/git.go index 10d5666f9b..bc586e9e0f 100644 --- a/go/pkg/git/git.go +++ b/go/pkg/git/git.go @@ -1,8 +1,14 @@ package git import ( + "encoding/json" + "fmt" + "io" + "net/http" "os/exec" + "strconv" "strings" + "time" ) // Info contains Git repository information @@ -14,6 +20,24 @@ type Info struct { IsRepo bool // Whether we're in a Git repository } +// CommitInfo contains detailed information about the current commit +type CommitInfo struct { + SHA string // Full commit SHA + Branch string // Current branch name + Message string // Commit message (first line only) + AuthorHandle string // Author's GitHub handle + AuthorAvatarURL string // URL to author's avatar image + CommitTimestamp int64 // Unix timestamp of the commit +} + +// githubCommitResponse represents the GitHub API commit response +type githubCommitResponse struct { + Author struct { + Login string `json:"login"` + AvatarURL string `json:"avatar_url"` + } `json:"author"` +} + // GetInfo safely extracts Git information from the current directory. // It never fails - returns sensible defaults if Git is unavailable or we're not in a repo. func GetInfo() Info { @@ -50,34 +74,156 @@ func GetInfo() Info { return info } +// GetCommitInfo retrieves detailed information about the current commit. +// Returns error if not in a git repository or if git commands fail. +func GetCommitInfo() (*CommitInfo, error) { + info := &CommitInfo{} + + // Get commit SHA + sha, err := execGitCommand("git", "rev-parse", "HEAD") + if err != nil { + return nil, fmt.Errorf("failed to get commit SHA: %w", err) + } + info.SHA = sha + + // Get current branch + branch, err := execGitCommand("git", "rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + return nil, fmt.Errorf("failed to get branch: %w", err) + } + info.Branch = branch + + // Get commit message (first line only) + message, err := execGitCommand("git", "log", "-1", "--pretty=%s") + if err != nil { + return nil, fmt.Errorf("failed to get commit message: %w", err) + } + info.Message = message + + // Get commit timestamp + timestampStr, err := execGitCommand("git", "log", "-1", "--pretty=%ct") + if err != nil { + return nil, fmt.Errorf("failed to get commit timestamp: %w", err) + } + info.CommitTimestamp, err = strconv.ParseInt(timestampStr, 10, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse timestamp: %w", err) + } + info.CommitTimestamp = info.CommitTimestamp * 1000 + + // Get remote URL to determine if it's a GitHub repo + remoteURL, err := execGitCommand("git", "config", "--get", "remote.origin.url") + if err == nil && isGitHubURL(remoteURL) { + // Extract owner and repo from GitHub URL + owner, repo := parseGitHubURL(remoteURL) + if owner != "" && repo != "" { + // Fetch author info from GitHub API + handle, avatarURL := fetchGitHubAuthorInfo(owner, repo, sha) + info.AuthorHandle = handle + info.AuthorAvatarURL = avatarURL + } + } + + return info, nil +} + +// isGitHubURL checks if the URL is a GitHub repository URL +func isGitHubURL(url string) bool { + return strings.Contains(url, "github.com") +} + +// parseGitHubURL extracts owner and repo name from GitHub URL +// Supports both HTTPS and SSH formats: +// - https://github.com/owner/repo.git +// - git@github.com:owner/repo.git +func parseGitHubURL(url string) (owner, repo string) { + url = strings.TrimSpace(url) + + // Remove .git suffix + url = strings.TrimSuffix(url, ".git") + + // Handle SSH format: git@github.com:owner/repo + if after, ok := strings.CutPrefix(url, "git@github.com:"); ok { + path := after + parts := strings.Split(path, "/") + if len(parts) == 2 { + return parts[0], parts[1] + } + } + + // Handle HTTPS format: https://github.com/owner/repo + if strings.Contains(url, "github.com/") { + parts := strings.Split(url, "github.com/") + if len(parts) == 2 { + pathParts := strings.Split(parts[1], "/") + if len(pathParts) >= 2 { + return pathParts[0], pathParts[1] + } + } + } + + return "", "" +} + +// TODO: We'll have something smarter after demo. As long as we are demoing in a pushed repo we are good. +// fetchGitHubAuthorInfo fetches the commit author's GitHub handle and avatar from GitHub API +func fetchGitHubAuthorInfo(owner, repo, sha string) (string, string) { + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/commits/%s", owner, repo, sha) + + client := &http.Client{ + Timeout: 5 * time.Second, + } + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "", "" + } + + // Set User-Agent header (required by GitHub API) + req.Header.Set("User-Agent", "unkey-cli") + + resp, err := client.Do(req) + if err != nil { + return "", "" + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", "" + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", "" + } + + var commitData githubCommitResponse + if err := json.Unmarshal(body, &commitData); err != nil { + return "", "" + } + + return commitData.Author.Login, commitData.Author.AvatarURL +} + // isGitRepo checks if we're in a Git repository func isGitRepo() bool { - cmd := exec.Command("git", "rev-parse", "--git-dir") - err := cmd.Run() + _, err := execGitCommand("git", "rev-parse", "--git-dir") return err == nil } // getCurrentBranch gets the current branch name func getCurrentBranch() string { - // Try to get branch name from HEAD - cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") - output, err := cmd.Output() + branch, err := execGitCommand("git", "rev-parse", "--abbrev-ref", "HEAD") if err != nil { return "" } - branch := strings.TrimSpace(string(output)) - - // If we're in detached HEAD state, try to get branch from describe if branch == "HEAD" { - cmd = exec.Command("git", "describe", "--contains", "--all", "HEAD") - describeOutput, describeErr := cmd.Output() + describeBranch, describeErr := execGitCommand("git", "describe", "--contains", "--all", "HEAD") if describeErr != nil { return "" } - branch = strings.TrimSpace(string(describeOutput)) - - // Clean up the output (remove refs/heads/ prefix if present) + branch = describeBranch branch = strings.TrimPrefix(branch, "heads/") branch = strings.TrimPrefix(branch, "remotes/origin/") } @@ -87,34 +233,42 @@ func getCurrentBranch() string { // getCommitSHA gets the current commit SHA func getCommitSHA() string { - cmd := exec.Command("git", "rev-parse", "HEAD") - output, err := cmd.Output() + sha, err := execGitCommand("git", "rev-parse", "HEAD") if err != nil { return "" } - return strings.TrimSpace(string(output)) + return sha } // isWorkingDirDirty checks if there are uncommitted changes func isWorkingDirDirty() bool { - // Check for staged changes - cmd := exec.Command("git", "diff-index", "--quiet", "--cached", "HEAD") - if err := cmd.Run(); err != nil { - return true // Has staged changes + _, err := execGitCommand("git", "diff-index", "--quiet", "--cached", "HEAD") + if err != nil { + return true } - // Check for unstaged changes - cmd = exec.Command("git", "diff-files", "--quiet") - if err := cmd.Run(); err != nil { - return true // Has unstaged changes + _, err = execGitCommand("git", "diff-files", "--quiet") + if err != nil { + return true } - // Check for untracked files - cmd = exec.Command("git", "ls-files", "--others", "--exclude-standard") - untrackedOutput, untrackedErr := cmd.Output() + untrackedOutput, untrackedErr := execGitCommand("git", "ls-files", "--others", "--exclude-standard") if untrackedErr != nil { - return false // Assume clean if we can't check + return false } - return strings.TrimSpace(string(untrackedOutput)) != "" + return untrackedOutput != "" +} + +// execGitCommand executes a git command and returns trimmed output +func execGitCommand(name string, args ...string) (string, error) { + cmd := exec.Command(name, args...) + output, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return "", fmt.Errorf("command failed: %s, stderr: %s", err, string(exitErr.Stderr)) + } + return "", err + } + return strings.TrimSpace(string(output)), nil } diff --git a/go/proto/ctrl/v1/deployment.proto b/go/proto/ctrl/v1/deployment.proto index 552e213b81..7d72c5b507 100644 --- a/go/proto/ctrl/v1/deployment.proto +++ b/go/proto/ctrl/v1/deployment.proto @@ -36,14 +36,13 @@ message CreateDeploymentRequest { // Extended git information string git_commit_sha = 7; // For git sources string git_commit_message = 8; - string git_commit_author_name = 9; // TODO: Add GitHub API integration to lookup username/avatar from email - string git_commit_author_username = 10; - string git_commit_author_avatar_url = 11; - int64 git_commit_timestamp = 12; // Unix epoch milliseconds + string git_commit_author_handle = 9; + string git_commit_author_avatar_url = 10; + int64 git_commit_timestamp = 11; // Unix epoch milliseconds // Keyspace ID for authentication - optional string keyspace_id = 13; + optional string keyspace_id = 12; } message CreateDeploymentResponse { @@ -95,11 +94,10 @@ message Deployment { // Extended git information string git_commit_message = 17; - string git_commit_author_name = 18; // Removed: email is PII and not stored - string git_commit_author_username = 20; - string git_commit_author_avatar_url = 21; - int64 git_commit_timestamp = 22; // Unix epoch milliseconds + string git_commit_author_handle = 18; + string git_commit_author_avatar_url = 19; + int64 git_commit_timestamp = 20; // Unix epoch milliseconds } message DeploymentStep { diff --git a/internal/db/src/schema/deployments.ts b/internal/db/src/schema/deployments.ts index b532a77960..95071a228a 100644 --- a/internal/db/src/schema/deployments.ts +++ b/internal/db/src/schema/deployments.ts @@ -20,9 +20,7 @@ export const deployments = mysqlTable( gitCommitSha: varchar("git_commit_sha", { length: 40 }), gitBranch: varchar("git_branch", { length: 256 }), gitCommitMessage: text("git_commit_message"), - gitCommitAuthorName: varchar("git_commit_author_name", { length: 256 }), - gitCommitAuthorEmail: varchar("git_commit_author_email", { length: 256 }), - gitCommitAuthorUsername: varchar("git_commit_author_username", { + gitCommitAuthorHandle: varchar("git_commit_author_handle", { length: 256, }), gitCommitAuthorAvatarUrl: varchar("git_commit_author_avatar_url", { diff --git a/internal/proto/generated/ctrl/v1/deployment_pb.ts b/internal/proto/generated/ctrl/v1/deployment_pb.ts index 6c36d1898b..6be0682d4a 100644 --- a/internal/proto/generated/ctrl/v1/deployment_pb.ts +++ b/internal/proto/generated/ctrl/v1/deployment_pb.ts @@ -12,7 +12,7 @@ import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf export const file_ctrl_v1_deployment: GenFile = /*@__PURE__*/ fileDesc( - "ChhjdHJsL3YxL2RlcGxveW1lbnQucHJvdG8SB2N0cmwudjEikwMKF0NyZWF0ZURlcGxveW1lbnRSZXF1ZXN0EhQKDHdvcmtzcGFjZV9pZBgBIAEoCRISCgpwcm9qZWN0X2lkGAIgASgJEg4KBmJyYW5jaBgDIAEoCRIYChBlbnZpcm9ubWVudF9zbHVnGAQgASgJEigKC3NvdXJjZV90eXBlGAUgASgOMhMuY3RybC52MS5Tb3VyY2VUeXBlEhQKDGRvY2tlcl9pbWFnZRgGIAEoCRIWCg5naXRfY29tbWl0X3NoYRgHIAEoCRIaChJnaXRfY29tbWl0X21lc3NhZ2UYCCABKAkSHgoWZ2l0X2NvbW1pdF9hdXRob3JfbmFtZRgJIAEoCRIiChpnaXRfY29tbWl0X2F1dGhvcl91c2VybmFtZRgKIAEoCRIkChxnaXRfY29tbWl0X2F1dGhvcl9hdmF0YXJfdXJsGAsgASgJEhwKFGdpdF9jb21taXRfdGltZXN0YW1wGAwgASgDEhgKC2tleXNwYWNlX2lkGA0gASgJSACIAQFCDgoMX2tleXNwYWNlX2lkIlwKGENyZWF0ZURlcGxveW1lbnRSZXNwb25zZRIVCg1kZXBsb3ltZW50X2lkGAEgASgJEikKBnN0YXR1cxgCIAEoDjIZLmN0cmwudjEuRGVwbG95bWVudFN0YXR1cyItChRHZXREZXBsb3ltZW50UmVxdWVzdBIVCg1kZXBsb3ltZW50X2lkGAEgASgJIkAKFUdldERlcGxveW1lbnRSZXNwb25zZRInCgpkZXBsb3ltZW50GAEgASgLMhMuY3RybC52MS5EZXBsb3ltZW50IqoFCgpEZXBsb3ltZW50EgoKAmlkGAEgASgJEhQKDHdvcmtzcGFjZV9pZBgCIAEoCRISCgpwcm9qZWN0X2lkGAMgASgJEhYKDmVudmlyb25tZW50X2lkGAQgASgJEhYKDmdpdF9jb21taXRfc2hhGAUgASgJEhIKCmdpdF9icmFuY2gYBiABKAkSKQoGc3RhdHVzGAcgASgOMhkuY3RybC52MS5EZXBsb3ltZW50U3RhdHVzEhUKDWVycm9yX21lc3NhZ2UYCCABKAkSTAoVZW52aXJvbm1lbnRfdmFyaWFibGVzGAkgAygLMi0uY3RybC52MS5EZXBsb3ltZW50LkVudmlyb25tZW50VmFyaWFibGVzRW50cnkSIwoIdG9wb2xvZ3kYCiABKAsyES5jdHJsLnYxLlRvcG9sb2d5EhIKCmNyZWF0ZWRfYXQYCyABKAMSEgoKdXBkYXRlZF9hdBgMIAEoAxIRCglob3N0bmFtZXMYDSADKAkSFwoPcm9vdGZzX2ltYWdlX2lkGA4gASgJEhAKCGJ1aWxkX2lkGA8gASgJEiYKBXN0ZXBzGBAgAygLMhcuY3RybC52MS5EZXBsb3ltZW50U3RlcBIaChJnaXRfY29tbWl0X21lc3NhZ2UYESABKAkSHgoWZ2l0X2NvbW1pdF9hdXRob3JfbmFtZRgSIAEoCRIiChpnaXRfY29tbWl0X2F1dGhvcl91c2VybmFtZRgUIAEoCRIkChxnaXRfY29tbWl0X2F1dGhvcl9hdmF0YXJfdXJsGBUgASgJEhwKFGdpdF9jb21taXRfdGltZXN0YW1wGBYgASgDGjsKGUVudmlyb25tZW50VmFyaWFibGVzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASJcCg5EZXBsb3ltZW50U3RlcBIOCgZzdGF0dXMYASABKAkSDwoHbWVzc2FnZRgCIAEoCRIVCg1lcnJvcl9tZXNzYWdlGAMgASgJEhIKCmNyZWF0ZWRfYXQYBCABKAMipgEKCFRvcG9sb2d5EhYKDmNwdV9taWxsaWNvcmVzGAEgASgFEhEKCW1lbW9yeV9tYhgCIAEoBRIoCgdyZWdpb25zGAMgAygLMhcuY3RybC52MS5SZWdpb25hbENvbmZpZxIcChRpZGxlX3RpbWVvdXRfc2Vjb25kcxgEIAEoBRIZChFoZWFsdGhfY2hlY2tfcGF0aBgFIAEoCRIMCgRwb3J0GAYgASgFIk4KDlJlZ2lvbmFsQ29uZmlnEg4KBnJlZ2lvbhgBIAEoCRIVCg1taW5faW5zdGFuY2VzGAIgASgFEhUKDW1heF9pbnN0YW5jZXMYAyABKAUiTQoPUm9sbGJhY2tSZXF1ZXN0EhwKFHNvdXJjZV9kZXBsb3ltZW50X2lkGAEgASgJEhwKFHRhcmdldF9kZXBsb3ltZW50X2lkGAIgASgJIhIKEFJvbGxiYWNrUmVzcG9uc2UiLgoOUHJvbW90ZVJlcXVlc3QSHAoUdGFyZ2V0X2RlcGxveW1lbnRfaWQYASABKAkiEQoPUHJvbW90ZVJlc3BvbnNlKu8BChBEZXBsb3ltZW50U3RhdHVzEiEKHURFUExPWU1FTlRfU1RBVFVTX1VOU1BFQ0lGSUVEEAASHQoZREVQTE9ZTUVOVF9TVEFUVVNfUEVORElORxABEh4KGkRFUExPWU1FTlRfU1RBVFVTX0JVSUxESU5HEAISHwobREVQTE9ZTUVOVF9TVEFUVVNfREVQTE9ZSU5HEAMSHQoZREVQTE9ZTUVOVF9TVEFUVVNfTkVUV09SSxAEEhsKF0RFUExPWU1FTlRfU1RBVFVTX1JFQURZEAUSHAoYREVQTE9ZTUVOVF9TVEFUVVNfRkFJTEVEEAYqWgoKU291cmNlVHlwZRIbChdTT1VSQ0VfVFlQRV9VTlNQRUNJRklFRBAAEhMKD1NPVVJDRV9UWVBFX0dJVBABEhoKFlNPVVJDRV9UWVBFX0NMSV9VUExPQUQQAjLDAgoRRGVwbG95bWVudFNlcnZpY2USWQoQQ3JlYXRlRGVwbG95bWVudBIgLmN0cmwudjEuQ3JlYXRlRGVwbG95bWVudFJlcXVlc3QaIS5jdHJsLnYxLkNyZWF0ZURlcGxveW1lbnRSZXNwb25zZSIAElAKDUdldERlcGxveW1lbnQSHS5jdHJsLnYxLkdldERlcGxveW1lbnRSZXF1ZXN0Gh4uY3RybC52MS5HZXREZXBsb3ltZW50UmVzcG9uc2UiABJBCghSb2xsYmFjaxIYLmN0cmwudjEuUm9sbGJhY2tSZXF1ZXN0GhkuY3RybC52MS5Sb2xsYmFja1Jlc3BvbnNlIgASPgoHUHJvbW90ZRIXLmN0cmwudjEuUHJvbW90ZVJlcXVlc3QaGC5jdHJsLnYxLlByb21vdGVSZXNwb25zZSIAQjZaNGdpdGh1Yi5jb20vdW5rZXllZC91bmtleS9nby9nZW4vcHJvdG8vY3RybC92MTtjdHJsdjFiBnByb3RvMw", + "ChhjdHJsL3YxL2RlcGxveW1lbnQucHJvdG8SB2N0cmwudjEi8QIKF0NyZWF0ZURlcGxveW1lbnRSZXF1ZXN0EhQKDHdvcmtzcGFjZV9pZBgBIAEoCRISCgpwcm9qZWN0X2lkGAIgASgJEg4KBmJyYW5jaBgDIAEoCRIYChBlbnZpcm9ubWVudF9zbHVnGAQgASgJEigKC3NvdXJjZV90eXBlGAUgASgOMhMuY3RybC52MS5Tb3VyY2VUeXBlEhQKDGRvY2tlcl9pbWFnZRgGIAEoCRIWCg5naXRfY29tbWl0X3NoYRgHIAEoCRIaChJnaXRfY29tbWl0X21lc3NhZ2UYCCABKAkSIAoYZ2l0X2NvbW1pdF9hdXRob3JfaGFuZGxlGAkgASgJEiQKHGdpdF9jb21taXRfYXV0aG9yX2F2YXRhcl91cmwYCiABKAkSHAoUZ2l0X2NvbW1pdF90aW1lc3RhbXAYCyABKAMSGAoLa2V5c3BhY2VfaWQYDCABKAlIAIgBAUIOCgxfa2V5c3BhY2VfaWQiXAoYQ3JlYXRlRGVwbG95bWVudFJlc3BvbnNlEhUKDWRlcGxveW1lbnRfaWQYASABKAkSKQoGc3RhdHVzGAIgASgOMhkuY3RybC52MS5EZXBsb3ltZW50U3RhdHVzIi0KFEdldERlcGxveW1lbnRSZXF1ZXN0EhUKDWRlcGxveW1lbnRfaWQYASABKAkiQAoVR2V0RGVwbG95bWVudFJlc3BvbnNlEicKCmRlcGxveW1lbnQYASABKAsyEy5jdHJsLnYxLkRlcGxveW1lbnQiiAUKCkRlcGxveW1lbnQSCgoCaWQYASABKAkSFAoMd29ya3NwYWNlX2lkGAIgASgJEhIKCnByb2plY3RfaWQYAyABKAkSFgoOZW52aXJvbm1lbnRfaWQYBCABKAkSFgoOZ2l0X2NvbW1pdF9zaGEYBSABKAkSEgoKZ2l0X2JyYW5jaBgGIAEoCRIpCgZzdGF0dXMYByABKA4yGS5jdHJsLnYxLkRlcGxveW1lbnRTdGF0dXMSFQoNZXJyb3JfbWVzc2FnZRgIIAEoCRJMChVlbnZpcm9ubWVudF92YXJpYWJsZXMYCSADKAsyLS5jdHJsLnYxLkRlcGxveW1lbnQuRW52aXJvbm1lbnRWYXJpYWJsZXNFbnRyeRIjCgh0b3BvbG9neRgKIAEoCzIRLmN0cmwudjEuVG9wb2xvZ3kSEgoKY3JlYXRlZF9hdBgLIAEoAxISCgp1cGRhdGVkX2F0GAwgASgDEhEKCWhvc3RuYW1lcxgNIAMoCRIXCg9yb290ZnNfaW1hZ2VfaWQYDiABKAkSEAoIYnVpbGRfaWQYDyABKAkSJgoFc3RlcHMYECADKAsyFy5jdHJsLnYxLkRlcGxveW1lbnRTdGVwEhoKEmdpdF9jb21taXRfbWVzc2FnZRgRIAEoCRIgChhnaXRfY29tbWl0X2F1dGhvcl9oYW5kbGUYEiABKAkSJAocZ2l0X2NvbW1pdF9hdXRob3JfYXZhdGFyX3VybBgTIAEoCRIcChRnaXRfY29tbWl0X3RpbWVzdGFtcBgUIAEoAxo7ChlFbnZpcm9ubWVudFZhcmlhYmxlc0VudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEiXAoORGVwbG95bWVudFN0ZXASDgoGc3RhdHVzGAEgASgJEg8KB21lc3NhZ2UYAiABKAkSFQoNZXJyb3JfbWVzc2FnZRgDIAEoCRISCgpjcmVhdGVkX2F0GAQgASgDIqYBCghUb3BvbG9neRIWCg5jcHVfbWlsbGljb3JlcxgBIAEoBRIRCgltZW1vcnlfbWIYAiABKAUSKAoHcmVnaW9ucxgDIAMoCzIXLmN0cmwudjEuUmVnaW9uYWxDb25maWcSHAoUaWRsZV90aW1lb3V0X3NlY29uZHMYBCABKAUSGQoRaGVhbHRoX2NoZWNrX3BhdGgYBSABKAkSDAoEcG9ydBgGIAEoBSJOCg5SZWdpb25hbENvbmZpZxIOCgZyZWdpb24YASABKAkSFQoNbWluX2luc3RhbmNlcxgCIAEoBRIVCg1tYXhfaW5zdGFuY2VzGAMgASgFIk0KD1JvbGxiYWNrUmVxdWVzdBIcChRzb3VyY2VfZGVwbG95bWVudF9pZBgBIAEoCRIcChR0YXJnZXRfZGVwbG95bWVudF9pZBgCIAEoCSISChBSb2xsYmFja1Jlc3BvbnNlIi4KDlByb21vdGVSZXF1ZXN0EhwKFHRhcmdldF9kZXBsb3ltZW50X2lkGAEgASgJIhEKD1Byb21vdGVSZXNwb25zZSrvAQoQRGVwbG95bWVudFN0YXR1cxIhCh1ERVBMT1lNRU5UX1NUQVRVU19VTlNQRUNJRklFRBAAEh0KGURFUExPWU1FTlRfU1RBVFVTX1BFTkRJTkcQARIeChpERVBMT1lNRU5UX1NUQVRVU19CVUlMRElORxACEh8KG0RFUExPWU1FTlRfU1RBVFVTX0RFUExPWUlORxADEh0KGURFUExPWU1FTlRfU1RBVFVTX05FVFdPUksQBBIbChdERVBMT1lNRU5UX1NUQVRVU19SRUFEWRAFEhwKGERFUExPWU1FTlRfU1RBVFVTX0ZBSUxFRBAGKloKClNvdXJjZVR5cGUSGwoXU09VUkNFX1RZUEVfVU5TUEVDSUZJRUQQABITCg9TT1VSQ0VfVFlQRV9HSVQQARIaChZTT1VSQ0VfVFlQRV9DTElfVVBMT0FEEAIywwIKEURlcGxveW1lbnRTZXJ2aWNlElkKEENyZWF0ZURlcGxveW1lbnQSIC5jdHJsLnYxLkNyZWF0ZURlcGxveW1lbnRSZXF1ZXN0GiEuY3RybC52MS5DcmVhdGVEZXBsb3ltZW50UmVzcG9uc2UiABJQCg1HZXREZXBsb3ltZW50Eh0uY3RybC52MS5HZXREZXBsb3ltZW50UmVxdWVzdBoeLmN0cmwudjEuR2V0RGVwbG95bWVudFJlc3BvbnNlIgASQQoIUm9sbGJhY2sSGC5jdHJsLnYxLlJvbGxiYWNrUmVxdWVzdBoZLmN0cmwudjEuUm9sbGJhY2tSZXNwb25zZSIAEj4KB1Byb21vdGUSFy5jdHJsLnYxLlByb21vdGVSZXF1ZXN0GhguY3RybC52MS5Qcm9tb3RlUmVzcG9uc2UiAEI2WjRnaXRodWIuY29tL3Vua2V5ZWQvdW5rZXkvZ28vZ2VuL3Byb3RvL2N0cmwvdjE7Y3RybHYxYgZwcm90bzM", ); /** @@ -65,34 +65,29 @@ export type CreateDeploymentRequest = Message<"ctrl.v1.CreateDeploymentRequest"> */ gitCommitMessage: string; - /** - * @generated from field: string git_commit_author_name = 9; - */ - gitCommitAuthorName: string; - /** * TODO: Add GitHub API integration to lookup username/avatar from email * - * @generated from field: string git_commit_author_username = 10; + * @generated from field: string git_commit_author_handle = 9; */ - gitCommitAuthorUsername: string; + gitCommitAuthorHandle: string; /** - * @generated from field: string git_commit_author_avatar_url = 11; + * @generated from field: string git_commit_author_avatar_url = 10; */ gitCommitAuthorAvatarUrl: string; /** * Unix epoch milliseconds * - * @generated from field: int64 git_commit_timestamp = 12; + * @generated from field: int64 git_commit_timestamp = 11; */ gitCommitTimestamp: bigint; /** * Keyspace ID for authentication * - * @generated from field: optional string keyspace_id = 13; + * @generated from field: optional string keyspace_id = 12; */ keyspaceId?: string; }; @@ -275,27 +270,22 @@ export type Deployment = Message<"ctrl.v1.Deployment"> & { */ gitCommitMessage: string; - /** - * @generated from field: string git_commit_author_name = 18; - */ - gitCommitAuthorName: string; - /** * Removed: email is PII and not stored * - * @generated from field: string git_commit_author_username = 20; + * @generated from field: string git_commit_author_handle = 18; */ - gitCommitAuthorUsername: string; + gitCommitAuthorHandle: string; /** - * @generated from field: string git_commit_author_avatar_url = 21; + * @generated from field: string git_commit_author_avatar_url = 19; */ gitCommitAuthorAvatarUrl: string; /** * Unix epoch milliseconds * - * @generated from field: int64 git_commit_timestamp = 22; + * @generated from field: int64 git_commit_timestamp = 20; */ gitCommitTimestamp: bigint; }; From 7fcd5155d7d260a4ba504c1269f8524d6cd5174c Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Mon, 29 Sep 2025 18:19:37 +0300 Subject: [PATCH 02/10] fix: empty state --- .../project-details-expandables/index.tsx | 3 +- .../_components/list/projects-card.tsx | 69 +++++++++++-------- .../_components/list/region-badges.tsx | 2 +- .../lib/collections/deploy/projects.ts | 6 +- .../lib/trpc/routers/deploy/project/list.ts | 6 +- 5 files changed, 51 insertions(+), 35 deletions(-) diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/index.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/index.tsx index 9fd2bba847..e4687bb0b3 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/index.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/index.tsx @@ -117,8 +117,9 @@ export const ProjectDetailsExpandable = ({ {data.project.name} {/*Top Section > Project actions*/}
{actions}
{/*Middle Section > Last commit title*/}
- - - {commitTitle} - - + {commitTitle ? ( + + + {commitTitle} + + + ) : ( +
No commit info
+ )} +
{commitTimestamp ? ( ) : ( - No deployments + + No deployments + )} {branch} - by - - - {author} - + {authorAvatar && ( + <> + by + + + + {author} + + + + )}
{/*Bottom Section > Regions*/} diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/_components/list/region-badges.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/_components/list/region-badges.tsx index 72b0cc00ca..4b704a3c56 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/projects/_components/list/region-badges.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/projects/_components/list/region-badges.tsx @@ -13,7 +13,7 @@ export const RegionBadges = ({ regions, repository }: RegionBadgesProps) => { const remainingCount = remainingRegions.length; return ( -
+
{visibleRegions.map((region) => (
r.region) ?? ["us-east-1"], - domain: row.domain ?? "project-temp.unkey.app", + domain: row.domain, }), ); }); From 92efcee6613d29710709c167a09decff8f4af0d5 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 30 Sep 2025 11:55:15 +0300 Subject: [PATCH 03/10] fix: domain order --- .../components/table/components/domain_list.tsx | 14 ++++++++++++++ .../components/table/deployments-list.tsx | 16 +++------------- .../project-details-expandables/index.tsx | 12 ++++++++---- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/deployments/components/table/components/domain_list.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/deployments/components/table/components/domain_list.tsx index fbf59a4ce6..ba449b3e72 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/deployments/components/table/components/domain_list.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/deployments/components/table/components/domain_list.tsx @@ -14,6 +14,10 @@ export const DomainList = ({ deploymentId }: Props) => { .orderBy(({ domain }) => domain.domain, "asc"), ); + if (domains.isLoading || !domains.data.length) { + return ; + } + return (
    {domains.data.map((domain) => ( @@ -22,3 +26,13 @@ export const DomainList = ({ deploymentId }: Props) => {
); }; + +const DomainListSkeleton = () => ( +
    + {[1, 2, 3].map((i) => ( +
  • +
    +
  • + ))} +
+); diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/deployments/components/table/deployments-list.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/deployments/components/table/deployments-list.tsx index af573dc270..84777b79cb 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/deployments/components/table/deployments-list.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/deployments/components/table/deployments-list.tsx @@ -4,12 +4,13 @@ import type { Column } from "@/components/virtual-table/types"; import { useIsMobile } from "@/hooks/use-mobile"; import type { Deployment, Environment } from "@/lib/collections"; import { shortenId } from "@/lib/shorten-id"; -import { BookBookmark, Cloud, CodeBranch, Cube } from "@unkey/icons"; +import { BookBookmark, CodeBranch, Cube } from "@unkey/icons"; import { Button, Empty, TimestampInfo } from "@unkey/ui"; import { cn } from "@unkey/ui/src/lib/utils"; import dynamic from "next/dynamic"; import { useMemo, useState } from "react"; import { Avatar } from "../../../details/active-deployment-card/git-avatar"; +import { StatusIndicator } from "../../../details/active-deployment-card/status-indicator"; import { useDeployments } from "../../hooks/use-deployments"; import { DeploymentStatusBadge } from "./components/deployment-status-badge"; import { DomainList } from "./components/domain_list"; @@ -64,18 +65,7 @@ export const DeploymentsList = () => { headerClassName: "pl-[18px]", render: ({ deployment, environment }) => { const isLive = liveDeployment?.id === deployment.id; - const isSelected = deployment.id === selectedDeployment?.deployment.id; - const iconContainer = ( -
- -
- ); + const iconContainer = ; return (
diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/index.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/index.tsx index e4687bb0b3..fde3523a3e 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/index.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/index.tsx @@ -52,6 +52,10 @@ export const ProjectDetailsExpandable = ({ repository: data.project.gitRepositoryUrl, }); + // This "environment" domain never changes even when you do a rollback this one stays stable. + const mainDomain = domainsData.at(0)?.domain; + const gitShaAndBranchNameDomains = domainsData.slice(1); + return (
{/* # is okay. This section is not accessible without deploy*/} - {data.project.domain} + {mainDomain} - {domainsData.slice(1).map((d) => ( + {gitShaAndBranchNameDomains.map((d) => (
- +{domainsData.slice(1).length} + +{gitShaAndBranchNameDomains.length}
From a73ade983154ca319d87c048c4156b264e5fe4a3 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 30 Sep 2025 12:25:50 +0300 Subject: [PATCH 04/10] chore: add back comments --- go/pkg/git/git.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/go/pkg/git/git.go b/go/pkg/git/git.go index bc586e9e0f..3252fab07d 100644 --- a/go/pkg/git/git.go +++ b/go/pkg/git/git.go @@ -213,11 +213,12 @@ func isGitRepo() bool { // getCurrentBranch gets the current branch name func getCurrentBranch() string { + // Try to get branch name from HEAD branch, err := execGitCommand("git", "rev-parse", "--abbrev-ref", "HEAD") if err != nil { return "" } - + // If we're in detached HEAD state, try to get branch from describe if branch == "HEAD" { describeBranch, describeErr := execGitCommand("git", "describe", "--contains", "--all", "HEAD") if describeErr != nil { @@ -242,16 +243,17 @@ func getCommitSHA() string { // isWorkingDirDirty checks if there are uncommitted changes func isWorkingDirDirty() bool { + // Check for staged changes _, err := execGitCommand("git", "diff-index", "--quiet", "--cached", "HEAD") if err != nil { return true } - + // Check for unstaged changes _, err = execGitCommand("git", "diff-files", "--quiet") if err != nil { return true } - + // Check for untracked files untrackedOutput, untrackedErr := execGitCommand("git", "ls-files", "--others", "--exclude-standard") if untrackedErr != nil { return false From ca0f0ee2d89d24f08adfd41718515c7e2180e519 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 30 Sep 2025 13:04:58 +0300 Subject: [PATCH 05/10] fix: domain order check --- .../details/project-details-expandables/index.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/index.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/index.tsx index fde3523a3e..4eb0b5cce6 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/index.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/index.tsx @@ -38,7 +38,10 @@ export const ProjectDetailsExpandable = ({ q .from({ domain: collections.domains }) .where(({ domain }) => eq(domain.deploymentId, data?.project.liveDeploymentId)) - .select(({ domain }) => ({ domain: domain.domain })) + .select(({ domain }) => ({ + domain: domain.domain, + environment: domain.sticky, + })) .orderBy(({ domain }) => domain.id, "asc"), [data?.project.liveDeploymentId], ); @@ -53,8 +56,8 @@ export const ProjectDetailsExpandable = ({ }); // This "environment" domain never changes even when you do a rollback this one stays stable. - const mainDomain = domainsData.at(0)?.domain; - const gitShaAndBranchNameDomains = domainsData.slice(1); + const mainDomain = domainsData.find((d) => d.environment === "environment")?.domain; + const gitShaAndBranchNameDomains = domainsData.filter((d) => d.environment !== "environment"); return (
From 62f301e96104df0a1ba9717261d012e21587197c Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 30 Sep 2025 13:05:11 +0300 Subject: [PATCH 06/10] refactor: merge gitInfo and gitCommit --- go/cmd/deploy/control_plane.go | 11 +--- go/pkg/git/git.go | 100 ++++++++++++--------------------- 2 files changed, 38 insertions(+), 73 deletions(-) diff --git a/go/cmd/deploy/control_plane.go b/go/cmd/deploy/control_plane.go index f7b30c4d58..405e381152 100644 --- a/go/cmd/deploy/control_plane.go +++ b/go/cmd/deploy/control_plane.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "log" "net/http" "strings" "time" @@ -53,13 +52,7 @@ func NewControlPlaneClient(opts DeployOptions) *ControlPlaneClient { // CreateDeployment creates a new deployment in the control plane func (c *ControlPlaneClient) CreateDeployment(ctx context.Context, dockerImage string) (string, error) { // Get git commit information - commitInfo, err := git.GetCommitInfo() - if err != nil { - // Log warning but don't fail the deployment if git info is unavailable - log.Printf("Warning: failed to get git commit info: %v", err) - commitInfo = &git.CommitInfo{} // Use empty struct - } - + commitInfo := git.GetInfo() createReq := connect.NewRequest(&ctrlv1.CreateDeploymentRequest{ WorkspaceId: c.opts.WorkspaceID, ProjectId: c.opts.ProjectID, @@ -68,7 +61,7 @@ func (c *ControlPlaneClient) CreateDeployment(ctx context.Context, dockerImage s SourceType: ctrlv1.SourceType_SOURCE_TYPE_CLI_UPLOAD, EnvironmentSlug: c.opts.Environment, DockerImage: dockerImage, - GitCommitSha: commitInfo.SHA, + GitCommitSha: commitInfo.CommitSHA, GitCommitMessage: commitInfo.Message, GitCommitAuthorHandle: commitInfo.AuthorHandle, GitCommitAuthorAvatarUrl: commitInfo.AuthorAvatarURL, diff --git a/go/pkg/git/git.go b/go/pkg/git/git.go index 3252fab07d..2f2614e8e1 100644 --- a/go/pkg/git/git.go +++ b/go/pkg/git/git.go @@ -11,23 +11,22 @@ import ( "time" ) -// Info contains Git repository information +// Info contains comprehensive Git repo and commit information type Info struct { - Branch string // Current branch name (e.g., "main", "feature/auth") + // Basic repo status + Branch string // Current branch name (e.g., "main", "feature/auth") + IsDirty bool // Whether there are uncommitted changes + IsRepo bool // Whether we're in a Git repo + + // Commit identification CommitSHA string // Full commit SHA (e.g., "abc123def456...") ShortSHA string // Short commit SHA (e.g., "abc123d") - IsDirty bool // Whether there are uncommitted changes - IsRepo bool // Whether we're in a Git repository -} -// CommitInfo contains detailed information about the current commit -type CommitInfo struct { - SHA string // Full commit SHA - Branch string // Current branch name + // Commit details Message string // Commit message (first line only) AuthorHandle string // Author's GitHub handle AuthorAvatarURL string // URL to author's avatar image - CommitTimestamp int64 // Unix timestamp of the commit + CommitTimestamp int64 // Unix timestamp of the commit in milliseconds } // githubCommitResponse represents the GitHub API commit response @@ -40,13 +39,12 @@ type githubCommitResponse struct { // GetInfo safely extracts Git information from the current directory. // It never fails - returns sensible defaults if Git is unavailable or we're not in a repo. +// Extended commit details (message, author, timestamp) are populated when available. func GetInfo() Info { info := Info{ - Branch: "main", // Default branch - CommitSHA: "", // Empty if not available - ShortSHA: "", // Empty if not available - IsDirty: false, // Assume clean if unknown - IsRepo: false, // Assume not a repo until proven otherwise + Branch: "main", // Default branch + IsDirty: false, // Assume clean if unknown + IsRepo: false, // Assume not a repo until proven otherwise } // Check if we're in a Git repository @@ -71,60 +69,34 @@ func GetInfo() Info { // Check if working directory is dirty info.IsDirty = isWorkingDirDirty() - return info -} - -// GetCommitInfo retrieves detailed information about the current commit. -// Returns error if not in a git repository or if git commands fail. -func GetCommitInfo() (*CommitInfo, error) { - info := &CommitInfo{} - - // Get commit SHA - sha, err := execGitCommand("git", "rev-parse", "HEAD") - if err != nil { - return nil, fmt.Errorf("failed to get commit SHA: %w", err) - } - info.SHA = sha - - // Get current branch - branch, err := execGitCommand("git", "rev-parse", "--abbrev-ref", "HEAD") - if err != nil { - return nil, fmt.Errorf("failed to get branch: %w", err) - } - info.Branch = branch + // Get extended commit details (best effort - ignore errors) + if info.CommitSHA != "" { + // Get commit message (first line only) + if message, err := execGitCommand("git", "log", "-1", "--pretty=%s"); err == nil { + info.Message = message + } - // Get commit message (first line only) - message, err := execGitCommand("git", "log", "-1", "--pretty=%s") - if err != nil { - return nil, fmt.Errorf("failed to get commit message: %w", err) - } - info.Message = message + // Get commit timestamp + if timestampStr, err := execGitCommand("git", "log", "-1", "--pretty=%ct"); err == nil { + if timestamp, err := strconv.ParseInt(timestampStr, 10, 64); err == nil { + info.CommitTimestamp = timestamp * 1000 // Convert to milliseconds + } + } - // Get commit timestamp - timestampStr, err := execGitCommand("git", "log", "-1", "--pretty=%ct") - if err != nil { - return nil, fmt.Errorf("failed to get commit timestamp: %w", err) - } - info.CommitTimestamp, err = strconv.ParseInt(timestampStr, 10, 64) - if err != nil { - return nil, fmt.Errorf("failed to parse timestamp: %w", err) - } - info.CommitTimestamp = info.CommitTimestamp * 1000 - - // Get remote URL to determine if it's a GitHub repo - remoteURL, err := execGitCommand("git", "config", "--get", "remote.origin.url") - if err == nil && isGitHubURL(remoteURL) { - // Extract owner and repo from GitHub URL - owner, repo := parseGitHubURL(remoteURL) - if owner != "" && repo != "" { - // Fetch author info from GitHub API - handle, avatarURL := fetchGitHubAuthorInfo(owner, repo, sha) - info.AuthorHandle = handle - info.AuthorAvatarURL = avatarURL + // Get remote URL to determine if it's a GitHub repo + if remoteURL, err := execGitCommand("git", "config", "--get", "remote.origin.url"); err == nil && isGitHubURL(remoteURL) { + // Extract owner and repo from GitHub URL + owner, repo := parseGitHubURL(remoteURL) + if owner != "" && repo != "" { + // Fetch author info from GitHub API (best effort) + handle, avatarURL := fetchGitHubAuthorInfo(owner, repo, info.CommitSHA) + info.AuthorHandle = handle + info.AuthorAvatarURL = avatarURL + } } } - return info, nil + return info } // isGitHubURL checks if the URL is a GitHub repository URL From c72836688aa562214bfecfcb28e13ccf2f27425b Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 30 Sep 2025 13:32:23 +0300 Subject: [PATCH 07/10] feat: add indicator --- .../status-indicator.tsx | 21 +++- .../detail-section.tsx | 38 +++++-- .../project-details-expandables/sections.tsx | 105 +++++++++++++++--- 3 files changed, 135 insertions(+), 29 deletions(-) diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/active-deployment-card/status-indicator.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/active-deployment-card/status-indicator.tsx index 275d88b56b..f23d4efe4c 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/active-deployment-card/status-indicator.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/active-deployment-card/status-indicator.tsx @@ -1,28 +1,37 @@ import { Cloud } from "@unkey/icons"; import { cn } from "@unkey/ui/src/lib/utils"; +type StatusIndicatorProps = { + withSignal?: boolean; + className?: string; +}; + export function StatusIndicator({ withSignal = false, -}: { - withSignal?: boolean; -}) { + className, +}: StatusIndicatorProps) { return (
-
+
{withSignal && (
{[0, 0.15, 0.3, 0.45].map((delay, index) => (
+ // biome-ignore lint/suspicious/noArrayIndexKey: its okay to use index as key key={index} className={cn( "absolute inset-0 size-2 rounded-full", index === 0 && "bg-successA-9 opacity-75", index === 1 && "bg-successA-10 opacity-60", index === 2 && "bg-successA-11 opacity-40", - index === 3 && "bg-successA-12 opacity-25", + index === 3 && "bg-successA-12 opacity-25" )} style={{ animation: "ping 2s cubic-bezier(0, 0, 0.2, 1) infinite", diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/detail-section.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/detail-section.tsx index a358894a2e..079e5945a8 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/detail-section.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/detail-section.tsx @@ -2,22 +2,40 @@ import type { ReactNode } from "react"; import type { DetailItem } from "./sections"; type DetailRowProps = { - icon: ReactNode; - label: string; + icon: ReactNode | null; + label: string | null; children: ReactNode; alignment?: "center" | "start"; }; -function DetailRow({ icon, label, children, alignment = "center" }: DetailRowProps) { +function DetailRow({ + icon, + label, + children, + alignment = "center", +}: DetailRowProps) { const alignmentClass = alignment === "start" ? "items-start" : "items-center"; + // If both icon and label are missing, let children take full space + if (!icon && !label) { + return ( +
+
+ {children} +
+
+ ); + } + return (
-
- {icon} -
- {label} + {icon && ( +
+ {icon} +
+ )} + {label && {label}}
{children}
@@ -30,7 +48,11 @@ type DetailSectionProps = { isFirst?: boolean; }; -export function DetailSection({ title, items, isFirst = false }: DetailSectionProps) { +export function DetailSection({ + title, + items, + isFirst = false, +}: DetailSectionProps) { return (
diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/sections.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/sections.tsx index 8148319503..b16cbe6fff 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/sections.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/sections.tsx @@ -1,5 +1,6 @@ import type { Deployment } from "@/lib/collections"; import { + ArrowRight, Bolt, ChartActivity, CircleHalfDottedClock, @@ -18,10 +19,11 @@ import { Badge, TimestampInfo } from "@unkey/ui"; import type { ReactNode } from "react"; import { RepoDisplay } from "../../../_components/list/repo-display"; import { Avatar } from "../active-deployment-card/git-avatar"; +import { StatusIndicator } from "../active-deployment-card/status-indicator"; export type DetailItem = { - icon: ReactNode; - label: string; + icon: ReactNode | null; + label: string | null; content: ReactNode; alignment?: "center" | "start"; }; @@ -34,6 +36,44 @@ export type DetailSection = { export const createDetailSections = ( details: Deployment & { repository: string | null }, ): DetailSection[] => [ + { + title: "OpenAPI changes", + items: [ + { + icon: null, + label: null, + alignment: "start", + content: ( +
+
+
+ +
+
+
from
+
+ v_charlie042 +
+
+
+ +
+
+ +
+
+
from
+
v_oz
+
+
+
+ ), + }, + ], + }, { title: "Active deployment", items: [ @@ -49,14 +89,20 @@ export const createDetailSections = ( ), }, { - icon: , + icon: ( + + ), label: "Branch", content: ( - {details.gitBranch} + + {details.gitBranch} + ), }, { - icon: , + icon: ( + + ), label: "Commit", content: ( @@ -65,11 +111,18 @@ export const createDetailSections = ( ), }, { - icon: , + icon: ( + + ), label: "Description", content: (
- {details.gitCommitMessage} + + {details.gitCommitMessage} +
), }, @@ -87,7 +140,12 @@ export const createDetailSections = ( ), }, { - icon: , + icon: ( + + ), label: "Created", content: ( - {details.runtimeConfig.regions.reduce((acc, region) => acc + region.vmCount, 0)} + {details.runtimeConfig.regions.reduce( + (acc, region) => acc + region.vmCount, + 0 + )} vm
), }, { - icon: , + icon: ( + + ), label: "Regions", alignment: "start", content: ( @@ -135,7 +198,9 @@ export const createDetailSections = ( label: "CPU", content: (
- {details.runtimeConfig.cpus} + + {details.runtimeConfig.cpus} + vCPUs
), @@ -145,13 +210,17 @@ export const createDetailSections = ( label: "Memory", content: (
- {details.runtimeConfig.memory} + + {details.runtimeConfig.memory} + mb
), }, { - icon: , + icon: ( + + ), label: "Storage", content: (
@@ -184,7 +253,12 @@ export const createDetailSections = ( ), }, { - icon: , + icon: ( + + ), label: "Scaling", alignment: "start", content: ( @@ -194,7 +268,8 @@ export const createDetailSections = ( {6} instances
- at 70% CPU threshold + at 70% CPU + threshold
), From 87368525996ae7284b870c3a91409d55f9e46932 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 30 Sep 2025 15:07:56 +0300 Subject: [PATCH 08/10] feat: add proper openapi section and explanation --- .../status-indicator.tsx | 100 ++++++++++++------ .../detail-section.tsx | 17 +-- .../project-details-expandables/sections.tsx | 93 +++------------- .../sections/open-api-diff.tsx | 90 ++++++++++++++++ .../projects/[projectId]/layout-provider.tsx | 3 + .../projects/[projectId]/layout.tsx | 1 + .../deploy/deployment/getOpenApiDiff.ts | 1 - internal/ui/src/components/tooltip.tsx | 20 ++-- 8 files changed, 193 insertions(+), 132 deletions(-) create mode 100644 apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/sections/open-api-diff.tsx diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/active-deployment-card/status-indicator.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/active-deployment-card/status-indicator.tsx index f23d4efe4c..cae4486718 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/active-deployment-card/status-indicator.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/active-deployment-card/status-indicator.tsx @@ -1,47 +1,87 @@ import { Cloud } from "@unkey/icons"; +import { InfoTooltip } from "@unkey/ui"; import { cn } from "@unkey/ui/src/lib/utils"; +export type DiffStatus = "breaking" | "warning" | "safe" | "loading"; + type StatusIndicatorProps = { + status?: DiffStatus; withSignal?: boolean; className?: string; }; +const getTooltipContent = (status: DiffStatus): string => { + switch (status) { + case "breaking": + return "Breaking changes detected - this deployment may break existing API clients"; + case "warning": + return "API changes detected - review the differences before deploying"; + case "safe": + return "No API changes detected"; + case "loading": + return "Analyzing API differences..."; + } +}; + export function StatusIndicator({ + status = "safe", withSignal = false, className, }: StatusIndicatorProps) { + const isBreaking = status === "breaking"; + const isWarning = status === "warning"; + const isLoading = status === "loading"; + + const pulseColors = isBreaking + ? ["bg-error-9", "bg-error-10", "bg-error-11", "bg-error-12"] + : isWarning + ? ["bg-warning-9", "bg-warning-10", "bg-warning-11", "bg-warning-12"] + : ["bg-successA-9", "bg-successA-10", "bg-successA-11", "bg-successA-12"]; + + const coreColor = isBreaking ? "bg-error-9" : isWarning ? "bg-warning-9" : "bg-successA-9"; + return ( -
-
+
+
+ +
+ {withSignal && !isLoading && ( +
+ {[0, 0.15, 0.3, 0.45].map((delay, index) => ( +
+ key={index} + className={cn( + "absolute inset-0 size-2 rounded-full", + pulseColors[index], + index === 0 && "opacity-75", + index === 1 && "opacity-60", + index === 2 && "opacity-40", + index === 3 && "opacity-25", + )} + style={{ + animation: "ping 2s cubic-bezier(0, 0, 0.2, 1) infinite", + animationDelay: `${delay}s`, + }} + /> + ))} +
+
)} - > -
- {withSignal && ( -
- {[0, 0.15, 0.3, 0.45].map((delay, index) => ( -
- ))} -
-
- )} -
+ ); } diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/detail-section.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/detail-section.tsx index 079e5945a8..505f2d6d42 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/detail-section.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/detail-section.tsx @@ -8,21 +8,14 @@ type DetailRowProps = { alignment?: "center" | "start"; }; -function DetailRow({ - icon, - label, - children, - alignment = "center", -}: DetailRowProps) { +function DetailRow({ icon, label, children, alignment = "center" }: DetailRowProps) { const alignmentClass = alignment === "start" ? "items-start" : "items-center"; // If both icon and label are missing, let children take full space if (!icon && !label) { return (
-
- {children} -
+
{children}
); } @@ -48,11 +41,7 @@ type DetailSectionProps = { isFirst?: boolean; }; -export function DetailSection({ - title, - items, - isFirst = false, -}: DetailSectionProps) { +export function DetailSection({ title, items, isFirst = false }: DetailSectionProps) { return (
diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/sections.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/sections.tsx index b16cbe6fff..063f528dfe 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/sections.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/sections.tsx @@ -1,6 +1,5 @@ import type { Deployment } from "@/lib/collections"; import { - ArrowRight, Bolt, ChartActivity, CircleHalfDottedClock, @@ -19,7 +18,7 @@ import { Badge, TimestampInfo } from "@unkey/ui"; import type { ReactNode } from "react"; import { RepoDisplay } from "../../../_components/list/repo-display"; import { Avatar } from "../active-deployment-card/git-avatar"; -import { StatusIndicator } from "../active-deployment-card/status-indicator"; +import { OpenApiDiff } from "./sections/open-api-diff"; export type DetailItem = { icon: ReactNode | null; @@ -43,34 +42,7 @@ export const createDetailSections = ( icon: null, label: null, alignment: "start", - content: ( -
-
-
- -
-
-
from
-
- v_charlie042 -
-
-
- -
-
- -
-
-
from
-
v_oz
-
-
-
- ), + content: , }, ], }, @@ -89,20 +61,14 @@ export const createDetailSections = ( ), }, { - icon: ( - - ), + icon: , label: "Branch", content: ( - - {details.gitBranch} - + {details.gitBranch} ), }, { - icon: ( - - ), + icon: , label: "Commit", content: ( @@ -111,18 +77,11 @@ export const createDetailSections = ( ), }, { - icon: ( - - ), + icon: , label: "Description", content: (
- - {details.gitCommitMessage} - + {details.gitCommitMessage}
), }, @@ -140,12 +99,7 @@ export const createDetailSections = ( ), }, { - icon: ( - - ), + icon: , label: "Created", content: ( - {details.runtimeConfig.regions.reduce( - (acc, region) => acc + region.vmCount, - 0 - )} + {details.runtimeConfig.regions.reduce((acc, region) => acc + region.vmCount, 0)} vm
), }, { - icon: ( - - ), + icon: , label: "Regions", alignment: "start", content: ( @@ -198,9 +147,7 @@ export const createDetailSections = ( label: "CPU", content: (
- - {details.runtimeConfig.cpus} - + {details.runtimeConfig.cpus} vCPUs
), @@ -210,17 +157,13 @@ export const createDetailSections = ( label: "Memory", content: (
- - {details.runtimeConfig.memory} - + {details.runtimeConfig.memory} mb
), }, { - icon: ( - - ), + icon: , label: "Storage", content: (
@@ -253,12 +196,7 @@ export const createDetailSections = ( ), }, { - icon: ( - - ), + icon: , label: "Scaling", alignment: "start", content: ( @@ -268,8 +206,7 @@ export const createDetailSections = ( {6} instances
- at 70% CPU - threshold + at 70% CPU threshold
), diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/sections/open-api-diff.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/sections/open-api-diff.tsx new file mode 100644 index 0000000000..e5107b4007 --- /dev/null +++ b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/sections/open-api-diff.tsx @@ -0,0 +1,90 @@ +import { shortenId } from "@/lib/shorten-id"; +import { trpc } from "@/lib/trpc/client"; +import { useLiveQuery } from "@tanstack/react-db"; +import { ArrowRight } from "@unkey/icons"; +import type { GetOpenApiDiffResponse } from "@unkey/proto"; +import { useProjectLayout } from "../../../layout-provider"; +import { type DiffStatus, StatusIndicator } from "../../active-deployment-card/status-indicator"; + +const getDiffStatus = (data?: GetOpenApiDiffResponse): DiffStatus => { + if (!data) { + return "loading"; + } + if (data.hasBreakingChanges) { + return "breaking"; + } + if (data.summary?.diff) { + return "warning"; + } + return "safe"; +}; + +export const OpenApiDiff = () => { + const { collections, liveDeploymentId } = useProjectLayout(); + + const query = useLiveQuery( + (q) => + q + .from({ deployment: collections.deployments }) + .orderBy(({ deployment }) => deployment.createdAt, "desc") + .limit(2) + .select((c) => ({ + id: c.deployment.id, + })), + [liveDeploymentId], + ); + + const [newDeployment, oldDeployment] = query.data ?? []; + + const diff = trpc.deploy.deployment.getOpenApiDiff.useQuery({ + newDeploymentId: newDeployment?.id ?? "", + oldDeploymentId: oldDeployment?.id ?? "", + }); + + // @ts-expect-error I have no idea why this whines about type diff + const status = getDiffStatus(diff.data); + + if (newDeployment && !oldDeployment) { + return ( +
+
+ +
+
+
current
+
+ {shortenId(newDeployment.id)} +
+
+
+ ); + } + + if (!newDeployment) { + return null; + } + + return ( +
+
+
+ +
+
+
from
+
{shortenId(oldDeployment.id)}
+
+
+ +
+
+ +
+
+
to
+
{shortenId(newDeployment.id)}
+
+
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/layout-provider.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/layout-provider.tsx index 238cdf0d91..da5a44867d 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/layout-provider.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/layout-provider.tsx @@ -4,7 +4,10 @@ import { createContext, useContext } from "react"; type ProjectLayoutContextType = { isDetailsOpen: boolean; setIsDetailsOpen: (open: boolean) => void; + projectId: string; + liveDeploymentId?: string | null; + collections: ReturnType; }; diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/layout.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/layout.tsx index e78ebbf900..17cc6f6a73 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/layout.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/layout.tsx @@ -58,6 +58,7 @@ const ProjectLayout = ({ projectId, children }: ProjectLayoutProps) => { setIsDetailsOpen, projectId, collections, + liveDeploymentId, }} >
diff --git a/apps/dashboard/lib/trpc/routers/deploy/deployment/getOpenApiDiff.ts b/apps/dashboard/lib/trpc/routers/deploy/deployment/getOpenApiDiff.ts index 046030906a..120c0655e7 100644 --- a/apps/dashboard/lib/trpc/routers/deploy/deployment/getOpenApiDiff.ts +++ b/apps/dashboard/lib/trpc/routers/deploy/deployment/getOpenApiDiff.ts @@ -1,4 +1,3 @@ -// trpc/routers/deployments/getOpenApiDiff.ts import { db } from "@/lib/db"; import { env } from "@/lib/env"; import { requireUser, requireWorkspace, t } from "@/lib/trpc/trpc"; diff --git a/internal/ui/src/components/tooltip.tsx b/internal/ui/src/components/tooltip.tsx index 0dd94ab052..78f51ac9b8 100644 --- a/internal/ui/src/components/tooltip.tsx +++ b/internal/ui/src/components/tooltip.tsx @@ -15,15 +15,17 @@ const TooltipContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, sideOffset = 4, ...props }, ref) => ( - + + + )); TooltipContent.displayName = TooltipPrimitive.Content.displayName; From 86a8728e9200489b656a6d090946ac843a310517 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 30 Sep 2025 18:11:41 +0300 Subject: [PATCH 09/10] feat: add new diffing section --- .../diff/[...compare]/components/client.tsx | 986 ------------------ .../[projectId]/diff/[...compare]/page.tsx | 316 ------ .../projects/[projectId]/diff/page.tsx | 310 ------ .../navigations/project-sub-navigation.tsx | 10 +- .../openapi-diff/components/client.tsx | 304 ++++++ .../openapi-diff/components/empty.tsx | 31 + .../openapi-diff/deployment-select.tsx | 80 ++ .../[projectId]/openapi-diff/page.tsx | 180 ++++ 8 files changed, 603 insertions(+), 1614 deletions(-) delete mode 100644 apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/diff/[...compare]/components/client.tsx delete mode 100644 apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/diff/[...compare]/page.tsx delete mode 100644 apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/diff/page.tsx create mode 100644 apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/openapi-diff/components/client.tsx create mode 100644 apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/openapi-diff/components/empty.tsx create mode 100644 apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/openapi-diff/deployment-select.tsx create mode 100644 apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/openapi-diff/page.tsx diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/diff/[...compare]/components/client.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/diff/[...compare]/components/client.tsx deleted file mode 100644 index f5f499b5b5..0000000000 --- a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/diff/[...compare]/components/client.tsx +++ /dev/null @@ -1,986 +0,0 @@ -"use client"; - -import type { ChangelogEntry } from "@unkey/proto"; -import { - AlertTriangle, - BarChart3, - ChevronDown, - ChevronRight, - Clock, - Copy, - Edit, - ExternalLink, - FileText, - Filter, - Info, - Layers, - Minus, - Plus, - Search, - X, -} from "lucide-react"; -import type React from "react"; -import { useMemo, useState } from "react"; -interface DiffViewerProps { - changelog: ChangelogEntry[]; - fromDeployment?: string; - toDeployment?: string; -} - -type ViewMode = "changes" | "side-by-side" | "timeline"; - -export const DiffViewer: React.FC = ({ - changelog, - fromDeployment = "v1", - toDeployment = "v2", -}) => { - // Early return if no diffData - if (!changelog) { - return ( -
- -

No Diff Data Available

-

Unable to load the comparison data.

-
- ); - } - - const [viewMode, setViewMode] = useState("changes"); - const [selectedChange, setSelectedChange] = useState(null); - const [selectedPath, setSelectedPath] = useState(null); - const [selectedOperation, setSelectedOperation] = useState(null); - const [expandedPaths, setExpandedPaths] = useState>( - new Set(["/users", "/users/{userId}"]), - ); - const [filters, setFilters] = useState({ - level: null as number | null, - operation: "all", - searchQuery: "", - }); - const [showFilters, setShowFilters] = useState(false); - - // Statistics - const stats = useMemo(() => { - const breaking = changelog.filter((c) => c.level === 3).length; - const warning = changelog.filter((c) => c.level === 2).length; - const info = changelog.filter((c) => c.level === 1).length; - const total = changelog.length; - - const operations = [...new Set(changelog.map((c) => c.operation))]; - const paths = [...new Set(changelog.map((c) => c.path))]; - - return { breaking, warning, info, total, operations, paths }; - }, [changelog]); - - // Filter changes - const filteredChanges = useMemo(() => { - return changelog.filter((change) => { - if (filters.level !== null && change.level !== filters.level) { - return false; - } - if (filters.operation !== "all" && change.operation !== filters.operation) { - return false; - } - if (filters.searchQuery) { - const query = filters.searchQuery.toLowerCase(); - return ( - change.text.toLowerCase().includes(query) || - change.path.toLowerCase().includes(query) || - change.id.toLowerCase().includes(query) - ); - } - return true; - }); - }, [changelog, filters]); - - // Group changes by path and operation - const groupedChanges = useMemo(() => { - const grouped: Record> = {}; - - filteredChanges.forEach((change) => { - if (!grouped[change.path]) { - grouped[change.path] = {}; - } - if (!grouped[change.path][change.operation]) { - grouped[change.path][change.operation] = []; - } - grouped[change.path][change.operation].push(change); - }); - - return grouped; - }, [filteredChanges]); - - // Helper functions - const togglePathExpansion = (path: string) => { - const newExpanded = new Set(expandedPaths); - if (newExpanded.has(path)) { - newExpanded.delete(path); - } else { - newExpanded.add(path); - } - setExpandedPaths(newExpanded); - }; - - const getSeverityIcon = (level: number) => { - switch (level) { - case 3: - return ; - case 2: - return ; - default: - return ; - } - }; - - const getSeverityColor = (level: number) => { - switch (level) { - case 3: - return "border-l-4 border-l-alert bg-red-2 hover:bg-red-3"; - case 2: - return "border-l-4 border-l-warn bg-amber-2 hover:bg-amber-3"; - default: - return "border-l-4 border-l-brand bg-background-subtle hover:bg-gray-100"; - } - }; - - const getOperationColor = (operation: string) => { - const colors: Record = { - GET: "bg-gray-100 text-gray-800 border border-gray-200", - POST: "bg-brand text-brand-foreground border border-gray-200", - PUT: "bg-amber-2 text-amber-11 border border-gray-200", - PATCH: "bg-gray-200 text-gray-700 border border-gray-300", - DELETE: "bg-red-2 text-red-11 border border-gray-200", - }; - return colors[operation] || "bg-gray-100 text-gray-800 border border-gray-200"; - }; - - const getChangeIcon = (changeId: string) => { - if (changeId.includes("added") || changeId.includes("new")) { - return ; - } - if (changeId.includes("removed") || changeId.includes("deleted")) { - return ; - } - if (changeId.includes("changed") || changeId.includes("modified")) { - return ; - } - return ; - }; - - // Code diff helpers - const getBeforeSpec = (path: string, operation: string) => { - if (path === "/users" && operation === "GET") { - return `{ - "get": { - "summary": "Get all users", - "parameters": [ - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer", - "default": 20 - } - }, - { - "name": "offset", - "in": "query", - "schema": { - "type": "integer", - "default": 0 - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "properties": { - "users": { "type": "array" }, - "total": { "type": "integer" }, - "limit": { "type": "integer" }, - "offset": { "type": "integer" } - } - } - } - } - } - } - } -}`; - } - return '{\n "endpoint": "not found"\n}'; - }; - - const getAfterSpec = (path: string, operation: string) => { - if (path === "/users" && operation === "GET") { - return `{ - "get": { - "summary": "Get all users", - "parameters": [ - { - "name": "pageSize", - "in": "query", - "schema": { - "type": "integer", - "default": 10 - } - }, - { - "name": "page", - "in": "query", - "schema": { - "type": "integer", - "default": 1 - } - }, - { - "name": "status", - "in": "query", - "required": true, - "schema": { - "type": "string", - "enum": ["active", "inactive", "suspended"] - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "properties": { - "data": { "type": "array" }, - "pagination": { "type": "object" } - } - } - } - } - } - } - } -}`; - } - return '{\n "endpoint": "not found"\n}'; - }; - - const renderCodeLine = ( - lineContent: string, - lineNumber: number, - type: "none" | "added" | "removed" | "modified" = "none", - ) => { - const getLineClasses = () => { - switch (type) { - case "added": - return "bg-gray-50 border-l-4 border-l-gray-400"; - case "removed": - return "bg-red-2 border-l-4 border-l-alert"; - case "modified": - return "bg-amber-2 border-l-4 border-l-warn"; - default: - return "bg-background"; - } - }; - - const getLineIcon = () => { - switch (type) { - case "added": - return ; - case "removed": - return ; - case "modified": - return ; - default: - return null; - } - }; - - return ( -
-
{lineNumber}
-
{getLineIcon()}
-
- {lineContent} -
-
- ); - }; - - const renderCodeDiff = ( - beforeCode: string, - afterCode: string, - path: string, - operation: string, - ) => { - const beforeLines = beforeCode.split("\n"); - const afterLines = afterCode.split("\n"); - - const getDiffType = (_lineIndex: number, side: "before" | "after", line: string) => { - if (path === "/users" && operation === "GET") { - if (side === "before") { - if (line.includes('"limit"') || line.includes('"offset"')) { - return "removed"; - } - if (line.includes('"users"') || line.includes('"total"')) { - return "removed"; - } - } else { - if (line.includes('"pageSize"') || line.includes('"page"') || line.includes('"status"')) { - return "added"; - } - if (line.includes('"data"') || line.includes('"pagination"')) { - return "added"; - } - } - } - return "none"; - }; - - return ( -
-
-
- Before ({fromDeployment}) -
-
- {beforeLines.map((line, index) => - renderCodeLine(line, index + 1, getDiffType(index, "before", line)), - )} -
-
-
-
- After ({toDeployment}) -
-
- {afterLines.map((line, index) => - renderCodeLine(line, index + 1, getDiffType(index, "after", line)), - )} -
-
-
- ); - }; - - // View components - const renderSideBySideView = () => ( -
-
-
-

Code Diff Comparison

- {selectedPath && selectedOperation && ( -
- - {selectedOperation} - - - {selectedPath} - -
- )} -
-
- -
-
- -
- {selectedPath && selectedOperation ? ( -
-
-
- - {selectedPath} - - {selectedOperation} - -
-
- - {renderCodeDiff( - getBeforeSpec(selectedPath, selectedOperation), - getAfterSpec(selectedPath, selectedOperation), - selectedPath, - selectedOperation, - )} - -
-

Legend:

-
-
- - - Added lines - -
-
- - - Removed lines - -
-
- - - Modified lines - -
-
-
- -
-

Changes for this endpoint:

-
- {changelog - .filter((c) => c.path === selectedPath && c.operation === selectedOperation) - .map((change, _index) => ( -
- {getSeverityIcon(change.level)} - {change.text} -
- ))} -
-
-
- ) : ( -
- -

Code Diff Comparison

-

- Select a specific endpoint from the Changes view to see the actual code differences. -

-
- -
-
- )} -
-
- ); - - const renderTimelineView = () => ( -
-
-
- -

Change Timeline

- ({filteredChanges.length} changes) -
-
- -
-
- {filteredChanges.map((change, index) => ( -
- {index < filteredChanges.length - 1 && ( -
- )} -
-
- {getSeverityIcon(change.level)} -
-
-
- - {change.operation} - - - {change.path} - - - {change.level === 3 ? "Breaking" : change.level === 2 ? "Warning" : "Info"} - - {getChangeIcon(change.id)} -
-

{change.text}

- {change.text && ( -

- {change.text} -

- )} -
-
- ID: {change.id} • Section: {change.id} -
- -
-
-
-
- ))} - - {filteredChanges.length === 0 && ( -
- -

No changes match the current filters

-
- )} -
-
-
- ); - - return ( -
- {/* View Mode Tabs */} -
-
-
- -
-
-
- -
- {viewMode === "changes" && ( -
- {/* Summary Panel */} -
-
-

- - Change Summary -

- -
-
- Total Changes - {stats.total} -
-
- - - Breaking - - {stats.breaking} -
-
- - - Warning - - {stats.warning} -
-
- Endpoints Affected - {stats.paths.length} -
-
- - - - {showFilters && ( -
-
- -
- - - setFilters((prev) => ({ - ...prev, - searchQuery: e.target.value, - })) - } - placeholder="Search changes..." - className="w-full pl-10 pr-4 py-2 border border-border rounded-md text-sm bg-background text-content focus:ring-2 focus:ring-brand focus:border-brand" - /> - {filters.searchQuery && ( - - )} -
-
- -
- - -
- -
- - -
- - {(filters.level !== null || - filters.operation !== "all" || - filters.searchQuery) && ( - - )} -
- )} -
-
- - {/* Changes List */} -
-
-
- {filteredChanges.length === 0 ? ( -
- -

- No changes match the current filters -

- -
- ) : ( -
- {Object.entries(groupedChanges).map(([path, operations]) => ( -
- -
- - - {expandedPaths.has(path) && ( -
- {Object.entries(operations).map(([operation, changes]) => ( -
-
- - {operation} - - - {changes.length} changes - -
- -
- {changes.map((change, index) => ( - -
-
-
- - ))} -
-
- ))} -
- )} -
- ))} -
- )} -
-
-
-
- )} - - {viewMode === "side-by-side" && renderSideBySideView()} - {viewMode === "timeline" && renderTimelineView()} -
-
- ); -}; diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/diff/[...compare]/page.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/diff/[...compare]/page.tsx deleted file mode 100644 index b7c0e01041..0000000000 --- a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/diff/[...compare]/page.tsx +++ /dev/null @@ -1,316 +0,0 @@ -"use client"; - -import { trpc } from "@/lib/trpc/client"; -import { eq, useLiveQuery } from "@tanstack/react-db"; -import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@unkey/ui"; -import { AlertCircle, ArrowLeft, GitCompare, Loader } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; -import { useProjectLayout } from "../../layout-provider"; -import { DiffViewer } from "./components/client"; - -interface Props { - params: { - projectId: string; - compare: string[]; // [from, to] or [from, to, additional-params] - }; - searchParams: { - [key: string]: string | string[] | undefined; - }; -} - -export default function DiffPage({ params }: Props) { - const { collections } = useProjectLayout(); - const router = useRouter(); - const [fromDeploymentId, toDeploymentId] = params.compare; - const [selectedFromDeployment, setSelectedFromDeployment] = useState( - fromDeploymentId || "", - ); - const [selectedToDeployment, setSelectedToDeployment] = useState(toDeploymentId || ""); - - // Fetch deployment details if needed in the future - - // Fetch all deployments for this project - - const deployments = useLiveQuery((q) => - q - .from({ deployment: collections.deployments }) - .where(({ deployment }) => eq(deployment.projectId, params.projectId)) - .join({ environment: collections.environments }, ({ environment, deployment }) => - eq(environment.id, deployment.environmentId), - ) - .orderBy(({ deployment }) => deployment.createdAt, "desc") - .limit(100), - ); - - // Fetch the diff data - const { - data: diffData, - isLoading: diffLoading, - error: diffError, - } = trpc.deploy.deployment.getOpenApiDiff.useQuery( - { - oldDeploymentId: selectedFromDeployment, - newDeploymentId: selectedToDeployment, - }, - { - enabled: !!selectedFromDeployment && !!selectedToDeployment, - }, - ); - - const sortedDeployments = deployments.data.sort( - (a, b) => b.deployment.createdAt - a.deployment.createdAt, - ); - - const handleCompare = () => { - if (selectedFromDeployment && selectedToDeployment) { - router.push( - `/projects/${params.projectId}/diff/${selectedFromDeployment}/${selectedToDeployment}`, - ); - } - }; - - return ( -
-
- {/* Header */} -
-
- -
- -
-
- -
-
-

OpenAPI Diff

-

- Compare OpenAPI specifications between deployments -

-
-
-
- - {/* Selector Panel */} -
-

Select Deployments to Compare

-

- Select environments and deployments to compare their OpenAPI specifications -

- -
- {/* From Selection */} -
-
-

From (Baseline)

- - {/* Deployment Selection */} -
-
- - -
-
-
-
- - {/* To Selection */} -
-
-

To (Comparison)

- - {/* Deployment Selection */} -
-
- - -
-
-
-
-
- - {/* Compare Button */} -
- -
-
- - {/* Diff Results */} - {diffLoading && ( -
- -

Generating diff...

-
- )} - - {diffError && ( -
-
- -
-

Error generating diff

-

{diffError.message}

-
-
-
- )} - - {diffData && ( -
-
-

Diff Results

-

- {diffData.changes?.length || 0} changes detected -

-
-
- -
-
- )} -
-
- ); -} diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/diff/page.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/diff/page.tsx deleted file mode 100644 index 00b1e5af8b..0000000000 --- a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/diff/page.tsx +++ /dev/null @@ -1,310 +0,0 @@ -"use client"; - -import { eq, useLiveQuery } from "@tanstack/react-db"; -import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@unkey/ui"; -import { ArrowLeft, GitBranch, GitCommit, GitCompare, Globe, Tag } from "lucide-react"; -import { useParams, useRouter } from "next/navigation"; -import { useState } from "react"; -import { useProjectLayout } from "../layout-provider"; - -export default function DiffSelectionPage(): JSX.Element { - const { collections } = useProjectLayout(); - const params = useParams(); - const router = useRouter(); - const projectId = params?.projectId as string; - - const [selectedFromDeployment, setSelectedFromDeployment] = useState(""); - const [selectedToDeployment, setSelectedToDeployment] = useState(""); - - // Fetch all deployments for this project - const deployments = useLiveQuery((q) => - q - .from({ deployment: collections.deployments }) - .where(({ deployment }) => eq(deployment.projectId, params?.projectId)) - .join({ environment: collections.environments }, ({ environment, deployment }) => - eq(environment.id, deployment.environmentId), - ) - .orderBy(({ deployment }) => deployment.createdAt, "desc") - .limit(100), - ); - - const handleCompare = () => { - if (selectedFromDeployment && selectedToDeployment) { - router.push( - `/${params?.workspaceSlug}/projects/${projectId}/diff/${selectedFromDeployment}/${selectedToDeployment}`, - ); - } - }; - - const sortedDeployments = deployments.data.sort( - (a, b) => b.deployment.createdAt - a.deployment.createdAt, - ); - - return ( -
-
- {/* Header */} -
-
- -
- -
-
- -
-
-

Compare Deployments

-

- Select two deployments to compare their OpenAPI specifications -

-
-
-
- - {/* Selection Form */} -
-
- {/* From Selection */} -
-

From (Baseline)

- -
- - -
-
- - {/* To Selection */} -
-

To (Comparison)

- -
- - -
-
-
- - {/* Compare Button */} -
- -
- - {/* Selected deployments preview */} - {selectedFromDeployment && selectedToDeployment && ( -
-

Comparison Preview

-
-
- - - From: {(() => { - const deployment = deployments.data.find( - (d) => d.deployment.id === selectedFromDeployment, - ); - return deployment ? deployment.deployment.id : "Unknown"; - })()} - -
- -
- - - To: {(() => { - const deployment = deployments.data.find( - (d) => d.deployment.id === selectedToDeployment, - ); - return deployment ? deployment.deployment.id : "Unknown"; - })()} - -
-
-
- )} -
-
-
- ); -} diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/navigations/project-sub-navigation.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/navigations/project-sub-navigation.tsx index 31a236ffec..0dc0e9755d 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/navigations/project-sub-navigation.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/navigations/project-sub-navigation.tsx @@ -2,7 +2,7 @@ import { useWorkspaceNavigation } from "@/hooks/use-workspace-navigation"; import { cn } from "@/lib/utils"; -import { Cloud, GridCircle, Layers3 } from "@unkey/icons"; +import { Cloud, Connections, GridCircle, Layers3 } from "@unkey/icons"; import type { IconProps } from "@unkey/icons/src/props"; import { useParams, usePathname, useRouter } from "next/navigation"; import { useEffect, useRef } from "react"; @@ -47,7 +47,7 @@ export const ProjectSubNavigation = ({ const tabIndex = segments.findIndex((segment) => segment === projectId) + 1; const currentTab = segments[tabIndex]; - const validTabs = ["overview", "deployments", "gateway-logs", "settings"]; + const validTabs = ["overview", "deployments", "gateway-logs", "settings", "openapi-diff"]; return validTabs.includes(currentTab) ? currentTab : "overview"; }; @@ -73,6 +73,12 @@ export const ProjectSubNavigation = ({ icon: Layers3, path: `${basePath}/${projectId}/gateway-logs`, }, + { + id: "openapi-diff", + label: "Open API Diff", + icon: Connections, + path: `${basePath}/${projectId}/openapi-diff`, + }, ]; const handleTabChange = (path: string) => { diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/openapi-diff/components/client.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/openapi-diff/components/client.tsx new file mode 100644 index 0000000000..42d6de797a --- /dev/null +++ b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/openapi-diff/components/client.tsx @@ -0,0 +1,304 @@ +"use client"; +import { + ChevronDown, + ChevronRight, + CircleInfo, + CircleWarning, + CircleXMark, + InputSearch, + TriangleWarning, +} from "@unkey/icons"; +import type { ChangelogEntry } from "@unkey/proto"; +import { + Badge, + Button, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@unkey/ui"; +import type React from "react"; +import { useMemo, useState } from "react"; + +type DiffViewerContentProps = { + changelog: ChangelogEntry[]; + fromDeployment?: string; + toDeployment?: string; +}; + +export const DiffViewerContent: React.FC = ({ + changelog, + fromDeployment = "Previous", + toDeployment = "Current", +}) => { + const [expandedPaths, setExpandedPaths] = useState>(new Set()); + const [filters, setFilters] = useState({ + level: null as number | null, + operation: "all", + searchQuery: "", + }); + + const stats = useMemo(() => { + const breaking = changelog.filter((c) => c.level === 3).length; + const warning = changelog.filter((c) => c.level === 2).length; + const info = changelog.filter((c) => c.level === 1).length; + const total = changelog.length; + const operations = [...new Set(changelog.map((c) => c.operation))]; + const paths = [...new Set(changelog.map((c) => c.path))]; + return { breaking, warning, info, total, operations, paths }; + }, [changelog]); + + const filteredChanges = useMemo(() => { + return changelog.filter((change) => { + if (filters.level !== null && change.level !== filters.level) { + return false; + } + if (filters.operation !== "all" && change.operation !== filters.operation) { + return false; + } + if (filters.searchQuery) { + const query = filters.searchQuery.toLowerCase(); + return ( + change.text.toLowerCase().includes(query) || + change.path.toLowerCase().includes(query) || + change.id.toLowerCase().includes(query) + ); + } + return true; + }); + }, [changelog, filters]); + + const groupedChanges = useMemo(() => { + const grouped: Record> = {}; + filteredChanges.forEach((change) => { + if (!grouped[change.path]) { + grouped[change.path] = {}; + } + if (!grouped[change.path][change.operation]) { + grouped[change.path][change.operation] = []; + } + grouped[change.path][change.operation].push(change); + }); + return grouped; + }, [filteredChanges]); + + const togglePathExpansion = (path: string) => { + const newExpanded = new Set(expandedPaths); + newExpanded.has(path) ? newExpanded.delete(path) : newExpanded.add(path); + setExpandedPaths(newExpanded); + }; + + const getSeverityIcon = (level: number) => { + if (level === 3) { + return ; + } + if (level === 2) { + return ; + } + return ; + }; + + const getSeverityColor = (level: number) => { + if (level === 3) { + return "border-l-2 border-l-error-9 bg-errorA-2"; + } + if (level === 2) { + return "border-l-2 border-l-warning-9 bg-warningA-2"; + } + return "border-l-2 border-l-gray-6 bg-grayA-1"; + }; + + if (!changelog || changelog.length === 0) { + return ( +
+

+ No differences between {fromDeployment} and {toDeployment} +

+
+ ); + } + + return ( + <> + {/* Stats header - integrated into parent card */} +
+
+
+
API Changes
+
+ {stats.total} changes • {stats.paths.length} endpoints +
+
+
+ {stats.breaking > 0 && ( + + + {stats.breaking} breaking + + )} + {stats.warning > 0 && ( + + + {stats.warning} warnings + + )} +
+
+
+ + {/* Filters */} +
+ setFilters((p) => ({ ...p, searchQuery: e.target.value }))} + placeholder="Search changes..." + leftIcon={} + rightIcon={ + filters.searchQuery ? ( + + ) : null + } + wrapperClassName="flex-1" + className="text-xs h-9 min-w-[500px] rounded-md" + /> + + + {(filters.level !== null || filters.operation !== "all" || filters.searchQuery) && ( + + )} +
+ + {/* Changes list */} +
+ {filteredChanges.length === 0 ? ( +
+

No changes match filters

+
+ ) : ( +
+ {Object.entries(groupedChanges).map(([path, operations]) => ( +
+ + {expandedPaths.has(path) && ( +
+ {Object.entries(operations).map(([operation, changes]) => ( +
+
+ + {operation} + + {changes.length} +
+
+ {changes.map((change, index) => ( +
+
+
+ {getSeverityIcon(change.level)} +
+
+

{change.text}

+
+ + {change.id} + + {change.operationId && ( + + • {change.operationId} + + )} +
+
+
+
+ ))} +
+
+ ))} +
+ )} +
+ ))} +
+ )} +
+ + ); +}; diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/openapi-diff/components/empty.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/openapi-diff/components/empty.tsx new file mode 100644 index 0000000000..8721f300ad --- /dev/null +++ b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/openapi-diff/components/empty.tsx @@ -0,0 +1,31 @@ +import { Magnifier } from "@unkey/icons"; +import { Card } from "@unkey/ui"; + +export const DiffViewerEmpty = () => ( + +
+ {/* Icon with subtle animation */} +
+
+
+ +
+
+ {/* Content */} +
+

No deployments selected

+

+ Select two deployments above to compare their OpenAPI specifications and see what changed + between versions. +

+
+
+ +); diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/openapi-diff/deployment-select.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/openapi-diff/deployment-select.tsx new file mode 100644 index 0000000000..45d109550d --- /dev/null +++ b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/openapi-diff/deployment-select.tsx @@ -0,0 +1,80 @@ +"use client"; +import type { Deployment } from "@/lib/collections"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@unkey/ui"; +import { format } from "date-fns"; + +type DeploymentSelectProps = { + value: string; + onValueChange: (value: string) => void; + deployments: Array<{ + deployment: Deployment; + }>; + isLoading: boolean; + placeholder?: string; + disabled?: boolean; + id?: string; + disabledDeploymentId?: string; +}; + +export function DeploymentSelect({ + value, + onValueChange, + deployments, + isLoading, + placeholder = "Select deployment", + disabled = false, + id, + disabledDeploymentId, +}: DeploymentSelectProps) { + const renderOptions = () => { + if (isLoading) { + return ( + + Loading... + + ); + } + if (deployments.length === 0) { + return ( + + No deployments found + + ); + } + + return deployments.map(({ deployment }) => { + const isDisabled = deployment.id === disabledDeploymentId; + const deployedAt = format(deployment.createdAt, "MMM d, h:mm a"); + const commitMessage = deployment.gitCommitMessage?.trim(); + const displayMessage = commitMessage || deployment.gitBranch; + const truncatedMessage = + displayMessage.length > 50 ? `${displayMessage.substring(0, 50)}...` : displayMessage; + const shortSha = deployment.gitCommitSha?.substring(0, 7) || deployment.id.substring(0, 7); + + return ( + +
+ {truncatedMessage} + + {deployedAt} + + {shortSha} +
+
+ ); + }); + }; + + return ( + + ); +} diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/openapi-diff/page.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/openapi-diff/page.tsx new file mode 100644 index 0000000000..31376f1500 --- /dev/null +++ b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/openapi-diff/page.tsx @@ -0,0 +1,180 @@ +"use client"; +import { trpc } from "@/lib/trpc/client"; +import { eq, useLiveQuery } from "@tanstack/react-db"; +import { ArrowRight, Magnifier } from "@unkey/icons"; +import { cn } from "@unkey/ui/src/lib/utils"; +import { Loader } from "lucide-react"; +import { useState } from "react"; +import { Card } from "../details/card"; +import { useProjectLayout } from "../layout-provider"; +import { DiffViewerContent } from "./components/client"; +import { DeploymentSelect } from "./deployment-select"; + +export default function DiffPage() { + const { collections, isDetailsOpen } = useProjectLayout(); + const [selectedFromDeployment, setSelectedFromDeployment] = useState(""); + const [selectedToDeployment, setSelectedToDeployment] = useState(""); + + const deployments = useLiveQuery((q) => + q + .from({ deployment: collections.deployments }) + .join({ environment: collections.environments }, ({ environment, deployment }) => + eq(environment.id, deployment.environmentId), + ) + .orderBy(({ deployment }) => deployment.createdAt, "desc") + .limit(100), + ); + + const { + data: diffData, + isLoading: diffLoading, + error: diffError, + } = trpc.deploy.deployment.getOpenApiDiff.useQuery( + { + oldDeploymentId: selectedFromDeployment, + newDeploymentId: selectedToDeployment, + }, + { + enabled: !!selectedFromDeployment && !!selectedToDeployment, + }, + ); + + const sortedDeployments = deployments.data.sort( + (a, b) => b.deployment.createdAt - a.deployment.createdAt, + ); + + const getDeploymentLabel = (deploymentId: string): string => { + const deployment = sortedDeployments.find((d) => d.deployment.id === deploymentId); + if (!deployment) { + return deploymentId; + } + + const commitSha = + deployment.deployment.gitCommitSha?.substring(0, 7) || + deployment.deployment.id.substring(0, 7); + const branch = deployment.deployment.gitBranch || "unknown"; + + return `${branch}:${commitSha}`; + }; + + const showEmptyState = !selectedFromDeployment || !selectedToDeployment; + const showContent = selectedFromDeployment && selectedToDeployment; + + return ( +
+
+ + {/* Header Section */} +
+
+
+
Compare Deployments
+
View API changes between deployments
+
+
+
+ + {/* Deployment Selectors */} +
+
+
+
+ +
+
+ { + setSelectedFromDeployment(value); + if (value === selectedToDeployment) { + setSelectedToDeployment(""); + } + }} + deployments={sortedDeployments} + isLoading={deployments.isLoading} + placeholder="Select baseline..." + disabledDeploymentId={selectedToDeployment} + /> + + + + { + setSelectedToDeployment(value); + if (value === selectedFromDeployment) { + setSelectedFromDeployment(""); + } + }} + deployments={sortedDeployments} + isLoading={deployments.isLoading} + placeholder="Select comparison..." + disabledDeploymentId={selectedFromDeployment} + /> +
+
+ + {/* Content Area - All states rendered inside card */} + + {showEmptyState && ( +
+ {/* Icon with subtle animation */} +
+
+
+ +
+
+ {/* Content */} +
+

No deployments selected

+

+ Select two deployments above to compare their OpenAPI specifications and see + what changed between versions. +

+
+
+ )} + + {showContent && ( + <> + {diffLoading && ( +
+ +

Analyzing changes...

+

Comparing API specifications

+
+ )} + + {diffError && ( +
+
+ Failed to generate diff +
+

{diffError.message}

+
+ )} + + {diffData && !diffLoading && ( + + )} + + )} +
+ +
+
+ ); +} From c8b4c9f105bbcf6d62253fcc0363056c152e9e39 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 1 Oct 2025 14:21:52 +0300 Subject: [PATCH 10/10] fix: styling issues of diffing --- .../sections/open-api-diff.tsx | 58 ++++-- .../openapi-diff/components/client.tsx | 179 ++++++++++-------- .../openapi-diff/deployment-select.tsx | 19 +- .../[projectId]/openapi-diff/page.tsx | 100 ++++++---- internal/ui/src/components/form/select.tsx | 2 +- 5 files changed, 213 insertions(+), 145 deletions(-) diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/sections/open-api-diff.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/sections/open-api-diff.tsx index e5107b4007..12d9e79fc3 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/sections/open-api-diff.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/details/project-details-expandables/sections/open-api-diff.tsx @@ -1,8 +1,12 @@ +"use client"; import { shortenId } from "@/lib/shorten-id"; import { trpc } from "@/lib/trpc/client"; import { useLiveQuery } from "@tanstack/react-db"; import { ArrowRight } from "@unkey/icons"; import type { GetOpenApiDiffResponse } from "@unkey/proto"; +import { InfoTooltip } from "@unkey/ui"; +import Link from "next/link"; +import { useParams } from "next/navigation"; import { useProjectLayout } from "../../../layout-provider"; import { type DiffStatus, StatusIndicator } from "../../active-deployment-card/status-indicator"; @@ -20,6 +24,7 @@ const getDiffStatus = (data?: GetOpenApiDiffResponse): DiffStatus => { }; export const OpenApiDiff = () => { + const params = useParams(); const { collections, liveDeploymentId } = useProjectLayout(); const query = useLiveQuery( @@ -64,27 +69,40 @@ export const OpenApiDiff = () => { return null; } + const diffUrl = `/${params?.workspaceSlug}/projects/${params?.projectId}/openapi-diff?from=${oldDeployment.id}&to=${newDeployment.id}`; return ( -
-
-
- -
-
-
from
-
{shortenId(oldDeployment.id)}
-
-
- -
-
- -
-
-
to
-
{shortenId(newDeployment.id)}
+ + +
+
+
+ +
+
+
from
+
+ {shortenId(oldDeployment.id)} +
+
+
+ +
+
+ +
+
+
to
+
+ {shortenId(newDeployment.id)} +
+
+
-
-
+ + ); }; diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/openapi-diff/components/client.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/openapi-diff/components/client.tsx index 42d6de797a..2da39a49fb 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/openapi-diff/components/client.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/openapi-diff/components/client.tsx @@ -1,7 +1,6 @@ "use client"; import { ChevronDown, - ChevronRight, CircleInfo, CircleWarning, CircleXMark, @@ -19,6 +18,7 @@ import { SelectTrigger, SelectValue, } from "@unkey/ui"; +import { cn } from "@unkey/ui/src/lib/utils"; import type React from "react"; import { useMemo, useState } from "react"; @@ -97,23 +97,23 @@ export const DiffViewerContent: React.FC = ({ if (level === 2) { return ; } - return ; + return ; }; const getSeverityColor = (level: number) => { if (level === 3) { - return "border-l-2 border-l-error-9 bg-errorA-2"; + return "border border-error-6 bg-errorA-2"; } if (level === 2) { - return "border-l-2 border-l-warning-9 bg-warningA-2"; + return "border border-warning-6 bg-warningA-2"; } - return "border-l-2 border-l-gray-6 bg-grayA-1"; + return "border border-gray-4 bg-grayA-1"; }; if (!changelog || changelog.length === 0) { return (
-

+

No differences between {fromDeployment} and {toDeployment}

@@ -122,26 +122,28 @@ export const DiffViewerContent: React.FC = ({ return ( <> - {/* Stats header - integrated into parent card */} + {/* Stats header */}
-
API Changes
-
+
API Changes
+
{stats.total} changes • {stats.paths.length} endpoints
-
+
{stats.breaking > 0 && ( - {stats.breaking} breaking + {stats.breaking} breaking )} {stats.warning > 0 && ( - + - {stats.warning} warnings + + {stats.warning} warning{stats.warning !== 1 ? "s" : ""} + )}
@@ -149,13 +151,13 @@ export const DiffViewerContent: React.FC = ({
{/* Filters */} -
+
setFilters((p) => ({ ...p, searchQuery: e.target.value }))} placeholder="Search changes..." - leftIcon={} + leftIcon={} rightIcon={ filters.searchQuery ? ( ) : null } wrapperClassName="flex-1" - className="text-xs h-9 min-w-[500px] rounded-md" + className="text-xs h-9 rounded-md" />